Add 2+2 color-split outerplanar survey and decomposition figure

Sanity check for the nested-outerplanar-shells construction: every
maximal planar graph through n=11 (1249 triangulations) admits a proper
4-coloring whose colors split into two complementary pairs, each inducing
an outerplanar even-cycle (bipartite) subgraph. Disconnected halves are
allowed. The odd-bipyramid "failures" of the earlier all-six-pairs test
decompose correctly under the right 2+2 split.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 04:19:58 -04:00
parent d9007c8697
commit f0fdae11d4
3 changed files with 292 additions and 0 deletions
@@ -0,0 +1,167 @@
"""Draw the two former "failures" (n=9 bipyramid, n=10 bipyramid+stacked) the
RIGHT way: a proper 4-colouring whose 4 colours split into two complementary
pairs, each inducing an outerplanar (bipartite => even-cycle) subgraph.
Top row: the planar drawing with the 4-colouring; edges of the two split
classes drawn in two styles. Bottom row: the two complementary subgraphs shown
separately, each annotated outerplanar (tree / forest / even cycle).
Renders into this experiments folder.
"""
import os
from itertools import combinations
import networkx as nx
import matplotlib.pyplot as plt
T24 = [(0, 2), (0, 3), (0, 4), (0, 5), (0, 6), (0, 7), (0, 8),
(1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7), (1, 8),
(2, 3), (2, 4), (3, 5), (4, 8), (5, 6), (6, 7), (7, 8)]
T94 = [(0, 2), (0, 3), (0, 4), (0, 5), (0, 6), (0, 7), (0, 8), (0, 9),
(1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7), (1, 8),
(2, 3), (2, 4), (3, 5), (4, 8), (5, 6), (6, 7), (7, 8),
(7, 9), (8, 9)]
CMAP = {0: '#444444', 1: '#d62728', 2: '#1f77b4', 3: '#2ca02c'}
CNAME = {0: 'grey', 1: 'red', 2: 'blue', 3: 'green'}
SPLITS = [({0, 1}, {2, 3}), ({0, 2}, {1, 3}), ({0, 3}, {1, 2})]
def is_outerplanar(G):
if G.number_of_nodes() <= 3:
return True
H = G.copy()
apex = max(H.nodes()) + 1
for v in G.nodes():
H.add_edge(apex, v)
return nx.check_planarity(H)[0]
def enumerate_4colorings(G):
nodes = list(G.nodes())
adj = {v: set(G.neighbors(v)) for v in nodes}
coloring = {}
def bt(i, mx):
if i == len(nodes):
yield dict(coloring)
return
v = nodes[i]
used = {coloring[w] for w in adj[v] if w in coloring}
for c in range(min(3, mx + 1) + 1):
if c in used:
continue
coloring[v] = c
yield from bt(i + 1, max(mx, c))
del coloring[v]
yield from bt(0, -1)
def find_good_split(G):
for col in enumerate_4colorings(G):
for A, B in SPLITS:
va = [v for v in G if col[v] in A]
vb = [v for v in G if col[v] in B]
GA, GB = G.subgraph(va), G.subgraph(vb)
if (is_outerplanar(GA) and nx.is_bipartite(GA)
and is_outerplanar(GB) and nx.is_bipartite(GB)):
return col, (A, B)
return None, None
def bipyramid_pos(rim_cycle, apexA, apexB):
k = len(rim_cycle)
order = rim_cycle[1:] + [rim_cycle[0]]
pos = {}
for i, v in enumerate(order):
x = 1.7 * (1 - 2 * i / (k - 1))
y = 1.0 * (x / 1.7) ** 2
pos[v] = (x, y)
pos[apexA] = (0.0, 0.62)
pos[apexB] = (0.0, -1.7)
return pos
def describe(G):
if G.number_of_edges() == 0:
return "edgeless (isolated vertices)"
if nx.is_forest(G):
return "forest (tree, no cycles)"
girth_even = all(len(c) % 2 == 0 for c in nx.cycle_basis(G))
comps = nx.number_connected_components(G)
tag = "even cycles" if girth_even else "ODD CYCLE!"
return f"outerplanar, {tag}, {comps} component(s)"
def draw_case(fig, gs_row, edges, pos, title):
G = nx.Graph(edges)
col, split = find_good_split(G)
A, B = split
node_colors = [CMAP[col[v]] for v in G.nodes()]
ea = [e for e in G.edges() if col[e[0]] in A and col[e[1]] in A]
eb = [e for e in G.edges() if col[e[0]] in B and col[e[1]] in B]
ecross = [e for e in G.edges() if e not in ea and e not in eb]
# left: whole graph, both classes highlighted
ax = fig.add_subplot(gs_row[0])
nx.draw_networkx_edges(G, pos, ax=ax, edgelist=ecross,
edge_color='#dddddd', width=1.0)
nx.draw_networkx_edges(G, pos, ax=ax, edgelist=ea,
edge_color='#e8860a', width=3.0)
nx.draw_networkx_edges(G, pos, ax=ax, edgelist=eb,
edge_color='#7b2fbf', width=3.0, style='dashed')
nx.draw_networkx_nodes(G, pos, node_color=node_colors, node_size=720,
edgecolors='black', linewidths=1.4, ax=ax)
nx.draw_networkx_labels(G, pos, ax=ax, font_color='white',
font_weight='bold', font_size=11)
an = "/".join(CNAME[c] for c in sorted(A))
bn = "/".join(CNAME[c] for c in sorted(B))
ax.set_title(f"{title}\nsplit [{an}] (orange solid) | "
f"[{bn}] (purple dashed)", fontsize=10)
ax.axis('off'); ax.set_aspect('equal')
# middle: subgraph A alone
GA = G.subgraph([v for v in G if col[v] in A])
axA = fig.add_subplot(gs_row[1])
nx.draw_networkx_edges(GA, pos, ax=axA, edge_color='#e8860a', width=3.0)
nx.draw_networkx_nodes(GA, pos, node_color=[CMAP[col[v]] for v in GA],
node_size=720, edgecolors='black',
linewidths=1.4, ax=axA)
nx.draw_networkx_labels(GA, pos, ax=axA, font_color='white',
font_weight='bold', font_size=11)
axA.set_title(f"[{an}] subgraph\n{describe(GA)}", fontsize=9)
axA.axis('off'); axA.set_aspect('equal')
# right: subgraph B alone
GB = G.subgraph([v for v in G if col[v] in B])
axB = fig.add_subplot(gs_row[2])
nx.draw_networkx_edges(GB, pos, ax=axB, edge_color='#7b2fbf', width=3.0)
nx.draw_networkx_nodes(GB, pos, node_color=[CMAP[col[v]] for v in GB],
node_size=720, edgecolors='black',
linewidths=1.4, ax=axB)
nx.draw_networkx_labels(GB, pos, ax=axB, font_color='white',
font_weight='bold', font_size=11)
axB.set_title(f"[{bn}] subgraph\n{describe(GB)}", fontsize=9)
axB.axis('off'); axB.set_aspect('equal')
rim = [2, 3, 5, 6, 7, 8, 4]
pos24 = bipyramid_pos(rim, 0, 1)
pos94 = dict(pos24)
pos94[9] = ((pos24[0][0] + pos24[7][0] + pos24[8][0]) / 3,
(pos24[0][1] + pos24[7][1] + pos24[8][1]) / 3)
fig = plt.figure(figsize=(15, 10))
gs = fig.add_gridspec(2, 3)
draw_case(fig, [gs[0, 0], gs[0, 1], gs[0, 2]], T24, pos24,
"n=9 T24: 7-gonal bipyramid")
draw_case(fig, [gs[1, 0], gs[1, 1], gs[1, 2]], T94, pos94,
"n=10 T94: bipyramid + stacked vertex 9")
fig.suptitle("Both decompose: 4-colouring -> 2+2 colour split -> two "
"complementary outerplanar even-cycle subgraphs", fontsize=12)
fig.tight_layout(rect=[0, 0, 1, 0.97])
out = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'split_decomposition.png')
fig.savefig(out, dpi=140)
print(f"wrote {out}")
Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

@@ -0,0 +1,125 @@
"""Corrected sanity check for the nested-outerplanar-shells construction.
The construction splits the FOUR colours into TWO complementary pairs (two
parity classes). Each pair induces a bipartite subgraph; the two subgraphs
partition the vertex set. For a nested-shell decomposition we want BOTH
complementary subgraphs to be outerplanar (bipartite => only even cycles, so
"even cycles" is automatic; outerplanar is the binding condition).
There are exactly 3 ways to split {0,1,2,3} into two pairs:
{0,1}|{2,3}, {0,2}|{1,3}, {0,3}|{1,2}.
Criterion (per triangulation): does SOME proper 4-colouring admit SOME split
whose two complementary subgraphs are both outerplanar?
This is the right test (an earlier version wrongly demanded that all SIX
colour pairs be outerplanar, which odd bipyramids fail on the apex/heavy-rim
pair -- but that pair is never one we'd use).
Usage: python3 two_color_split_survey.py [n_max] (default 10)
"""
import sys
import os
import time
import networkx as nx
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, '/Users/didericis/Code/math-research/papers/'
'level_resolutions_of_maximal_planar_graphs/experiments')
from triangulation_gen import enumerate_all_triangulations
# the 3 ways to split 4 colours into two complementary pairs
SPLITS = [(({0, 1}), ({2, 3})),
(({0, 2}), ({1, 3})),
(({0, 3}), ({1, 2}))]
def is_outerplanar(G):
if G.number_of_nodes() <= 3:
return True
H = G.copy()
apex = max(H.nodes()) + 1
for v in G.nodes():
H.add_edge(apex, v)
return nx.check_planarity(H)[0]
def enumerate_4colorings(G):
nodes = list(G.nodes())
adj = {v: set(G.neighbors(v)) for v in nodes}
coloring = {}
def bt(i, mx):
if i == len(nodes):
yield dict(coloring)
return
v = nodes[i]
used = {coloring[w] for w in adj[v] if w in coloring}
for c in range(min(3, mx + 1) + 1):
if c in used:
continue
coloring[v] = c
yield from bt(i + 1, max(mx, c))
del coloring[v]
yield from bt(0, -1)
def outerplanar_even(G):
"""Outerplanar AND every cycle even (i.e. bipartite). Disconnected ok.
For a two-colour subgraph of a proper colouring bipartiteness is automatic,
but we verify it explicitly so the criterion is honestly enforced."""
return is_outerplanar(G) and nx.is_bipartite(G)
def good_split(G, col):
"""Return the first (sideA, sideB) split whose two complementary
subgraphs are both outerplanar-with-even-cycles, or None.
Disconnected subgraphs are allowed."""
for A, B in SPLITS:
va = [v for v in G if col[v] in A]
vb = [v for v in G if col[v] in B]
if outerplanar_even(G.subgraph(va)) and outerplanar_even(G.subgraph(vb)):
return (A, B)
return None
def has_good_coloring(G):
"""True iff SOME proper 4-colouring admits a valid 2+2 split.
Early-exits on the first good colouring."""
for col in enumerate_4colorings(G):
if good_split(G, col) is not None:
return True
return False
def survey_n(n):
t0 = time.time()
tris = enumerate_all_triangulations(n)
n_good = 0
bad = []
for gi, G in enumerate(tris):
ok = has_good_coloring(G)
if ok:
n_good += 1
else:
bad.append((gi, G))
return n, len(tris), n_good, bad, time.time() - t0
def main():
n_max = int(sys.argv[1]) if len(sys.argv) > 1 else 10
print(f"{'n':>3} {'tris':>6} {'has 2+2 split':>14} {'time(s)':>8}")
print("-" * 38)
for n in range(6, n_max + 1):
n, ntri, ngood, bad, dt = survey_n(n)
flag = "" if ngood == ntri else " <-- GAP"
print(f"{n:>3} {ntri:>6} {ngood:>9}/{ntri:<4} {dt:>8.1f}{flag}")
for gi, G in bad[:5]:
edges = sorted(tuple(sorted(e)) for e in G.edges())
print(f" no split: T{gi} edges={edges}")
if __name__ == "__main__":
main()