coloring_nested_tire_graphs: shared-layout figure for cut-and-depth-label procedure

Computes a single nice layout for the full G' (Holton-McKay #0) by
trying sage-planar, sage-spring, and networkx-planar layouts and
picking the one with smallest edge-length coefficient of variation.
Spring layout wins (CV^2 = 0.049).

Then uses the SAME positions for G'_0 and G'_1, with pendant
vertices placed offset from their boundary vertex in the direction
of their cut-edge neighbor.  This makes the visual correspondence
between G' and its two halves immediate.

Layout: 3 vertical panels showing G' (with cut edges highlighted),
G'_0, G'_1.  Each subgraph draws only its own vertices (no orphan
vertices from the other side); all three share the same x-y limits
so positions align across panels.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 15:08:06 -04:00
parent d9748e38d9
commit d065c5c31b
2 changed files with 154 additions and 52 deletions
@@ -130,37 +130,52 @@ def find_six_edge_cut(G, prefer_balanced=True, prefer_matching=True):
return unique[0]
def apply_procedure(G, S, cut, side_label='0'):
def apply_procedure(G, S, cut, base_pos, side_label='0',
pendant_start_id=None):
"""Build G'_i from G[S] plus pendant edges at degree-2 vertices.
Per the user's procedure:
a. V = vertices of degree 2 in the induced subgraph (i.e.,
original cubic vertices that have exactly 1 cut edge,
hence degree 3 - 1 = 2 in the induced subgraph).
b. Add 1 pendant edge per v in V.
c. Label pendants depth 0, BFS-propagate.
Returns (graph, pos, edge_depths, deg2_vertices, pendant_map,
high_deg_loss). high_deg_loss tracks vertices with >= 2 cut
edges that don't receive pendants under the strict procedure.
"""
Reuses positions from base_pos (a dict v -> (x, y) for G'); pendant
vertices are placed near their boundary vertex, offset in the
direction of the cut edge's other endpoint."""
induced_edges = [(u, v) for (u, v) in G.edges(labels=False)
if u in S and v in S]
H = Graph(induced_edges, multiedges=False, loops=False)
# Ensure all vertices of S are present (some may be isolated)
for v in S:
H.add_vertex(v)
# Compute degree of each vertex in induced subgraph
induced_deg = {v: H.degree(v) for v in S}
# V = degree-2 vertices
V_deg2 = sorted([v for v in S if induced_deg[v] == 2])
high_loss = sorted([v for v in S if induced_deg[v] < 2])
pendant_edges = []
next_pendant_id = (max(G.vertices()) + 1)
if pendant_start_id is None:
pendant_start_id = max(G.vertices()) + 1
next_pendant_id = pendant_start_id
pendant_to_boundary = {}
# Position pendants: for each boundary vertex v in V_deg2, find
# its cut-edge neighbor across the cut and offset the pendant in
# that direction.
cut_neighbor = {}
for (u, w) in cut:
if u in S and w not in S:
cut_neighbor.setdefault(u, []).append(w)
if w in S and u not in S:
cut_neighbor.setdefault(w, []).append(u)
pos = dict(base_pos)
for v in V_deg2:
H.add_edge(v, next_pendant_id)
pendant_edges.append((min(v, next_pendant_id),
max(v, next_pendant_id)))
pendant_to_boundary[next_pendant_id] = v
# Position: pendant goes in direction of cut neighbor
nbrs = cut_neighbor.get(v, [])
if nbrs:
nx_, ny_ = base_pos[nbrs[0]]
vx, vy = base_pos[v]
dx, dy = nx_ - vx, ny_ - vy
norm = (dx * dx + dy * dy) ** 0.5 or 1.0
pos[next_pendant_id] = (vx + 0.35 * dx / norm,
vy + 0.35 * dy / norm)
else:
vx, vy = base_pos[v]
pos[next_pendant_id] = (vx + 0.3, vy)
next_pendant_id += 1
# BFS-label edges by depth
edge_depth = {}
@@ -178,26 +193,24 @@ def apply_procedure(G, S, cut, side_label='0'):
if e_nb not in edge_depth:
edge_depth[e_nb] = d + 1
queue.append((e_nb, d + 1))
# Layout: use Sage's planar embedding
pos = H.layout(layout='planar') if H.is_planar() else H.layout()
return H, pos, edge_depth, V_deg2, pendant_to_boundary, high_loss
def draw_labeled_graph(ax, H, pos, edge_depth, boundary_vertices,
pendant_to_boundary, title):
H_verts = set(H.vertices())
max_depth = max(edge_depth.values()) if edge_depth else 0
cmap = plt.get_cmap('viridis', max_depth + 1)
# Draw edges by depth
cmap = plt.get_cmap('viridis', max_depth + 2)
legend_handles = []
for d in range(max_depth + 1):
color = cmap(d / max(max_depth, 1))
color = cmap((d + 0.5) / (max_depth + 1))
for e, ed in edge_depth.items():
if ed != d: continue
u, v = e
(x1, y1) = pos[u]
(x2, y2) = pos[v]
ax.plot([x1, x2], [y1, y2], color=color,
linewidth=2.0 + (1.5 if d == 0 else 0),
linewidth=2.3,
linestyle=('--' if d == 0 else '-'),
zorder=1)
legend_handles.append(
@@ -205,21 +218,69 @@ def draw_labeled_graph(ax, H, pos, edge_depth, boundary_vertices,
linestyle='--' if d == 0 else '-',
label=f'depth {d}')
)
# Draw vertices
for v, (x, y) in pos.items():
for v in H_verts:
x, y = pos[v]
if v in pendant_to_boundary:
ax.plot(x, y, 's', color='#ffaa66', markersize=8, zorder=2,
ax.plot(x, y, 's', color='#ffaa66', markersize=9, zorder=2,
markeredgecolor='#aa5500')
elif v in boundary_vertices:
ax.plot(x, y, 'o', color='#ff5555', markersize=10, zorder=2,
markeredgecolor='#aa0000')
ax.plot(x, y, 'o', color='#ff5555', markersize=11, zorder=3,
markeredgecolor='#aa0000', markeredgewidth=1.0)
else:
ax.plot(x, y, 'o', color='#888', markersize=6, zorder=2)
ax.plot(x, y, 'o', color='#666', markersize=7, zorder=2)
ax.set_aspect('equal')
ax.axis('off')
ax.set_title(title, fontsize=11)
ax.legend(handles=legend_handles, loc='upper left',
bbox_to_anchor=(1.02, 1.0), fontsize=8, frameon=False)
bbox_to_anchor=(1.02, 1.0), fontsize=9, frameon=False)
def compute_nice_layout(G):
"""Compute a nice layout for G. Tries several methods, picks the
best by a heuristic that penalizes long/crossing edges."""
# First try Sage's planar layout from the planar embedding.
G.is_planar(set_embedding=True)
pos_planar = G.layout(layout='planar')
# Spring layout as fallback / alternative.
try:
pos_spring = G.layout(layout='spring', iterations=200)
except Exception:
pos_spring = None
# Use networkx's planar layout if available.
try:
import networkx as nx
nxg = nx.Graph()
for (u, v) in G.edges(labels=False):
nxg.add_edge(u, v)
pos_nx_planar = nx.planar_layout(nxg)
pos_nx_planar = {k: tuple(v) for k, v in pos_nx_planar.items()}
except Exception:
pos_nx_planar = None
def edge_length_variance(pos):
if pos is None:
return float('inf')
lengths = []
for (u, v) in G.edges(labels=False):
x1, y1 = pos[u]; x2, y2 = pos[v]
lengths.append(((x2 - x1)**2 + (y2 - y1)**2) ** 0.5)
if not lengths:
return float('inf')
mean = sum(lengths) / len(lengths)
var = sum((l - mean)**2 for l in lengths) / len(lengths)
return var / (mean * mean + 1e-9) # coefficient of variation^2
options = [
('sage-planar', pos_planar),
('sage-spring', pos_spring),
('nx-planar', pos_nx_planar),
]
scored = [(edge_length_variance(p), name, p) for name, p in options if p]
scored.sort()
name, _, pos = scored[0][1], scored[0][0], scored[0][2]
print(f'Selected layout: {name} '
f'(edge-length CV^2 = {scored[0][0]:.4f})')
return pos
def main():
@@ -230,12 +291,11 @@ def main():
gs = parse_planar_code(HM_FILE)
print(f'Loaded {len(gs)} Holton-McKay graphs')
# Pick the first one
G = gs[0]
print(f'Graph 0: {G.order()} vertices, {G.size()} edges, '
f'cubic={all(d == 3 for d in G.degree())}, planar={G.is_planar()}')
f'cubic={all(d == 3 for d in G.degree())}, '
f'planar={G.is_planar()}')
# Find a 6-edge cut
S, cut = find_six_edge_cut(G)
if S is None:
print('No 6-edge cut found by greedy search.')
@@ -243,36 +303,78 @@ def main():
print(f'Found 6-edge cut: |S| = {len(S)}, |cut| = {len(cut)}')
print(f'Cut edges: {cut}')
# Compute a single nice layout for G' once
base_pos = compute_nice_layout(G)
S0 = frozenset(S)
S1 = frozenset(G.vertices()) - S0
H0, pos0, ed0, bv0, pmap0, hl0 = apply_procedure(G, S0, cut, '0')
H1, pos1, ed1, bv1, pmap1, hl1 = apply_procedure(G, S1, cut, '1')
H0, pos0, ed0, bv0, pmap0, hl0 = apply_procedure(
G, S0, cut, base_pos, side_label='0',
pendant_start_id=max(G.vertices()) + 1)
H1, pos1, ed1, bv1, pmap1, hl1 = apply_procedure(
G, S1, cut, base_pos, side_label='1',
pendant_start_id=max(G.vertices()) + 1 + len(bv0))
print(f"G'_0: {H0.order()} vertices ({len(S0)} original + "
f'{len(pmap0)} pendant), {H0.size()} edges')
print(f" V (degree-2 vertices receiving pendants): {len(bv0)} vertices")
print(f" Multi-cut vertices not in V: {hl0}")
print(f' Max depth: {max(ed0.values())}')
print(f"G'_1: {H1.order()} vertices ({len(S1)} original + "
f'{len(pmap1)} pendant), {H1.size()} edges')
print(f" V (degree-2 vertices receiving pendants): {len(bv1)} vertices")
print(f" Multi-cut vertices not in V: {hl1}")
print(f' Max depth: {max(ed1.values())}')
print(f"G'_0: {H0.order()} vertices, {H0.size()} edges, "
f"|V|={len(bv0)}, max depth={max(ed0.values())}")
print(f"G'_1: {H1.order()} vertices, {H1.size()} edges, "
f"|V|={len(bv1)}, max depth={max(ed1.values())}")
# Draw
fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(16, 8.0))
draw_labeled_graph(ax0, H0, pos0, ed0, bv0, pmap0,
# Three-panel figure: G' (full), G'_0, G'_1, all with same positions
# Choose figure size based on aspect ratio of the layout.
xs = [p[0] for p in base_pos.values()]
ys = [p[1] for p in base_pos.values()]
xrange = max(xs) - min(xs)
yrange = max(ys) - min(ys)
aspect = xrange / yrange if yrange > 0 else 1.0
panel_w = 6.5
panel_h = panel_w / aspect
fig, axes = plt.subplots(3, 1, figsize=(panel_w * 1.4, 3 * panel_h * 1.05))
# Panel 0: G' (full) with cut edges highlighted
ax = axes[0]
cut_set = set(frozenset(e) for e in cut)
for (u, v) in G.edges(labels=False):
x1, y1 = base_pos[u]; x2, y2 = base_pos[v]
if frozenset((u, v)) in cut_set:
ax.plot([x1, x2], [y1, y2], color='#d62728',
linewidth=2.5, linestyle='--', zorder=2)
else:
ax.plot([x1, x2], [y1, y2], color='#bbbbbb',
linewidth=1.2, zorder=1)
for v, (x, y) in base_pos.items():
if v in S0:
ax.plot(x, y, 'o', color='#4c72b0', markersize=8, zorder=3)
else:
ax.plot(x, y, 'o', color='#dd8452', markersize=8, zorder=3)
ax.set_title(f"$G'$ = Holton-McKay #0 with 6-edge cut highlighted\n"
f"Blue = $S$ ($|S|={len(S0)}$); "
f"orange = $V \\setminus S$ ($|V\\setminus S|={len(S1)}$); "
f"red dashed = cut",
fontsize=10)
ax.set_aspect('equal'); ax.axis('off')
# Panels 1 and 2: G'_0 and G'_1 in the same layout
draw_labeled_graph(axes[1], H0, pos0, ed0, bv0, pmap0,
f"$G'_0$ (|S| = {len(S0)}, |V| = {len(bv0)}, "
f"max depth = {max(ed0.values())})")
draw_labeled_graph(ax1, H1, pos1, ed1, bv1, pmap1,
draw_labeled_graph(axes[2], H1, pos1, ed1, bv1, pmap1,
f"$G'_1$ (|S| = {len(S1)}, |V| = {len(bv1)}, "
f"max depth = {max(ed1.values())})")
fig.suptitle("Cut-and-depth-label procedure on Holton-McKay graph #0 "
"(38 vertices, cubic, planar, non-Hamiltonian)\n"
"Dashed orange = pendant edges (depth 0); colored by "
"BFS depth from pendants in line-graph sense",
fontsize=11, y=1.00)
# Force same axis limits for the three panels (using base_pos)
xs = [p[0] for p in base_pos.values()]
ys = [p[1] for p in base_pos.values()]
xmin, xmax = min(xs) - 0.5, max(xs) + 0.5
ymin, ymax = min(ys) - 0.5, max(ys) + 0.5
for ax in axes:
ax.set_xlim(xmin, xmax)
ax.set_ylim(ymin, ymax)
fig.suptitle("Cut-and-depth-label procedure on Holton-McKay graph #0\n"
"Same vertex positions used across all three panels.",
fontsize=12, y=1.00)
plt.tight_layout()
plt.savefig(out, dpi=160, bbox_inches='tight')
plt.close()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 202 KiB