Treat each disjoint annular cycle as its own full medial tire graph

A tread's annular frontier can split into several disjoint cycles; each
is now recognised as a separate full medial tire graph instead of
disqualifying the whole tread.

- recognise() returns a list of (g, bij), one per annular cycle
  component; add annular_cycle_components() and _recognise_one(), and
  iterate components in iter_pieces().
- Key tires/results by (depth, component) throughout both experiment
  drivers: _label_treads chains each tire to a parent-depth down tooth
  sharing its apex; _cap_cut/_assemble_cut_graph/to_json/summary and the
  dual-cut collectors/draws follow suit.

Source vertex selection for the dual-cut experiment now deep-embeds a
random face and roots at the outer-cap vertex. The source-dual figure
labels the source-graph vertices, highlights the entry medial vertex,
and uses a cap-rooted concentric layout.

For seed 7 / face (14,15,19) this recognises treads 3 and 4 as two
tires each (3.0,3.1,4.0,4.1), so every dual face is now cut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 12:01:36 -04:00
parent 7554582056
commit b605931678
6 changed files with 423 additions and 188 deletions
@@ -178,19 +178,17 @@ def extract_tread(faces, levels, d):
}
def annular_cycle_order(M: nx.Graph, annular: set):
"""Cyclic order of the annular medial vertices (they induce a cycle)."""
sub = M.subgraph(annular)
if sub.number_of_nodes() == 0 or any(sub.degree(v) != 2 for v in sub):
def _cycle_order(sub: nx.Graph, comp):
"""Cyclic order of a 2-regular connected component ``comp`` of ``sub``;
None if it is not a single simple cycle of length >= 3."""
csub = sub.subgraph(comp)
if csub.number_of_nodes() < 3 or any(csub.degree(v) != 2 for v in csub):
return None
if not nx.is_connected(sub):
return None
start = next(iter(annular))
start = next(iter(comp))
order = [start]
prev = None
cur = start
prev, cur = None, start
while True:
nbrs = [w for w in sub.neighbors(cur) if w != prev]
nbrs = [w for w in csub.neighbors(cur) if w != prev]
if not nbrs:
break
nxt = nbrs[0]
@@ -198,7 +196,33 @@ def annular_cycle_order(M: nx.Graph, annular: set):
break
order.append(nxt)
prev, cur = cur, nxt
return order if len(order) == len(annular) else None
return order if len(order) == csub.number_of_nodes() else None
def annular_cycle_order(M: nx.Graph, annular: set):
"""Cyclic order of the annular medial vertices when they induce a *single*
cycle; None otherwise. See ``annular_cycle_components`` for the
multi-component case."""
sub = M.subgraph(annular)
if not annular or not nx.is_connected(sub):
return None
return _cycle_order(sub, set(annular))
def annular_cycle_components(M: nx.Graph, annular: set):
"""Cyclic orders of the annular medial vertices, one per connected
component of the annular subgraph.
A tread's annular frontier may split into several disjoint cycles (one per
boundary component); each is its own full medial tire graph. Components
that are not a single simple cycle of length >= 3 are skipped."""
sub = M.subgraph(annular)
orders = []
for comp in nx.connected_components(sub):
order = _cycle_order(sub, comp)
if order is not None:
orders.append(order)
return orders
# --------------------------------------------------------------------------- #
@@ -226,38 +250,35 @@ def _linear_cut(n, bite_pairs):
return None
def recognise(M, tread):
"""Return (FullMedialTireGraph, bijection fmt-name -> medial vertex) or None.
def _recognise_one(M, order, up, ann_global):
"""Recognise a single annular cycle (given as the cyclic order of its
medial vertices) as a ``FullMedialTireGraph``.
``M`` here is the tread-face model M(T) (cycle + teeth + bites)."""
annular = tread["annular"]
order = annular_cycle_order(M, annular)
if order is None or len(order) < 3:
return None
``up`` is the tread's up-edge medial-vertex set; ``ann_global`` is the full
annular set of the tread (used to exclude annular vertices, including those
of *other* components, when picking each cycle edge's apex). Returns
``(g, bij)`` or None."""
n = len(order)
ann_set = set(annular)
if n < 3:
return None
ann_set = set(order)
apex_of_edge = []
for i in range(n):
a, b = order[i], order[(i + 1) % n]
common = [w for w in set(M.neighbors(a)) & set(M.neighbors(b)) if w not in ann_set]
common = [w for w in set(M.neighbors(a)) & set(M.neighbors(b))
if w not in ann_global]
if len(common) != 1:
return None
apex_of_edge.append(common[0])
up = set(tread["up"])
# bite apex: serves two cycle edges (== adjacent to four annular vertices)
apex_positions = defaultdict(list)
for i, ap in enumerate(apex_of_edge):
apex_positions[ap].append(i)
tooth = []
bite_pairs = []
for ap, positions in apex_positions.items():
if len(positions) == 2:
bite_pairs.append(tuple(sorted(positions)))
for i, ap in enumerate(apex_of_edge):
tooth.append("U" if ap in up else "D")
bite_pairs = [tuple(sorted(positions))
for positions in apex_positions.values() if len(positions) == 2]
tooth = ["U" if ap in up else "D" for ap in apex_of_edge]
cut = _linear_cut(n, bite_pairs)
if cut is None:
@@ -279,14 +300,35 @@ def recognise(M, tread):
for (i, j) in sorted(g.bites):
bij[f"p{i}_{j}"] = apex_of_edge[(i + r) % n]
# verify the reconstructed graph is edge-faithful to the tread-face M(T)
mt_edges = {ekey(*e) for e in M.edges()}
# verify the reconstructed graph is edge-faithful to this cycle's sub-model
# (its annular vertices together with their tooth apexes).
sub_nodes = ann_set | set(apex_of_edge)
sub_edges = {ekey(*e) for e in M.subgraph(sub_nodes).edges()}
rec_edges = {ekey(bij[u], bij[v]) for u, v in g.edges()}
if rec_edges != mt_edges:
if rec_edges != sub_edges:
return None
return g, bij
def recognise(M, tread):
"""Recognise the tread's medial-tire structure.
A tread's annular frontier may be several disjoint cycles, each its own
full medial tire graph. Returns a list of ``(FullMedialTireGraph,
bijection fmt-name -> medial vertex)`` -- one per annular cycle component
that recognises -- or ``[]`` if none do.
``M`` here is the tread-face model M(T) (cycle(s) + teeth + bites)."""
up = set(tread["up"])
ann_global = set(tread["annular"])
tires = []
for order in annular_cycle_components(M, tread["annular"]):
rec = _recognise_one(M, order, up, ann_global)
if rec is not None:
tires.append(rec)
return tires
def canonical(coloring, ordered):
remap, out = {}, []
for v in ordered:
@@ -341,32 +383,29 @@ def iter_pieces(seed: int, color_limit: int = 400000):
if tread is None or len(tread["up"]) < 3:
continue
mt = medial_tire_facemodel(tread["tread_faces"])
rec = recognise(mt, tread)
if rec is None:
continue
g, bij = rec
mt_nodes = list(bij.values())
name_of = {v: k for k, v in bij.items()}
for comp, (g, bij) in enumerate(recognise(mt, tread)):
mt_nodes = list(bij.values())
name_of = {v: k for k, v in bij.items()}
realized = set()
for col in global_colorings:
realized.add(canonical({v: col[v] for v in mt_nodes}, mt_nodes))
realized = set()
for col in global_colorings:
realized.add(canonical({v: col[v] for v in mt_nodes}, mt_nodes))
colorings = []
seen = set()
for col in proper_3_colorings_subgraph(mt, mt_nodes):
key = canonical(col, mt_nodes)
if key in seen:
continue
seen.add(key)
fmt_col = {name_of[v]: c for v, c in col.items()}
balanced = kempe_classify(g, fmt_col).valid
is_real = key in realized
cat = ("Invalid" if not balanced
else "Realized" if is_real else "Unrealized")
colorings.append((fmt_col, cat))
meta = {"source": s, "tread": d}
yield (meta, g, colorings)
colorings = []
seen = set()
for col in proper_3_colorings_subgraph(mt, mt_nodes):
key = canonical(col, mt_nodes)
if key in seen:
continue
seen.add(key)
fmt_col = {name_of[v]: c for v, c in col.items()}
balanced = kempe_classify(g, fmt_col).valid
is_real = key in realized
cat = ("Invalid" if not balanced
else "Realized" if is_real else "Unrealized")
colorings.append((fmt_col, cat))
meta = {"source": s, "tread": d, "comp": comp}
yield (meta, g, colorings)
def analyse(seed: int, color_limit: int = 400000):