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()
+10 -6
View File
@@ -6,15 +6,19 @@
\@writefile{toc}{\contentsline {section}{\tocsection {}{2}{Cutting a full medial tire graph}}{1}{}\protected@file@percent }
\newlabel{def:walk-depth-cut}{{2.1}{1}}
\citation{bauerfeld-medial-tire}
\citation{bauerfeld-medial-tire}
\newlabel{rem:closing-tooth}{{2.2}{2}}
\newlabel{ex:worked-cut}{{2.3}{2}}
\@writefile{toc}{\contentsline {section}{\tocsection {}{3}{Chaining across the tire tree}}{2}{}\protected@file@percent }
\citation{bauerfeld-medial-tire}
\bibcite{bauerfeld-medial-tire}{1}
\@writefile{lof}{\contentsline {figure}{\numberline {1}{\ignorespaces A full medial tire graph (left) and its walk-depth labelling and cut (right), from Example\nonbreakingspace 2.3\hbox {}. Black vertices are the annular medial vertices of the cycle $A(T)$; blue vertices are up-tooth apexes, red vertices are down-tooth apexes, and the larger red vertex is the shared apex of the bite on annular edges $0$ and $4$. On the right, each tooth carries its walk depth, and the two red slits mark the cuts: \emph {cut\nonbreakingspace 1} duplicates $a_5$ as the root-face traversal closes, and \emph {cut\nonbreakingspace 2} duplicates $a_1$ as the bite's inner-gap face closes. After the cuts the only bounded faces are the eight teeth.}}{3}{}\protected@file@percent }
\newlabel{fig:worked-cut}{{1}{3}}
\newlabel{rem:chaining-candidates}{{3.1}{3}}
\newlabel{tocindent-1}{0pt}
\newlabel{tocindent0}{12.7778pt}
\newlabel{tocindent1}{17.77782pt}
\newlabel{tocindent2}{0pt}
\newlabel{tocindent3}{0pt}
\newlabel{rem:closing-tooth}{{2.2}{2}}
\newlabel{ex:worked-cut}{{2.3}{2}}
\@writefile{toc}{\contentsline {section}{\tocsection {}{}{References}}{2}{}\protected@file@percent }
\@writefile{lof}{\contentsline {figure}{\numberline {1}{\ignorespaces A full medial tire graph (left) and its walk-depth labelling and cut (right), from Example\nonbreakingspace 2.3\hbox {}. Black vertices are the annular medial vertices of the cycle $A(T)$; blue vertices are up-tooth apexes, red vertices are down-tooth apexes, and the larger red vertex is the shared apex of the bite on annular edges $0$ and $4$. On the right, each tooth carries its walk depth, and the two red slits mark the cuts: \emph {cut\nonbreakingspace 1} duplicates $a_5$ as the root-face traversal closes, and \emph {cut\nonbreakingspace 2} duplicates $a_1$ as the bite's inner-gap face closes. After the cuts the only bounded faces are the eight teeth.}}{3}{}\protected@file@percent }
\newlabel{fig:worked-cut}{{1}{3}}
\gdef \@abspage@last{3}
\@writefile{toc}{\contentsline {section}{\tocsection {}{}{References}}{4}{}\protected@file@percent }
\gdef \@abspage@last{4}
+24 -24
View File
@@ -1,4 +1,4 @@
This is pdfTeX, Version 3.141592653-2.6-1.40.24 (TeX Live 2022) (preloaded format=pdflatex 2022.10.5) 14 JUN 2026 21:56
This is pdfTeX, Version 3.141592653-2.6-1.40.24 (TeX Live 2022) (preloaded format=pdflatex 2022.10.5) 14 JUN 2026 23:46
entering extended mode
restricted \write18 enabled.
%&-line parsing enabled.
@@ -498,33 +498,33 @@ e
LaTeX Warning: `h' float specifier changed to `ht'.
[2] [3] (./paper.aux) )
[2] [3] [4] (./paper.aux) )
Here is how much of TeX's memory you used:
13647 strings out of 478268
272595 string characters out of 5846347
554433 words of memory out of 5000000
31476 multiletter control sequences out of 15000+600000
13648 strings out of 478268
272620 string characters out of 5846347
558283 words of memory out of 5000000
31477 multiletter control sequences out of 15000+600000
477049 words of font info for 58 fonts, out of 8000000 for 9000
1302 hyphenation exceptions out of 8191
84i,9n,89p,936b,704s stack positions out of 10000i,1000n,20000p,200000b,200000s
</usr/local/texlive/2022/texmf-dist/fonts/type1/public/a
msfonts/cm/cmbx10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/am
sfonts/cm/cmbx7.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsf
onts/cm/cmcsc10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsf
onts/cm/cmmi10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfo
nts/cm/cmr10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfont
s/cm/cmr6.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/c
m/cmr7.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/cm/c
mr8.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/cm/cmss
10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/cm/cmsy1
0.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/cm/cmti10
.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/cm/cmti8.p
fb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/cm/cmtt10.pf
b>
Output written on paper.pdf (3 pages, 172798 bytes).
84i,9n,89p,801b,704s stack positions out of 10000i,1000n,20000p,200000b,200000s
</usr/local/texlive/2022/texmf-dist/fonts/type1/publ
ic/amsfonts/cm/cmbx10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/publi
c/amsfonts/cm/cmbx7.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/
amsfonts/cm/cmcsc10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/
amsfonts/cm/cmmi10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/a
msfonts/cm/cmr10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/ams
fonts/cm/cmr6.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfon
ts/cm/cmr7.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/
cm/cmr8.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/cm/
cmss10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/cm/c
msy10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/cm/cm
ti10.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/cm/cmt
i8.pfb></usr/local/texlive/2022/texmf-dist/fonts/type1/public/amsfonts/cm/cmtt1
0.pfb>
Output written on paper.pdf (4 pages, 176366 bytes).
PDF statistics:
82 PDF objects out of 1000 (max. 8388607)
50 compressed objects within 1 object stream
85 PDF objects out of 1000 (max. 8388607)
52 compressed objects within 1 object stream
0 named destinations out of 1000 (max. 500000)
13 words of extra memory for PDF output out of 10000 (max. 10000000)
Binary file not shown.
+48
View File
@@ -261,6 +261,54 @@ the cuts the only bounded faces are the eight teeth.}
\label{fig:worked-cut}
\end{figure}
\section{Chaining across the tire tree}
Definition~\ref{def:walk-depth-cut} labels and cuts a single full medial
tire graph. We extend it to the whole medial graph $M(G)$ through the
medial tire decomposition of~\cite{bauerfeld-medial-tire}: the tire tree
decomposes $M(G)$ into full medial tire graphs $\mathsf{M}(T)$, one per
tread $T$, glued along their boundary medial vertices. A parent tread's
inner level cycle is a child tread's outer level cycle, and the boundary
medial vertices on that shared cycle belong to both treads.
The key incidence is this. A \emph{boundary} (singleton) down tooth of a
parent tread and the up tooth of the child tread glued to it across the
shared level cycle have the \emph{same apex}: both apexes are the same
medial vertex of $M(G)$, namely the medial vertex of an edge with both
endpoints on the shared level cycle. We use this to carry the walk depth
from a parent into its children.
We label tread by tread, outward from the root:
\begin{itemize}
\item a tread with no parent in the decomposition---in particular the
innermost recognised tread---is treated as a \emph{root} and entered at
an arbitrary up tooth with walk depth $0$;
\item a child tread is entered at the up tooth whose apex is the parent's
boundary down tooth of lowest walk depth; that entry up tooth's walk depth
is one more than that down tooth's, and the walk then increments locally
within the child as in Definition~\ref{def:walk-depth-cut}.
\end{itemize}
\begin{remark}[Candidate down teeth for chaining]
\label{rem:chaining-candidates}
The down teeth eligible to fix a child's entry are exactly the
\emph{boundary} (singleton) down teeth of the parent: those lying in a
single tread face, whose apex is the shared boundary medial vertex glued to
a child up tooth. A bite's two down teeth are \emph{not} eligible. By the
definition of a bite in~\cite{bauerfeld-medial-tire} its annular edge borders
two tread faces, so a bite tooth is interior to the parent tread and its
apex is a boundary medial vertex of no child. Hence ``the down tooth of
lowest walk depth'' is read among the boundary down teeth only; a bite of
even lower walk depth is skipped.
\end{remark}
Applying every tread's cuts to $M(G)$ assembles the per-tread labellings
and cuts into a single cut graph of $M(G)$ together with a global
walk-depth label map. This pipeline---random maximal planar graph, medial
graph, tire decomposition at a vertex level source, and chained walk-depth
labelling and cut---is carried out by the experiment script
\texttt{experiments/run\_medial\_tire\_cut\_experiment.py}.
\begin{thebibliography}{9}
\bibitem{bauerfeld-medial-tire}