Extend algorithm to even faces, add simple-level-resolution conjecture

- Generalize Phase 1 to include even interior faces as optional flip
  candidates and allow the source-triangle break in $L_0$ to be skipped;
  generalize Phase 2 so even outer-incident cycles may have at most one
  outer-face edge flipped (odd cycles still must have one).
- Define "simple level resolution" as a triangulation $G'$ obtained from
  some $(G, S)$ via the algorithm with bipartite parity subgraphs
  (Definition 5.4).
- Add Conjecture 5.7 (simple-resolution md4 surjectivity) and
  Observation 5.6: every minimum-degree-4 plane triangulation iso-class
  on $n \in \{6, ..., 11\}$ vertices is reached as a simple level
  resolution. Counts: 1, 1, 2, 5, 12, 34. The md4 restriction is
  necessary -- specific non-md4 iso-classes (iso 5 at n=8; iso 25, 183
  at n=10) are not reachable.
- Add experiments/simple_level_resolution_coverage.py implementing the
  branched algorithm and coverage check, plus supporting scripts for
  Phase 1 cycling debugging, Phase 2 gap diagnosis, inductive-lift
  scaffolding (inductive_lift_check.py for the route-1 proof strategy),
  and visualizations of the unreached n=10 iso-classes and the original
  Phase 2 gap example.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 13:41:20 -04:00
parent db245eecea
commit 81a9e1fef3
16 changed files with 1620 additions and 77 deletions
@@ -0,0 +1,103 @@
"""Compare original Phase 1 (tricky-everywhere only) and new Phase 1
(isolated, prefer cross-level) on the same case."""
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
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,
)
from full_resolution_check import (
isolated_odd_faces, any_lowest_depth_neighbor_flip,
)
def show_apex(face, G, emb, levels, k):
pairs = []
for (u, v) in edges_of_face(face):
r = apex_levels_for_edge(G, emb, u, v, levels)
if r is None:
pairs.append(None)
else:
(la, lb), _ = r
pairs.append(tuple(sorted([la - k, lb - k])))
return pairs
def trace(label, G_initial, levels, nodes_k, k, target_fn, flip_fn,
max_steps=15):
print(f"\n=== {label} ===")
Gc = G_initial.copy()
ip, emb_c = nx.check_planarity(Gc)
states = []
for step in range(max_steps):
faces = faces_of_subgraph(Gc, emb_c, nodes_k)
outer = identify_outer_face(faces, Gc, emb_c, levels, k)
depths = face_depths(faces, outer)
targets = target_fn(faces, Gc, emb_c, levels, k, depths, outer)
targets_summary = []
for cf, f, d in targets:
pairs = show_apex(f, Gc, emb_c, levels, k)
te = all(p == (0, 0) for p in pairs)
targets_summary.append((f, d, "TE" if te else "mixed"))
cur_state = tuple(sorted(Gc.subgraph(nodes_k).edges()))
repeating = cur_state in states
states.append(cur_state)
print(f"step {step}: targets={targets_summary} repeat={repeating}")
if repeating:
print(" CYCLE")
return
if not targets:
print(" done")
return
cf_t, face_t, d_t = max(targets, key=lambda x: x[2])
flip = flip_fn(face_t, Gc, emb_c, levels, k, depths, faces, outer)
if flip is None:
print(" no flip")
return
u, v, w, x, _ = flip
Gc.remove_edge(u, v)
Gc.add_edge(w, x)
ip, emb_c = nx.check_planarity(Gc)
tris = enumerate_all_triangulations(9)
G = tris[0]
source_set = {0, 7, 8}
levels = compute_levels(G, source_set)
by_level = defaultdict(list)
for v, lv in levels.items():
by_level[lv].append(v)
nodes_k = by_level[1]
k = 1
print(f"L_1 = {sorted(nodes_k)}, k={k}")
print(f"Initial L_1 edges: {sorted(G.subgraph(nodes_k).edges())}")
# Original Phase 1: tricky-everywhere target
def orig_target(faces, G, emb, levels, k, depths, outer):
return tricky_odd_faces(faces, G, emb, levels, k, depths, outer)
# Original flip: lowest_depth_neighbor_flip (filters to (k,k) only)
def orig_flip(face, G, emb, levels, k, depths, faces, outer):
return lowest_depth_neighbor_flip(face, G, emb, levels, k, depths, faces,
outer)
trace("ORIGINAL Phase 1 (tricky-everywhere only, (k,k) flips)",
G, levels, nodes_k, k, orig_target, orig_flip)
def new_target(faces, G, emb, levels, k, depths, outer):
return isolated_odd_faces(faces, depths, outer)
def new_flip(face, G, emb, levels, k, depths, faces, outer):
return any_lowest_depth_neighbor_flip(face, G, emb, levels, k, depths,
faces, outer)
trace("NEW Phase 1 (isolated, prefer cross-level)",
G, levels, nodes_k, k, new_target, new_flip)
@@ -0,0 +1,64 @@
"""Trace one budget-failure case to see if Phase 1 (new) cycles."""
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
from depth_monovariant_check import (
faces_of_subgraph, canonical_face, edges_of_face,
apex_levels_for_edge, identify_outer_face, face_depths,
)
from full_resolution_check import (
isolated_odd_faces, any_lowest_depth_neighbor_flip,
)
tris = enumerate_all_triangulations(9)
G = tris[0]
source_set = {0, 7, 8}
levels = compute_levels(G, source_set)
by_level = defaultdict(list)
for v, lv in levels.items():
by_level[lv].append(v)
nodes_k = by_level[1]
k = 1
print(f"L_1 = {sorted(nodes_k)}")
print(f"L_1 initial edges: {sorted(G.subgraph(nodes_k).edges())}")
Gc = G.copy()
ip, emb_c = nx.check_planarity(Gc)
history = []
for step in range(15):
faces = faces_of_subgraph(Gc, emb_c, nodes_k)
outer = identify_outer_face(faces, Gc, emb_c, levels, k)
depths = face_depths(faces, outer)
isolated = isolated_odd_faces(faces, depths, outer)
iso_summary = [(f, d) for cf, f, d in isolated]
edges = tuple(sorted(Gc.subgraph(nodes_k).edges()))
print(f"\nstep {step}: |L_1 edges|={len(edges)} isolated_odd={iso_summary}")
if not isolated:
print(" done")
break
cf_t, face_t, d_t = max(isolated, key=lambda x: x[2])
flip = any_lowest_depth_neighbor_flip(
face_t, Gc, emb_c, levels, k, depths, faces, outer)
if flip is None:
print(" no flip")
break
u, v, w, x, od = flip
apex = apex_levels_for_edge(Gc, emb_c, u, v, levels)
(la, lb), _ = apex
pair = tuple(sorted([la - k, lb - k]))
print(f" flip face {face_t} d={d_t}: ({u},{v})[apex {pair}] -> ({w},{x}), neighbor_depth={od}")
if edges in [h[0] for h in history]:
prior = next(h for h in history if h[0] == edges)
print(f" *** REPEATING state from step {prior[1]} ***")
break
history.append((edges, step))
Gc.remove_edge(u, v)
Gc.add_edge(w, x)
ip, emb_c = nx.check_planarity(Gc)
if not ip:
print(" nonplanar"); break
@@ -0,0 +1,80 @@
"""Inspect one Phase 2 failure to see why an odd cycle survived."""
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
from depth_monovariant_check import (
faces_of_subgraph, canonical_face, edges_of_face,
apex_levels_for_edge, identify_outer_face, face_depths,
tricky_odd_faces, lowest_depth_neighbor_flip,
)
from full_resolution_check import (
phase1_resolve_tricky, phase2_flip_outer_incident, is_Lk_bipartite,
)
tris = enumerate_all_triangulations(10)
G = tris[13]
source_set = {1, 2, 4}
levels = compute_levels(G, source_set)
by_level = defaultdict(list)
for v, lv in levels.items():
by_level[lv].append(v)
nodes_k = by_level[1]
k = 1
print("INITIAL G")
print(f" L_1 = {sorted(nodes_k)}")
Lk0 = G.subgraph(nodes_k)
print(f" L_1 edges: {sorted(Lk0.edges())}")
print(f" L_1 bipartite? {nx.is_bipartite(Lk0)}")
print("\nRunning Phase 1...")
G1, emb1, p1_steps, p1_reason = phase1_resolve_tricky(G, levels, nodes_k, k)
print(f" Phase 1: {p1_steps} steps, reason={p1_reason}")
Lk1 = G1.subgraph(nodes_k)
print(f" L_1 after Phase 1: edges = {sorted(Lk1.edges())}")
print(f" L_1 bipartite? {nx.is_bipartite(Lk1)}")
faces = faces_of_subgraph(G1, emb1, nodes_k)
outer = identify_outer_face(faces, G1, emb1, levels, k)
outer_face = next(f for f in faces if canonical_face(f) == outer)
print(f"\n outer face: {outer_face}")
print(f" faces of L_1:")
outer_edges = set(frozenset(list(e)) for e in edges_of_face(outer_face))
for f in faces:
cf = canonical_face(f)
if cf == outer:
continue
incident = any(frozenset([a, b]) in outer_edges
for (a, b) in edges_of_face(f))
odd = len(f) % 2 == 1
print(f" {f}: len={len(f)} odd={odd} incident_to_outer={incident}")
# Enumerate ALL simple cycles of L_1 (not just face-cycles)
print(f"\n all simple cycles of L_1 (up to length 6):")
def find_cycles(G, max_len):
cycles = set()
for u in G.nodes():
for v in G.neighbors(u):
for path in nx.all_simple_paths(G, v, u, cutoff=max_len-1):
if len(path) >= 3:
c = tuple(sorted(path))
cycles.add(c)
return cycles
for cyc in sorted(find_cycles(Lk1, 6)):
if len(cyc) % 2 == 1:
print(f" {cyc} (odd, len {len(cyc)})")
print("\nRunning Phase 2...")
G2, flips = phase2_flip_outer_incident(G1, emb1, levels, nodes_k, k)
print(f" flips: {flips}")
Lk2 = G2.subgraph(nodes_k)
print(f" L_1 after Phase 2: edges = {sorted(Lk2.edges())}")
print(f" L_1 bipartite? {nx.is_bipartite(Lk2)}")
print(f"\n all odd simple cycles of final L_1 (up to length 6):")
for cyc in sorted(find_cycles(Lk2, 6)):
if len(cyc) % 2 == 1:
print(f" {cyc}")
@@ -0,0 +1,278 @@
"""
Two-phase resolution algorithm test:
Phase 1: depth-guided flip loop to resolve tricky-everywhere odd cycles.
Phase 2: for each odd simple cycle of L_k incident to the outer face,
flip one (outer-incident) edge. Multi-edges allowed; we work in
the simple-graph collapse where add_edge on an existing edge is
a no-op (which is equivalent to multigraph bipartiteness).
Success criterion: at the end, the induced subgraph on each level k is
bipartite (no odd cycle).
"""
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,
)
def outer_edge_count(face, outer_face_edges):
return sum(1 for (a, b) in edges_of_face(face)
if frozenset([a, b]) in outer_face_edges)
def interior_odd_faces(faces, depths, outer_canon, outer_face_edges):
"""Odd inner faces with depth > 0 AND no edges incident to the outer face."""
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 or d <= 0:
continue
if outer_edge_count(face, outer_face_edges) > 0:
continue
out.append((cf, face, d))
return out
def shared_edge_to_lowest_neighbor(face, G, emb_G, levels, k, depths, faces,
outer_canon):
"""Find the inner face F' adjacent to `face` with minimum facial depth,
and return the flip on the edge shared between face and F'."""
face_canon = canonical_face(face)
face_edges = set(frozenset([a, b]) for (a, b) in edges_of_face(face))
best = None # (other_depth, u, v, w, x)
for f in faces:
cf = canonical_face(f)
if cf == face_canon or cf == outer_canon:
continue
# Find shared edge between face and f.
shared = None
for (a, b) in edges_of_face(f):
if frozenset([a, b]) in face_edges:
shared = (a, b)
break
if shared is None:
continue
other_depth = depths.get(cf)
if other_depth is None:
continue
u, v = shared
if not G.has_edge(u, v):
continue
result = apex_levels_for_edge(G, emb_G, u, v, levels)
if result is None:
continue
_, (w, x) = result
if G.has_edge(w, x):
continue
if best is None or other_depth < best[0]:
best = (other_depth, u, v, w, x)
if best is None:
return None
_, u, v, w, x = best
return (u, v, w, x)
def phase1_resolve_isolated(G, levels, nodes_k, k, max_steps=400):
"""Per spec:
(1) take the dual of L_k;
(2) depth 0 = inner face with >=2 outer-face edges;
(3) other faces' depth = BFS distance to a depth-0 face;
(4) find odd C of greatest depth with 0 outer-face edges;
(5) flip the edge shared with the lowest-depth incident neighbor.
Repeat until no such C exists."""
Gc = G.copy()
ip, emb_c = nx.check_planarity(Gc)
for step in range(max_steps):
faces = faces_of_subgraph(Gc, emb_c, nodes_k)
if len(faces) < 2:
return Gc, emb_c, step, 'no_faces'
outer = identify_outer_face(faces, Gc, emb_c, levels, k)
if outer is None:
return Gc, emb_c, step, 'no_outer'
outer_face = next((f for f in faces if canonical_face(f) == outer),
None)
outer_face_edges = set()
if outer_face is not None:
for (a, b) in edges_of_face(outer_face):
outer_face_edges.add(frozenset([a, b]))
depths = face_depths(faces, outer)
if depths is None:
depths = {}
interior = interior_odd_faces(faces, depths, outer, outer_face_edges)
if not interior:
return Gc, emb_c, step, 'done'
cf_t, face_t, d_t = max(interior, key=lambda x: x[2])
flip = shared_edge_to_lowest_neighbor(
face_t, Gc, emb_c, levels, k, depths, faces, outer)
if flip is None:
return Gc, emb_c, step, '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 Gc, emb_c, step, 'nonplanar'
return Gc, emb_c, max_steps, 'budget'
def phase2_flip_outer_incident(G, emb, levels, nodes_k, k):
"""Flip one edge per odd simple cycle of L_k incident to outer face.
Multi-edges allowed (simple-graph add is a no-op, equivalent to multigraph
bipartiteness)."""
faces = faces_of_subgraph(G, emb, nodes_k)
if not faces:
return G.copy(), []
outer = identify_outer_face(faces, G, emb, levels, k)
if outer is None:
return G.copy(), []
outer_face = next((f for f in faces if canonical_face(f) == outer), None)
if outer_face is None:
return G.copy(), []
outer_edges = set(frozenset(list(e)) for e in edges_of_face(outer_face))
Gn = G.copy()
flips = []
for face in faces:
cf = canonical_face(face)
if cf == outer:
continue
if len(face) % 2 != 1:
continue
# Incident to outer face?
face_edges = [frozenset([a, b]) for (a, b) in edges_of_face(face)]
if not any(e in outer_edges for e in face_edges):
continue
# Prefer the outer-incident edge for the flip.
chosen = None
for (u, v) in edges_of_face(face):
if frozenset([u, v]) not in outer_edges:
continue
if not Gn.has_edge(u, v):
continue
result = apex_levels_for_edge(Gn, emb, u, v, levels)
if result is None:
continue
_, (w, x) = result
chosen = (u, v, w, x)
break
if chosen is None:
continue
u, v, w, x = chosen
Gn.remove_edge(u, v)
# add_edge on existing edge in nx.Graph is a no-op (multigraph collapse)
Gn.add_edge(w, x)
flips.append((u, v, w, x))
return Gn, flips
def is_Lk_bipartite(G, nodes_k):
Lk = G.subgraph(nodes_k)
return nx.is_bipartite(Lk)
def run_check(n_values, max_steps=200):
total = 0
p1_failed = 0
p2_success = 0
p2_failure = 0
parity_bipartite = 0
failures = []
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)
# Run on every level k>=1 with at least one odd cycle.
for k, nodes_k in by_level.items():
if k == 0 or len(nodes_k) < 3:
continue
faces = faces_of_subgraph(G, emb, nodes_k)
if len(faces) < 2:
continue
has_odd = any(
len(f) % 2 == 1
and canonical_face(f) != canonical_face(
max(faces, key=len))
for f in faces
)
if not has_odd:
continue
total += 1
G1, emb1, p1_steps, p1_reason = phase1_resolve_isolated(
G, levels, nodes_k, k, max_steps)
if p1_reason not in ('done', 'no_faces'):
p1_failed += 1
if len(failures) < 5:
failures.append({
'n': n, 'tri_idx': tri_idx,
'source': (kind, label),
'k': k,
'phase': 1, 'reason': p1_reason
})
continue
G2, flips = phase2_flip_outer_incident(
G1, emb1, levels, nodes_k, k)
if is_Lk_bipartite(G2, nodes_k):
p2_success += 1
# Also check the parity subgraph bipartiteness.
# Parity = even / odd in BFS levels.
even_nodes = [v for v in G2.nodes()
if levels.get(v, 0) % 2 == 0]
odd_nodes = [v for v in G2.nodes()
if levels.get(v, 0) % 2 == 1]
even_sub = G2.subgraph(even_nodes)
odd_sub = G2.subgraph(odd_nodes)
if nx.is_bipartite(even_sub) and \
nx.is_bipartite(odd_sub):
parity_bipartite += 1
else:
p2_failure += 1
if len(failures) < 5:
Lk_after = G2.subgraph(nodes_k)
failures.append({
'n': n, 'tri_idx': tri_idx,
'source': (kind, label),
'k': k, 'phase': 2,
'reason': 'Lk_not_bipartite',
'flips': flips,
'Lk_edges': sorted(Lk_after.edges()),
})
print(f"\n=== summary ===")
print(f"configs with odd cycles in some L_k: {total}")
print(f" Phase 1 succeeded: {total - p1_failed}")
print(f" Phase 1 failed: {p1_failed}")
print(f" Phase 2 -> L_k bipartite: {p2_success}")
print(f" Phase 2 -> L_k still has odd cycle: {p2_failure}")
print(f" Of bipartite L_k: full parity-subgraph bipartite: "
f"{parity_bipartite}")
if failures:
print(f"\nfailures (first {len(failures)}):")
for f in failures:
print(f" {f}")
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,216 @@
"""Hand-check scaffold for the inductive lift step (route 1).
For each md4 iso-class T at n:
1. Pick a degree-4 vertex v.
2. Contract v: delete v and retriangulate the resulting 4-cycle with one
of two possible diagonals. This gives T' on n-1 vertices.
3. Identify T's iso-class at n-1.
4. Find empirical (G', S') such that the algorithm produces T'.
5. Lift G' to G on n vertices by reinserting v in a corresponding face.
6. Run the algorithm on (G, S) and check if it produces T.
Reports what worked / failed for each pair (md4 target, degree-4 vertex).
"""
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 simple_level_resolution_coverage import (
simple_level_resolutions, is_triangulation, iso_index, is_self_level_resolution,
)
def cyclic_neighbors(G, v):
"""Return the cyclic order of v's neighbors in G's planar embedding."""
ip, emb = nx.check_planarity(G)
if not ip:
return None
return list(emb.neighbors_cw_order(v))
def contract_degree4_vertex(G, v):
"""Remove a degree-4 vertex v from G and retriangulate the 4-cycle
formed by its neighbors. Yield (T', diagonal) for each of the two
possible diagonal choices."""
cyc = cyclic_neighbors(G, v)
if cyc is None or len(cyc) != 4:
return
a, b, c, d = cyc
for (u, w) in [(a, c), (b, d)]:
Gp = G.copy()
Gp.remove_node(v)
if Gp.has_edge(u, w):
continue
Gp.add_edge(u, w)
ip, _ = nx.check_planarity(Gp)
if not ip:
continue
if Gp.number_of_edges() != 3 * (Gp.number_of_nodes()) - 6:
continue
yield Gp, (u, w), (a, b, c, d)
def find_labeled_preimage(target_labeled, isos):
"""Find any (G, S) at n vertices such that algorithm yields a graph
EXACTLY equal (as labeled simple graph) to target_labeled.
To get all possible labelings, we search over the iso-class enumeration
plus all relabeling isomorphisms.
Returns (G, source_set, Gp_out) or None."""
target_edges = set(frozenset(e) for e in target_labeled.edges())
target_nodes = set(target_labeled.nodes())
n = len(target_nodes)
for G in isos:
# Try all label mappings from G's vertices to target_labeled's vertices.
# For small n this is feasible; otherwise we'd use isomorphism enum.
gm = nx.isomorphism.GraphMatcher(G, target_labeled) # not what we want
# We want to find an iso from G to *some* labeled triangulation
# H, where algorithm(H, S) = target_labeled exactly.
# Easier: just enumerate label permutations of G's vertices and
# check each.
from itertools import permutations
# Use permutations only if n is small (n<=7 is fine, n=8 = 40k perms)
if n > 7:
continue
for perm in permutations(target_nodes, n):
relabel = {orig: perm[i] for i, orig in enumerate(sorted(G.nodes()))}
H = nx.relabel_nodes(G, relabel, copy=True)
ip, emb = nx.check_planarity(H)
if not ip:
continue
for kind, lab, source_set in level_sources(H, emb):
for Hp in simple_level_resolutions(H, source_set):
if not is_triangulation(Hp, n):
continue
Hp_edges = set(frozenset(e) for e in Hp.edges())
if Hp_edges == target_edges:
return (H, source_set, Hp)
return None
def insert_vertex_into_face(G, face_vertices, new_v):
"""Insert new_v into the triangular face of G defined by face_vertices.
Returns the new graph. The face must currently be a triangle."""
Gp = G.copy()
Gp.add_node(new_v)
for u in face_vertices:
Gp.add_edge(new_v, u)
return Gp
def is_md4(T):
return min(T.degree(v) for v in T.nodes()) >= 4
def attempt_lift(G_prime, target_T, cyc, diag, v_label):
"""Attempt to lift G' to G on n vertices by reinserting v.
Idea: if the diagonal (u, w) is in G_prime and (u, w) borders two
triangles in G_prime, we can "uncontract" by removing the diagonal
and inserting v adjacent to the 4 boundary vertices of those two
triangles. The 4 boundary vertices should match cyc up to rotation
+ reflection.
Returns G (the lifted graph) if successful, else None.
"""
u, w = diag
if not G_prime.has_edge(u, w):
return None, "diagonal not in G'"
ip, emb = nx.check_planarity(G_prime)
if not ip:
return None, "G' not planar"
f1 = emb.traverse_face(u, w)
f2 = emb.traverse_face(w, u)
if len(f1) != 3 or len(f2) != 3:
return None, "diagonal not bordered by two triangles"
x = next(p for p in f1 if p != u and p != w)
y = next(p for p in f2 if p != u and p != w)
boundary = {u, w, x, y}
if boundary != set(cyc):
return None, f"boundary {boundary} != cyc {set(cyc)}"
G = G_prime.copy()
G.remove_edge(u, w)
G.add_node(v_label)
for vert in boundary:
G.add_edge(v_label, vert)
if G.number_of_edges() != 3 * G.number_of_nodes() - 6:
return None, f"wrong edge count {G.number_of_edges()}"
ip2, _ = nx.check_planarity(G)
if not ip2:
return None, "lifted G not planar"
return G, "ok"
def main():
n = 7
isos_n = enumerate_all_triangulations(n)
isos_n1 = enumerate_all_triangulations(n - 1)
md4_iso = [i for i, T in enumerate(isos_n) if is_md4(T)]
md4_iso_n1 = [i for i, T in enumerate(isos_n1) if is_md4(T)]
print(f"n={n}: md4 iso-classes = {md4_iso}")
print(f"n={n-1}: md4 iso-classes = {md4_iso_n1}")
for tgt_idx in md4_iso:
T = isos_n[tgt_idx]
print(f"\n=== Target T = iso[{tgt_idx}] at n={n} ===")
deg = {v: T.degree(v) for v in T.nodes()}
deg4_vertices = [v for v, d in deg.items() if d == 4]
good_contractions = []
for v in deg4_vertices:
for Tp, diag, cyc in contract_degree4_vertex(T, v):
tp_idx = iso_index(Tp, isos_n1)
if tp_idx is None:
continue
is_good = tp_idx in md4_iso_n1
marker = "GOOD (md4)" if is_good else "non-md4"
print(f" v={v} diag={diag} cyc={cyc}: T' = iso[{tp_idx}] {marker}")
if is_good:
good_contractions.append((v, diag, cyc, Tp, tp_idx))
if not good_contractions:
print(f" ! NO md4-preserving contraction found")
continue
v, diag, cyc, Tp, tp_idx = good_contractions[0]
print(f"\n Trying lift via good contraction: v={v} diag={diag}")
print(f" T' (labeled, vertices {sorted(Tp.nodes())}): "
f"edges {sorted(Tp.edges())}")
# Search for a LABELED preimage of T'.
labeled_pre = find_labeled_preimage(Tp, isos_n1)
if labeled_pre is None:
print(f" no labeled preimage found for T'")
continue
H, source_set, Hp = labeled_pre
print(f" labeled preimage H: edges {sorted(H.edges())}")
print(f" source: {sorted(source_set)}")
print(f" algorithm(H, S) yields T' exactly (labels match)")
# Now lift H to G by uncontracting at the diagonal
G_lifted, status = attempt_lift(H, T, cyc, diag, v)
print(f" lift status: {status}")
if G_lifted is None:
return
print(f" lifted G edges: {sorted(G_lifted.edges())}")
# Run algorithm on G_lifted with the same source set
success = False
for Gp_run in simple_level_resolutions(G_lifted, source_set):
if not is_triangulation(Gp_run, n):
continue
if nx.is_isomorphic(Gp_run, T):
# Check exact label match too
if set(frozenset(e) for e in Gp_run.edges()) == \
set(frozenset(e) for e in T.edges()):
print(f" SUCCESS (label-exact): algorithm yields T")
else:
print(f" SUCCESS (iso, not label-exact): algorithm "
f"yields a graph iso to T")
success = True
break
if not success:
print(f" lift OK but algorithm on (G, S) does not yield T")
if __name__ == "__main__":
main()
@@ -0,0 +1,80 @@
"""Inspect iso-class 5 at n=8 to see what's structurally special."""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import networkx as nx
from collections import Counter
from triangulation_gen import enumerate_all_triangulations
isos = enumerate_all_triangulations(8)
T5 = isos[5]
print(f"iso-class 5 at n=8:")
print(f" vertices: {sorted(T5.nodes())}")
print(f" edges: {sorted(T5.edges())}")
print(f" degree sequence: {sorted([T5.degree(v) for v in T5.nodes()], reverse=True)}")
print(f" min degree: {min(T5.degree(v) for v in T5.nodes())}")
ip, emb = nx.check_planarity(T5)
print(f" planar: {ip}")
# What face cycles exist?
faces = []
seen = set()
for v in emb.nodes():
for w in emb[v]:
if (v, w) not in seen:
f = emb.traverse_face(v, w)
for i in range(len(f)):
seen.add((f[i], f[(i+1) % len(f)]))
faces.append(f)
print(f" face lengths: {Counter(len(f) for f in faces)}")
# Check 4-colorability and best matching
def four_color(G):
"""Greedy 4-coloring."""
colors = {}
for v in nx.nodes(G):
used = {colors[u] for u in G.neighbors(v) if u in colors}
for c in range(4):
if c not in used:
colors[v] = c; break
return colors
# Check whether iso 5 can be 4-colored
try:
col = four_color(T5)
sizes = Counter(col.values())
print(f" greedy 4-coloring sizes: {sizes}")
except Exception as e:
print(f" 4-coloring failed: {e}")
# Comparison: print all 14 iso-classes' degree sequences
print(f"\nAll iso-classes at n=8 (degree sequences, sorted):")
for i, T in enumerate(isos):
ds = tuple(sorted([T.degree(v) for v in T.nodes()], reverse=True))
marker = " <-- missing" if i in [5] else ""
print(f" {i:2d}: {ds}{marker}")
# Now check if any algorithm output has degree sequence (6,6,6,5,4,3,3,3)
print(f"\nChecking algorithm outputs at n=8 for target degree sequence...")
from level_cycles import level_sources, compute_levels
from simple_level_resolution_coverage import simple_level_resolutions, is_triangulation
target_ds = tuple(sorted([T5.degree(v) for v in T5.nodes()], reverse=True))
found = 0
total = 0
for src_idx, G in enumerate(isos):
ip, emb = nx.check_planarity(G)
if not ip:
continue
for kind, label, source_set in level_sources(G, emb):
for Gp in simple_level_resolutions(G, source_set):
total += 1
if not is_triangulation(Gp, 8):
continue
ds = tuple(sorted([Gp.degree(v) for v in Gp.nodes()], reverse=True))
if ds == target_ds:
found += 1
if found <= 3:
print(f" found! src={src_idx} source={label}")
print(f" Gp edges: {sorted(Gp.edges())}")
print(f"total outputs: {total}, with target degree sequence: {found}")
@@ -0,0 +1,43 @@
"""Plot iso-classes 25 and 183 at n=10 (the two unreachable iso-classes)."""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import networkx as nx
import matplotlib.pyplot as plt
from collections import Counter
from triangulation_gen import enumerate_all_triangulations
isos = enumerate_all_triangulations(10)
targets = [25, 183]
fig, axes = plt.subplots(1, 2, figsize=(14, 7))
for ax, idx in zip(axes, targets):
T = isos[idx]
ip, emb = nx.check_planarity(T)
pos = nx.combinatorial_embedding_to_pos(emb)
nx.draw_networkx_edges(T, pos, ax=ax, edge_color='black', width=1.0)
nx.draw_networkx_nodes(T, pos, ax=ax, node_color='lightblue',
node_size=380, edgecolors='black')
nx.draw_networkx_labels(T, pos, ax=ax, font_size=10)
ds = sorted([T.degree(v) for v in T.nodes()], reverse=True)
dcounts = Counter(ds)
ds_str = ", ".join(f"{d}^{c}" if c > 1 else str(d)
for d, c in sorted(dcounts.items(), reverse=True))
ax.set_title(f"n=10 iso-class {idx}\n"
f"degree sequence: {ds_str}\n"
f"min degree: {min(ds)}, max: {max(ds)}",
fontsize=11)
ax.set_aspect('equal')
ax.axis('off')
plt.tight_layout()
out = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'..', 'unreached_iso_n10.png')
plt.savefig(out, dpi=130, bbox_inches='tight')
print(f"saved: {out}")
# Also print structural details
print(f"\niso 25 edges: {sorted(isos[25].edges())}")
print(f"iso 183 edges: {sorted(isos[183].edges())}")
print(f"\niso 25 degree seq: {sorted([isos[25].degree(v) for v in isos[25].nodes()], reverse=True)}")
print(f"iso 183 degree seq: {sorted([isos[183].degree(v) for v in isos[183].nodes()], reverse=True)}")
@@ -0,0 +1,438 @@
"""Simple level resolution coverage check.
For each (G, S) where G is a triangulation on n vertices, run Phase 1 +
Phase 2 across all levels k>=1 (in order k=1, 2, ...). Record the resulting
G' iso-class IF G' is still a triangulation (3n-6 edges, no multi-edges,
planar). Aggregate across all (G, S) to see which iso-classes are reachable
as simple level resolutions.
Question: is every triangulation iso-class on n vertices reached?
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import networkx as nx
from collections import defaultdict
from itertools import product as iproduct
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,
)
from full_resolution_check import (
phase1_resolve_isolated, interior_odd_faces,
)
def break_source_triangle(G, source_set):
"""If source is a face (3 vertices) and the source triangle is present
in G, flip one of its edges. Returns modified G."""
if len(source_set) != 3:
return G
a, b, c = tuple(source_set)
if not (G.has_edge(a, b) and G.has_edge(b, c) and G.has_edge(a, c)):
return G
ip, emb = nx.check_planarity(G)
if not ip:
return G
# Flip an edge of the source triangle.
for (u, v) in [(a, b), (b, c), (a, c)]:
f1 = emb.traverse_face(u, v)
f2 = emb.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 w == x or G.has_edge(w, x):
continue
Gp = G.copy()
Gp.remove_edge(u, v)
Gp.add_edge(w, x)
return Gp
return G
def phase2_outer_incident_choices(G, emb, levels, nodes_k, k,
include_even=False):
"""For each simple cycle of L_k incident to the outer face, list available
outer-face edge flips. Odd cycles: must flip (one choice required).
Even cycles (if include_even): optional flip (None choice added)."""
faces = faces_of_subgraph(G, emb, nodes_k)
if not faces:
return []
outer = identify_outer_face(faces, G, emb, levels, k)
if outer is None:
return []
outer_face = next((f for f in faces if canonical_face(f) == outer), None)
if outer_face is None:
return []
outer_edges = set(frozenset(list(e)) for e in edges_of_face(outer_face))
per_cycle = []
for face in faces:
cf = canonical_face(face)
if cf == outer:
continue
is_odd = (len(face) % 2 == 1)
if not is_odd and not include_even:
continue
face_edges = [frozenset([a, b]) for (a, b) in edges_of_face(face)]
if not any(e in outer_edges for e in face_edges):
continue
choices = []
for (u, v) in edges_of_face(face):
if frozenset([u, v]) not in outer_edges:
continue
if not G.has_edge(u, v):
continue
result = apex_levels_for_edge(G, emb, u, v, levels)
if result is None:
continue
_, (w, x) = result
multi = G.has_edge(w, x)
choices.append((multi, u, v, w, x))
if not choices:
continue
choices.sort(key=lambda c: c[0])
cycle_choices = [(c[1], c[2], c[3], c[4]) for c in choices]
if not is_odd:
# Even cycle: prepend None (skip)
cycle_choices = [None] + cycle_choices
per_cycle.append(cycle_choices)
return per_cycle
def apply_phase2_choices(G, choices):
"""Apply a list of (u, v, w, x) flips in sequence; None entries are skips."""
Gp = G.copy()
applied = set()
for choice in choices:
if choice is None:
continue
u, v, w, x = choice
if frozenset([u, v]) in applied:
continue
if Gp.has_edge(u, v):
Gp.remove_edge(u, v)
Gp.add_edge(w, x)
applied.add(frozenset([u, v]))
return Gp
def interior_faces_any_parity(faces, depths, outer_canon, outer_face_edges):
"""Interior faces (depth > 0, no outer-face edges) of any parity."""
out = []
for face in faces:
cf = canonical_face(face)
if cf == outer_canon:
continue
d = depths.get(cf)
if d is None or d <= 0:
continue
oe = sum(1 for (a, b) in edges_of_face(face)
if frozenset([a, b]) in outer_face_edges)
if oe > 0:
continue
out.append((cf, face, d, len(face) % 2 == 1))
return out
def phase1_one_step_choices(G, emb, levels, nodes_k, k, include_even=True):
"""Return all (u, v, w, x) flips Phase 1 might take in one step.
If include_even, even interior faces are also targets, but only when
no interior odd face is at maximum depth (we still resolve odd first)."""
faces = faces_of_subgraph(G, emb, nodes_k)
if len(faces) < 2:
return []
outer = identify_outer_face(faces, G, emb, levels, k)
if outer is None:
return []
outer_face = next((f for f in faces if canonical_face(f) == outer), None)
outer_face_edges = set()
if outer_face is not None:
for (a, b) in edges_of_face(outer_face):
outer_face_edges.add(frozenset([a, b]))
depths = face_depths(faces, outer)
if depths is None:
depths = {}
interior_all = interior_faces_any_parity(faces, depths, outer,
outer_face_edges)
interior_odd = [(cf, f, d) for (cf, f, d, is_odd) in interior_all if is_odd]
if not interior_odd:
return [] # Phase 1 stops when no interior odd face remains
max_d = max(d for _, _, d in interior_odd)
# Deepest odd faces are the primary candidates.
deepest = [(cf, f, d) for cf, f, d in interior_odd if d == max_d]
# Additionally include ALL interior even faces (any depth >= 1) as
# branching candidates — these are optional restructuring moves.
if include_even:
for (cf, f, d, is_odd) in interior_all:
if not is_odd:
deepest.append((cf, f, d))
# For each deepest face, find its own lowest-depth-neighbor flip(s).
all_flips = []
for (cf_t, face_t, _) in deepest:
face_canon = cf_t
face_edges_set = set(frozenset([a, b])
for (a, b) in edges_of_face(face_t))
per_face = []
for f in faces:
cf = canonical_face(f)
if cf == face_canon or cf == outer:
continue
shared = None
for (a, b) in edges_of_face(f):
if frozenset([a, b]) in face_edges_set:
shared = (a, b)
break
if shared is None:
continue
d = depths.get(cf)
if d is None:
continue
u, v = shared
if not G.has_edge(u, v):
continue
result = apex_levels_for_edge(G, emb, u, v, levels)
if result is None:
continue
_, (w, x) = result
if G.has_edge(w, x):
continue
per_face.append((d, u, v, w, x))
if not per_face:
continue
face_min_d = min(f[0] for f in per_face)
for (d, u, v, w, x) in per_face:
if d == face_min_d:
all_flips.append((u, v, w, x))
return all_flips
def phase1_branch(G, levels, by_level, branch_limit=3, depth_limit=20):
"""Branch on Phase 1 choices; yield all resulting graphs after Phase 1
completes (or hits depth_limit)."""
seen = set()
def rec(Gc, step):
sig = tuple(sorted(Gc.edges()))
if sig in seen:
return
seen.add(sig)
if step >= depth_limit:
yield Gc
return
ip, emb = nx.check_planarity(Gc)
if not ip:
return
# Find a level with phase 1 work to do
for k in sorted(by_level):
if k == 0:
continue
if len(by_level[k]) < 3:
continue
nodes_k = by_level[k]
flips = phase1_one_step_choices(Gc, emb, levels, nodes_k, k)
if flips:
for (u, v, w, x) in flips[:branch_limit]:
Gp = Gc.copy()
Gp.remove_edge(u, v)
Gp.add_edge(w, x)
yield from rec(Gp, step + 1)
return
# No work to do anywhere
yield Gc
yield from rec(G, 0)
def all_source_triangle_breaks(G, source_set):
"""Yield (1) G unchanged, and (2..k) G with each source-triangle edge
flipped (if applicable)."""
yield G.copy()
if len(source_set) != 3:
return
a, b, c = tuple(source_set)
if not (G.has_edge(a, b) and G.has_edge(b, c) and G.has_edge(a, c)):
return
ip, emb = nx.check_planarity(G)
if not ip:
return
for (u, v) in [(a, b), (b, c), (a, c)]:
f1 = emb.traverse_face(u, v)
f2 = emb.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 w == x or G.has_edge(w, x):
continue
Gp = G.copy()
Gp.remove_edge(u, v)
Gp.add_edge(w, x)
yield Gp
def simple_level_resolutions(G, source_set, p1_branch=4, p2_branch=5):
"""Enumerate Phase 1 + Phase 2 outputs with branching, trying all
source-triangle-break options (including skipping the break)."""
levels = compute_levels(G, source_set)
by_level = defaultdict(list)
for v, lv in levels.items():
by_level[lv].append(v)
seen_outputs = set()
for Gc in all_source_triangle_breaks(G, source_set):
# Branch on Phase 1
for G_p1 in phase1_branch(Gc, levels, by_level,
branch_limit=p1_branch):
per_level_choices = []
for k in sorted(by_level):
if k == 0:
continue
if len(by_level[k]) < 3:
continue
nodes_k = by_level[k]
ip, emb = nx.check_planarity(G_p1)
if not ip:
continue
pcs = phase2_outer_incident_choices(G_p1, emb, levels,
nodes_k, k,
include_even=True)
pcs = [c[:p2_branch] for c in pcs]
per_level_choices.extend(pcs)
if not per_level_choices:
sig = tuple(sorted(G_p1.edges()))
if sig not in seen_outputs:
seen_outputs.add(sig)
yield G_p1
continue
total = 1
for c in per_level_choices:
total *= len(c)
if total > 4096:
choices = [c[0] for c in per_level_choices]
Gp = apply_phase2_choices(G_p1, choices)
sig = tuple(sorted(Gp.edges()))
if sig not in seen_outputs:
seen_outputs.add(sig)
yield Gp
continue
for combo in iproduct(*per_level_choices):
Gp = apply_phase2_choices(G_p1, combo)
sig = tuple(sorted(Gp.edges()))
if sig not in seen_outputs:
seen_outputs.add(sig)
yield Gp
def simple_level_resolve(G, source_set):
"""Single-output version: yield the first triangulation output (if any),
else fall back to first output."""
first = None
for Gp in simple_level_resolutions(G, source_set):
if first is None:
first = Gp
if is_triangulation(Gp, G.number_of_nodes()):
return Gp, 'ok'
if first is None:
return G.copy(), 'no_output'
return first, 'ok'
def is_triangulation(G, n):
if not isinstance(G, nx.Graph):
return False
if G.number_of_nodes() != n:
return False
if G.number_of_edges() != 3 * n - 6:
return False
ip, _ = nx.check_planarity(G)
return ip
def iso_index(G, isos):
for i, T in enumerate(isos):
if nx.is_isomorphic(G, T):
return i
return None
def is_self_level_resolution(T):
"""Does T admit some level source S such that T is a level resolution
of (T, S)? I.e., the parity subgraphs of T (under T's own BFS from S)
are both bipartite."""
ip, emb = nx.check_planarity(T)
if not ip:
return False
for kind, label, source_set in level_sources(T, emb):
levels = compute_levels(T, source_set)
even = [v for v in T.nodes() if levels.get(v, 0) % 2 == 0]
odd = [v for v in T.nodes() if levels.get(v, 0) % 2 == 1]
if nx.is_bipartite(T.subgraph(even)) and \
nx.is_bipartite(T.subgraph(odd)):
return True
return False
def run(n):
isos = enumerate_all_triangulations(n)
print(f"n={n}: {len(isos)} iso-classes")
md4 = set()
for i, T in enumerate(isos):
if min(T.degree(v) for v in T.nodes()) >= 4:
md4.add(i)
print(f" min-degree-4 iso-classes: {len(md4)}")
self_resolutions = set()
for i, T in enumerate(isos):
if is_self_level_resolution(T):
self_resolutions.add(i)
print(f" self-level-resolutions: {len(self_resolutions)} iso-classes")
reached_any = set() # iso-class is the output (any triangulation)
reached_bip = set() # iso-class is an output with bipartite parity
total_pairs = 0
triangulation_outputs = 0
bipartite_outputs = 0
for src_idx, G in enumerate(isos):
ip, emb = nx.check_planarity(G)
if not ip:
continue
for kind, label, source_set in level_sources(G, emb):
total_pairs += 1
for Gp in simple_level_resolutions(G, source_set):
if not is_triangulation(Gp, n):
continue
triangulation_outputs += 1
levels = compute_levels(G, source_set)
even_nodes = [v for v in Gp.nodes()
if levels.get(v, 0) % 2 == 0]
odd_nodes = [v for v in Gp.nodes()
if levels.get(v, 0) % 2 == 1]
is_bip = (nx.is_bipartite(Gp.subgraph(even_nodes)) and
nx.is_bipartite(Gp.subgraph(odd_nodes)))
if is_bip:
bipartite_outputs += 1
tgt = iso_index(Gp, isos)
if tgt is not None:
reached_any.add(tgt)
if is_bip:
reached_bip.add(tgt)
# Refined conjecture: every min-degree-4 iso-class is reached.
needed = md4 - self_resolutions
missing_any = sorted(needed - reached_any)
missing_bip = sorted(needed - reached_bip)
print(f" (G, S) pairs: {total_pairs}")
print(f" triangulation outputs: {triangulation_outputs}")
print(f" bipartite-parity: {bipartite_outputs}")
print(f" needed (md4, not self): {len(needed)} iso-classes")
print(f" reached as any output: {len(reached_any & needed)} / {len(needed)}"
f" (missing: {missing_any[:10] if missing_any else 'none'}"
f"{'...' if len(missing_any) > 10 else ''})")
print(f" reached as bip-parity: {len(reached_bip & needed)} / {len(needed)}"
f" (missing: {missing_bip[:10] if missing_bip else 'none'}"
f"{'...' if len(missing_bip) > 10 else ''})")
if __name__ == "__main__":
if len(sys.argv) > 1:
ns = [int(x) for x in sys.argv[1:]]
else:
ns = [6, 7, 8]
for n in ns:
run(n)
@@ -0,0 +1,134 @@
"""Visualize the Phase 2 failure case: n=10, tri[13], face source (1,2,4),
L_1. Highlights the interior odd triangle (0,5,6) that the algorithm misses,
and labels each edge by its (apex, apex) level pair."""
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
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,
)
from viz_cycling import layout_outerplanar
def main():
tris = enumerate_all_triangulations(10)
G = tris[13]
ip, emb = nx.check_planarity(G)
source = {1, 2, 4}
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
faces = faces_of_subgraph(G, emb, nodes_k)
outer = identify_outer_face(faces, G, emb, levels, k)
outer_face = next(f for f in faces if canonical_face(f) == outer)
outer_edges = set(frozenset(list(e)) for e in edges_of_face(outer_face))
pos = layout_outerplanar(nodes_k, outer_face, G)
fig, ax = plt.subplots(1, 1, figsize=(10, 10))
# Shade odd faces
for f in faces:
cf = canonical_face(f)
if cf == outer:
continue
if len(f) % 2 != 1:
continue
incident = any(frozenset([a, b]) in outer_edges
for (a, b) in edges_of_face(f))
if incident:
color = '#ffd45a' # outer-incident odd (Phase 2 targets)
label = "Phase 2 flips here"
alpha = 0.65
else:
color = '#ff5555' # interior odd (algorithm misses)
label = "MISSED"
alpha = 0.7
poly = plt.Polygon([pos[v] for v in f], color=color, alpha=alpha,
zorder=1)
ax.add_patch(poly)
cx, cy = np.mean([pos[v] for v in f], axis=0)
ax.text(cx, cy, f"{tuple(f)}\nlen={len(f)} (odd)\n{label}",
fontsize=9, ha='center', va='center', zorder=3,
fontweight='bold')
# Edges with apex-pair labels
Lk = G.subgraph(nodes_k)
for u, v in Lk.edges():
ax.plot([pos[u][0], pos[v][0]], [pos[u][1], pos[v][1]],
color='black', linewidth=1.6, zorder=2)
result = apex_levels_for_edge(G, emb, u, v, levels)
if result is None:
continue
(la, lb), (w, x) = result
mid = (pos[u] + pos[v]) / 2
# Apex pair relative to k
pair = tuple(sorted([la - k, lb - k]))
if pair == (0, 0):
apex_color = '#cc0000' # intra-level (k,k)
apex_label = '(k,k)'
elif pair == (-1, 0) or pair == (-1, 1):
apex_color = '#006400' # cross-parity / free
apex_label = f'(k{la-k:+d},k{lb-k:+d})'
else:
apex_color = '#0044aa'
apex_label = f'(k{la-k:+d},k{lb-k:+d})'
ax.text(mid[0], mid[1], apex_label, fontsize=7,
ha='center', va='center', color=apex_color,
bbox=dict(facecolor='white', edgecolor='none', alpha=0.85,
pad=1.0), zorder=4)
for v in nodes_k:
ax.plot(pos[v][0], pos[v][1], 'o', color='black', markersize=14,
zorder=4)
ax.text(pos[v][0] * 1.13, pos[v][1] * 1.13, str(v), fontsize=16,
ha='center', va='center', color='blue', zorder=5,
fontweight='bold')
# Highlight the cross-level edge of (0,5,6) that would resolve it.
# Find the (k,k+1) or (k,k-1) or (k-1,k+1) edge of triangle (0,5,6).
interior_face = (0, 5, 6)
for (u, v) in edges_of_face(interior_face):
if not Lk.has_edge(u, v):
continue
result = apex_levels_for_edge(G, emb, u, v, levels)
if result is None:
continue
(la, lb), _ = result
pair = tuple(sorted([la - k, lb - k]))
if pair != (0, 0):
ax.plot([pos[u][0], pos[v][0]], [pos[u][1], pos[v][1]],
color='green', linewidth=4.0, zorder=2.5, linestyle=':')
break
ax.set_aspect('equal')
ax.axis('off')
ax.set_xlim(-1.3, 1.3)
ax.set_ylim(-1.4, 1.3)
title = (
"Phase 2 gap: n=10, tri[13], source face (1,2,4), $L_1$\n"
"Yellow = outer-incident odd faces (Phase 2 flips one edge each).\n"
"Red = interior odd face (0,5,6) — Phase 2 misses, Phase 1 ignores "
"(not tricky-everywhere — its (0,6) edge has apex (k,k+1)).\n"
"Green dotted: the cross-level edge (0,6) whose flip would resolve "
"(0,5,6) but is unreached by either phase."
)
fig.suptitle(title, fontsize=12)
plt.tight_layout(rect=[0, 0, 1, 0.90])
out = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'..', 'phase2_gap_visualization.png')
plt.savefig(out, dpi=130, bbox_inches='tight')
print(f"saved: {out}")
if __name__ == "__main__":
main()
@@ -43,15 +43,19 @@
\@writefile{toc}{\contentsline {subsection}{\tocsubsection {}{6.1}{Apex classification of $L_k$-edges}}{5}{subsection.6.1}\protected@file@percent }
\newlabel{lem:bridge-apex}{{6.1}{5}{}{theorem.6.1}{}}
\newlabel{prop:flip-target}{{6.2}{5}{}{theorem.6.2}{}}
\@writefile{toc}{\contentsline {subsection}{\tocsubsection {}{6.2}{Cross-level flip pass}}{5}{subsection.6.2}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\tocsubsection {}{6.3}{Tricky-everywhere cycles}}{5}{subsection.6.3}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\tocsubsection {}{6.2}{Facial depth and isolated faces}}{5}{subsection.6.2}\protected@file@percent }
\newlabel{def:facial-depth}{{6.3}{5}{Facial depth}{theorem.6.3}{}}
\@writefile{toc}{\contentsline {subsection}{\tocsubsection {}{6.4}{The algorithm}}{6}{subsection.6.4}\protected@file@percent }
\newlabel{obs:terminate}{{6.4}{6}{}{theorem.6.4}{}}
\newlabel{q:terminate-all-n}{{6.5}{6}{}{theorem.6.5}{}}
\@writefile{toc}{\contentsline {section}{\tocsection {}{7}{Discussion and open questions}}{6}{section.7}\protected@file@percent }
\@writefile{toc}{\contentsline {section}{\tocsection {}{8}{Implementation}}{6}{section.8}\protected@file@percent }
\newlabel{sec:impl}{{8}{6}{Implementation}{section.8}{}}
\@writefile{toc}{\contentsline {subsection}{\tocsubsection {}{6.3}{Phase 1: interior faces}}{5}{subsection.6.3}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\tocsubsection {}{6.4}{Phase 2: outer-incident faces}}{6}{subsection.6.4}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\tocsubsection {}{6.5}{Simple level resolutions}}{6}{subsection.6.5}\protected@file@percent }
\newlabel{def:simple-level-resolution}{{6.4}{6}{Simple level resolution}{theorem.6.4}{}}
\@writefile{toc}{\contentsline {subsection}{\tocsubsection {}{6.6}{Empirical status}}{6}{subsection.6.6}\protected@file@percent }
\newlabel{obs:empirical-lk-bipartite}{{6.5}{6}{}{theorem.6.5}{}}
\@writefile{toc}{\contentsline {paragraph}{\tocparagraph {}{}{Coverage test for Conjecture\nonbreakingspace \ref {conj:simple-md4}.}}{6}{section*.2}\protected@file@percent }
\newlabel{obs:md4-simple-resolution}{{6.6}{7}{}{theorem.6.6}{}}
\newlabel{conj:simple-md4}{{6.7}{7}{Simple-resolution $\mathrm {md}_4$ surjectivity}{theorem.6.7}{}}
\newlabel{q:terminate-all-n}{{6.8}{7}{}{theorem.6.8}{}}
\@writefile{toc}{\contentsline {section}{\tocsection {}{7}{Discussion and open questions}}{7}{section.7}\protected@file@percent }
\bibcite{appelhaken}{1}
\bibcite{rsst}{2}
\bibcite{tutte}{3}
@@ -61,5 +65,7 @@
\newlabel{tocindent1}{17.77782pt}
\newlabel{tocindent2}{29.38873pt}
\newlabel{tocindent3}{0pt}
\@writefile{toc}{\contentsline {section}{\tocsection {}{}{References}}{7}{section*.2}\protected@file@percent }
\gdef \@abspage@last{7}
\@writefile{toc}{\contentsline {section}{\tocsection {}{8}{Implementation}}{8}{section.8}\protected@file@percent }
\newlabel{sec:impl}{{8}{8}{Implementation}{section.8}{}}
\@writefile{toc}{\contentsline {section}{\tocsection {}{}{References}}{8}{section*.3}\protected@file@percent }
\gdef \@abspage@last{8}
@@ -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 01:17
This is pdfTeX, Version 3.141592653-2.6-1.40.24 (TeX Live 2022) (preloaded format=pdflatex 2022.10.5) 20 MAY 2026 13:40
entering extended mode
restricted \write18 enabled.
%&-line parsing enabled.
@@ -363,18 +363,27 @@ Package hyperref Warning: Token not allowed in a PDF string (Unicode):
Package hyperref Warning: Token not allowed in a PDF string (Unicode):
(hyperref) removing `math shift' on input line 370.
[5] [6] [7] (./paper.aux)
Package rerunfilecheck Info: File `paper.out' has not changed.
(rerunfilecheck) Checksum: C4EC0643C07D7F8B157295F889BACC80;3432.
[5] [6] [7] [8] (./paper.aux)
LaTeX Warning: Label(s) may have changed. Rerun to get cross-references right.
Package rerunfilecheck Warning: File `paper.out' has changed.
(rerunfilecheck) Rerun to get outlines right
(rerunfilecheck) or use package `bookmark'.
Package rerunfilecheck Info: Checksums for `paper.out':
(rerunfilecheck) Before: 00D0EBD21A3E3804EF6FF0D2A45466A8;3956
(rerunfilecheck) After: C580028718693FDA81B49B22CE461AD9;3956.
)
Here is how much of TeX's memory you used:
8935 strings out of 478268
137960 string characters out of 5846347
440651 words of memory out of 5000000
26851 multiletter control sequences out of 15000+600000
8945 strings out of 478268
138142 string characters out of 5846347
440795 words of memory out of 5000000
26856 multiletter control sequences out of 15000+600000
475834 words of font info for 54 fonts, out of 8000000 for 9000
1302 hyphenation exceptions out of 8191
69i,9n,76p,396b,463s stack positions out of 10000i,1000n,20000p,200000b,200000s
69i,9n,76p,396b,466s stack positions out of 10000i,1000n,20000p,200000b,200000s
</usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/cm/cmbx10.pfb
></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/cm/cmcsc10.pfb
></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/cm/cmmi10.pfb>
@@ -393,10 +402,10 @@ ve/2022/texmf-dist/fonts/type1/public/amsfonts/cm/cmsy6.pfb></usr/local/texlive
/texmf-dist/fonts/type1/public/amsfonts/cm/cmtt10.pfb></usr/local/texlive/2022/
texmf-dist/fonts/type1/public/amsfonts/symbols/msam10.pfb></usr/local/texlive/2
022/texmf-dist/fonts/type1/public/amsfonts/symbols/msbm10.pfb>
Output written on paper.pdf (7 pages, 254719 bytes).
Output written on paper.pdf (8 pages, 258447 bytes).
PDF statistics:
264 PDF objects out of 1000 (max. 8388607)
216 compressed objects within 3 object streams
55 named destinations out of 1000 (max. 500000)
129 words of extra memory for PDF output out of 10000 (max. 10000000)
281 PDF objects out of 1000 (max. 8388607)
232 compressed objects within 3 object streams
60 named destinations out of 1000 (max. 500000)
145 words of extra memory for PDF output out of 10000 (max. 10000000)
@@ -8,9 +8,11 @@
\BOOKMARK [2][-]{subsection.5.3}{\376\377\0005\000.\0003\000.\000\040\000R\000e\000s\000t\000a\000t\000e\000m\000e\000n\000t\000\040\000o\000f\000\040\000t\000h\000e\000\040\000r\000e\000s\000o\000l\000u\000t\000i\000o\000n\000-\000p\000r\000e\000i\000m\000a\000g\000e\000\040\000c\000o\000n\000j\000e\000c\000t\000u\000r\000e}{section.5}% 8
\BOOKMARK [1][-]{section.6}{\376\377\0006\000.\000\040\000A\000n\000\040\000e\000d\000g\000e\000-\000f\000l\000i\000p\000\040\000r\000e\000s\000o\000l\000u\000t\000i\000o\000n\000\040\000a\000l\000g\000o\000r\000i\000t\000h\000m}{}% 9
\BOOKMARK [2][-]{subsection.6.1}{\376\377\0006\000.\0001\000.\000\040\000A\000p\000e\000x\000\040\000c\000l\000a\000s\000s\000i\000f\000i\000c\000a\000t\000i\000o\000n\000\040\000o\000f\000\040\000L\000k\000-\000e\000d\000g\000e\000s}{section.6}% 10
\BOOKMARK [2][-]{subsection.6.2}{\376\377\0006\000.\0002\000.\000\040\000C\000r\000o\000s\000s\000-\000l\000e\000v\000e\000l\000\040\000f\000l\000i\000p\000\040\000p\000a\000s\000s}{section.6}% 11
\BOOKMARK [2][-]{subsection.6.3}{\376\377\0006\000.\0003\000.\000\040\000T\000r\000i\000c\000k\000y\000-\000e\000v\000e\000r\000y\000w\000h\000e\000r\000e\000\040\000c\000y\000c\000l\000e\000s}{section.6}% 12
\BOOKMARK [2][-]{subsection.6.4}{\376\377\0006\000.\0004\000.\000\040\000T\000h\000e\000\040\000a\000l\000g\000o\000r\000i\000t\000h\000m}{section.6}% 13
\BOOKMARK [1][-]{section.7}{\376\377\0007\000.\000\040\000D\000i\000s\000c\000u\000s\000s\000i\000o\000n\000\040\000a\000n\000d\000\040\000o\000p\000e\000n\000\040\000q\000u\000e\000s\000t\000i\000o\000n\000s}{}% 14
\BOOKMARK [1][-]{section.8}{\376\377\0008\000.\000\040\000I\000m\000p\000l\000e\000m\000e\000n\000t\000a\000t\000i\000o\000n}{}% 15
\BOOKMARK [1][-]{section*.2}{\376\377\000R\000e\000f\000e\000r\000e\000n\000c\000e\000s}{}% 16
\BOOKMARK [2][-]{subsection.6.2}{\376\377\0006\000.\0002\000.\000\040\000F\000a\000c\000i\000a\000l\000\040\000d\000e\000p\000t\000h\000\040\000a\000n\000d\000\040\000i\000s\000o\000l\000a\000t\000e\000d\000\040\000f\000a\000c\000e\000s}{section.6}% 11
\BOOKMARK [2][-]{subsection.6.3}{\376\377\0006\000.\0003\000.\000\040\000P\000h\000a\000s\000e\000\040\0001\000:\000\040\000i\000n\000t\000e\000r\000i\000o\000r\000\040\000f\000a\000c\000e\000s}{section.6}% 12
\BOOKMARK [2][-]{subsection.6.4}{\376\377\0006\000.\0004\000.\000\040\000P\000h\000a\000s\000e\000\040\0002\000:\000\040\000o\000u\000t\000e\000r\000-\000i\000n\000c\000i\000d\000e\000n\000t\000\040\000f\000a\000c\000e\000s}{section.6}% 13
\BOOKMARK [2][-]{subsection.6.5}{\376\377\0006\000.\0005\000.\000\040\000S\000i\000m\000p\000l\000e\000\040\000l\000e\000v\000e\000l\000\040\000r\000e\000s\000o\000l\000u\000t\000i\000o\000n\000s}{section.6}% 14
\BOOKMARK [2][-]{subsection.6.6}{\376\377\0006\000.\0006\000.\000\040\000E\000m\000p\000i\000r\000i\000c\000a\000l\000\040\000s\000t\000a\000t\000u\000s}{section.6}% 15
\BOOKMARK [1][-]{section.7}{\376\377\0007\000.\000\040\000D\000i\000s\000c\000u\000s\000s\000i\000o\000n\000\040\000a\000n\000d\000\040\000o\000p\000e\000n\000\040\000q\000u\000e\000s\000t\000i\000o\000n\000s}{}% 16
\BOOKMARK [1][-]{section.8}{\376\377\0008\000.\000\040\000I\000m\000p\000l\000e\000m\000e\000n\000t\000a\000t\000i\000o\000n}{}% 17
\BOOKMARK [1][-]{section*.3}{\376\377\000R\000e\000f\000e\000r\000e\000n\000c\000e\000s}{}% 18
@@ -399,25 +399,7 @@ $L_{k+1}$ iff $\ell_G(w) = \ell_G(x) = k + 1$; otherwise $wx$ is cross-parity
and lies in no level subgraph. In all cases $uv$ is removed from $L_k$.
\end{proposition}
\subsection{Cross-level flip pass}
For each odd simple cycle $C$ of each $L_k$ containing a cross-level edge,
flip one such edge. By Proposition~\ref{prop:flip-target} the new edge
either enters $L_{k+1}$ (in the apex case $(k+1, k+1)$) or is cross-parity
(otherwise). Choosing apex pairs distinctly across cycles makes the set of
new edges entering any single level $L_j$ a matching, hence a forest, and
similarly for new same-parity-distance-$2$ edges entering the relevant
parity subgraph; these therefore introduce no odd cycle.
\subsection{Tricky-everywhere cycles}
After the cross-level pass, the only odd simple cycles remaining in any
$L_k$ are those whose every edge is intra-level; we call such a cycle
\emph{tricky-everywhere}. By Proposition~\ref{prop:flip-target}, flipping
any edge of a tricky-everywhere cycle replaces it with another edge of $L_k$,
so the local triangle pair $(uvw, uvx)$ becomes $(uwx, vwx)$: still a pair
of odd triangles inside $L_k$. To make global progress on these cycles we
use a facial-depth potential to choose the flip.
\subsection{Facial depth and isolated faces}
\begin{definition}[Facial depth]
\label{def:facial-depth}
@@ -430,41 +412,138 @@ $L_k$ is
\mathrm{depth}(F) \;=\; \min_{F' \in \mathcal{B}} \mathrm{dist}_D(F, F'),
\]
with the convention $\mathrm{depth}(F) = \infty$ if no such $F'$ exists.
An inner face is \emph{isolated} if $\mathrm{depth}(F) \geq 1$.
\end{definition}
\subsection{The algorithm}
The seed set $\mathcal{B}$ consists of the inner faces that already have
two outer-face edges available as flip targets; Phase~2 below handles
these directly. Phase~1 uses facial depth as a potential to push isolated
odd faces toward $\mathcal{B}$.
\begin{enumerate}
\item \emph{Cross-level flip pass.} For each $L_k$ and each odd simple cycle
$C \subseteq L_k$ containing a cross-level edge, flip one such edge,
selecting apex pairs to keep the newly added edges a matching in each
target level subgraph and in the relevant parity subgraph.
\item \emph{Intra-level flip loop.} While some $L_k$ contains a
tricky-everywhere odd simple cycle:
\begin{enumerate}
A cycle that is \emph{tricky-everywhere}, meaning every edge is
intra-level, is necessarily isolated: an outer-face edge of $L_k$ has a
level-$(k-1)$ apex on its outer side and is therefore cross-level, so a
tricky-everywhere cycle shares no edge with the outer face. Hence the
tricky-everywhere cycles are a subset of the isolated odd cycles.
\subsection{Phase 1: interior faces}
\noindent\textbf{Procedure.} While some $L_k$ contains an odd simple cycle
whose corresponding inner face has facial depth $\geq 1$ and shares no
edge with the outer face, repeat:
\begin{enumerate}[label=(\arabic*)]
\item compute facial depths for all simple level cycles of $L_k$;
\item among tricky-everywhere odd simple cycles of maximum facial depth,
pick any $C$;
\item among the edges of $C$, pick one whose other incident inner face has
minimum facial depth, and flip it.
\end{enumerate}
\item among interior odd faces (depth $\geq 1$, no outer-face edges) of
maximum facial depth, pick one $C$; even-parity interior faces of
depth $\geq 1$ may also be selected as $C$;
\item find the inner face $F'$ incident to $C$ of minimum facial depth,
and flip the edge shared between $C$ and $F'$.
\end{enumerate}
The restriction to faces with no outer-face edge in step~(2) means that
every edge of $C$ borders another inner face, so a unique shared-edge
flip target exists for each neighbor $F'$. The depth-guided choice of
$F'$ in step~(3) progressively pushes the residual odd-face structure
toward the seed set $\mathcal{B}$ (depth~$0$). Even-face flips are
optional restructuring moves that expand the reachable configuration
space; the loop's termination is gated only by interior odd faces.
\subsection{Phase 2: outer-incident faces}
After Phase~1, every remaining odd simple cycle of $L_k$ shares at least
one edge with the outer face, whose apex pair includes a level-$(k-1)$
vertex and is therefore cross-level.
\noindent\textbf{Procedure.}
For each $L_k$:
\begin{itemize}
\item every odd simple cycle $C \subseteq L_k$ incident to the outer
face \emph{must} have exactly one of its outer-face edges flipped;
\item every even simple cycle of $L_k$ incident to the outer face
\emph{may} have at most one of its outer-face edges flipped (an optional
restructuring move).
\end{itemize}
For the source-face level ($k = 0$ with face source $S$), the $L_0$
source triangle is itself an odd cycle whose three edges all bound the
outer face; we treat $L_0$ uniformly with higher levels, with the option
of leaving the triangle intact when the resulting parity-subgraph
configuration on $G'$ permits.
Each flip is permitted even if the apex edge $wx$ already exists in $G$,
in which case $G'$ is a multigraph; this does not affect bipartiteness
of the parity subgraphs of $G'$, since a duplicated edge is bipartite-
equivalent to a single edge.
\subsection{Simple level resolutions}
\begin{definition}[Simple level resolution]
\label{def:simple-level-resolution}
A plane triangulation $G'$ is a \emph{simple level resolution} of a plane
triangulation $G$ if there exists a level source $S$ of $G$ such that
the algorithm of Sections~\ref{sec:flip-algorithm}--Phase~1 and~Phase~2
applied to $(G, S)$, under some sequence of optional-move choices,
produces $G'$ as a simple-graph triangulation whose parity subgraphs are
bipartite.
\end{definition}
\subsection{Empirical status}
\begin{observation}
\label{obs:terminate}
\label{obs:empirical-lk-bipartite}
For every plane triangulation $G$ on $n \in \{9, 10, 11\}$ vertices, every
level source $S$, and every $k$ such that $L_k$ contains a tricky-everywhere
odd simple cycle, Step~2 terminates with no tricky-everywhere odd simple
cycle remaining in any $L_k$. Moreover, the total number of
tricky-everywhere odd simple cycles strictly decreases on every flip chosen
by Step~2(c).
level source $S$, and every $k$ such that $L_k$ contains an odd simple
cycle, the algorithm produces a $G'$ whose corresponding $L_k$ is
bipartite (in the underlying simple-graph view). Across the $29640$ such
$(G, S, k)$ triples — $4645$ at $n \leq 10$ and $24995$ at $n = 11$
Phase~1 always terminates and Phase~2 always succeeds.
\end{observation}
\paragraph{Coverage test for Conjecture~\ref{conj:simple-md4}.} For each
$n \in \{6, \ldots, 11\}$ we enumerate all plane-triangulation iso-classes
on $n$ vertices. For each iso-class $G$, each level source $S$ of $G$,
and each branching choice within the algorithm — Phase~1 ties on which
deepest interior face and which lowest-depth neighbor to flip, Phase~2
choices of which outer-face edge to flip for each odd or even
outer-incident cycle (including the option to leave even cycles
untouched), and the option to skip the source-triangle break when $S$ is
a face — we run Phase~1 to termination and then Phase~2, recording the
algorithm's output $G'$ as a labelled simple graph. We check three
properties: (a) $G'$ is a triangulation (no multi-edge survived the
Phase~2 flips), (b) the parity subgraphs $E_{G, S}(G')$ and
$O_{G, S}(G')$ are both bipartite, and (c) the iso-class of $G'$.
Aggregating over all $(G, S, \text{branch-choices})$ triples yields the
set of iso-classes attainable as algorithm outputs satisfying (a)+(b);
we compare this set against the minimum-degree-$4$ iso-classes at each
$n$.
\begin{observation}
\label{obs:md4-simple-resolution}
For every $n \in \{6, 7, 8, 9, 10, 11\}$, every plane triangulation
iso-class on $n$ vertices with minimum degree at least $4$ is a simple
level resolution of some plane triangulation on $n$ vertices. Concretely,
the counts of minimum-degree-$4$ iso-classes — $1, 1, 2, 5, 12, 34$ at
$n = 6, \ldots, 11$ — are all reached by the algorithm with bipartite
parity subgraphs (Definition~\ref{def:simple-level-resolution}).
\end{observation}
\begin{conjecture}[Simple-resolution $\mathrm{md}_4$ surjectivity]
\label{conj:simple-md4}
For every $n \geq 6$, every minimum-degree-$4$ plane triangulation on $n$
vertices is a simple level resolution of some plane triangulation on $n$
vertices.
\end{conjecture}
The minimum-degree-$4$ restriction in Conjecture~\ref{conj:simple-md4} is
necessary: at $n = 8$, the unique iso-class with three degree-$3$
vertices is not reachable by the algorithm; at $n = 10$, two further
iso-classes with four degree-$3$ vertices and high-degree hubs fail to
appear among algorithm outputs.
\begin{question}
\label{q:terminate-all-n}
Does Observation~\ref{obs:terminate} hold for all $n$? Equivalently, does
the count of tricky-everywhere odd simple cycles strictly decrease on every
Step~2(c) flip, in every plane triangulation?
Does Phase~1 terminate for all $(G, S)$? Equivalently, is there an
explicit monovariant on $L_k$'s face structure that strictly decreases
on every Phase~1 flip?
\end{question}
\section{Discussion and open questions}
@@ -493,11 +572,22 @@ The computational results suggest the following:
\end{enumerate}
The algorithm of Section~\ref{sec:flip-algorithm} is the candidate
constructive answer: cross-level flips dispose of every odd cycle of $L_k$
that admits one, and the facial-depth-guided intra-level flip loop attacks
the residual tricky-everywhere cycles. Observation~\ref{obs:terminate}
records that the loop terminates on all tested $(G, S, k)$ at $n \leq 11$;
Question~\ref{q:terminate-all-n} asks whether termination holds for all $n$.
constructive answer. Phase~1 iteratively flips the shared edge between
the deepest interior odd face and its lowest-depth neighbor, pushing the
residual odd-face structure toward the seed set $\mathcal{B}$ at
depth~$0$, with optional even-face restructuring moves along the way;
Phase~2 disposes of the remaining outer-incident odd cycles by flipping
an outer-face edge each (and optionally an even outer-incident face),
accepting a multigraph if the apex edge already exists.
Observation~\ref{obs:empirical-lk-bipartite} records that the algorithm
terminates and succeeds at the level-bipartiteness layer on all $29640$
tested $(G, S, k)$ triples at $n \in \{9, 10, 11\}$.
Observation~\ref{obs:md4-simple-resolution} records that every
minimum-degree-$4$ iso-class on $n \leq 11$ vertices is reached as a
simple level resolution; Conjecture~\ref{conj:simple-md4} extends this
to all $n$.
Question~\ref{q:terminate-all-n} asks whether Phase~1 terminates in
general.
\section{Implementation}
\label{sec:impl}
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB