diff --git a/papers/medial_tire_cuts/experiments/medial_tire_cut_labelling.py b/papers/medial_tire_cuts/experiments/medial_tire_cut_labelling.py index 5a5441e..530c32b 100644 --- a/papers/medial_tire_cuts/experiments/medial_tire_cut_labelling.py +++ b/papers/medial_tire_cuts/experiments/medial_tire_cut_labelling.py @@ -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: diff --git a/papers/medial_tire_cuts/experiments/run_medial_tire_cut_experiment.py b/papers/medial_tire_cuts/experiments/run_medial_tire_cut_experiment.py new file mode 100644 index 0000000..11c7857 --- /dev/null +++ b/papers/medial_tire_cuts/experiments/run_medial_tire_cut_experiment.py @@ -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() diff --git a/papers/medial_tire_cuts/paper.aux b/papers/medial_tire_cuts/paper.aux index 572f5be..a51b9a6 100644 --- a/papers/medial_tire_cuts/paper.aux +++ b/papers/medial_tire_cuts/paper.aux @@ -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} diff --git a/papers/medial_tire_cuts/paper.log b/papers/medial_tire_cuts/paper.log index f9e67be..eaca148 100644 --- a/papers/medial_tire_cuts/paper.log +++ b/papers/medial_tire_cuts/paper.log @@ -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 - -Output written on paper.pdf (3 pages, 172798 bytes). + 84i,9n,89p,801b,704s stack positions out of 10000i,1000n,20000p,200000b,200000s + +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) diff --git a/papers/medial_tire_cuts/paper.pdf b/papers/medial_tire_cuts/paper.pdf index a4de8e9..746d9dd 100644 Binary files a/papers/medial_tire_cuts/paper.pdf and b/papers/medial_tire_cuts/paper.pdf differ diff --git a/papers/medial_tire_cuts/paper.tex b/papers/medial_tire_cuts/paper.tex index 5c18ac6..3040cca 100644 --- a/papers/medial_tire_cuts/paper.tex +++ b/papers/medial_tire_cuts/paper.tex @@ -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}