Add full medial tire graph generator and n=9 atlas

Name A(T) the "annular cycle" (Thm 3.3, Def 3.4); clarify the bite-face
condition in Remark 3.8 to count down-tooth apexes interior to each face;
add the non-incidence stipulation for bite edges to Def 3.7.

Add an exhaustive generator over |A(T)| enforcing the 3.1-3.9 properties
(tooth word, non-crossing non-incident bites, >=3 up teeth, bite-face
condition), plus a plotting script and the n=9 atlas (81 dihedral classes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 12:23:57 -04:00
parent 4062e87c61
commit 8cc94fb6b9
12 changed files with 1002 additions and 103 deletions
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

@@ -0,0 +1,69 @@
# Atlas of full medial tire graphs with |A(T)| = 9
This note collects every full medial tire graph whose annular cycle `A(T)` has
nine vertices, generated exhaustively from the structural properties in
Definitions/Remarks 3.13.9 of `paper.tex`.
## What is being enumerated
A full medial tire graph of size `n = |A(T)|` is determined by:
- a tooth word in `{U, D}^n` — one up (`U`) or down (`D`) tooth per annular
edge (Def. 3.4), with **at least three up teeth** (Rem. 3.6);
- a **non-crossing matching** of the down edges into *bites* — pairs of down
teeth sharing an apex (Rem. 3.5, Def. 3.7); unmatched down teeth are
singletons. The two annular edges of a bite must be **non-incident**
(Def. 3.7): they share no annular vertex, so cyclically adjacent edges
cannot pair;
- subject to the **bite-face condition** (Rem. 3.8): in `B(T) = A(T) + bite
apexes`, every interior non-tooth face must contain `0` or `≥ 3`
down-tooth apexes in its interior (equivalently, no face holds exactly one
or two singleton down teeth).
Graphs are identified up to the dihedral symmetry of the annular cycle
(rotations and reflections), since these give isomorphic plane graphs.
## The atlas
![All full medial tire graphs with |A(T)| = 9](full_medial_tire_n9.png)
High-resolution vector copy: [`full_medial_tire_n9.pdf`](full_medial_tire_n9.pdf).
Full textual index: [`full_medial_tire_n9_index.txt`](full_medial_tire_n9_index.txt).
In each diagram the thick black ring is `A(T)`; **blue** outer apexes are up
teeth, **red** inner apexes are singleton down teeth, and a **dark-red** inner
apex with four spokes is a bite (its two paired annular edges). The label under
each diagram is the tooth word and the bite pairs (edge indices).
## Counts
There are **81** classes for `n = 9` (cf. `3:1, 4:1, 5:2, 6:6, 7:13, 8:36,
9:81` for `n = 3..9`, with the non-incidence stipulation in force). Breakdown
of the 81 classes:
| down teeth | classes | | bites | classes | | up teeth | classes |
|-----------:|--------:|---|------:|--------:|---|---------:|--------:|
| 0 | 1 | | 0 | 35 | | 3 | 23 |
| 2 | 3 | | 1 | 35 | | 4 | 29 |
| 3 | 7 | | 2 | 8 | | 5 | 18 |
| 4 | 18 | | 3 | 3 | | 6 | 7 |
| 5 | 29 | | | | | 7 | 3 |
| 6 | 23 | | | | | 9 | 1 |
46 of the 81 classes contain at least one bite. (Every singleton down tooth
must sit in a face holding `≥ 3` of them, so e.g. words with exactly one or two
down teeth only survive when those down teeth are paired into a bite — and now
only when the paired edges are non-incident, which is why the counts fall
sharply from the unrestricted `n = 9` total of 159.)
## Reproduce
```sh
# from this directory, using the repo .venv
../../../.venv/bin/python plot_full_medial_tire_n9.py # figure + index
python full_medial_tire_generator.py --min-n 9 --max-n 9 --dedup --show 5
```
`full_medial_tire_generator.py` is the generator (`generate(n, dedup=True)`
yields `FullMedialTireGraph` objects); `plot_full_medial_tire_n9.py` draws the
atlas.
@@ -0,0 +1,81 @@
0 word=UUUUUUUUU up=9 down=0 bites=-
1 word=UUUUUUDUD up=7 down=2 bites=(6,8)
2 word=UUUUUUDDD up=6 down=3 bites=-
3 word=UUUUUDUUD up=7 down=2 bites=(5,8)
4 word=UUUUUDUDD up=6 down=3 bites=-
5 word=UUUUUDDDD up=5 down=4 bites=-
6 word=UUUUDUUUD up=7 down=2 bites=(4,8)
7 word=UUUUDUUDD up=6 down=3 bites=-
8 word=UUUUDUDUD up=6 down=3 bites=-
9 word=UUUUDUDDD up=5 down=4 bites=-
10 word=UUUUDDUDD up=5 down=4 bites=-
11 word=UUUUDDUDD up=5 down=4 bites=(4,8),(5,7)
12 word=UUUUDDDDD up=4 down=5 bites=-
13 word=UUUUDDDDD up=4 down=5 bites=(4,8)
14 word=UUUDUUUDD up=6 down=3 bites=-
15 word=UUUDUUDUD up=6 down=3 bites=-
16 word=UUUDUUDDD up=5 down=4 bites=-
17 word=UUUDUDUDD up=5 down=4 bites=-
18 word=UUUDUDUDD up=5 down=4 bites=(3,8),(5,7)
19 word=UUUDUDDUD up=5 down=4 bites=-
20 word=UUUDUDDUD up=5 down=4 bites=(3,5),(6,8)
21 word=UUUDUDDDD up=4 down=5 bites=-
22 word=UUUDUDDDD up=4 down=5 bites=(3,5)
23 word=UUUDUDDDD up=4 down=5 bites=(3,8)
24 word=UUUDDUUDD up=5 down=4 bites=-
25 word=UUUDDUUDD up=5 down=4 bites=(3,8),(4,7)
26 word=UUUDDUDDD up=4 down=5 bites=-
27 word=UUUDDUDDD up=4 down=5 bites=(4,6)
28 word=UUUDDUDDD up=4 down=5 bites=(3,8)
29 word=UUUDDDDDD up=3 down=6 bites=-
30 word=UUUDDDDDD up=3 down=6 bites=(3,8)
31 word=UUDUUDUUD up=6 down=3 bites=-
32 word=UUDUUDUDD up=5 down=4 bites=-
33 word=UUDUUDUDD up=5 down=4 bites=(2,8),(5,7)
34 word=UUDUUDDDD up=4 down=5 bites=-
35 word=UUDUUDDDD up=4 down=5 bites=(2,5)
36 word=UUDUDUUDD up=5 down=4 bites=-
37 word=UUDUDUUDD up=5 down=4 bites=(2,8),(4,7)
38 word=UUDUDUDUD up=5 down=4 bites=-
39 word=UUDUDUDUD up=5 down=4 bites=(2,4),(6,8)
40 word=UUDUDUDUD up=5 down=4 bites=(2,8),(4,6)
41 word=UUDUDUDDD up=4 down=5 bites=-
42 word=UUDUDUDDD up=4 down=5 bites=(4,6)
43 word=UUDUDUDDD up=4 down=5 bites=(2,4)
44 word=UUDUDUDDD up=4 down=5 bites=(2,8)
45 word=UUDUDDUDD up=4 down=5 bites=-
46 word=UUDUDDUDD up=4 down=5 bites=(5,7)
47 word=UUDUDDUDD up=4 down=5 bites=(2,4)
48 word=UUDUDDUDD up=4 down=5 bites=(2,8)
49 word=UUDUDDDUD up=4 down=5 bites=-
50 word=UUDUDDDUD up=4 down=5 bites=(6,8)
51 word=UUDUDDDUD up=4 down=5 bites=(2,8)
52 word=UUDUDDDDD up=3 down=6 bites=-
53 word=UUDUDDDDD up=3 down=6 bites=(2,4)
54 word=UUDUDDDDD up=3 down=6 bites=(2,8)
55 word=UUDDUUDDD up=4 down=5 bites=-
56 word=UUDDUUDDD up=4 down=5 bites=(3,6)
57 word=UUDDUDUDD up=4 down=5 bites=-
58 word=UUDDUDUDD up=4 down=5 bites=(5,7)
59 word=UUDDUDUDD up=4 down=5 bites=(2,8)
60 word=UUDDUDDDD up=3 down=6 bites=-
61 word=UUDDUDDDD up=3 down=6 bites=(3,5)
62 word=UUDDUDDDD up=3 down=6 bites=(2,8)
63 word=UUDDDUDDD up=3 down=6 bites=-
64 word=UUDDDUDDD up=3 down=6 bites=(4,6)
65 word=UUDDDUDDD up=3 down=6 bites=(2,8)
66 word=UUDDDUDDD up=3 down=6 bites=(2,8),(3,7),(4,6)
67 word=UDUDUDUDD up=4 down=5 bites=-
68 word=UDUDUDUDD up=4 down=5 bites=(5,7)
69 word=UDUDUDUDD up=4 down=5 bites=(3,5)
70 word=UDUDUDDDD up=3 down=6 bites=-
71 word=UDUDUDDDD up=3 down=6 bites=(3,5)
72 word=UDUDUDDDD up=3 down=6 bites=(1,3)
73 word=UDUDDUDDD up=3 down=6 bites=-
74 word=UDUDDUDDD up=3 down=6 bites=(4,6)
75 word=UDUDDUDDD up=3 down=6 bites=(1,3)
76 word=UDUDDUDDD up=3 down=6 bites=(1,8)
77 word=UDUDDUDDD up=3 down=6 bites=(1,8),(3,7),(4,6)
78 word=UDDUDDUDD up=3 down=6 bites=-
79 word=UDDUDDUDD up=3 down=6 bites=(5,7)
80 word=UDDUDDUDD up=3 down=6 bites=(1,8),(2,4),(5,7)
@@ -0,0 +1,139 @@
"""Draw every full medial tire graph with |A(T)| = 9.
Generates the dihedral-symmetry classes from ``full_medial_tire_generator`` and
draws each one in the tooth style of the paper figures: the annular cycle A(T)
as a thick ring of black vertices, up teeth pointing outward (blue apexes),
singleton down teeth pointing inward (red apexes), and bites as a single inner
apex (dark red) with four spokes to the two paired annular edges.
Output is a single grid figure (PNG + PDF) plus a text index of the classes.
"""
from __future__ import annotations
import argparse
import math
import os
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from full_medial_tire_generator import FullMedialTireGraph, generate
HERE = os.path.dirname(os.path.abspath(__file__))
def vertex_xy(k: int, n: int, radius: float) -> tuple[float, float]:
"""Annular vertex k, placed clockwise from the top of the unit circle."""
angle = math.pi / 2 - 2 * math.pi * k / n
return radius * math.cos(angle), radius * math.sin(angle)
def edge_midpoint_angle(i: int, n: int) -> float:
return math.pi / 2 - 2 * math.pi * (i + 0.5) / n
def draw_graph(ax, g: FullMedialTireGraph) -> None:
n = g.n
ann = [vertex_xy(k, n, 1.0) for k in range(n)]
matched = g.bite_edges
# annular cycle A(T)
cyc_x = [p[0] for p in ann] + [ann[0][0]]
cyc_y = [p[1] for p in ann] + [ann[0][1]]
ax.plot(cyc_x, cyc_y, color="black", lw=1.6, zorder=2)
# up teeth (outer apexes) and singleton down teeth (inner apexes)
for i, tooth in enumerate(g.tooth_word):
if tooth == "U":
r, color = 1.42, "#2b6cb0"
elif i not in matched: # singleton down tooth
r, color = 0.58, "#c53030"
else:
continue
ang = edge_midpoint_angle(i, n)
apex = (r * math.cos(ang), r * math.sin(ang))
a0, a1 = ann[i], ann[(i + 1) % n]
ax.plot([apex[0], a0[0]], [apex[1], a0[1]], color="#9a9a9a", lw=0.5, zorder=1)
ax.plot([apex[0], a1[0]], [apex[1], a1[1]], color="#9a9a9a", lw=0.5, zorder=1)
ax.scatter([apex[0]], [apex[1]], s=14, color=color, zorder=3)
# bites: one shared inner apex with four spokes
for i, j in sorted(g.bites):
corners = [ann[i], ann[(i + 1) % n], ann[j], ann[(j + 1) % n]]
cx = sum(p[0] for p in corners) / 4.0
cy = sum(p[1] for p in corners) / 4.0
# pull slightly toward the centre so nested bites stay distinguishable
apex = (cx * 0.82, cy * 0.82)
for corner in corners:
ax.plot([apex[0], corner[0]], [apex[1], corner[1]],
color="#9a9a9a", lw=0.5, zorder=1)
ax.scatter([apex[0]], [apex[1]], s=26, color="#7b1f1f",
edgecolors="black", linewidths=0.4, zorder=4)
# annular vertices on top
ax.scatter([p[0] for p in ann], [p[1] for p in ann],
s=10, color="black", zorder=5)
bites = ",".join(f"{i}{j}" for i, j in sorted(g.bites)) or "-"
ax.set_title(f"{g.tooth_word}\n{bites}", fontsize=5.5, pad=1.5)
ax.set_xlim(-1.6, 1.6)
ax.set_ylim(-1.6, 1.6)
ax.set_aspect("equal")
ax.axis("off")
def run(args: argparse.Namespace) -> None:
graphs = list(generate(args.n, min_up_teeth=args.min_up, dedup=True))
print(f"n={args.n}: {len(graphs)} dihedral classes")
cols = args.cols
rows = math.ceil(len(graphs) / cols)
fig, axes = plt.subplots(rows, cols, figsize=(cols * 1.45, rows * 1.6))
axes = axes.reshape(rows, cols)
for idx in range(rows * cols):
ax = axes[idx // cols][idx % cols]
if idx < len(graphs):
draw_graph(ax, graphs[idx])
else:
ax.axis("off")
fig.suptitle(
f"All full medial tire graphs with $|A(T)|={args.n}$ "
f"({len(graphs)} classes up to dihedral symmetry)",
fontsize=12, y=0.997,
)
fig.tight_layout(rect=(0, 0, 1, 0.985))
png = os.path.join(HERE, f"full_medial_tire_n{args.n}.png")
pdf = os.path.join(HERE, f"full_medial_tire_n{args.n}.pdf")
fig.savefig(png, dpi=200)
fig.savefig(pdf)
print(f"wrote {png}")
print(f"wrote {pdf}")
if args.index:
index_path = os.path.join(HERE, f"full_medial_tire_n{args.n}_index.txt")
with open(index_path, "w") as fh:
for idx, g in enumerate(graphs):
bites = ",".join(f"({i},{j})" for i, j in sorted(g.bites)) or "-"
fh.write(
f"{idx:3d} word={g.tooth_word} up={len(g.up_edges)} "
f"down={len(g.down_edges)} bites={bites}\n"
)
print(f"wrote {index_path}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--n", type=int, default=9)
parser.add_argument("--min-up", type=int, default=3)
parser.add_argument("--cols", type=int, default=12)
parser.add_argument("--index", action="store_true", default=True)
run(parser.parse_args())
if __name__ == "__main__":
main()