dual_decomposition: chord-apex proof + diagrams

- Replace the chord-apex TODO with a full proof by contradiction: assume
  merged != spike, define X, Y, Z, W, lift to G' so that the externals
  inherit \psi(f) = (X, Y, Z, W, W), and split on W in {X, Z}. Either case
  meets the hypothesis of lem:pentagonal-externals, which extends \psi to a
  proper 3-edge-colouring of G' --- contradicting non-3-edge-colourability
  via Tait.
- Add fig:chord-apex-proof: the assumed reduced-dual colouring on top, and
  the two lifted-G' cases (W=Z, W=X) below, rendered on the dodecahedron.
- Add experiments/draw_chord_apex_proof.py.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 02:31:54 -04:00
parent 0303225f39
commit 409dea565a
8 changed files with 293 additions and 39 deletions
@@ -0,0 +1,180 @@
r"""Draw diagrams illustrating the proof of Lemma 2.6 (chord-apex).
Three figures rendered on the dodecahedral G':
step1: the assumed proper 3-edge-colouring of \widehat{G}'_{v,0}, with the
v_n-edges receiving distinct colours X (red), Y (green, spike),
Z (blue), and the chord coloured W with W != Y, forcing W in {X, Z}.
step2: lift to G' in the case W = Z. The externals inherit
psi(f) = (X, Y, Z, Z, Z) and Lemma 2.4 completes partial F_v.
step3: lift to G' in the case W = X with psi(f) = (X, Y, Z, X, X).
"""
import os
import matplotlib.pyplot as plt
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__)))
C1 = '#dc2626' # red -> X
C2 = '#16a34a' # green -> Y (spike)
C3 = '#2563eb' # blue -> Z
DARK = '#374151'
DEG2 = '#f59e0b'
GRAY = '#9ca3af'
LIGHT = '#e5e7eb'
APEX_FILL = '#fef3c7'
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 draw_lift(ax, Gp, pos, A, B, other_surv, f_cols, e_cols, f_labels):
"""G' with f_k and partial F_v boundary edges coloured; rest in light gray."""
pentagon_edges = set()
for k in range(5):
pentagon_edges.add(frozenset((A[k], B[k]))) # f_k
pentagon_edges.add(frozenset((B[k], B[(k + 1) % 5]))) # boundary
for u, v in Gp.edges():
if frozenset((u, v)) in pentagon_edges:
continue
(x0, y0), (x1, y1) = pos[u], pos[v]
ax.plot([x0, x1], [y0, y1], color=LIGHT, lw=1.2, zorder=1)
other = [v for v in other_surv if v not in A and v not in B]
xs = [pos[v][0] for v in other]
ys = [pos[v][1] for v in other]
ax.scatter(xs, ys, s=50, color=LIGHT, zorder=2)
for k, v in enumerate(A):
ax.scatter(*pos[v], s=240, color=DEG2, edgecolors='black',
linewidths=0.8, zorder=4)
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 k, v in enumerate(B):
ax.scatter(*pos[v], s=160, color='white', edgecolors=DARK,
linewidths=1.2, zorder=4)
ax.annotate(f'$B_{k}$', (pos[v][0] * 0.62, pos[v][1] * 0.62),
ha='center', va='center', fontsize=11, color=DARK,
zorder=5)
for k in range(5):
(x0, y0), (x1, y1) = pos[B[k]], pos[A[k]]
ax.plot([x0, x1], [y0, y1], color=f_cols[k], lw=3.2, zorder=3)
mx, my = 0.55 * x0 + 0.45 * x1, 0.55 * y0 + 0.45 * y1
ax.annotate(f_labels[k], (mx, my), fontsize=10, color=f_cols[k],
fontweight='bold', ha='center', va='center', zorder=7,
bbox=dict(boxstyle='round,pad=0.15', fc='white',
ec=f_cols[k], lw=0.5))
for k in range(5):
(x0, y0), (x1, y1) = pos[B[k]], pos[B[(k + 1) % 5]]
ax.plot([x0, x1], [y0, y1], color=e_cols[k], lw=3.2, zorder=3)
def main():
Gp, pos, Fv = build_dual()
res = apply_reduction(Gp, pos, Fv, i=0)
npos, A = res['pos'], res['A']
v_n, apex_nbrs, chord = res['v_n'], res['apex_nbrs'], res['chord']
a_pair = {}
for u in A:
for nbr in Gp.neighbors(u):
if nbr in Fv:
a_pair[u] = nbr
break
B = [a_pair[A[k]] for k in range(5)]
survivors = [v for v in Gp if v not in Fv]
surv_set = set(survivors)
surv_edges = [(u, v) for u, v in Gp.edges()
if u in surv_set and v in surv_set]
other_surv = [v for v in survivors if v not in A]
# ----- Step 1: assumed colouring of the reduced dual -----
fig, ax = base_canvas(
"Step 1: $\\varphi$ on $\\widehat{G}'_{v,0}$ assigns distinct colours "
"$X, Y, Z$ to the $v_n$-edges (propriety at $v_n$);\n"
"by hypothesis $W \\neq Y$, forcing $W \\in \\{X, Z\\}$.")
for u, v in surv_edges:
(x0, y0), (x1, y1) = pos[u], pos[v]
ax.plot([x0, x1], [y0, y1], color=GRAY, lw=1.4, zorder=1)
xs = [pos[v][0] for v in other_surv]
ys = [pos[v][1] for v in other_surv]
ax.scatter(xs, ys, s=80, color=DARK, zorder=3)
for k, v in enumerate(A):
ax.scatter(*pos[v], s=240, color=DEG2, edgecolors='black',
linewidths=0.8, zorder=4)
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 k, (color, lbl) in enumerate([(C1, '$X$'), (C2, '$Y$'), (C3, '$Z$')]):
u = A[k]
(x0, y0), (x1, y1) = npos[v_n], pos[u]
ax.plot([x0, x1], [y0, y1], color=color, lw=3.4, zorder=5)
mx, my = 0.5 * x1 + 0.5 * x0, 0.5 * y1 + 0.5 * y0
ax.annotate(lbl, (mx, my), fontsize=12, color=color, fontweight='bold',
ha='center', va='center', zorder=7,
bbox=dict(boxstyle='round,pad=0.18', fc='white', ec=color,
lw=0.6))
ax.scatter(*npos[v_n], s=300, color=APEX_FILL, 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=DARK,
zorder=7)
(x0, y0), (x1, y1) = pos[chord[0]], pos[chord[1]]
ax.plot([x0, x1], [y0, y1], color=DARK, lw=2.4, ls='--', zorder=5)
mx, my = (x0 + x1) / 2, (y0 + y1) / 2
ax.annotate('$W$', (mx, my), fontsize=12, color=DARK, fontweight='bold',
ha='center', va='center', zorder=7,
bbox=dict(boxstyle='round,pad=0.18', fc='white', ec=DARK,
lw=0.6))
ax.legend(handles=[
Line2D([0], [0], color=C1, lw=3, label='$X$ (side)'),
Line2D([0], [0], color=C2, lw=3, label='$Y$ (spike)'),
Line2D([0], [0], color=C3, lw=3, label='$Z$ (side)'),
Line2D([0], [0], color=DARK, lw=2, ls='--',
label='$W$ (merged), $W \\neq Y$'),
], loc='upper left', fontsize=10)
fig.savefig(os.path.join(OUT_DIR, 'fig_chord_apex_step1.png'),
dpi=170, bbox_inches='tight')
plt.close(fig)
cases = [
dict(
title=("Step 2: lift to $G'$ when $W = Z$. The externals inherit "
"$\\psi(f) = (X, Y, Z, Z, Z)$;\n"
"Lemma 2.4 colours the five edges of $\\partial F_v$."),
f_cols=[C1, C2, C3, C3, C3],
e_cols=[C3, C1, C2, C1, C2],
f_labels=['$X$', '$Y$', '$Z$', '$Z$', '$Z$'],
out='fig_chord_apex_step2.png',
),
dict(
title=("Step 3: lift to $G'$ when $W = X$. The externals inherit "
"$\\psi(f) = (X, Y, Z, X, X)$;\n"
"Lemma 2.4 colours the five edges of $\\partial F_v$."),
f_cols=[C1, C2, C3, C1, C1],
e_cols=[C3, C1, C2, C3, C2],
f_labels=['$X$', '$Y$', '$Z$', '$X$', '$X$'],
out='fig_chord_apex_step3.png',
),
]
for case in cases:
fig, ax = base_canvas(case['title'])
draw_lift(ax, Gp, pos, A, B, other_surv,
case['f_cols'], case['e_cols'], case['f_labels'])
fig.savefig(os.path.join(OUT_DIR, case['out']),
dpi=170, bbox_inches='tight')
plt.close(fig)
print(f"wrote fig_chord_apex_step{{1,2,3}}.png to {OUT_DIR}")
if __name__ == '__main__':
main()