dual_decomposition: reduced-dual definition, verification, and step figures
Add Definition 2.1 (reduced dual) and a remark on cubicity/planarity, plus an experiment verifying it on the icosahedron/dodecahedron and four figures, one per construction step. reduced_dual.py builds G' = dodecahedron (dual of the icosahedron), applies the construction, and confirms the result is a cubic, planar, simple graph whose dual is a simple triangulation. Finding: the construction is an n -> n-2 reduction (12 -> 10 here), not n-1, since the single apex v_n collapses one more vertex than a standard pentagon re-triangulation; the result also re-introduces degree-3 and degree-4 vertices (degree seq [7,5,5,5,5,5,5,4,4,3]). draw_reduced_dual_steps.py renders fig_reduced_dual_step1..4.png, embedded as a 2x2 grid after the definition. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+174
@@ -0,0 +1,174 @@
|
||||
"""Draw the four steps of the reduced-dual construction (Definition 2.1).
|
||||
|
||||
Uses the dodecahedron G' = dual of the icosahedron, with F_v the inner pentagon,
|
||||
as built in reduced_dual.py. Produces fig_reduced_dual_step{1..4}.png.
|
||||
"""
|
||||
import os
|
||||
import math
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.patches import Polygon
|
||||
from matplotlib.lines import Line2D
|
||||
|
||||
from reduced_dual import build_dual, apply_reduction
|
||||
|
||||
OUT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
GRAY = '#9ca3af'
|
||||
DARK = '#374151'
|
||||
GHOST = '#fca5a5'
|
||||
DEG2 = '#f59e0b'
|
||||
APEX = '#16a34a'
|
||||
CHORD = '#2563eb'
|
||||
FACE = '#fef9c3'
|
||||
|
||||
|
||||
def draw_edges(ax, G, pos, nodes=None, **kw):
|
||||
for u, v in G.edges():
|
||||
if nodes is not None and (u not in nodes or v not in nodes):
|
||||
continue
|
||||
(x0, y0), (x1, y1) = pos[u], pos[v]
|
||||
ax.plot([x0, x1], [y0, y1], **kw)
|
||||
|
||||
|
||||
def draw_nodes(ax, pos, nodes, **kw):
|
||||
xs = [pos[v][0] for v in nodes]
|
||||
ys = [pos[v][1] for v in nodes]
|
||||
ax.scatter(xs, ys, **kw)
|
||||
|
||||
|
||||
def face_F_polygon(pos):
|
||||
"""The new central face F: decagon alternating b_i, c_i clockwise."""
|
||||
order = []
|
||||
for i in range(5):
|
||||
order += [('b', i), ('c', i)]
|
||||
return [pos[v] for v in order]
|
||||
|
||||
|
||||
def base_canvas(title):
|
||||
fig, ax = plt.subplots(figsize=(8.5, 8.5))
|
||||
ax.set_aspect('equal')
|
||||
ax.axis('off')
|
||||
ax.set_title(title, fontsize=12)
|
||||
return fig, ax
|
||||
|
||||
|
||||
def main():
|
||||
Gp, pos, Fv = build_dual()
|
||||
res = apply_reduction(Gp, pos, Fv, i=0)
|
||||
Ghat, npos, A = res['Ghat'], res['pos'], res['A']
|
||||
v_n, apex_nbrs, chord = res['v_n'], res['apex_nbrs'], res['chord']
|
||||
|
||||
survivors = [v for v in Gp if v not in Fv] # b, c, d families
|
||||
surv_set = set(survivors)
|
||||
deg2 = list(A) # the five b_i
|
||||
|
||||
# surviving edges (both endpoints survive) vs deleted edges (touch an a_i)
|
||||
surv_edges = [(u, v) for u, v in Gp.edges()
|
||||
if u in surv_set and v in surv_set]
|
||||
del_edges = [(u, v) for u, v in Gp.edges()
|
||||
if u not in surv_set or v not in surv_set]
|
||||
|
||||
def draw_surviving(ax):
|
||||
ax.add_patch(Polygon(face_F_polygon(pos), closed=True,
|
||||
facecolor=FACE, edgecolor='none', zorder=0))
|
||||
for u, v in surv_edges:
|
||||
(x0, y0), (x1, y1) = pos[u], pos[v]
|
||||
ax.plot([x0, x1], [y0, y1], color=GRAY, lw=1.6, zorder=1)
|
||||
others = [v for v in survivors if v not in deg2]
|
||||
draw_nodes(ax, pos, others, s=120, color=DARK, zorder=3)
|
||||
|
||||
def draw_ghosts(ax):
|
||||
for u, v in del_edges:
|
||||
(x0, y0), (x1, y1) = pos[u], pos[v]
|
||||
ax.plot([x0, x1], [y0, y1], color=GHOST, lw=1.2, ls='--', zorder=1)
|
||||
draw_nodes(ax, pos, Fv, s=120, color='white', edgecolors=GHOST,
|
||||
linewidths=1.5, zorder=2)
|
||||
for v in Fv:
|
||||
ax.plot(*pos[v], marker='x', color=GHOST, ms=8, zorder=3)
|
||||
|
||||
# ----- Step 1: delete F_v's boundary; five degree-2 vertices on face F -----
|
||||
fig, ax = base_canvas(
|
||||
"Step 1: delete the five dual vertices on $\\partial F_v$.\n"
|
||||
"Their outer neighbours drop to degree 2 (orange) and lie on a new "
|
||||
"face $F$ (shaded).")
|
||||
draw_surviving(ax)
|
||||
draw_ghosts(ax)
|
||||
draw_nodes(ax, pos, deg2, s=260, color=DEG2, edgecolors='black',
|
||||
linewidths=1.0, zorder=4)
|
||||
cx = sum(pos[('a', i)][0] for i in range(5)) / 5
|
||||
cy = sum(pos[('a', i)][1] for i in range(5)) / 5
|
||||
ax.text(cx, cy, '$F$', fontsize=16, ha='center', va='center',
|
||||
color='#a16207', zorder=5)
|
||||
ax.legend(handles=[
|
||||
Line2D([0], [0], marker='x', color=GHOST, lw=0, label='deleted (was $\\partial F_v$)'),
|
||||
Line2D([0], [0], marker='o', color='w', markerfacecolor=DEG2,
|
||||
markeredgecolor='black', label='degree-2 vertex'),
|
||||
], loc='upper left', fontsize=10)
|
||||
fig.savefig(os.path.join(OUT_DIR, 'fig_reduced_dual_step1.png'),
|
||||
dpi=170, bbox_inches='tight'); plt.close(fig)
|
||||
|
||||
# ----- Step 2: order the five degree-2 vertices clockwise as A_0..A_4 -----
|
||||
fig, ax = base_canvas(
|
||||
"Step 2: list the degree-2 vertices clockwise around $F$ as "
|
||||
"$A_0,\\dots,A_4$.")
|
||||
draw_surviving(ax)
|
||||
draw_nodes(ax, pos, deg2, s=300, color=DEG2, edgecolors='black',
|
||||
linewidths=1.0, zorder=4)
|
||||
for k, v in enumerate(A):
|
||||
x, y = pos[v]
|
||||
ax.annotate(f'$A_{k}$', (x, y), textcoords='offset points',
|
||||
xytext=(0, 0), ha='center', va='center', fontsize=10,
|
||||
fontweight='bold', color='black', zorder=5)
|
||||
# outward label too
|
||||
ax.annotate(f'$A_{k}$', (x * 1.18, y * 1.18), ha='center', va='center',
|
||||
fontsize=12, color='#a16207', zorder=5)
|
||||
fig.savefig(os.path.join(OUT_DIR, 'fig_reduced_dual_step2.png'),
|
||||
dpi=170, bbox_inches='tight'); plt.close(fig)
|
||||
|
||||
# ----- Step 3: add v_n joined to A_i, A_{i+1}, A_{i+2} -----
|
||||
fig, ax = base_canvas(
|
||||
"Step 3: add a vertex $v_n$ joined to $A_i, A_{i+1}, A_{i+2}$ "
|
||||
"(here $i=0$).")
|
||||
draw_surviving(ax)
|
||||
draw_nodes(ax, pos, deg2, s=300, color=DEG2, edgecolors='black',
|
||||
linewidths=1.0, zorder=4)
|
||||
for k, v in enumerate(A):
|
||||
ax.annotate(f'$A_{k}$', (pos[v][0] * 1.18, pos[v][1] * 1.18),
|
||||
ha='center', va='center', fontsize=12, color='#a16207', zorder=5)
|
||||
for u in apex_nbrs:
|
||||
(x0, y0), (x1, y1) = npos[v_n], pos[u]
|
||||
ax.plot([x0, x1], [y0, y1], color=APEX, lw=2.4, zorder=5)
|
||||
draw_nodes(ax, npos, [v_n], s=320, color=APEX, marker='s',
|
||||
edgecolors='black', linewidths=1.0, zorder=6)
|
||||
ax.annotate('$v_n$', npos[v_n], textcoords='offset points', xytext=(0, 14),
|
||||
ha='center', fontsize=12, fontweight='bold', color=APEX, zorder=7)
|
||||
fig.savefig(os.path.join(OUT_DIR, 'fig_reduced_dual_step3.png'),
|
||||
dpi=170, bbox_inches='tight'); plt.close(fig)
|
||||
|
||||
# ----- Step 4: add chord A_{i+3} A_{i+4}; the reduced dual -----
|
||||
fig, ax = base_canvas(
|
||||
"Step 4: add the edge $A_{i+3} A_{i+4}$. The result $\\widehat{G}'_{v,i}$ "
|
||||
"is again cubic and planar.")
|
||||
draw_surviving(ax)
|
||||
draw_nodes(ax, pos, deg2, s=300, color=DEG2, edgecolors='black',
|
||||
linewidths=1.0, zorder=4)
|
||||
for k, v in enumerate(A):
|
||||
ax.annotate(f'$A_{k}$', (pos[v][0] * 1.18, pos[v][1] * 1.18),
|
||||
ha='center', va='center', fontsize=12, color='#a16207', zorder=5)
|
||||
for u in apex_nbrs:
|
||||
(x0, y0), (x1, y1) = npos[v_n], pos[u]
|
||||
ax.plot([x0, x1], [y0, y1], color=APEX, lw=2.4, zorder=5)
|
||||
draw_nodes(ax, npos, [v_n], s=320, color=APEX, marker='s',
|
||||
edgecolors='black', linewidths=1.0, zorder=6)
|
||||
ax.annotate('$v_n$', npos[v_n], textcoords='offset points', xytext=(0, 14),
|
||||
ha='center', fontsize=12, fontweight='bold', color=APEX, zorder=7)
|
||||
(x0, y0), (x1, y1) = pos[chord[0]], pos[chord[1]]
|
||||
ax.plot([x0, x1], [y0, y1], color=CHORD, lw=2.8, zorder=5)
|
||||
fig.savefig(os.path.join(OUT_DIR, 'fig_reduced_dual_step4.png'),
|
||||
dpi=170, bbox_inches='tight'); plt.close(fig)
|
||||
|
||||
print("wrote fig_reduced_dual_step1..4.png to", OUT_DIR)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Reduced dual: construction and verification.
|
||||
|
||||
Test input is the icosahedron G (the unique 5-regular triangulation, n=12).
|
||||
Its dual G' is the dodecahedron (a cubic plane graph, 20 vertices). We pick a
|
||||
degree-5 vertex v of G -- equivalently a pentagonal face F_v of G' -- and apply
|
||||
the reduced-dual construction of Definition 2.1:
|
||||
|
||||
1. delete the 5 dual vertices on the boundary of F_v (and incident edges),
|
||||
leaving 5 degree-2 vertices on a new face F;
|
||||
2. order those 5 vertices clockwise around F as A_0..A_4;
|
||||
3. add a vertex v_n joined to A_i, A_{i+1}, A_{i+2};
|
||||
4. add an edge A_{i+3} A_{i+4}.
|
||||
|
||||
We verify the result is again a cubic plane graph, and report the triangulation
|
||||
it is the dual of (its face count = the primal vertex count), to see how the
|
||||
vertex count changes relative to n.
|
||||
|
||||
The dodecahedron is built directly in its concentric "Schlegel" layout with
|
||||
F_v the inner pentagon, so the figures (draw_reduced_dual_steps.py) are clean.
|
||||
"""
|
||||
import math
|
||||
import networkx as nx
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build G' = dodecahedron with concentric positions; F_v = inner pentagon.
|
||||
# Vertex families a (inner pentagon), b, c, d (outer pentagon), 5 each.
|
||||
# Angles increase *clockwise* (90 - 72*i deg) so index order is clockwise.
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_dual():
|
||||
pos = {}
|
||||
R = {'a': 1.0, 'b': 2.2, 'c': 3.6, 'd': 4.8}
|
||||
for i in range(5):
|
||||
for fam in ('a', 'b'):
|
||||
th = math.radians(90 - 72 * i)
|
||||
pos[(fam, i)] = (R[fam] * math.cos(th), R[fam] * math.sin(th))
|
||||
for fam in ('c', 'd'):
|
||||
th = math.radians(90 - 72 * i - 36) # offset half a step
|
||||
pos[(fam, i)] = (R[fam] * math.cos(th), R[fam] * math.sin(th))
|
||||
|
||||
Gp = nx.Graph()
|
||||
Gp.add_nodes_from(pos)
|
||||
for i in range(5):
|
||||
Gp.add_edge(('a', i), ('a', (i + 1) % 5)) # inner pentagon
|
||||
Gp.add_edge(('a', i), ('b', i)) # spokes a-b
|
||||
Gp.add_edge(('b', i), ('c', i)) # b-c
|
||||
Gp.add_edge(('b', i), ('c', (i - 1) % 5)) # b-c (other side)
|
||||
Gp.add_edge(('c', i), ('d', i)) # spokes c-d
|
||||
Gp.add_edge(('d', i), ('d', (i + 1) % 5)) # outer pentagon
|
||||
|
||||
Fv_boundary = [('a', i) for i in range(5)] # inner pentagon
|
||||
return Gp, pos, Fv_boundary
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Face / dual helpers.
|
||||
# ---------------------------------------------------------------------------
|
||||
def faces_of(G):
|
||||
"""Return the list of faces (each a list of vertices) of a plane graph."""
|
||||
ok, emb = nx.check_planarity(G)
|
||||
assert ok, "graph is not planar"
|
||||
seen, faces = set(), []
|
||||
for u in emb:
|
||||
for v in emb[u]:
|
||||
if (u, v) not in seen:
|
||||
faces.append(emb.traverse_face(u, v, mark_half_edges=seen))
|
||||
return faces
|
||||
|
||||
|
||||
def dual_of(G):
|
||||
"""Combinatorial dual (all faces, including outer) of a plane graph."""
|
||||
faces = faces_of(G)
|
||||
edge_faces = {}
|
||||
for fi, face in enumerate(faces):
|
||||
for j in range(len(face)):
|
||||
e = frozenset((face[j], face[(j + 1) % len(face)]))
|
||||
edge_faces.setdefault(e, []).append(fi)
|
||||
D = nx.MultiGraph()
|
||||
D.add_nodes_from(range(len(faces)))
|
||||
for e, fs in edge_faces.items():
|
||||
if len(fs) == 2:
|
||||
D.add_edge(fs[0], fs[1])
|
||||
elif len(fs) == 1: # shouldn't happen for 2-connected G
|
||||
pass
|
||||
return D, faces
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The reduced-dual construction.
|
||||
# ---------------------------------------------------------------------------
|
||||
def clockwise_order(verts, pos):
|
||||
"""Order verts clockwise around their centroid, starting from the topmost."""
|
||||
cx = sum(pos[v][0] for v in verts) / len(verts)
|
||||
cy = sum(pos[v][1] for v in verts) / len(verts)
|
||||
ang = {v: math.atan2(pos[v][1] - cy, pos[v][0] - cx) for v in verts}
|
||||
ccw = sorted(verts, key=lambda v: ang[v]) # counterclockwise
|
||||
cw = list(reversed(ccw)) # clockwise
|
||||
start = max(range(len(cw)), key=lambda k: pos[cw[k]][1]) # topmost first
|
||||
return cw[start:] + cw[:start]
|
||||
|
||||
|
||||
def apply_reduction(Gp, pos, Fv_boundary, i=0):
|
||||
"""Apply Definition 2.1 and return a dict capturing each stage."""
|
||||
Ghat = Gp.copy()
|
||||
npos = dict(pos)
|
||||
|
||||
# (1) delete the 5 boundary dual vertices of F_v
|
||||
Ghat.remove_nodes_from(Fv_boundary)
|
||||
deg2 = [v for v in Ghat if Ghat.degree(v) == 2]
|
||||
assert len(deg2) == 5, f"expected 5 degree-2 vertices, got {len(deg2)}"
|
||||
|
||||
# (2) order them clockwise around the new face F
|
||||
A = clockwise_order(deg2, pos)
|
||||
|
||||
# (3) new vertex v_n joined to A_i, A_{i+1}, A_{i+2}
|
||||
apex_nbrs = [A[(i + k) % 5] for k in range(3)]
|
||||
ax = sum(npos[v][0] for v in apex_nbrs) / 3
|
||||
ay = sum(npos[v][1] for v in apex_nbrs) / 3
|
||||
v_n = 'v_n'
|
||||
npos[v_n] = (ax * 0.55, ay * 0.55) # pull toward the 3 nbrs
|
||||
Ghat.add_node(v_n)
|
||||
for u in apex_nbrs:
|
||||
Ghat.add_edge(v_n, u)
|
||||
|
||||
# (4) chord between the remaining two
|
||||
chord = (A[(i + 3) % 5], A[(i + 4) % 5])
|
||||
Ghat.add_edge(*chord)
|
||||
|
||||
return {
|
||||
'Ghat': Ghat, 'pos': npos, 'A': A, 'v_n': v_n,
|
||||
'apex_nbrs': apex_nbrs, 'chord': chord,
|
||||
'deleted': list(Fv_boundary),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
Gp, pos, Fv = build_dual()
|
||||
|
||||
# --- verify G' is the dodecahedron = dual of the icosahedron ---
|
||||
assert nx.check_planarity(Gp)[0]
|
||||
assert all(d == 3 for _, d in Gp.degree()), "G' not cubic"
|
||||
assert nx.is_isomorphic(Gp, nx.dodecahedral_graph()), "G' is not dodecahedron"
|
||||
Dico, _ = dual_of(Gp)
|
||||
Dico = nx.Graph(Dico)
|
||||
print(f"G (icosahedron) : dual of G' has {Dico.number_of_nodes()} vertices, "
|
||||
f"degrees {sorted({d for _, d in Dico.degree()})}")
|
||||
print(f"G' (dodecahedron): {Gp.number_of_nodes()} vertices, "
|
||||
f"{Gp.number_of_edges()} edges, "
|
||||
f"{len(faces_of(Gp))} faces; cubic={all(d==3 for _,d in Gp.degree())}")
|
||||
|
||||
# --- apply the reduced-dual construction ---
|
||||
res = apply_reduction(Gp, pos, Fv, i=0)
|
||||
Ghat = res['Ghat']
|
||||
cubic = all(d == 3 for _, d in Ghat.degree())
|
||||
planar = nx.check_planarity(Ghat)[0]
|
||||
ghat_simple = (nx.number_of_selfloops(Ghat) == 0) # Graph: no parallels
|
||||
nfaces = len(faces_of(Ghat))
|
||||
print()
|
||||
print(f"reduced dual G^_v,i : {Ghat.number_of_nodes()} vertices, "
|
||||
f"{Ghat.number_of_edges()} edges, {nfaces} faces")
|
||||
print(f" cubic : {cubic}")
|
||||
print(f" planar : {planar}")
|
||||
print(f" simple : {ghat_simple}")
|
||||
|
||||
# --- the triangulation it is dual to ---
|
||||
Dred_multi, _ = dual_of(Ghat)
|
||||
Dred = nx.Graph(Dred_multi)
|
||||
dred_simple = (Dred.number_of_edges() == Dred_multi.number_of_edges())
|
||||
is_tri = all(len(f) == 3 for f in faces_of(Dred)) if planar else None
|
||||
print()
|
||||
print(f"dual of reduced dual : {Dred.number_of_nodes()} vertices "
|
||||
f"(= faces of G^), degree seq "
|
||||
f"{sorted((d for _, d in Dred.degree()), reverse=True)}")
|
||||
print(f" is a triangulation : {is_tri}")
|
||||
print(f" simple : {dred_simple}")
|
||||
|
||||
n = Dico.number_of_nodes()
|
||||
print()
|
||||
print(f"VERTEX COUNT: G has n = {n}; reduced triangulation has "
|
||||
f"{Dred.number_of_nodes()} (change = "
|
||||
f"{Dred.number_of_nodes() - n}).")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user