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:
@@ -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 |
Reference in New Issue
Block a user