Add medial tire cut experiment and chaining section

New experiments/run_medial_tire_cut_experiment.py: generates a random
maximal planar graph (stacked seed + random diagonal flips), builds the
medial graph, takes the tire decomposition at a random vertex level
source, walk-depth labels and cuts each full medial tire graph chained
down the tire tree, and assembles one final cut graph of M(G) with a
global label map (data only; graphics go in a separate script).

Fix label_and_cut: the root face is None, which collided with the
next(..., None) sentinel, leaving teeth unlabelled when the entry up
tooth lay inside a bite gap; use a distinct sentinel so the ascent to
the root face runs.

Add a "Chaining across the tire tree" section to the paper, clarifying
that the candidate parent down teeth are the boundary (singleton) down
teeth only -- bite teeth are interior to the parent and shared with no
child, so a lower-walk bite is skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 23:46:49 -04:00
parent b4ddc7da8b
commit a22ca4b888
6 changed files with 388 additions and 33 deletions
@@ -179,7 +179,9 @@ def label_and_cut(graph: FullMedialTireGraph, entry_edge: int,
# Steps 1-3: the entry face.
traverse(innermost_bite(entry_edge, graph.bites), entry_edge, is_entry=True)
# Steps 4-6: descend through bites, deepest first.
# Steps 4-6: descend (or ascend) through bites, deepest first. The root
# face is ``None``, so we use a distinct sentinel for "no unlabelled face".
_MISSING = object()
while len(depth) < graph.n:
labelled_bite_teeth = sorted(
(e for e in depth if door_bite(graph, e) is not None),
@@ -188,8 +190,8 @@ def label_and_cut(graph: FullMedialTireGraph, entry_edge: int,
for t in labelled_bite_teeth:
target = next((F for F in faces_bordered(graph, t)
if any(e not in depth for e in face_boundary(graph, F))),
None)
if target is not None:
_MISSING)
if target is not _MISSING:
traverse(target, t, is_entry=False)
break
else:
@@ -0,0 +1,301 @@
"""Medial tire cut experiment.
End-to-end experiment for the *Medial Tire Cuts* paper:
1. Generate a random maximal planar graph G on n vertices (stacked seed plus
random diagonal flips; ``random_maximal_planar`` from the medial tire
decompositions experiments).
2. Build its medial graph M(G).
3. Take the nested tire decomposition at one random vertex level source: the
BFS-level treads, each realized as a FullMedialTireGraph.
4. Walk-depth label and cut each full medial tire graph, chaining the labels
down the tire tree, and assemble one final cut graph of M(G) with a global
label map.
This script produces *data* (the final cut graph and its labels); it draws
nothing. Anything for the paper (figures) lives in a separate script that
imports ``run_experiment`` from here.
Chaining rule (walk depths across the tire tree).
* The root tread (no recognised parent) is entered at an arbitrary up tooth
with walk depth 0.
* A child tread is entered at the up tooth whose apex is the *same medial
vertex* as the parent's down tooth of lowest walk depth -- a parent down
tooth and the child up tooth glued to it across the shared level cycle are
the same medial vertex of M(G). The entry up tooth's walk depth is that
parent down tooth's depth + 1, and the walk increments locally from there.
Run with the repo venv (networkx + scipy): ``.venv/bin/python``.
"""
from __future__ import annotations
import argparse
import json
import os
import random
import sys
import networkx as nx
# Reuse the realization pipeline from the medial tire decompositions paper, and
# the walk-depth labelling/cut from this paper's companion script.
_HERE = os.path.dirname(os.path.abspath(__file__))
_MTD = os.path.normpath(os.path.join(
_HERE, "..", "..",
"medial_tire_decompositions_of_plane_triangulations", "experiments"))
sys.path.insert(0, _MTD)
sys.path.insert(0, _HERE)
from tire_realization_analysis import ( # noqa: E402
extract_tread, medial_graph, medial_tire_facemodel, random_maximal_planar,
recognise, triangular_faces,
)
from medial_tire_cut_labelling import door_bite, label_and_cut # noqa: E402
# --------------------------------------------------------------------------- #
# 4. Walk-depth labelling and cut, chained down the tire tree.
# --------------------------------------------------------------------------- #
def _apex_vertex(g, bij, edge):
"""The medial vertex that is the apex of the tooth on annular ``edge``."""
return bij[g.apex_of_edge(edge)]
def _label_treads(treads, results):
"""Fill ``results[d]`` with the walk-depth labelling and cuts for each
recognised tread ``d``, chaining child entries to parent down teeth."""
for d in sorted(treads):
g, bij = treads[d]
parent = treads.get(d - 1)
if parent is None:
entry_edge, start_depth = g.up_edges[0], 0 # arbitrary root entry
else:
pg, pbij = parent
pdepth = results[d - 1]["depth"]
# parent down teeth, lowest walk depth first
down = sorted((pdepth[k], _apex_vertex(pg, pbij, k))
for k in pg.down_edges)
child_up_apex = {bij[f"u{m}"]: m for m in g.up_edges}
entry_edge = start_depth = None
for value, apex in down:
if apex in child_up_apex:
entry_edge, start_depth = child_up_apex[apex], value + 1
break
if entry_edge is None: # no shared apex (degenerate); root-style
entry_edge, start_depth = g.up_edges[0], 0
depth, cuts = label_and_cut(g, entry_edge, start_depth=start_depth)
results[d] = {"g": g, "bij": bij, "entry_edge": entry_edge,
"start_depth": start_depth, "depth": depth, "cuts": cuts}
# --------------------------------------------------------------------------- #
# Assemble one final cut graph of M(G) with a global label map.
# --------------------------------------------------------------------------- #
def _assemble_cut_graph(M, results):
"""Apply every tread's cuts to M(G).
Each cut duplicates an annular medial vertex, splitting its four incident
medial edges along the slit between the two teeth meeting there: the tooth
on the previous annular edge (with that edge's far annular vertex) goes to
one copy, the tooth on the next annular edge to the other.
Returns ``(H, label_records, warnings)`` where ``H`` is the cut graph (a
networkx graph whose split vertices are keyed ``(medial_vertex, "A"/"B",
tread)``) and ``label_records`` lists every tooth's walk depth.
"""
# Per cut annular vertex: map each original neighbour -> which copy keeps it.
split = {} # medial_vertex -> {neighbour_medial_vertex: copy_node}
warnings = []
for d in sorted(results):
g, bij = results[d]["g"], results[d]["bij"]
n = g.n
for c in results[d]["cuts"]:
kk = c.vertex
if kk is None:
continue
mv = bij[f"a{kk}"]
if mv in split:
warnings.append(f"annular vertex a{kk} of tread {d} cut twice; "
f"second cut not applied")
continue
e_prev, e_next = (kk - 1) % n, kk
copy_a = (mv, "A", d)
copy_b = (mv, "B", d)
split[mv] = {
bij[f"a{(kk - 1) % n}"]: copy_a,
_apex_vertex(g, bij, e_prev): copy_a,
bij[f"a{(kk + 1) % n}"]: copy_b,
_apex_vertex(g, bij, e_next): copy_b,
}
def resolve(node, other):
return split[node][other] if node in split else node
H = nx.Graph()
H.add_nodes_from(v for v in M.nodes() if v not in split)
for v, copies in split.items():
H.add_nodes_from(set(copies.values()))
for u, v in M.edges():
H.add_edge(resolve(u, v), resolve(v, u))
label_records = []
for d in sorted(results):
g, bij, depth = results[d]["g"], results[d]["bij"], results[d]["depth"]
for k in range(g.n):
role = ("up" if g.tooth_word[k] == "U"
else "bite" if door_bite(g, k) is not None else "down")
label_records.append({
"tread": d, "edge": k, "role": role,
"apex": _apex_vertex(g, bij, k), "walk": depth[k],
})
return H, label_records, warnings
# --------------------------------------------------------------------------- #
# Driver.
# --------------------------------------------------------------------------- #
def run_experiment(n: int = 12, seed: int = 0, flips: int = 400) -> dict:
"""Run the full pipeline and return a structured result.
Result keys: ``n, seed, G, M, source, treads`` (dict depth -> (g, bij)),
``results`` (dict depth -> labelling/cut record), ``skipped`` (list of
(depth, reason)), ``cut_graph`` (networkx graph), ``labels`` (list of tooth
records), ``warnings``.
"""
G = random_maximal_planar(n, seed, flips=flips)
faces, _ = triangular_faces(G)
M = medial_graph(G)
source = random.Random(f"source-{seed}").choice(sorted(G.nodes()))
levels = nx.single_source_shortest_path_length(G, source)
treads, skipped = {}, []
for d in range(max(levels.values())):
tread = extract_tread(faces, levels, d)
if tread is None:
skipped.append((d, "no tread faces"))
continue
if len(tread["up"]) < 3:
skipped.append((d, f"only {len(tread['up'])} up teeth"))
continue
rec = recognise(medial_tire_facemodel(tread["tread_faces"]), tread)
if rec is None:
skipped.append((d, "not a valid full medial tire graph"))
continue
treads[d] = rec
results = {}
_label_treads(treads, results)
cut_graph, labels, warnings = _assemble_cut_graph(M, results)
return {
"n": n, "seed": seed, "G": G, "M": M, "source": source,
"treads": treads, "results": results, "skipped": skipped,
"cut_graph": cut_graph, "labels": labels, "warnings": warnings,
}
# --------------------------------------------------------------------------- #
# Serialization / reporting.
# --------------------------------------------------------------------------- #
def _vname(v) -> str:
"""Stable string name for a medial vertex (an edge key) or a split node."""
if isinstance(v, tuple) and len(v) == 3 and v[1] in ("A", "B"):
mv, side, d = v
return f"{mv[0]}-{mv[1]}#{side}@T{d}"
return f"{v[0]}-{v[1]}"
def to_json(result: dict) -> dict:
res = result["results"]
treads_out = []
for d in sorted(res):
g, bij = res[d]["g"], res[d]["bij"]
depth, cuts = res[d]["depth"], res[d]["cuts"]
teeth = [{
"edge": k,
"role": ("up" if g.tooth_word[k] == "U"
else "bite" if door_bite(g, k) is not None else "down"),
"apex": _vname(_apex_vertex(g, bij, k)),
"walk": depth[k],
} for k in range(g.n)]
treads_out.append({
"depth": d, "n": g.n, "tooth_word": g.tooth_word,
"bites": sorted(list(b) for b in g.bites),
"entry_edge": res[d]["entry_edge"], "start_depth": res[d]["start_depth"],
"teeth": teeth,
"cuts": [{
"annular_index": c.vertex,
"annular_vertex": _vname(bij[f"a{c.vertex}"]),
"last_edge": c.last_tooth, "closing_edge": c.closing_tooth,
} for c in cuts],
})
H = result["cut_graph"]
return {
"n": result["n"], "seed": result["seed"], "source": result["source"],
"graph_edges": sorted([int(u), int(v)] for u, v in result["G"].edges()),
"medial_vertices": result["M"].number_of_nodes(),
"skipped": [[d, why] for d, why in result["skipped"]],
"treads": treads_out,
"cut_graph": {
"nodes": sorted(_vname(v) for v in H.nodes()),
"edges": sorted([_vname(u), _vname(v)] for u, v in H.edges()),
},
"labels": [{
"tread": r["tread"], "edge": r["edge"], "role": r["role"],
"apex": _vname(r["apex"]), "walk": r["walk"],
} for r in result["labels"]],
"warnings": result["warnings"],
}
def summary(result: dict) -> str:
H, res = result["cut_graph"], result["results"]
lines = [
f"random maximal planar graph: n={result['n']} seed={result['seed']} "
f"({result['G'].number_of_edges()} edges)",
f"medial graph M(G): {result['M'].number_of_nodes()} vertices",
f"level source: vertex {result['source']}",
f"recognised treads: {sorted(res)}",
f"skipped treads: {result['skipped']}",
]
for d in sorted(res):
g = res[d]["g"]
ncuts = len(res[d]["cuts"])
lines.append(
f" tread {d}: |A(T)|={g.n} word={g.tooth_word} "
f"bites={sorted(g.bites)} entry=e{res[d]['entry_edge']} "
f"start_depth={res[d]['start_depth']} cuts={ncuts}")
lines.append(
f"final cut graph: {H.number_of_nodes()} vertices, "
f"{H.number_of_edges()} edges, "
f"{sum(len(r['cuts']) for r in res.values())} cuts total")
if result["warnings"]:
lines.append("warnings: " + "; ".join(result["warnings"]))
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("-n", type=int, default=12, help="number of vertices")
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--flips", type=int, default=400,
help="number of random diagonal flips when building G")
parser.add_argument("--json", metavar="PATH",
help="write the full result as JSON to PATH")
args = parser.parse_args()
result = run_experiment(n=args.n, seed=args.seed, flips=args.flips)
print(summary(result))
if args.json:
with open(args.json, "w") as fh:
json.dump(to_json(result), fh, indent=2)
print(f"wrote {args.json}")
if __name__ == "__main__":
main()