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()