Extract medial tire decomposition helpers

This commit is contained in:
2026-06-15 14:05:17 -04:00
parent b605931678
commit 6ef1dc710c
5 changed files with 523 additions and 500 deletions
@@ -1,311 +1,16 @@
"""Exhaustive generator for full medial tire graphs, indexed by |A(T)|. """Compatibility wrapper for the medial tire generator now kept in ../lib."""
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 from __future__ import annotations
import argparse import os
import itertools import sys
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. PAPER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
Bite = tuple[int, int] if PAPER_DIR not in sys.path:
Matching = frozenset[Bite] sys.path.insert(0, PAPER_DIR)
from lib.full_medial_tire_generator import * # noqa: F401,F403
# --------------------------------------------------------------------------- from lib.full_medial_tire_generator import main
# 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__": if __name__ == "__main__":
@@ -19,14 +19,25 @@ is a bite.
from __future__ import annotations from __future__ import annotations
import itertools import os
import random import random
from collections import defaultdict import sys
import networkx as nx import networkx as nx
import numpy as np import numpy as np
import scipy.spatial 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: def random_sphere_triangulation(n: int, seed: int) -> nx.Graph:
"""A random maximal planar graph: convex hull of random points on S^2.""" """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 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. # 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 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): def triangular_faces(g: nx.Graph):
ok, emb = nx.check_planarity(g) ok, emb = nx.check_planarity(g)
if not ok: 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 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): def canonical(coloring, ordered):
remap, out = {}, [] remap, out = {}, []
for v in ordered: for v in ordered:
@@ -0,0 +1 @@
"""Reusable helpers for medial tire decomposition experiments."""
@@ -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()
@@ -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