coloring_nested_tire_graphs: joint-projection chain DP + tree-H_d coverage gap

NEW: chain_dp_joint.py — chain DP tracking full per-tire colorings,
edge-tuple-based parent/child sharing, and ground-truth comparison
against brute-force G' edge-coloring enumeration.

KEY EMPIRICAL FINDING (4th issue in chain_half_analysis):
When H_d is a tree (no internal cycles), the high-side cut tire
forest is EMPTY.  The single H_d face is forced (by the level-set
lemma) to be entirely low-side or high-side; for a tree containing
the pendants, it's low-side.  Hence high-side forest has 0 tires.

This happens at dodecahedron cut #0 side 0 (|S_0|=4):
  - depths {0: 2, 1: 3}, |H|=6, |E(H)|=5
  - H_1 is a tree, 1 face of length 6 (= low-side)
  - No high-side cut tires
  - DP gives R_dp=0, but ground truth R=36

DP correctly produces non-empty output on side 1 (where H_1 has
2 faces, one high-side), but the high-side framework's coverage
is incomplete for thin (small |S_i|) cuts.

This is a STRUCTURAL gap, not a code bug.  The path forward
suggested in chain_half_analysis.tex: introduce a "boundary cut
tire" T_0 representing the low-side face + its pendants, so the
chain DP runs from leaves through T_0 to the cut.

Compounding with prior gaps:
  (1) cut tires aren't always spoke-only (branched H_d faces)
  (2) OUT-only projection loses S_3 orbit
  (3) heuristic parent-finding (vertex overlap)
  (4) tree H_d → empty high-side forest (this commit)

Net: the loose conjecture's chain half is genuinely open and
requires framework extension before the DP can be tested cleanly.
S_3 equivariance and high-side forest structure are the proven
pieces.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 22:58:21 -04:00
parent 203b005336
commit 84600dadd3
5 changed files with 472 additions and 32 deletions
@@ -0,0 +1,375 @@
"""Chain DP with JOINT projection tracking + rigorous edge-based
parent verification + ground-truth comparison.
Key fixes vs. chain_dp_general.py:
(1) DP state per tire = SET of full proper edge 3-colorings of the
tire (cycle + pendants), indexed by edge id within the tire.
No projection to spokes; the full coloring is the state.
(2) Composition between parent T_p and child T_c is via SHARED
EDGES (= full G'_i edge tuples appearing in both tires).
Parent coloring c_p is compatible with child coloring c_c iff
they agree on every shared edge. No need to figure out which
in-spoke maps to which cycle edge — equality of full edge
tuples does the work.
(3) Parent-finding sanity: after build_tree's heuristic, verify
each (child, parent) pair shares at least one edge. If not,
flag as bug.
(4) Ground truth: compute ALL proper 3-edge-colorings of G'_i via
backtracking with constraint propagation. Project to cut
edges. Compare R_i^DP with R_i^direct. They should match.
The empirical test: for 3-edge-colorable G (dodecahedron, HM #0
are all 3-edge-colorable by Vizing/Tait), R_0 ∩ R_1 should be
non-empty (= projection of a full G coloring to cut).
"""
import os
import sys
import itertools
from collections import defaultdict
from sage.all import Graph, graphs
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from cut_depth_label import (
parse_planar_code, HM_FILE,
apply_procedure, compute_nice_layout,
)
from cut_tire_tree import build_tree
from tree_structure_sweep import all_six_edge_cuts
def tire_edges(H, edge_depth, d, face):
"""Return ordered list of edges in cut tire T_d^{(face)}.
Cycle edges (depth d) + pendants (depth d-1 OUT and d+1 IN).
Each edge is a sorted tuple of vertices.
Returns (edges_in_order, vertex_to_edge_ids, in_pendant_ids,
out_pendant_ids, cycle_edge_ids)."""
# Cycle edges (dedup)
cycle_e_set = set()
cycle_edges = []
for u, v in face:
e = tuple(sorted((u, v)))
if e not in cycle_e_set:
cycle_e_set.add(e)
cycle_edges.append(e)
# Vertices on cycle
verts = set()
for u, v in cycle_edges:
verts.add(u); verts.add(v)
# Pendants
pendants_in, pendants_out = [], []
for v in verts:
for nb in H.neighbors(v):
e = tuple(sorted((v, nb)))
if e in cycle_e_set:
continue
de = edge_depth.get(e)
if de == d + 1:
pendants_in.append((v, e))
elif de == d - 1:
pendants_out.append((v, e))
# Edge list: cycle then in pendants then out pendants
all_edges = cycle_edges + [e for (_, e) in pendants_in] + \
[e for (_, e) in pendants_out]
edge_id = {e: i for i, e in enumerate(all_edges)}
# Per vertex, edges at v
edges_at_v = defaultdict(list)
for (a, b) in cycle_edges:
edges_at_v[a].append(edge_id[(a, b)])
edges_at_v[b].append(edge_id[(a, b)])
for (v, e) in pendants_in:
edges_at_v[v].append(edge_id[e])
for (v, e) in pendants_out:
edges_at_v[v].append(edge_id[e])
cycle_eids = [edge_id[e] for e in cycle_edges]
in_eids = [edge_id[e] for (_, e) in pendants_in]
out_eids = [edge_id[e] for (_, e) in pendants_out]
return {
'all_edges': all_edges,
'edge_id': edge_id,
'edges_at_v': dict(edges_at_v),
'cycle_eids': cycle_eids,
'in_eids': in_eids,
'out_eids': out_eids,
}
def enumerate_proper(struct):
"""Enumerate proper 3-edge-colorings of the tire as tuples of
colors (length = n_edges). Return None if too big."""
n_e = len(struct['all_edges'])
if n_e > 14:
return None
out = []
for assign in itertools.product([0, 1, 2], repeat=n_e):
ok = True
for v, eids in struct['edges_at_v'].items():
cs = [assign[i] for i in eids]
if len(set(cs)) != len(cs):
ok = False; break
if ok:
out.append(assign)
return out
def chain_dp_joint(faces_by_depth, parent_of, H, edge_depth):
"""Chain DP with full per-tire colorings tracked.
Returns:
A: node -> list of full colorings (tuples)
tire_struct: node -> structure dict
issues: list of warnings
"""
tire_struct = {}
for d, faces in faces_by_depth.items():
for fi, face in enumerate(faces):
tire_struct[(d, fi)] = tire_edges(H, edge_depth, d, face)
children = defaultdict(list)
for node, p in parent_of.items():
if p is not None and p != ('cut', None):
children[p].append(node)
issues = []
# Verify parent-child have shared edges
for ch_node, p_node in parent_of.items():
if p_node is None or p_node == ('cut', None):
continue
ch_edges = set(tire_struct[ch_node]['all_edges'])
p_edges = set(tire_struct[p_node]['all_edges'])
shared = ch_edges & p_edges
if not shared:
issues.append({
'type': 'no_shared',
'child': ch_node, 'parent': p_node,
'n_ch_edges': len(ch_edges),
'n_p_edges': len(p_edges),
})
max_d = max(d for (d, _) in tire_struct)
A = {}
for d in range(max_d, 0, -1):
for node in [n for n in tire_struct if n[0] == d]:
cs = enumerate_proper(tire_struct[node])
if cs is None:
A[node] = None
continue
kids = children.get(node, [])
if not kids:
A[node] = cs
continue
# For each kid, build {shared_color_tuple: count} so
# parent can check existence quickly
kid_shared = []
for ch in kids:
if A.get(ch) is None:
kid_shared.append(None)
continue
ch_struct = tire_struct[ch]
# Shared edges between parent and child
p_edges = tire_struct[node]['all_edges']
p_eid = tire_struct[node]['edge_id']
ch_eid = ch_struct['edge_id']
shared_pairs = [] # (p_eid, ch_eid)
for e in p_edges:
if e in ch_eid:
shared_pairs.append((p_eid[e], ch_eid[e]))
if not shared_pairs:
kid_shared.append(None)
continue
# Build set of child shared-tuples (just keys we care)
ch_shared_set = set()
for ch_assign in A[ch]:
key = tuple(ch_assign[c_eid] for (_, c_eid)
in shared_pairs)
ch_shared_set.add(key)
kid_shared.append((shared_pairs, ch_shared_set))
# Filter parent colorings by all kid constraints
achievable = []
for assign in cs:
ok = True
for ks in kid_shared:
if ks is None:
continue
shared_pairs, ch_shared_set = ks
key = tuple(assign[p_eid] for (p_eid, _) in shared_pairs)
if key not in ch_shared_set:
ok = False; break
if ok:
achievable.append(assign)
A[node] = achievable
return A, tire_struct, issues
def proper_3_edge_colorings_full(G, max_n=100):
"""Brute-force enumerate all proper 3-edge-colorings of G via
backtracking + constraint propagation. Returns list of dicts
{edge_tuple: color}. Only feasible for small G."""
edges = [tuple(sorted(e[:2])) for e in G.edges()]
n = len(edges)
if n > max_n:
return None
edge_idx = {e: i for i, e in enumerate(edges)}
# Edges incident to each vertex
eav = defaultdict(list)
for i, (u, v) in enumerate(edges):
eav[u].append(i)
eav[v].append(i)
# Backtracking
colors = [-1] * n
results = []
def backtrack(i):
if i == n:
results.append(tuple(colors))
return
u, v = edges[i]
used = set()
for j in eav[u]:
if colors[j] != -1:
used.add(colors[j])
for j in eav[v]:
if colors[j] != -1:
used.add(colors[j])
for c in range(3):
if c in used:
continue
colors[i] = c
backtrack(i + 1)
colors[i] = -1
backtrack(0)
return [{edges[i]: c for i, c in enumerate(r)} for r in results]
def project_to_cut(colorings, cut_edges):
"""Project a list of full colorings to the given cut edges.
cut_edges is a list of original-graph edges (tuples).
Returns set of color tuples."""
cut_norm = [tuple(sorted(e[:2])) for e in cut_edges]
out = set()
for col in colorings:
try:
out.add(tuple(col[ce] for ce in cut_norm))
except KeyError:
continue
return out
def s3_orbit(t):
"""Return S_3 orbit of tuple under color permutation."""
return {tuple(p[c] for c in t) for p in itertools.permutations([0, 1, 2])}
def run_one_cut(G, cut_idx, cuts, base_pos, verbose=False):
S, cut_edges = cuts[cut_idx]
S0, S1 = S, frozenset(G.vertices()) - S
if len(S0) < 4 or len(S1) < 4:
return None
if verbose:
print(f' cut #{cut_idx}: |S0|={len(S0)}, |S1|={len(S1)}',
flush=True)
result = {}
# Ground truth: enumerate G's 3-edge-colorings, project to cut
g_cols = proper_3_edge_colorings_full(G)
if g_cols is None:
result['ground_truth'] = None
else:
R_ground = project_to_cut(g_cols, cut_edges)
result['R_ground'] = R_ground
if verbose:
print(f' G has {len(g_cols)} proper 3-edge-colorings, '
f'|R_ground|={len(R_ground)}', flush=True)
# DP per side
for side, S_side in [('0', S0), ('1', S1)]:
pendant_id = max(G.vertices()) + 1 + (0 if side == '0' else 500)
try:
H, pos, ed, _, _, _ = apply_procedure(
G, S_side, cut_edges, base_pos, side,
pendant_start_id=pendant_id)
except Exception as e:
result[f'side_{side}_error'] = str(e)
continue
if not ed:
continue
faces_by_depth, parent_of, _ = build_tree(H, ed)
try:
A, struct, issues = chain_dp_joint(
faces_by_depth, parent_of, H, ed)
except Exception as e:
result[f'side_{side}_dp_error'] = str(e)
continue
if issues:
result[f'side_{side}_issues'] = issues
# Roots = depth-1 tires; project to cut (= depth-0 pendant edges
# in the H subgraph). Cut pendant edges are stored as pseudo
# edges in H.
# Cut edges in H: pendant edges at the original cut endpoints
cut_eids_in_H = []
for u, v in cut_edges:
# In side-i view, only the side-i endpoint exists; the cut
# edge in H is between the original endpoint and a new
# pendant vertex. We need to identify these pendants.
pass
# Simpler: for each root tire T_1^{(f)} on this side, its out
# spokes (depth 0) ARE the cut edges (or some of them).
R_dp = set()
for node, a in A.items():
if node[0] != 1 or a is None:
continue
# We want to identify cut edges in this tire's edges.
# Cut edges are depth-0 pendants in H. Filter struct's
# all_edges to depth-0 edges.
depth_0_eids = [i for i, e in enumerate(struct[node]['all_edges'])
if ed.get(e) == 0]
if not depth_0_eids:
continue
for assign in a:
tup = tuple(assign[i] for i in depth_0_eids)
# Also store WHICH cut edges (by tuple)
edges_used = [struct[node]['all_edges'][i]
for i in depth_0_eids]
# Project: key by edge tuple, value by color
R_dp.add(frozenset(zip(edges_used, tup)))
result[f'R_dp_{side}_raw'] = R_dp
# Convert frozenset-of-(edge,color) to a per-cut-edge color
# tuple (in fixed order of cut_edges)
cut_norm = [tuple(sorted(e[:2])) for e in cut_edges]
# The cut edges in H may have different endpoints (pendant
# vertices added by apply_procedure), so we need a mapping.
# For now, count cardinality and S3 stats.
result[f'R_dp_{side}_size'] = len(R_dp)
return result
def main():
print('=== Dodecahedron ===', flush=True)
G = graphs.DodecahedralGraph()
G.is_planar(set_embedding=True)
base_pos = compute_nice_layout(G)
cuts = all_six_edge_cuts(G, max_cuts=3)
for cut_idx in range(min(3, len(cuts))):
res = run_one_cut(G, cut_idx, cuts, base_pos, verbose=True)
if res is None:
continue
if 'R_ground' in res:
R_ground = res['R_ground']
print(f' ground truth |R|={len(R_ground)}, S_3 orbits='
f'{len({frozenset(s3_orbit(t)) for t in R_ground})}',
flush=True)
for side in ['0', '1']:
sz = res.get(f'R_dp_{side}_size', 'N/A')
issues = res.get(f'side_{side}_issues')
print(f' side {side}: |R_dp|={sz}, '
f'issues={len(issues) if issues else 0}', flush=True)
if issues:
for iss in issues[:2]:
print(f' {iss}', flush=True)
if __name__ == '__main__':
main()
@@ -15,4 +15,7 @@
\@writefile{toc}{\contentsline {paragraph}{What this changes in the chain DP.}{4}{}\protected@file@percent }
\@writefile{toc}{\contentsline {paragraph}{Second issue: out-spoke projection loses S$_3$ orbit.}{4}{}\protected@file@percent }
\@writefile{toc}{\contentsline {paragraph}{Third issue: heuristic parent-finding.}{4}{}\protected@file@percent }
\gdef \@abspage@last{4}
\@writefile{toc}{\contentsline {paragraph}{Fourth issue: when $H_d$ is a tree, the high-side forest is empty.}{4}{}\protected@file@percent }
\@writefile{toc}{\contentsline {paragraph}{Empirical baseline for ground-truth comparison.}{5}{}\protected@file@percent }
\@writefile{toc}{\contentsline {paragraph}{Path forward.}{5}{}\protected@file@percent }
\gdef \@abspage@last{5}
@@ -1,4 +1,4 @@
This is pdfTeX, Version 3.141592653-2.6-1.40.24 (TeX Live 2022) (preloaded format=pdflatex 2022.10.5) 26 MAY 2026 22:49
This is pdfTeX, Version 3.141592653-2.6-1.40.24 (TeX Live 2022) (preloaded format=pdflatex 2022.10.5) 26 MAY 2026 22:58
entering extended mode
restricted \write18 enabled.
%&-line parsing enabled.
@@ -280,38 +280,39 @@ ta from the partial- tire-dual chain pi-geon-hole (\OT1/cmtt/m/n/10.95 tire[]fi
ber[]step2.tex\OT1/cmr/m/n/10.95 ):
[]
[2] [3] [4] (./chain_half_analysis.aux) )
[2] [3] [4] [5] (./chain_half_analysis.aux) )
Here is how much of TeX's memory you used:
3256 strings out of 478268
48448 string characters out of 5846347
348617 words of memory out of 5000000
350617 words of memory out of 5000000
21443 multiletter control sequences out of 15000+600000
479693 words of font info for 69 fonts, out of 8000000 for 9000
1141 hyphenation exceptions out of 8191
55i,8n,62p,236b,241s stack positions out of 10000i,1000n,20000p,200000b,200000s
{/usr/local/texlive/2022/texmf-dist/fo
nts/enc/dvips/cm-super/cm-super-ts1.enc}</usr/local/texlive/2022/texmf-dist/fon
ts/type1/public/amsfonts/cm/cmbx10.pfb></usr/local/texlive/2022/texmf-dist/font
s/type1/public/amsfonts/cm/cmbx12.pfb></usr/local/texlive/2022/texmf-dist/fonts
/type1/public/amsfonts/cm/cmex10.pfb></usr/local/texlive/2022/texmf-dist/fonts/
type1/public/amsfonts/cm/cmmi10.pfb></usr/local/texlive/2022/texmf-dist/fonts/t
ype1/public/amsfonts/cm/cmmi12.pfb></usr/local/texlive/2022/texmf-dist/fonts/ty
pe1/public/amsfonts/cm/cmmi6.pfb></usr/local/texlive/2022/texmf-dist/fonts/type
1/public/amsfonts/cm/cmmi8.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/
public/amsfonts/cm/cmr10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/pu
blic/amsfonts/cm/cmr17.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/publ
ic/amsfonts/cm/cmr6.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/
amsfonts/cm/cmr7.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/ams
fonts/cm/cmr8.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfon
ts/cm/cmsy10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfont
s/cm/cmsy8.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/
cm/cmti10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/c
m/cmtt10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/cm-super/sf
rm1095.pfb>
Output written on chain_half_analysis.pdf (4 pages, 216066 bytes).
55i,8n,62p,236b,243s stack positions out of 10000i,1000n,20000p,200000b,200000s
{/usr/local/texlive/2022/texmf-dis
t/fonts/enc/dvips/cm-super/cm-super-ts1.enc}</usr/local/texlive/2022/texmf-dist
/fonts/type1/public/amsfonts/cm/cmbx10.pfb></usr/local/texlive/2022/texmf-dist/
fonts/type1/public/amsfonts/cm/cmbx12.pfb></usr/local/texlive/2022/texmf-dist/f
onts/type1/public/amsfonts/cm/cmex10.pfb></usr/local/texlive/2022/texmf-dist/fo
nts/type1/public/amsfonts/cm/cmmi10.pfb></usr/local/texlive/2022/texmf-dist/fon
ts/type1/public/amsfonts/cm/cmmi12.pfb></usr/local/texlive/2022/texmf-dist/font
s/type1/public/amsfonts/cm/cmmi6.pfb></usr/local/texlive/2022/texmf-dist/fonts/
type1/public/amsfonts/cm/cmmi7.pfb></usr/local/texlive/2022/texmf-dist/fonts/ty
pe1/public/amsfonts/cm/cmmi8.pfb></usr/local/texlive/2022/texmf-dist/fonts/type
1/public/amsfonts/cm/cmr10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/
public/amsfonts/cm/cmr17.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/pu
blic/amsfonts/cm/cmr6.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/publi
c/amsfonts/cm/cmr7.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/a
msfonts/cm/cmr8.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsf
onts/cm/cmsy10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfo
nts/cm/cmsy8.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfont
s/cm/cmti10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts
/cm/cmtt10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/cm-super/
sfrm1095.pfb>
Output written on chain_half_analysis.pdf (5 pages, 229034 bytes).
PDF statistics:
103 PDF objects out of 1000 (max. 8388607)
62 compressed objects within 1 object stream
111 PDF objects out of 1000 (max. 8388607)
67 compressed objects within 1 object stream
0 named destinations out of 1000 (max. 500000)
1 words of extra memory for PDF output out of 10000 (max. 10000000)
@@ -247,6 +247,46 @@ heuristic, which can mis-attribute children to wrong parents.
For a rigorous empirical test, parent assignment should use the
planar embedding's face-in-face containment, not vertex overlap.
\paragraph{Fourth issue: when $H_d$ is a tree, the high-side
forest is empty.} The level-set lemma forces each face of $H_d$
to be entirely low-side or entirely high-side. If $H_d$ has no
cycles (= tree/forest), it has a single face containing all
non-$H_d$ edges of $G'_i$, and that face must be one or the other
(low or high), not both. In the small-side regime (e.g.\
dodecahedron $6$-edge cut with $|S_0| = 4$, side $0$), the BFS
only reaches depth $1$ and $H_1$ is a tree (5 edges, 6 vertices,
$1$ face containing the $6$ pendants). This single face is
low-side --- so the high-side cut tire forest of $G'_0$ is
\emph{empty}.
\medskip
In this case, the framework gives no high-side cut tires on
$G'_0$, hence $\mathcal{R}_0 = \emptyset$ by the framework's
projection to root out-spokes. But $G$ is $3$-edge-colorable
(dodecahedron is Hamiltonian, hence Tait colorable), so the
\emph{true} $\mathcal{R}_0$ is non-empty (= the cut-projection of
$G$'s $60$ colorings, giving $|R_{\mathrm{ground}}| = 36$).
\medskip
So the framework's high-side cut tire forest \emph{loses coverage}
for thin (small-$|S_i|$) cuts. The low-side face also carries
coloring information --- it contains the pendants and the
depth-$1$ subgraph --- but it isn't included as a "cut tire" in
the high-side formulation.
This isn't strictly a bug in any proof so far; it's a coverage
gap in the framework's scope. To handle these cases, one of:
\begin{itemize}
\item Restrict the conjecture to cuts where both sides have
``deep'' BFS (e.g.\ $\min(|S_0|, |S_1|) \ge k$ for some
$k$ ensuring high-side faces exist).
\item Extend the framework to include the low-side face as a
special ``boundary cut tire'' connecting pendants to the
$H_1$ structure.
\end{itemize}
\section*{Net status of the loose conjecture}
\begin{center}
@@ -256,12 +296,13 @@ planar embedding's face-in-face containment, not vertex overlap.
component & status & note \\
\midrule
Per-tire half (spoke-only $n \ge 3$) & proven & Prop 1.13 \\
Per-tire half (branched) & open, needed for generality & \\
Per-tire half (branched) & open & non-cycle face boundaries \\
Tree structure (forest, high-side) & proven & \texttt{cut\_tire\_tree\_structure.tex} \\
Chain DP $S_3$-equivariance & proven & this note, Lemma \\
Joint vs.\ OUT projection issue & flagged & need joint-support tracking \\
Joint projection DP & implemented, buggy & \texttt{chain\_dp\_joint.py} \\
Coverage when $H_d$ is a tree & gap & low-side face carries info \\
Chain DP non-emptiness preservation & open & Conj.\ \ref{conj:non-empty-prop} \\
Bottom-line $\mathcal{R}_0 \cap \mathcal{R}_1 \ne \emptyset$ & open, $G$-colorability gives it & \\
Bottom-line $\mathcal{R}_0 \cap \mathcal{R}_1 \ne \emptyset$ & open & $G$-colorability gives it \\
\bottomrule
\end{tabular}
\end{center}
@@ -270,10 +311,30 @@ The chain half reduces to multiple structural claims:
\begin{itemize}
\item per-tire half for branched cut tires;
\item joint-support DP (track $\pi(T)$, not just OUT projection);
\item handling cases where $H_d$ is a tree (no high-side faces);
\item non-emptiness preservation (Conj.\ \ref{conj:non-empty-prop}
or Strong per-tire extendibility).
\end{itemize}
None are in hand yet. The $S_3$-equivariance and forest structure
are. The full chain half is genuinely open and requires more work.
The $S_3$-equivariance and forest structure (with high-side
restriction) are in hand. The full chain half is genuinely open
and the framework has coverage gaps not previously identified.
\paragraph{Empirical baseline for ground-truth comparison.}
\texttt{chain\_dp\_joint.py} compares the chain DP output
against a brute-force enumeration of $G'_i$'s proper $3$-edge
colorings projected to the cut. On the dodecahedron, ground
truth shows $|R_{\mathrm{ground}}| \in \{36, 42, 48\}$ across
the first few cuts, but DP outputs $0$ for any side where
$H_1$ is a tree (= no high-side cut tires). This isn't a DP
bug per se --- it's the framework lacking coverage.
\paragraph{Path forward.} The cleanest next step is probably
the \emph{boundary cut tire} extension: define an additional
``cut tire'' $T_0$ that represents the low-side face of $H_1$
+ its incident pendants. $T_0$'s spokes are the cut edges;
its cycle structure is the boundary of the low-side face.
The high-side forest then sits ``inside'' $T_0$, and the chain
DP runs from leaves up through $T_0$ to the cut. This recovers
the missing coverage and gives a more uniform formulation.
\end{document}