From 6ef1dc710cc179d7c7c3a92edaa4a79376b1a6ee Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 15 Jun 2026 14:05:17 -0400 Subject: [PATCH] Extract medial tire decomposition helpers --- .../experiments/full_medial_tire_generator.py | 311 +---------------- .../experiments/tire_realization_analysis.py | 211 +----------- .../lib/__init__.py | 1 + .../lib/full_medial_tire_generator.py | 312 ++++++++++++++++++ .../lib/medial_tire_decomposition.py | 188 +++++++++++ 5 files changed, 523 insertions(+), 500 deletions(-) create mode 100644 papers/medial_tire_decompositions_of_plane_triangulations/lib/__init__.py create mode 100644 papers/medial_tire_decompositions_of_plane_triangulations/lib/full_medial_tire_generator.py create mode 100644 papers/medial_tire_decompositions_of_plane_triangulations/lib/medial_tire_decomposition.py diff --git a/papers/medial_tire_decompositions_of_plane_triangulations/experiments/full_medial_tire_generator.py b/papers/medial_tire_decompositions_of_plane_triangulations/experiments/full_medial_tire_generator.py index 7a6f5f5..5e6cdc1 100644 --- a/papers/medial_tire_decompositions_of_plane_triangulations/experiments/full_medial_tire_generator.py +++ b/papers/medial_tire_decompositions_of_plane_triangulations/experiments/full_medial_tire_generator.py @@ -1,311 +1,16 @@ -"""Exhaustive generator for full medial tire graphs, indexed by |A(T)|. - -Model (Definitions/Remarks 3.1--3.9 of the medial tire decompositions paper). - - * The annular medial vertices induce a cycle A(T), the *annular cycle* - (Theorem 3.3). Write n = |A(T)| for its number of vertices = number of - annular faces = number of annular edges e_0,...,e_{n-1}. - - * Each edge e_i of A(T) carries exactly one tooth (a triangle of M(T)) - whose third vertex is a non-annular apex (Definition 3.4). A tooth is an - *up tooth* (apex in the outer region) or a *down tooth* (apex in the inner - region). We record the tooth types as a word in {U, D}^n. - - * No two up teeth share an apex; at most two down teeth share an apex - (Remark 3.5). Two down teeth sharing an apex form a *bite* (Definition - 3.7). So the down teeth are partitioned into singletons and bite pairs. - A bite pairs two down-edges and is drawn as an apex inside the disk with - spokes to the four endpoints; bites must be mutually non-crossing, i.e. - the bite pairs form a non-crossing (laminar) matching of the down-edges. - The two annular edges of a bite must be non-incident (Definition 3.7): - they share no annular vertex, so cyclically adjacent edges cannot pair. - - * There are at least three up teeth (Remark 3.6). - - * Bite-face condition (Remark 3.8). Let B(T) = A(T) together with the bite - apexes. Its interior non-tooth faces are the root face plus one inner-gap - face per bite. A singleton down tooth lies in the innermost bite enclosing - its edge (or in the root face if none). For every interior non-tooth face - the number of down-tooth apexes lying in that face must be 0 or at least 3. - Equivalently: no face holds exactly one or two singleton down teeth. - -The generator enumerates, for a given n, every (tooth word, bite matching) -pair satisfying these properties and emits the resulting full medial tire -graph as an explicit vertex/edge structure. Configurations may optionally be -reduced modulo the dihedral symmetry of the cycle. -""" +"""Compatibility wrapper for the medial tire generator now kept in ../lib.""" from __future__ import annotations -import argparse -import itertools -from collections import defaultdict -from dataclasses import dataclass -from functools import lru_cache -from typing import Iterator +import os +import sys -# A bite is an unordered pair of down-edge indices (i, j) with i < j. -Bite = tuple[int, int] -Matching = frozenset[Bite] +PAPER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if PAPER_DIR not in sys.path: + sys.path.insert(0, PAPER_DIR) - -# --------------------------------------------------------------------------- -# Non-crossing (laminar) matchings of the down edges. -# --------------------------------------------------------------------------- - -@lru_cache(maxsize=None) -def noncrossing_matchings(positions: tuple[int, ...]) -> tuple[Matching, ...]: - """All non-crossing partial matchings of ``positions`` (sorted ascending). - - Bite pairs drawn inside the disk are non-crossing iff, read in cyclic - order, no two pairs interleave. Cutting the cycle at the gap before the - first edge turns this into ordinary non-crossing interval matchings, which - obey the Catalan recursion below. - """ - if not positions: - return (frozenset(),) - head, *rest = positions - out: list[Matching] = [] - # head left unmatched (a singleton down tooth, if its edge is down) - for tail in noncrossing_matchings(tuple(rest)): - out.append(tail) - # head matched with positions[k]; the strictly-enclosed block must be - # matched within itself to stay non-crossing. - for k in range(1, len(positions)): - partner = positions[k] - inside = tuple(positions[1:k]) - outside = tuple(positions[k + 1:]) - for m_in in noncrossing_matchings(inside): - for m_out in noncrossing_matchings(outside): - out.append(frozenset({(head, partner)}) | m_in | m_out) - return tuple(out) - - -# --------------------------------------------------------------------------- -# The bite-face condition (Remark 3.8). -# --------------------------------------------------------------------------- - -def incident_edges(i: int, j: int, n: int) -> bool: - """Whether annular edges i and j share an annular vertex on the n-cycle.""" - return (j - i) % n == 1 or (i - j) % n == 1 - - -def has_incident_bite(bites: Matching, n: int) -> bool: - """Whether any bite pairs two incident (cyclically adjacent) edges.""" - return any(incident_edges(i, j, n) for i, j in bites) - - -def innermost_bite(edge: int, bites: Matching) -> Bite | None: - """The minimal-span bite whose open interval contains ``edge``, or None.""" - enclosing = [b for b in bites if b[0] < edge < b[1]] - if not enclosing: - return None - return min(enclosing, key=lambda b: b[1] - b[0]) - - -def face_singleton_counts( - tooth_word: str, bites: Matching -) -> dict[Bite | None, int]: - """Down-singletons per interior non-tooth face of B(T). - - The key ``None`` is the root face; a bite key is that bite's inner-gap - face. Faces with no singletons are simply absent from the result. - """ - matched = {edge for pair in bites for edge in pair} - counts: dict[Bite | None, int] = defaultdict(int) - for edge, tooth in enumerate(tooth_word): - if tooth != "D" or edge in matched: - continue # only singleton down teeth contribute apexes - counts[innermost_bite(edge, bites)] += 1 - return dict(counts) - - -def satisfies_bite_face_condition(tooth_word: str, bites: Matching) -> bool: - """Remark 3.8: every non-tooth face holds 0 or >=3 down-tooth apexes.""" - return all(count >= 3 for count in face_singleton_counts(tooth_word, bites).values()) - - -# --------------------------------------------------------------------------- -# The full medial tire graph as an explicit object. -# --------------------------------------------------------------------------- - -@dataclass(frozen=True) -class FullMedialTireGraph: - """A full medial tire graph M(T) determined by its combinatorial data. - - Vertices are named: - a{k} annular medial vertex k (k = 0..n-1), forming A(T); - u{i} apex of the up tooth on edge i; - d{i} apex of the singleton down tooth on edge i; - p{i}_{j} apex of the bite pairing edges i and j (i < j). - """ - - n: int - tooth_word: str - bites: Matching - - @property - def up_edges(self) -> tuple[int, ...]: - return tuple(i for i, t in enumerate(self.tooth_word) if t == "U") - - @property - def down_edges(self) -> tuple[int, ...]: - return tuple(i for i, t in enumerate(self.tooth_word) if t == "D") - - @property - def bite_edges(self) -> frozenset[int]: - return frozenset(edge for pair in self.bites for edge in pair) - - @property - def singleton_down_edges(self) -> tuple[int, ...]: - bite = self.bite_edges - return tuple(i for i in self.down_edges if i not in bite) - - def apex_of_edge(self, edge: int) -> str: - if self.tooth_word[edge] == "U": - return f"u{edge}" - for i, j in self.bites: - if edge in (i, j): - return f"p{i}_{j}" - return f"d{edge}" - - def vertices(self) -> list[str]: - verts = [f"a{k}" for k in range(self.n)] - for i in self.up_edges: - verts.append(f"u{i}") - for i in self.singleton_down_edges: - verts.append(f"d{i}") - for i, j in sorted(self.bites): - verts.append(f"p{i}_{j}") - return verts - - def edges(self) -> list[tuple[str, str]]: - n = self.n - out: list[tuple[str, str]] = [] - # annular cycle A(T) - for k in range(n): - out.append((f"a{k}", f"a{(k + 1) % n}")) - # singleton teeth (up and down): two spokes each - for i in self.up_edges: - out += [(f"u{i}", f"a{i}"), (f"u{i}", f"a{(i + 1) % n}")] - for i in self.singleton_down_edges: - out += [(f"d{i}", f"a{i}"), (f"d{i}", f"a{(i + 1) % n}")] - # bites: a shared apex with four spokes - for i, j in sorted(self.bites): - apex = f"p{i}_{j}" - for edge in (i, j): - out += [(apex, f"a{edge}"), (apex, f"a{(edge + 1) % n}")] - return [tuple(sorted(e)) for e in out] - - def canonical_key(self) -> tuple: - """Representative under the dihedral group of the cycle (rotations and - reflections), so symmetric configurations collapse to one key.""" - n = self.n - best: tuple | None = None - for a in (1, -1): - for b in range(n): - relabel = lambda i: (a * i + b) % n - word = [""] * n - for i, t in enumerate(self.tooth_word): - word[relabel(i)] = t - mapped = tuple(sorted( - tuple(sorted((relabel(i), relabel(j)))) for i, j in self.bites - )) - key = (tuple(word), mapped) - if best is None or key < best: - best = key - return best - - -# --------------------------------------------------------------------------- -# Enumeration. -# --------------------------------------------------------------------------- - -def generate( - n: int, min_up_teeth: int = 3, dedup: bool = False -) -> Iterator[FullMedialTireGraph]: - """Yield every full medial tire graph whose annular cycle has size ``n``. - - ``min_up_teeth`` defaults to 3 (Remark 3.6). With ``dedup`` set, only one - representative per dihedral symmetry class is returned. - """ - seen: set[tuple] = set() - for word_tuple in itertools.product("UD", repeat=n): - tooth_word = "".join(word_tuple) - if tooth_word.count("U") < min_up_teeth: - continue - down = tuple(i for i, t in enumerate(tooth_word) if t == "D") - for bites in noncrossing_matchings(down): - if has_incident_bite(bites, n): - continue - if not satisfies_bite_face_condition(tooth_word, bites): - continue - graph = FullMedialTireGraph(n=n, tooth_word=tooth_word, bites=bites) - if dedup: - key = graph.canonical_key() - if key in seen: - continue - seen.add(key) - yield graph - - -# --------------------------------------------------------------------------- -# CLI. -# --------------------------------------------------------------------------- - -def figure_one() -> FullMedialTireGraph: - """The example graph of Figure 1 (Remark 3.8): 12 edges, one bite (0,6).""" - return FullMedialTireGraph( - n=12, - tooth_word="DDDDDUDUUUUU", # edges 0-4,6 down; 5,7,8,9,10,11 up - bites=frozenset({(0, 6)}), - ) - - -def describe(graph: FullMedialTireGraph) -> str: - counts = face_singleton_counts(graph.tooth_word, graph.bites) - face_strs = [] - for face, c in sorted(counts.items(), key=lambda kv: (kv[0] is not None, kv[0])): - name = "root" if face is None else f"bite{face}" - face_strs.append(f"{name}:{c}") - bites = ",".join(f"({i},{j})" for i, j in sorted(graph.bites)) or "-" - faces = " ".join(face_strs) or "-" - return ( - f"word={graph.tooth_word} up={len(graph.up_edges)} " - f"down={len(graph.down_edges)} bites={bites} faces[{faces}]" - ) - - -def run(args: argparse.Namespace) -> None: - if args.check_figure: - g = figure_one() - print("Figure 1 check:") - print(f" {describe(g)}") - ok = satisfies_bite_face_condition(g.tooth_word, g.bites) - print(f" satisfies Remark 3.8: {ok} (expect True; faces 4 and 0)") - print() - - for n in range(args.min_n, args.max_n + 1): - graphs = list(generate(n, min_up_teeth=args.min_up, dedup=args.dedup)) - label = "classes" if args.dedup else "graphs" - print(f"n={n}: {len(graphs)} {label}") - if args.show: - for g in graphs[: args.show]: - print(f" {describe(g)}") - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--min-n", type=int, default=3) - parser.add_argument("--max-n", type=int, default=8) - parser.add_argument("--min-up", type=int, default=3, help="Remark 3.6 bound") - parser.add_argument("--dedup", action="store_true", - help="reduce modulo dihedral symmetry of the cycle") - parser.add_argument("--show", type=int, default=0, - help="print up to this many graphs per n") - parser.add_argument("--check-figure", action="store_true", - help="verify the Figure 1 example against Remark 3.8") - run(parser.parse_args()) +from lib.full_medial_tire_generator import * # noqa: F401,F403 +from lib.full_medial_tire_generator import main if __name__ == "__main__": diff --git a/papers/medial_tire_decompositions_of_plane_triangulations/experiments/tire_realization_analysis.py b/papers/medial_tire_decompositions_of_plane_triangulations/experiments/tire_realization_analysis.py index 3d3db8d..706e977 100644 --- a/papers/medial_tire_decompositions_of_plane_triangulations/experiments/tire_realization_analysis.py +++ b/papers/medial_tire_decompositions_of_plane_triangulations/experiments/tire_realization_analysis.py @@ -19,14 +19,25 @@ is a bite. from __future__ import annotations -import itertools +import os import random -from collections import defaultdict +import sys import networkx as nx import numpy as np import scipy.spatial +PAPER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if PAPER_DIR not in sys.path: + sys.path.insert(0, PAPER_DIR) + +from lib.medial_tire_decomposition import ( + ekey, + extract_tread, + medial_tire_facemodel, + recognise, +) + def random_sphere_triangulation(n: int, seed: int) -> nx.Graph: """A random maximal planar graph: convex hull of random points on S^2.""" @@ -40,19 +51,6 @@ def random_sphere_triangulation(n: int, seed: int) -> nx.Graph: return g -def medial_tire_facemodel(tread_faces) -> nx.Graph: - """Full medial tire graph M(T): the ambient tread-face model. Each tread - face contributes a triangle on its three edge-medial-vertices; no medial - edges between two same-boundary edges (those come from non-tread corners).""" - Mt = nx.Graph() - for f in tread_faces: - es = [ekey(f[0], f[1]), ekey(f[1], f[2]), ekey(f[2], f[0])] - Mt.add_nodes_from(es) - for a in range(3): - Mt.add_edge(es[a], es[(a + 1) % 3]) - return Mt - - # --------------------------------------------------------------------------- # # Maximal planar graph on n vertices: stacked seed + random diagonal flips. # --------------------------------------------------------------------------- # @@ -89,10 +87,6 @@ def random_maximal_planar(n: int, seed: int, flips: int = 400) -> nx.Graph: return g -def ekey(u, v): - return (u, v) if (str(u), u) <= (str(v), v) else (v, u) - - def triangular_faces(g: nx.Graph): ok, emb = nx.check_planarity(g) if not ok: @@ -146,189 +140,12 @@ def proper_3_colorings(M: nx.Graph, limit: int): # --------------------------------------------------------------------------- # -# Tread extraction from a BFS-level decomposition. +# Classify colourings of recognised full medial tire graphs. # --------------------------------------------------------------------------- # -def extract_tread(faces, levels, d): - """Tread T_d: faces spanning levels {d, d+1}. Returns its edge classes.""" - tread_faces = [] - for f in faces: - lv = [levels[x] for x in f] - if min(lv) == d and max(lv) == d + 1: - tread_faces.append(f) - if not tread_faces: - return None - annular, up, down = set(), set(), set() - face_of_down = defaultdict(int) - for f in tread_faces: - for x, y in ((f[0], f[1]), (f[1], f[2]), (f[2], f[0])): - e = ekey(x, y) - lx, ly = levels[x], levels[y] - if {lx, ly} == {d, d + 1}: - annular.add(e) - elif lx == ly == d: - up.add(e) - elif lx == ly == d + 1: - down.add(e) - face_of_down[e] += 1 - bites = {e for e in down if face_of_down[e] == 2} - return { - "tread_faces": tread_faces, - "annular": annular, "up": up, "down": down, "bites": bites, - } - - -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 - start = next(iter(comp)) - order = [start] - prev, cur = None, start - while True: - nbrs = [w for w in csub.neighbors(cur) if w != prev] - if not nbrs: - break - nxt = nbrs[0] - if nxt == start: - break - order.append(nxt) - prev, cur = cur, nxt - 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 - - -# --------------------------------------------------------------------------- # -# Recognise a genuine tread as a FullMedialTireGraph and classify colourings. -# --------------------------------------------------------------------------- # - -from full_medial_tire_generator import FullMedialTireGraph from kempe_valid_colorings import classify as kempe_classify -def _linear_cut(n, bite_pairs): - """Rotate the cycle so the bite pairs become linear non-crossing intervals.""" - for r in range(n): - rel = [tuple(sorted(((i - r) % n, (j - r) % n))) for i, j in bite_pairs] - ok = True - for a, b in rel: - for c, d in rel: - if (a, b) != (c, d) and (a < c < b < d or c < a < d < b): - ok = False - break - if not ok: - break - if ok: - return r, rel - return 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``. - - ``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) - 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_global] - if len(common) != 1: - return None - apex_of_edge.append(common[0]) - - # 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) - 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: - return None - r, rel_bites = cut - word = [""] * n - for i in range(n): - word[(i - r) % n] = tooth[i] - g = FullMedialTireGraph(n=n, tooth_word="".join(word), bites=frozenset(rel_bites)) - - # bijection fmt-name -> medial vertex - bij = {} - for k in range(n): - bij[f"a{k}"] = order[(k + r) % n] - for i in g.up_edges: - bij[f"u{i}"] = apex_of_edge[(i + r) % n] - for i in g.singleton_down_edges: - bij[f"d{i}"] = apex_of_edge[(i + r) % n] - 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 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 != 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: diff --git a/papers/medial_tire_decompositions_of_plane_triangulations/lib/__init__.py b/papers/medial_tire_decompositions_of_plane_triangulations/lib/__init__.py new file mode 100644 index 0000000..8335684 --- /dev/null +++ b/papers/medial_tire_decompositions_of_plane_triangulations/lib/__init__.py @@ -0,0 +1 @@ +"""Reusable helpers for medial tire decomposition experiments.""" diff --git a/papers/medial_tire_decompositions_of_plane_triangulations/lib/full_medial_tire_generator.py b/papers/medial_tire_decompositions_of_plane_triangulations/lib/full_medial_tire_generator.py new file mode 100644 index 0000000..7a6f5f5 --- /dev/null +++ b/papers/medial_tire_decompositions_of_plane_triangulations/lib/full_medial_tire_generator.py @@ -0,0 +1,312 @@ +"""Exhaustive generator for full medial tire graphs, indexed by |A(T)|. + +Model (Definitions/Remarks 3.1--3.9 of the medial tire decompositions paper). + + * The annular medial vertices induce a cycle A(T), the *annular cycle* + (Theorem 3.3). Write n = |A(T)| for its number of vertices = number of + annular faces = number of annular edges e_0,...,e_{n-1}. + + * Each edge e_i of A(T) carries exactly one tooth (a triangle of M(T)) + whose third vertex is a non-annular apex (Definition 3.4). A tooth is an + *up tooth* (apex in the outer region) or a *down tooth* (apex in the inner + region). We record the tooth types as a word in {U, D}^n. + + * No two up teeth share an apex; at most two down teeth share an apex + (Remark 3.5). Two down teeth sharing an apex form a *bite* (Definition + 3.7). So the down teeth are partitioned into singletons and bite pairs. + A bite pairs two down-edges and is drawn as an apex inside the disk with + spokes to the four endpoints; bites must be mutually non-crossing, i.e. + the bite pairs form a non-crossing (laminar) matching of the down-edges. + The two annular edges of a bite must be non-incident (Definition 3.7): + they share no annular vertex, so cyclically adjacent edges cannot pair. + + * There are at least three up teeth (Remark 3.6). + + * Bite-face condition (Remark 3.8). Let B(T) = A(T) together with the bite + apexes. Its interior non-tooth faces are the root face plus one inner-gap + face per bite. A singleton down tooth lies in the innermost bite enclosing + its edge (or in the root face if none). For every interior non-tooth face + the number of down-tooth apexes lying in that face must be 0 or at least 3. + Equivalently: no face holds exactly one or two singleton down teeth. + +The generator enumerates, for a given n, every (tooth word, bite matching) +pair satisfying these properties and emits the resulting full medial tire +graph as an explicit vertex/edge structure. Configurations may optionally be +reduced modulo the dihedral symmetry of the cycle. +""" + +from __future__ import annotations + +import argparse +import itertools +from collections import defaultdict +from dataclasses import dataclass +from functools import lru_cache +from typing import Iterator + +# A bite is an unordered pair of down-edge indices (i, j) with i < j. +Bite = tuple[int, int] +Matching = frozenset[Bite] + + +# --------------------------------------------------------------------------- +# Non-crossing (laminar) matchings of the down edges. +# --------------------------------------------------------------------------- + +@lru_cache(maxsize=None) +def noncrossing_matchings(positions: tuple[int, ...]) -> tuple[Matching, ...]: + """All non-crossing partial matchings of ``positions`` (sorted ascending). + + Bite pairs drawn inside the disk are non-crossing iff, read in cyclic + order, no two pairs interleave. Cutting the cycle at the gap before the + first edge turns this into ordinary non-crossing interval matchings, which + obey the Catalan recursion below. + """ + if not positions: + return (frozenset(),) + head, *rest = positions + out: list[Matching] = [] + # head left unmatched (a singleton down tooth, if its edge is down) + for tail in noncrossing_matchings(tuple(rest)): + out.append(tail) + # head matched with positions[k]; the strictly-enclosed block must be + # matched within itself to stay non-crossing. + for k in range(1, len(positions)): + partner = positions[k] + inside = tuple(positions[1:k]) + outside = tuple(positions[k + 1:]) + for m_in in noncrossing_matchings(inside): + for m_out in noncrossing_matchings(outside): + out.append(frozenset({(head, partner)}) | m_in | m_out) + return tuple(out) + + +# --------------------------------------------------------------------------- +# The bite-face condition (Remark 3.8). +# --------------------------------------------------------------------------- + +def incident_edges(i: int, j: int, n: int) -> bool: + """Whether annular edges i and j share an annular vertex on the n-cycle.""" + return (j - i) % n == 1 or (i - j) % n == 1 + + +def has_incident_bite(bites: Matching, n: int) -> bool: + """Whether any bite pairs two incident (cyclically adjacent) edges.""" + return any(incident_edges(i, j, n) for i, j in bites) + + +def innermost_bite(edge: int, bites: Matching) -> Bite | None: + """The minimal-span bite whose open interval contains ``edge``, or None.""" + enclosing = [b for b in bites if b[0] < edge < b[1]] + if not enclosing: + return None + return min(enclosing, key=lambda b: b[1] - b[0]) + + +def face_singleton_counts( + tooth_word: str, bites: Matching +) -> dict[Bite | None, int]: + """Down-singletons per interior non-tooth face of B(T). + + The key ``None`` is the root face; a bite key is that bite's inner-gap + face. Faces with no singletons are simply absent from the result. + """ + matched = {edge for pair in bites for edge in pair} + counts: dict[Bite | None, int] = defaultdict(int) + for edge, tooth in enumerate(tooth_word): + if tooth != "D" or edge in matched: + continue # only singleton down teeth contribute apexes + counts[innermost_bite(edge, bites)] += 1 + return dict(counts) + + +def satisfies_bite_face_condition(tooth_word: str, bites: Matching) -> bool: + """Remark 3.8: every non-tooth face holds 0 or >=3 down-tooth apexes.""" + return all(count >= 3 for count in face_singleton_counts(tooth_word, bites).values()) + + +# --------------------------------------------------------------------------- +# The full medial tire graph as an explicit object. +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class FullMedialTireGraph: + """A full medial tire graph M(T) determined by its combinatorial data. + + Vertices are named: + a{k} annular medial vertex k (k = 0..n-1), forming A(T); + u{i} apex of the up tooth on edge i; + d{i} apex of the singleton down tooth on edge i; + p{i}_{j} apex of the bite pairing edges i and j (i < j). + """ + + n: int + tooth_word: str + bites: Matching + + @property + def up_edges(self) -> tuple[int, ...]: + return tuple(i for i, t in enumerate(self.tooth_word) if t == "U") + + @property + def down_edges(self) -> tuple[int, ...]: + return tuple(i for i, t in enumerate(self.tooth_word) if t == "D") + + @property + def bite_edges(self) -> frozenset[int]: + return frozenset(edge for pair in self.bites for edge in pair) + + @property + def singleton_down_edges(self) -> tuple[int, ...]: + bite = self.bite_edges + return tuple(i for i in self.down_edges if i not in bite) + + def apex_of_edge(self, edge: int) -> str: + if self.tooth_word[edge] == "U": + return f"u{edge}" + for i, j in self.bites: + if edge in (i, j): + return f"p{i}_{j}" + return f"d{edge}" + + def vertices(self) -> list[str]: + verts = [f"a{k}" for k in range(self.n)] + for i in self.up_edges: + verts.append(f"u{i}") + for i in self.singleton_down_edges: + verts.append(f"d{i}") + for i, j in sorted(self.bites): + verts.append(f"p{i}_{j}") + return verts + + def edges(self) -> list[tuple[str, str]]: + n = self.n + out: list[tuple[str, str]] = [] + # annular cycle A(T) + for k in range(n): + out.append((f"a{k}", f"a{(k + 1) % n}")) + # singleton teeth (up and down): two spokes each + for i in self.up_edges: + out += [(f"u{i}", f"a{i}"), (f"u{i}", f"a{(i + 1) % n}")] + for i in self.singleton_down_edges: + out += [(f"d{i}", f"a{i}"), (f"d{i}", f"a{(i + 1) % n}")] + # bites: a shared apex with four spokes + for i, j in sorted(self.bites): + apex = f"p{i}_{j}" + for edge in (i, j): + out += [(apex, f"a{edge}"), (apex, f"a{(edge + 1) % n}")] + return [tuple(sorted(e)) for e in out] + + def canonical_key(self) -> tuple: + """Representative under the dihedral group of the cycle (rotations and + reflections), so symmetric configurations collapse to one key.""" + n = self.n + best: tuple | None = None + for a in (1, -1): + for b in range(n): + relabel = lambda i: (a * i + b) % n + word = [""] * n + for i, t in enumerate(self.tooth_word): + word[relabel(i)] = t + mapped = tuple(sorted( + tuple(sorted((relabel(i), relabel(j)))) for i, j in self.bites + )) + key = (tuple(word), mapped) + if best is None or key < best: + best = key + return best + + +# --------------------------------------------------------------------------- +# Enumeration. +# --------------------------------------------------------------------------- + +def generate( + n: int, min_up_teeth: int = 3, dedup: bool = False +) -> Iterator[FullMedialTireGraph]: + """Yield every full medial tire graph whose annular cycle has size ``n``. + + ``min_up_teeth`` defaults to 3 (Remark 3.6). With ``dedup`` set, only one + representative per dihedral symmetry class is returned. + """ + seen: set[tuple] = set() + for word_tuple in itertools.product("UD", repeat=n): + tooth_word = "".join(word_tuple) + if tooth_word.count("U") < min_up_teeth: + continue + down = tuple(i for i, t in enumerate(tooth_word) if t == "D") + for bites in noncrossing_matchings(down): + if has_incident_bite(bites, n): + continue + if not satisfies_bite_face_condition(tooth_word, bites): + continue + graph = FullMedialTireGraph(n=n, tooth_word=tooth_word, bites=bites) + if dedup: + key = graph.canonical_key() + if key in seen: + continue + seen.add(key) + yield graph + + +# --------------------------------------------------------------------------- +# CLI. +# --------------------------------------------------------------------------- + +def figure_one() -> FullMedialTireGraph: + """The example graph of Figure 1 (Remark 3.8): 12 edges, one bite (0,6).""" + return FullMedialTireGraph( + n=12, + tooth_word="DDDDDUDUUUUU", # edges 0-4,6 down; 5,7,8,9,10,11 up + bites=frozenset({(0, 6)}), + ) + + +def describe(graph: FullMedialTireGraph) -> str: + counts = face_singleton_counts(graph.tooth_word, graph.bites) + face_strs = [] + for face, c in sorted(counts.items(), key=lambda kv: (kv[0] is not None, kv[0])): + name = "root" if face is None else f"bite{face}" + face_strs.append(f"{name}:{c}") + bites = ",".join(f"({i},{j})" for i, j in sorted(graph.bites)) or "-" + faces = " ".join(face_strs) or "-" + return ( + f"word={graph.tooth_word} up={len(graph.up_edges)} " + f"down={len(graph.down_edges)} bites={bites} faces[{faces}]" + ) + + +def run(args: argparse.Namespace) -> None: + if args.check_figure: + g = figure_one() + print("Figure 1 check:") + print(f" {describe(g)}") + ok = satisfies_bite_face_condition(g.tooth_word, g.bites) + print(f" satisfies Remark 3.8: {ok} (expect True; faces 4 and 0)") + print() + + for n in range(args.min_n, args.max_n + 1): + graphs = list(generate(n, min_up_teeth=args.min_up, dedup=args.dedup)) + label = "classes" if args.dedup else "graphs" + print(f"n={n}: {len(graphs)} {label}") + if args.show: + for g in graphs[: args.show]: + print(f" {describe(g)}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--min-n", type=int, default=3) + parser.add_argument("--max-n", type=int, default=8) + parser.add_argument("--min-up", type=int, default=3, help="Remark 3.6 bound") + parser.add_argument("--dedup", action="store_true", + help="reduce modulo dihedral symmetry of the cycle") + parser.add_argument("--show", type=int, default=0, + help="print up to this many graphs per n") + parser.add_argument("--check-figure", action="store_true", + help="verify the Figure 1 example against Remark 3.8") + run(parser.parse_args()) + + +if __name__ == "__main__": + main() diff --git a/papers/medial_tire_decompositions_of_plane_triangulations/lib/medial_tire_decomposition.py b/papers/medial_tire_decompositions_of_plane_triangulations/lib/medial_tire_decomposition.py new file mode 100644 index 0000000..50d937f --- /dev/null +++ b/papers/medial_tire_decompositions_of_plane_triangulations/lib/medial_tire_decomposition.py @@ -0,0 +1,188 @@ +"""Medial tire decomposition helpers for plane triangulation experiments.""" + +from __future__ import annotations + +from collections import defaultdict + +import networkx as nx + +from .full_medial_tire_generator import FullMedialTireGraph + + +def ekey(u, v): + return (u, v) if (str(u), u) <= (str(v), v) else (v, u) + + +def medial_tire_facemodel(tread_faces) -> nx.Graph: + """Build the ambient tread-face model of a full medial tire graph M(T).""" + mt = nx.Graph() + for f in tread_faces: + es = [ekey(f[0], f[1]), ekey(f[1], f[2]), ekey(f[2], f[0])] + mt.add_nodes_from(es) + for a in range(3): + mt.add_edge(es[a], es[(a + 1) % 3]) + return mt + + +def extract_tread(faces, levels, d): + """Tread T_d: faces spanning levels {d, d+1}. Return its edge classes.""" + tread_faces = [] + for f in faces: + lv = [levels[x] for x in f] + if min(lv) == d and max(lv) == d + 1: + tread_faces.append(f) + if not tread_faces: + return None + + annular, up, down = set(), set(), set() + face_of_down = defaultdict(int) + for f in tread_faces: + for x, y in ((f[0], f[1]), (f[1], f[2]), (f[2], f[0])): + e = ekey(x, y) + lx, ly = levels[x], levels[y] + if {lx, ly} == {d, d + 1}: + annular.add(e) + elif lx == ly == d: + up.add(e) + elif lx == ly == d + 1: + down.add(e) + face_of_down[e] += 1 + + bites = {e for e in down if face_of_down[e] == 2} + return { + "tread_faces": tread_faces, + "annular": annular, + "up": up, + "down": down, + "bites": bites, + } + + +def _cycle_order(sub: nx.Graph, comp): + """Cyclic order of a simple 2-regular component, or None.""" + csub = sub.subgraph(comp) + if csub.number_of_nodes() < 3 or any(csub.degree(v) != 2 for v in csub): + return None + start = next(iter(comp)) + order = [start] + prev, cur = None, start + while True: + nbrs = [w for w in csub.neighbors(cur) if w != prev] + if not nbrs: + break + nxt = nbrs[0] + if nxt == start: + break + order.append(nxt) + prev, cur = cur, nxt + return order if len(order) == csub.number_of_nodes() else None + + +def annular_cycle_order(M: nx.Graph, annular: set): + """Cyclic order of annular medial vertices when they induce one cycle.""" + 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 annular medial vertices, one per cycle component.""" + 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 + + +def _linear_cut(n, bite_pairs): + """Rotate the cycle so bite pairs become linear non-crossing intervals.""" + for r in range(n): + rel = [tuple(sorted(((i - r) % n, (j - r) % n))) for i, j in bite_pairs] + ok = True + for a, b in rel: + for c, d in rel: + if (a, b) != (c, d) and (a < c < b < d or c < a < d < b): + ok = False + break + if not ok: + break + if ok: + return r, rel + return None + + +def _recognise_one(M, order, up, ann_global): + """Recognise one annular cycle as a FullMedialTireGraph.""" + n = len(order) + 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_global + ] + if len(common) != 1: + return None + apex_of_edge.append(common[0]) + + apex_positions = defaultdict(list) + for i, ap in enumerate(apex_of_edge): + apex_positions[ap].append(i) + 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: + return None + r, rel_bites = cut + word = [""] * n + for i in range(n): + word[(i - r) % n] = tooth[i] + graph = FullMedialTireGraph( + n=n, tooth_word="".join(word), bites=frozenset(rel_bites) + ) + + bij = {} + for k in range(n): + bij[f"a{k}"] = order[(k + r) % n] + for i in graph.up_edges: + bij[f"u{i}"] = apex_of_edge[(i + r) % n] + for i in graph.singleton_down_edges: + bij[f"d{i}"] = apex_of_edge[(i + r) % n] + for i, j in sorted(graph.bites): + bij[f"p{i}_{j}"] = apex_of_edge[(i + r) % n] + + 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 graph.edges()} + if rec_edges != sub_edges: + return None + return graph, 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 one ``(FullMedialTireGraph, bijection)`` + pair per annular cycle component that recognises. + """ + 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