Add Level Switching paper with surface-switch framework

Defines level cycles, edge switches, surface switches, and facial depth
on level components of plane triangulations. Proves outerplanarity of
level components and a depth-descent lemma. Introduces balanced surface
switches and proves they remove a depth-d level cycle while creating
1-2 new depth-(d-1) cycles. Documents the 9-vertex counterexample where
no balanced switch exists and sketches preprocessing toward
balancedness, leaving general termination open.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 23:08:22 -04:00
parent e8b2e47e44
commit 7183dc1b67
18 changed files with 1744 additions and 0 deletions
@@ -0,0 +1,98 @@
"""9-vertex L_k where the unique depth-1 face has NO balanced surface switch.
Outer cycle: 0..8. Triangulated with chords 0-2, 0-3, 3-5, 3-6, 0-6, 6-8.
Central triangle F = (0,3,6) has depth 1; its three neighbours
(0,2,3), (3,5,6), (6,8,0) are all depth 0 but each has only ONE
outer-cycle edge (not two), so none is an "ear" of F.
For d = 1, balancedness requires F' to be an ear of uv (both non-uv
edges on the outer cycle). No neighbour of F qualifies.
"""
import os
import math
import networkx as nx
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)
n = 9
POS = {i: (math.cos(math.radians(90 - i * 360 / n)),
math.sin(math.radians(90 - i * 360 / n))) for i in range(n)}
OUTER_EDGES = [(i, (i + 1) % n) for i in range(n)]
CHORDS = [(0, 2), (0, 3), (3, 5), (3, 6), (0, 6), (6, 8)]
FACES = [
(0, 1, 2), # ear
(0, 2, 3), # 1 outer edge, depth 0
(3, 4, 5), # ear
(3, 5, 6), # 1 outer edge, depth 0
(6, 7, 8), # ear
(6, 8, 0), # 1 outer edge, depth 0
(0, 3, 6), # central, depth 1 -- the troublemaker
]
def face_edges(f):
return {frozenset((f[0], f[1])), frozenset((f[1], f[2])),
frozenset((f[0], f[2]))}
outer_set = {frozenset(e) for e in OUTER_EDGES}
D = nx.Graph()
D.add_nodes_from(range(len(FACES)))
for i, fi in enumerate(FACES):
for j, fj in enumerate(FACES):
if i < j and face_edges(fi) & face_edges(fj):
D.add_edge(i, j)
B = [i for i, f in enumerate(FACES)
if len(face_edges(f) & outer_set) >= 1]
depth = {i: min(nx.shortest_path_length(D, i, b) for b in B)
for i in range(len(FACES))}
palette = {0: '#86efac', 1: '#fde68a', 2: '#fca5a5'}
edge_pal = {0: '#16a34a', 1: '#d97706', 2: '#dc2626'}
fig, ax = plt.subplots(figsize=(7, 7))
for i, f in enumerate(FACES):
d = depth[i]
poly = Polygon([POS[v] for v in f], closed=True,
facecolor=palette[d], edgecolor=edge_pal[d],
linewidth=1.6, alpha=0.7, zorder=0)
ax.add_patch(poly)
cx = sum(POS[v][0] for v in f) / 3
cy = sum(POS[v][1] for v in f) / 3
ax.text(cx, cy, rf'$\mathrm{{depth}}={d}$',
ha='center', va='center', fontsize=10,
color=edge_pal[d], fontweight='bold')
# Mark the three "bad" chord edges (would-be-switched edges of F that
# fail balancedness because the chord side has no outer-cycle edge to
# pair with).
F_edges = [(0, 3), (3, 6), (0, 6)]
for (a, b) in OUTER_EDGES + CHORDS:
color = '#333'; lw = 1.2
if (a, b) in F_edges or (b, a) in F_edges:
color = '#dc2626'; lw = 3.0
ax.plot([POS[a][0], POS[b][0]], [POS[a][1], POS[b][1]],
color=color, linewidth=lw, zorder=1)
for i, (x, y) in POS.items():
ax.scatter([x], [y], s=300, c='#1f2937', edgecolors='black',
linewidths=1.0, zorder=2)
ax.text(x, y, str(i), ha='center', va='center',
fontsize=10, color='white', fontweight='bold', zorder=3)
ax.set_aspect('equal'); ax.axis('off')
ax.set_xlim(-1.3, 1.3); ax.set_ylim(-1.3, 1.3)
ax.set_title('Depth-1 face with no balanced surface switch',
fontsize=12)
fig.tight_layout()
out = os.path.join(OUT_DIR, 'fig_no_balanced_switch.png')
fig.savefig(out, dpi=180, bbox_inches='tight')
plt.close(fig)
print(f'wrote {out}')
for i, f in enumerate(FACES):
print(f' {f} -> depth {depth[i]}')
@@ -0,0 +1,179 @@
"""Counterexample showing that a surface switch on the edge between a
depth-d face F and a depth-(d-1) face F' can create a new face of depth
d (not d-1) when the depth-0 neighbor of F' lies on only one side of
the shared edge.
14-vertex maximal outerplanar L_k. Outer cycle order:
u, p1, p2, p3, x, q1, v, b1, b2, b3, w, a1, a2, a3 -> u
Central triangle F = (u, v, w) has depth 2.
F' = (u, v, x) has depth 1 (its depth-0 neighbor is the q1-ear on the
v-side of x; its u-side neighbor A_ux is depth 1).
A_uw = (u, a2, w), A_vw = (v, b2, w) are both depth 1.
Surface switch on uv: flip uv -> wx. New faces are
A = (u, w, x) and B = (v, w, x).
B inherits the depth-0 q1-ear, so depth(B) = 1.
A's neighbors are A_uw (1), A_ux (1), B (1), so depth(A) = 2 = d. BAD.
"""
import os
import math
import networkx as nx
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)
OUTER = ['u', 'p1', 'p2', 'p3', 'x', 'q1', 'v',
'b1', 'b2', 'b3', 'w', 'a1', 'a2', 'a3']
n = len(OUTER)
POS = {}
for i, name in enumerate(OUTER):
a = math.radians(90 - i * (360 / n))
POS[name] = (math.cos(a), math.sin(a))
OUTER_EDGES = [(OUTER[i], OUTER[(i + 1) % n]) for i in range(n)]
CHORDS_BEFORE = [
('u', 'v'), ('u', 'w'), ('v', 'w'), # central F = (u,v,w)
('u', 'x'), ('v', 'x'), # F' = (u,v,x)
('u', 'a2'), ('a2', 'w'), # A_uw = (u,a2,w)
('v', 'b2'), ('b2', 'w'), # A_vw = (v,b2,w)
('u', 'p2'), ('p2', 'x'), # A_ux = (u,p2,x)
]
CHORDS_AFTER = [c for c in CHORDS_BEFORE if set(c) != {'u', 'v'}] + [('w', 'x')]
FACES_BEFORE = [
('u', 'v', 'w'), # F (depth 2 -- bad)
('u', 'v', 'x'), # F' (depth 1)
('u', 'a2', 'w'), # A_uw (depth 1)
('v', 'b2', 'w'), # A_vw (depth 1)
('u', 'p2', 'x'), # A_ux (depth 1)
('v', 'q1', 'x'), # A_vx (depth 0)
('u', 'a1', 'a2'), # depth 0
('a2', 'a3', 'w'), # depth 0
('v', 'b1', 'b2'), # depth 0
('b2', 'b3', 'w'), # depth 0
('u', 'p1', 'p2'), # depth 0
('p2', 'p3', 'x'), # depth 0
]
FACES_AFTER = [
('u', 'w', 'x'), # A (depth 2 -- still bad!)
('v', 'w', 'x'), # B (depth 1)
('u', 'a2', 'w'),
('v', 'b2', 'w'),
('u', 'p2', 'x'),
('v', 'q1', 'x'),
('u', 'a1', 'a2'),
('a2', 'a3', 'w'),
('v', 'b1', 'b2'),
('b2', 'b3', 'w'),
('u', 'p1', 'p2'),
('p2', 'p3', 'x'),
]
def compute_depths(faces, chords):
"""Compute facial depth for each face using threshold-1 definition."""
outer_set = {frozenset(e) for e in OUTER_EDGES}
def face_edges(f):
return {frozenset((f[0], f[1])), frozenset((f[1], f[2])),
frozenset((f[0], f[2]))}
# Build inner-face dual
D = nx.Graph()
D.add_nodes_from(range(len(faces)))
for i, fi in enumerate(faces):
for j, fj in enumerate(faces):
if i < j and face_edges(fi) & face_edges(fj):
D.add_edge(i, j)
B = [i for i, f in enumerate(faces)
if len(face_edges(f) & outer_set) >= 1]
depth = {}
for i in range(len(faces)):
if not B:
depth[i] = float('inf')
continue
depth[i] = min(nx.shortest_path_length(D, i, b) for b in B)
return depth
def draw_panel(ax, faces, chords, depth, title, highlight_edge=None,
highlight_face=None):
palette = {0: '#86efac', 1: '#fde68a', 2: '#fca5a5'}
edge_pal = {0: '#16a34a', 1: '#d97706', 2: '#dc2626'}
for i, f in enumerate(faces):
d = depth[i]
face_color = palette.get(d, '#ddd')
face_edge = edge_pal.get(d, '#333')
lw = 1.4
if highlight_face is not None and i == highlight_face:
face_edge = '#7c2d12'
lw = 3.0
poly = Polygon([POS[v] for v in f], closed=True,
facecolor=face_color, edgecolor=face_edge,
linewidth=lw, alpha=0.7, zorder=0)
ax.add_patch(poly)
cx = sum(POS[v][0] for v in f) / 3
cy = sum(POS[v][1] for v in f) / 3
ax.text(cx, cy, str(d), ha='center', va='center', fontsize=11,
color=edge_pal.get(d, '#333'), fontweight='bold')
# Draw edges
all_edges = OUTER_EDGES + list(chords)
for (a, b) in all_edges:
color = '#333'; lw = 1.2
if highlight_edge is not None and {a, b} == set(highlight_edge):
color = '#dc2626'; lw = 3.5
elif (a, b) == ('w', 'x') or (a, b) == ('x', 'w'):
color = '#16a34a'; lw = 3.0
ax.plot([POS[a][0], POS[b][0]], [POS[a][1], POS[b][1]],
color=color, linewidth=lw, zorder=1)
# Draw vertices
for name, (x, y) in POS.items():
ax.scatter([x], [y], s=260, c='#1f2937', edgecolors='black',
linewidths=0.8, zorder=2)
ax.text(x, y, name, ha='center', va='center',
fontsize=8, color='white', fontweight='bold', zorder=3)
ax.set_aspect('equal'); ax.axis('off')
ax.set_xlim(-1.35, 1.35); ax.set_ylim(-1.35, 1.35)
ax.set_title(title, fontsize=12)
def main():
depth_before = compute_depths(FACES_BEFORE, CHORDS_BEFORE)
depth_after = compute_depths(FACES_AFTER, CHORDS_AFTER)
print("BEFORE:")
for i, f in enumerate(FACES_BEFORE):
print(f" {f} -> depth {depth_before[i]}")
print("AFTER:")
for i, f in enumerate(FACES_AFTER):
print(f" {f} -> depth {depth_after[i]}")
fig, axes = plt.subplots(1, 2, figsize=(15, 7.5))
draw_panel(axes[0], FACES_BEFORE, CHORDS_BEFORE, depth_before,
r'Before: $F=(u,v,w)$ depth 2, $F\'=(u,v,x)$ depth 1',
highlight_edge=('u', 'v'), highlight_face=0)
draw_panel(axes[1], FACES_AFTER, CHORDS_AFTER, depth_after,
r'After surface switch on $uv$: $A=(u,w,x)$ still depth 2!',
highlight_face=0)
fig.tight_layout()
out = os.path.join(OUT_DIR, 'fig_counterexample_surface_switch.png')
fig.savefig(out, dpi=180, bbox_inches='tight')
plt.close(fig)
print(f'wrote {out}')
if __name__ == '__main__':
main()
@@ -0,0 +1,370 @@
"""Generate the three definition figures for the Level Switching paper.
Uses a stacked 7-vertex triangulation T:
outer triangle {0,1,2}, inner vertex 3 connected to all three,
then vertices 4,5,6 inserted into faces (1,2,3),(0,2,3),(0,1,3).
"""
import os
import networkx as nx
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)
# Vertex positions (hand-placed for a clean planar drawing).
POS = {
0: (-1.5, -0.9),
1: (1.5, -0.9),
2: (0.0, 1.6),
3: (0.0, 0.0),
4: (0.55, 0.2), # in face (1,2,3)
5: (-0.55, 0.2), # in face (0,2,3)
6: (0.0, -0.55), # in face (0,1,3)
}
EDGES = [
(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3), # K_4
(4, 1), (4, 2), (4, 3), # stack in (1,2,3)
(5, 0), (5, 2), (5, 3), # stack in (0,2,3)
(6, 0), (6, 1), (6, 3), # stack in (0,1,3)
]
def make_graph():
G = nx.Graph()
G.add_nodes_from(POS.keys())
G.add_edges_from(EDGES)
return G
def draw_base(ax, G, node_colors, node_size=520, font_color='white',
edge_color='#555', edge_width=1.4):
nx.draw_networkx_edges(G, POS, ax=ax, edge_color=edge_color, width=edge_width)
nx.draw_networkx_nodes(G, POS, ax=ax, node_color=node_colors,
node_size=node_size, edgecolors='black', linewidths=1.0)
nx.draw_networkx_labels(G, POS, ax=ax, font_color=font_color,
font_size=11, font_weight='bold')
ax.set_aspect('equal')
ax.axis('off')
# ---------------------------------------------------------------------------
# Figure 1: Level source (face source vs. degree-3 vertex source)
# ---------------------------------------------------------------------------
def fig_level_source():
G = make_graph()
fig, axes = plt.subplots(1, 2, figsize=(10, 5))
# Panel A: face source S = {0,1,2}
ax = axes[0]
face_S = {0, 1, 2}
colors = ['#ef4444' if v in face_S else '#cbd5e1' for v in G.nodes()]
# Highlight the source face
tri = Polygon([POS[v] for v in [0, 1, 2]], closed=True,
facecolor='#fecaca', edgecolor='#ef4444', linewidth=2.0,
alpha=0.45, zorder=0)
ax.add_patch(tri)
draw_base(ax, G, colors)
ax.set_title(r'Face source $S = \{0,1,2\}$', fontsize=12)
# Panel B: degree-3 vertex source S = {4}
ax = axes[1]
vert_S = {4}
colors = ['#ef4444' if v in vert_S else '#cbd5e1' for v in G.nodes()]
draw_base(ax, G, colors)
ax.set_title(r'Degree-3 vertex source $S = \{4\}$', fontsize=12)
fig.tight_layout()
out = os.path.join(OUT_DIR, 'fig_level_source.png')
fig.savefig(out, dpi=200, bbox_inches='tight')
plt.close(fig)
print(f'wrote {out}')
# ---------------------------------------------------------------------------
# Figure 2: Levels (BFS distance from a source)
# ---------------------------------------------------------------------------
def fig_levels():
G = make_graph()
source = 4 # degree-3 vertex source
levels = nx.single_source_shortest_path_length(G, source)
# Color by level
palette = {0: '#ef4444', 1: '#f59e0b', 2: '#3b82f6'}
colors = [palette[levels[v]] for v in G.nodes()]
# Labels = level numbers
labels = {v: rf'$\ell={levels[v]}$' for v in G.nodes()}
fig, ax = plt.subplots(figsize=(6.5, 5.5))
nx.draw_networkx_edges(G, POS, ax=ax, edge_color='#555', width=1.4)
nx.draw_networkx_nodes(G, POS, ax=ax, node_color=colors,
node_size=720, edgecolors='black', linewidths=1.0)
# Draw vertex id slightly above, level label inside
for v, (x, y) in POS.items():
ax.text(x, y, str(v), ha='center', va='center',
fontsize=10, fontweight='bold', color='white')
ax.text(x + 0.18, y + 0.18, rf'$\ell={levels[v]}$',
fontsize=10, color='black',
bbox=dict(boxstyle='round,pad=0.15',
facecolor='white', edgecolor='#999', linewidth=0.6))
ax.set_aspect('equal')
ax.axis('off')
ax.set_title(r'Levels $\ell_G(v)$ from source $S=\{4\}$', fontsize=12)
fig.tight_layout()
out = os.path.join(OUT_DIR, 'fig_levels.png')
fig.savefig(out, dpi=200, bbox_inches='tight')
plt.close(fig)
print(f'wrote {out}')
# ---------------------------------------------------------------------------
# Figure 3: Parity subgraph (even and odd induced subgraphs)
# ---------------------------------------------------------------------------
def fig_parity_subgraph():
G = make_graph()
source = 4
levels = nx.single_source_shortest_path_length(G, source)
parity = {v: levels[v] % 2 for v in G.nodes()}
even = [v for v in G.nodes() if parity[v] == 0]
odd = [v for v in G.nodes() if parity[v] == 1]
even_color = '#3b82f6' # blue
odd_color = '#f59e0b' # orange
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
# Panel A: full triangulation, vertices coloured by parity
ax = axes[0]
colors = [even_color if parity[v] == 0 else odd_color for v in G.nodes()]
draw_base(ax, G, colors)
ax.set_title(r"$G'$ with vertices coloured by $\ell_G$ mod 2", fontsize=12)
# Panel B: even parity subgraph (induced on even vertices)
ax = axes[1]
# Draw all edges faintly, then the induced subgraph in colour
nx.draw_networkx_edges(G, POS, ax=ax, edge_color='#ddd', width=1.0)
even_sub = G.subgraph(even)
nx.draw_networkx_edges(even_sub, POS, ax=ax, edge_color=even_color, width=2.4)
node_colors = [even_color if v in even else '#e5e7eb' for v in G.nodes()]
nx.draw_networkx_nodes(G, POS, ax=ax, node_color=node_colors,
node_size=520, edgecolors='black', linewidths=1.0)
nx.draw_networkx_labels(G, POS, ax=ax,
font_color='white', font_size=11, font_weight='bold')
ax.set_aspect('equal')
ax.axis('off')
ax.set_title(r"Even parity subgraph $E_{G,S}(G')$", fontsize=12)
# Panel C: odd parity subgraph
ax = axes[2]
nx.draw_networkx_edges(G, POS, ax=ax, edge_color='#ddd', width=1.0)
odd_sub = G.subgraph(odd)
nx.draw_networkx_edges(odd_sub, POS, ax=ax, edge_color=odd_color, width=2.4)
node_colors = [odd_color if v in odd else '#e5e7eb' for v in G.nodes()]
nx.draw_networkx_nodes(G, POS, ax=ax, node_color=node_colors,
node_size=520, edgecolors='black', linewidths=1.0)
nx.draw_networkx_labels(G, POS, ax=ax,
font_color='white', font_size=11, font_weight='bold')
ax.set_aspect('equal')
ax.axis('off')
ax.set_title(r"Odd parity subgraph $O_{G,S}(G')$", fontsize=12)
fig.tight_layout()
out = os.path.join(OUT_DIR, 'fig_parity_subgraph.png')
fig.savefig(out, dpi=200, bbox_inches='tight')
plt.close(fig)
print(f'wrote {out}')
# ---------------------------------------------------------------------------
# Figure: Level cycle (simple cycle within a single level)
# ---------------------------------------------------------------------------
def fig_level_cycle():
G = make_graph()
source = 4
levels = nx.single_source_shortest_path_length(G, source)
palette = {0: '#ef4444', 1: '#f59e0b', 2: '#3b82f6'}
colors = [palette[levels[v]] for v in G.nodes()]
# Level cycle: 1-2-3-1 lies entirely in L_1
cycle_edges = [(1, 2), (2, 3), (1, 3)]
fig, ax = plt.subplots(figsize=(6.5, 5.5))
nx.draw_networkx_edges(G, POS, ax=ax, edge_color='#bbb', width=1.2)
nx.draw_networkx_edges(G, POS, edgelist=cycle_edges, ax=ax,
edge_color='#dc2626', width=3.4)
nx.draw_networkx_nodes(G, POS, ax=ax, node_color=colors,
node_size=620, edgecolors='black', linewidths=1.0)
nx.draw_networkx_labels(G, POS, ax=ax, font_color='white',
font_size=11, font_weight='bold')
# Annotate levels in small floating labels
for v, (x, y) in POS.items():
ax.text(x + 0.18, y + 0.18, rf'$\ell={levels[v]}$',
fontsize=9, color='black',
bbox=dict(boxstyle='round,pad=0.12',
facecolor='white', edgecolor='#999', linewidth=0.5))
ax.set_aspect('equal')
ax.axis('off')
ax.set_title(r'Level cycle in $L_1 = \{1,2,3\}$ (highlighted)', fontsize=12)
fig.tight_layout()
out = os.path.join(OUT_DIR, 'fig_level_cycle.png')
fig.savefig(out, dpi=200, bbox_inches='tight')
plt.close(fig)
print(f'wrote {out}')
# ---------------------------------------------------------------------------
# Figure: Edge switch (flip on a level-cycle edge)
# ---------------------------------------------------------------------------
def fig_edge_switch():
G = make_graph()
source = 4
levels = nx.single_source_shortest_path_length(G, source)
palette = {0: '#ef4444', 1: '#f59e0b', 2: '#3b82f6'}
colors = [palette[levels[v]] for v in G.nodes()]
# We switch edge (1,2), which lies in the L_1 cycle 1-2-3-1.
# Its two adjacent faces in T are (0,1,2) and (1,2,4); the flip
# removes 1-2 and adds 0-4.
removed = (1, 2)
added = (0, 4)
Gprime = G.copy()
Gprime.remove_edge(*removed)
Gprime.add_edge(*added)
fig, axes = plt.subplots(1, 2, figsize=(12, 5.5))
# Panel A: before — highlight the level-cycle edge to be switched
ax = axes[0]
other_edges = [e for e in G.edges() if set(e) != set(removed)]
nx.draw_networkx_edges(G, POS, edgelist=other_edges, ax=ax,
edge_color='#bbb', width=1.2)
nx.draw_networkx_edges(G, POS, edgelist=[removed], ax=ax,
edge_color='#dc2626', width=3.4)
nx.draw_networkx_nodes(G, POS, ax=ax, node_color=colors,
node_size=560, edgecolors='black', linewidths=1.0)
nx.draw_networkx_labels(G, POS, ax=ax, font_color='white',
font_size=11, font_weight='bold')
ax.set_aspect('equal'); ax.axis('off')
ax.set_title(r'Before: edge $1\!-\!2$ lies on the $L_1$ cycle',
fontsize=12)
# Panel B: after — the new edge highlighted in green
ax = axes[1]
other_edges = [e for e in Gprime.edges() if set(e) != set(added)]
nx.draw_networkx_edges(Gprime, POS, edgelist=other_edges, ax=ax,
edge_color='#bbb', width=1.2)
nx.draw_networkx_edges(Gprime, POS, edgelist=[added], ax=ax,
edge_color='#16a34a', width=3.4)
nx.draw_networkx_nodes(Gprime, POS, ax=ax, node_color=colors,
node_size=560, edgecolors='black', linewidths=1.0)
nx.draw_networkx_labels(Gprime, POS, ax=ax, font_color='white',
font_size=11, font_weight='bold')
ax.set_aspect('equal'); ax.axis('off')
ax.set_title(r'After: $1\!-\!2$ replaced by $0\!-\!4$',
fontsize=12)
fig.tight_layout()
out = os.path.join(OUT_DIR, 'fig_edge_switch.png')
fig.savefig(out, dpi=200, bbox_inches='tight')
plt.close(fig)
print(f'wrote {out}')
# ---------------------------------------------------------------------------
# Figure: Facial depth (depths in an outerplanar L_k)
# ---------------------------------------------------------------------------
def fig_facial_depth():
import math
# 12-vertex maximal outerplanar graph with 3-fold symmetry.
# Central triangle (0,4,8); three "in-between" triangles (0,2,4),
# (4,6,8), (0,8,10) sit between the central triangle and the
# outer "ears" (0,1,2), (2,3,4), (4,5,6), (6,7,8), (8,9,10),
# (10,11,0).
n = 12
pos = {}
for i in range(n):
a = math.radians(90 - i * (360 / n))
pos[i] = (math.cos(a), math.sin(a))
outer_edges = [(i, (i + 1) % n) for i in range(n)]
diagonals = [(0, 2), (2, 4), (4, 6), (6, 8), (8, 10), (10, 0), # "short" chords
(0, 4), (4, 8), (0, 8)] # central triangle
L = nx.Graph()
L.add_nodes_from(pos)
L.add_edges_from(outer_edges + diagonals)
inner_faces = [
(0, 1, 2), (2, 3, 4), (4, 5, 6),
(6, 7, 8), (8, 9, 10), (10, 11, 0), # 6 outer "ears"
(0, 2, 4), (4, 6, 8), (0, 8, 10), # 3 in-between
(0, 4, 8), # central
]
# Build dual graph on inner faces: edge iff faces share an edge
def face_edges(f):
a, b, c = f
return {frozenset((a, b)), frozenset((b, c)), frozenset((a, c))}
outer_edge_set = {frozenset(e) for e in outer_edges}
D = nx.Graph()
D.add_nodes_from(range(len(inner_faces)))
for i, fi in enumerate(inner_faces):
for j, fj in enumerate(inner_faces):
if i < j and face_edges(fi) & face_edges(fj):
D.add_edge(i, j)
# Boundary set B: faces whose bounding level cycle has >= 1 outer-cycle edge
B = [i for i, f in enumerate(inner_faces)
if len(face_edges(f) & outer_edge_set) >= 1]
# Depth = min distance in D to any face in B
depth = {}
for i in range(len(inner_faces)):
dists = [nx.shortest_path_length(D, i, b) for b in B]
depth[i] = min(dists)
# Colour by depth
depth_color = {0: '#86efac', 1: '#fde68a', 2: '#fca5a5'}
depth_edge = {0: '#16a34a', 1: '#d97706', 2: '#dc2626'}
fig, ax = plt.subplots(figsize=(7, 7))
# Fill faces by depth
for i, f in enumerate(inner_faces):
poly = Polygon([pos[v] for v in f], closed=True,
facecolor=depth_color[depth[i]],
edgecolor=depth_edge[depth[i]],
linewidth=1.5, alpha=0.7, zorder=0)
ax.add_patch(poly)
cx = sum(pos[v][0] for v in f) / 3
cy = sum(pos[v][1] for v in f) / 3
ax.text(cx, cy, rf'$\mathrm{{depth}}={depth[i]}$',
ha='center', va='center', fontsize=10,
color=depth_edge[depth[i]], fontweight='bold')
# Draw the graph on top
nx.draw_networkx_edges(L, pos, ax=ax, edge_color='#333', width=1.4)
nx.draw_networkx_nodes(L, pos, ax=ax, node_color='#1f2937',
node_size=320, edgecolors='black', linewidths=1.0)
nx.draw_networkx_labels(L, pos, ax=ax, font_color='white',
font_size=10, font_weight='bold')
ax.set_aspect('equal'); ax.axis('off')
ax.set_xlim(-1.3, 1.3); ax.set_ylim(-1.3, 1.3)
ax.set_title(r'Facial depth in an outerplanar $L_k$', fontsize=12)
fig.tight_layout()
out = os.path.join(OUT_DIR, 'fig_facial_depth.png')
fig.savefig(out, dpi=200, bbox_inches='tight')
plt.close(fig)
print(f'wrote {out}')
if __name__ == '__main__':
fig_level_source()
fig_levels()
fig_level_cycle()
fig_edge_switch()
fig_parity_subgraph()
fig_facial_depth()
@@ -0,0 +1,118 @@
"""Demonstrate the preprocessing strategy on the 9-vertex example.
Start: F = (0,3,6) at depth 1, no balanced surface switch exists
(F has no edge of "span 1" -- no edge with a single outer-cycle
vertex between the endpoints, hence no ear neighbour).
Step: perform the (unbalanced) surface switch on edge uv = 03, with
F' = (0,2,3) and third vertex x = 2; in Case (ii) the flip removes 03
and adds wx = 62.
Result: A = (0,2,6) at depth 1 has edge 02 at span 1, so the ear
(0,1,2) is now a balanced-switch target.
"""
import os
import math
import networkx as nx
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)
n = 9
POS = {i: (math.cos(math.radians(90 - i * 360 / n)),
math.sin(math.radians(90 - i * 360 / n))) for i in range(n)}
OUTER_EDGES = [(i, (i + 1) % n) for i in range(n)]
outer_set = {frozenset(e) for e in OUTER_EDGES}
def face_edges(f):
return {frozenset((f[0], f[1])), frozenset((f[1], f[2])),
frozenset((f[0], f[2]))}
def compute_depths(faces):
D = nx.Graph()
D.add_nodes_from(range(len(faces)))
for i, fi in enumerate(faces):
for j, fj in enumerate(faces):
if i < j and face_edges(fi) & face_edges(fj):
D.add_edge(i, j)
B = [i for i, f in enumerate(faces)
if len(face_edges(f) & outer_set) >= 1]
return {i: min(nx.shortest_path_length(D, i, b) for b in B)
for i in range(len(faces))}
CHORDS_BEFORE = [(0, 2), (0, 3), (3, 5), (3, 6), (0, 6), (6, 8)]
FACES_BEFORE = [
(0, 1, 2), (0, 2, 3), (3, 4, 5), (3, 5, 6),
(6, 7, 8), (6, 8, 0), (0, 3, 6),
]
# After non-balanced switch on edge 03: remove 03, add 26
CHORDS_AFTER = [c for c in CHORDS_BEFORE if set(c) != {0, 3}] + [(2, 6)]
FACES_AFTER = [
(0, 1, 2), (3, 4, 5), (3, 5, 6),
(6, 7, 8), (6, 8, 0),
(0, 2, 6), (2, 3, 6),
]
def draw(ax, faces, chords, depth, title, highlight_edges=None,
green_edges=None):
palette = {0: '#86efac', 1: '#fde68a', 2: '#fca5a5'}
edge_pal = {0: '#16a34a', 1: '#d97706', 2: '#dc2626'}
for i, f in enumerate(faces):
d = depth[i]
poly = Polygon([POS[v] for v in f], closed=True,
facecolor=palette.get(d, '#ddd'),
edgecolor=edge_pal.get(d, '#333'),
linewidth=1.4, alpha=0.7, zorder=0)
ax.add_patch(poly)
cx = sum(POS[v][0] for v in f) / 3
cy = sum(POS[v][1] for v in f) / 3
ax.text(cx, cy, str(d), ha='center', va='center', fontsize=11,
color=edge_pal.get(d, '#333'), fontweight='bold')
for (a, b) in OUTER_EDGES + chords:
color = '#333'; lw = 1.2
if highlight_edges and ((a, b) in highlight_edges or
(b, a) in highlight_edges):
color = '#dc2626'; lw = 3.0
if green_edges and ((a, b) in green_edges or
(b, a) in green_edges):
color = '#16a34a'; lw = 3.0
ax.plot([POS[a][0], POS[b][0]], [POS[a][1], POS[b][1]],
color=color, linewidth=lw, zorder=1)
for i, (x, y) in POS.items():
ax.scatter([x], [y], s=270, c='#1f2937', edgecolors='black',
linewidths=1.0, zorder=2)
ax.text(x, y, str(i), ha='center', va='center',
fontsize=9, color='white', fontweight='bold', zorder=3)
ax.set_aspect('equal'); ax.axis('off')
ax.set_xlim(-1.3, 1.3); ax.set_ylim(-1.3, 1.3)
ax.set_title(title, fontsize=11)
depth_before = compute_depths(FACES_BEFORE)
depth_after = compute_depths(FACES_AFTER)
print('BEFORE:')
for i, f in enumerate(FACES_BEFORE):
print(f' {f} -> depth {depth_before[i]}')
print('AFTER:')
for i, f in enumerate(FACES_AFTER):
print(f' {f} -> depth {depth_after[i]}')
fig, axes = plt.subplots(1, 2, figsize=(14, 7))
draw(axes[0], FACES_BEFORE, CHORDS_BEFORE, depth_before,
'Before: F=(0,3,6) depth 1; spans (2,2,2) so no ear neighbour',
highlight_edges=[(0, 3)])
draw(axes[1], FACES_AFTER, CHORDS_AFTER, depth_after,
'After non-balanced switch 03->26: A=(0,2,6) depth 1; edge 02 has span 1',
green_edges=[(2, 6)], highlight_edges=[(0, 2)])
fig.tight_layout()
out = os.path.join(OUT_DIR, 'fig_preprocessing.png')
fig.savefig(out, dpi=180, bbox_inches='tight')
plt.close(fig)
print(f'wrote {out}')