Add medial tire decomposition paper

This commit is contained in:
2026-06-08 15:34:53 -04:00
parent 6400fdfc5e
commit 20fe6c24ca
14 changed files with 2087 additions and 119 deletions
@@ -0,0 +1,350 @@
"""Compare full and reduced medial tire graphs on generated tires.
The new medial decomposition paper defines:
* full medial tire graph: the subgraph of M(G) induced by medial
vertices corresponding to edges incident to tread triangles;
* reduced medial tire graph: delete same-boundary medial edges and
chord-only medial edges.
For a tire tread inside an ambient triangulation, the medial edges
visible in the tread come from annular triangular faces. This script
checks whether any same-boundary medial edges are actually present in
that model. It also compares against the older standalone drawing
model, which added artificial outer/inner boundary faces.
"""
from __future__ import annotations
import argparse
import itertools
import random
from collections import Counter
Edge = tuple[int, int]
MedialEdge = tuple[Edge, Edge]
def random_tire(m: int, k: int, n_chords: int = 0, seed: int | None = None) -> dict:
"""Generate the same labelled annular tires used in earlier experiments."""
rng = random.Random(seed)
outer = list(range(m))
inner = list(range(m, m + k))
edges: set[Edge] = set()
for i in range(m):
edges.add(edge_key(outer[i], outer[(i + 1) % m]))
for j in range(k):
edges.add(edge_key(inner[j], inner[(j + 1) % k]))
inner_chords = set()
candidates = []
for a in range(k):
for b in range(a + 2, k):
if not (a == 0 and b == k - 1):
candidates.append((a, b))
rng.shuffle(candidates)
for a, b in candidates:
if len(inner_chords) >= n_chords:
break
if any((a < a2 < b < b2) or (a2 < a < b2 < b) for a2, b2 in inner_chords):
continue
inner_chords.add((a, b))
edges.add(edge_key(inner[a], inner[b]))
edges.add(edge_key(outer[0], inner[0]))
moves = ["O"] * m + ["I"] * k
rng.shuffle(moves)
triangles = []
i, j = 0, 0
for move in moves:
if move == "O":
tri = (outer[i % m], inner[j % k], outer[(i + 1) % m])
triangles.append(tri)
edges.add(edge_key(inner[j % k], outer[(i + 1) % m]))
i += 1
else:
tri = (outer[i % m], inner[j % k], inner[(j + 1) % k])
triangles.append(tri)
edges.add(edge_key(outer[i % m], inner[(j + 1) % k]))
j += 1
return {
"m": m,
"k": k,
"n_chords": len(inner_chords),
"outer": outer,
"inner": inner,
"edges": sorted(edges),
"triangles": triangles,
"inner_chords": sorted(inner_chords),
"lattice_path": "".join(moves),
"seed": seed,
}
def tire_from_path(m: int, k: int, chords: tuple[tuple[int, int], ...], path: str) -> dict:
outer = list(range(m))
inner = list(range(m, m + k))
edges: set[Edge] = set()
for i in range(m):
edges.add(edge_key(outer[i], outer[(i + 1) % m]))
for j in range(k):
edges.add(edge_key(inner[j], inner[(j + 1) % k]))
for a, b in chords:
edges.add(edge_key(inner[a], inner[b]))
edges.add(edge_key(outer[0], inner[0]))
triangles = []
i, j = 0, 0
for move in path:
if move == "O":
tri = (outer[i % m], inner[j % k], outer[(i + 1) % m])
triangles.append(tri)
edges.add(edge_key(inner[j % k], outer[(i + 1) % m]))
i += 1
else:
tri = (outer[i % m], inner[j % k], inner[(j + 1) % k])
triangles.append(tri)
edges.add(edge_key(outer[i % m], inner[(j + 1) % k]))
j += 1
return {
"m": m,
"k": k,
"n_chords": len(chords),
"outer": outer,
"inner": inner,
"edges": sorted(edges),
"triangles": triangles,
"inner_chords": sorted(chords),
"lattice_path": path,
"seed": None,
}
def chord_crosses(c1: tuple[int, int], c2: tuple[int, int]) -> bool:
a, b = c1
c, d = c2
return (a < c < b < d) or (c < a < d < b)
def chord_sets(k: int, max_chords: int) -> list[tuple[tuple[int, int], ...]]:
candidates = []
for a in range(k):
for b in range(a + 2, k):
if not (a == 0 and b == k - 1):
candidates.append((a, b))
out = [()]
def rec(start: int, chosen: tuple[tuple[int, int], ...]) -> None:
if len(chosen) >= max_chords:
return
for idx in range(start, len(candidates)):
chord = candidates[idx]
if any(chord_crosses(chord, old) for old in chosen):
continue
nxt = chosen + (chord,)
out.append(nxt)
rec(idx + 1, nxt)
rec(0, ())
return out
def lattice_paths(m: int, k: int):
for o_positions in itertools.combinations(range(m + k), m):
o_set = set(o_positions)
yield "".join("O" if idx in o_set else "I" for idx in range(m + k))
def edge_key(u: int, v: int) -> Edge:
return tuple(sorted((u, v)))
def face_edges(face: tuple[int, ...]) -> list[Edge]:
return [edge_key(face[i], face[(i + 1) % len(face)]) for i in range(len(face))]
def is_cycle_edge(edge: Edge, cycle: list[int]) -> bool:
cycle_set = set(cycle)
if not set(edge) <= cycle_set:
return False
n = len(cycle)
idx = {v: i for i, v in enumerate(cycle)}
a, b = idx[edge[0]], idx[edge[1]]
return (a - b) % n in (1, n - 1)
def is_inner_chord(edge: Edge, m: int, k: int) -> bool:
u, v = edge
if not (m <= u < m + k and m <= v < m + k):
return False
a, b = u - m, v - m
d = abs(a - b)
return min(d, k - d) != 1
def suppress_reason(e1: Edge, e2: Edge, tire: dict) -> str | None:
outer = tire["outer"]
inner = tire["inner"]
if is_cycle_edge(e1, outer) and is_cycle_edge(e2, outer):
return "outer_boundary"
if is_cycle_edge(e1, inner) and is_cycle_edge(e2, inner):
return "inner_boundary"
m, k = tire["m"], tire["k"]
if is_inner_chord(e1, m, k) or is_inner_chord(e2, m, k):
return "inner_chord"
return None
def medial_from_faces(faces: list[tuple[int, ...]], retained: set[Edge]) -> set[MedialEdge]:
medial_edges: set[MedialEdge] = set()
for face in faces:
boundary = [e for e in face_edges(face) if e in retained]
if len(boundary) < 2:
continue
for i, e in enumerate(boundary):
nxt = boundary[(i + 1) % len(boundary)]
if e != nxt:
medial_edges.add(tuple(sorted((e, nxt))))
return medial_edges
def compare_tire(tire: dict, *, standalone_boundary_faces: bool) -> dict:
annular_faces = [tuple(tri) for tri in tire["triangles"]]
faces = list(annular_faces)
if standalone_boundary_faces:
faces.append(tuple(tire["outer"]))
faces.append(tuple(reversed(tire["inner"])))
# Definition 3.1 includes edges incident to at least one tread triangle.
retained = {e for face in annular_faces for e in face_edges(face)}
full_edges = medial_from_faces(faces, retained)
removed = {me for me in full_edges if suppress_reason(me[0], me[1], tire)}
reduced_edges = full_edges - removed
reasons = Counter(suppress_reason(me[0], me[1], tire) for me in removed)
reasons.pop(None, None)
return {
"vertices": len(retained),
"full_edges": len(full_edges),
"reduced_edges": len(reduced_edges),
"removed": len(removed),
"reasons": reasons,
"examples": sorted(removed)[:5],
}
def run_sweep(args: argparse.Namespace) -> None:
ambient_cases = 0
ambient_differ = []
standalone_cases = 0
standalone_differ = []
ambient_reasons: Counter[str] = Counter()
standalone_reasons: Counter[str] = Counter()
max_chords = args.max_chords
for m in range(args.min_cycle, args.max_cycle + 1):
for k in range(args.min_cycle, args.max_cycle + 1):
for chords in range(max_chords + 1):
for seed in range(args.seeds):
tire = random_tire(m=m, k=k, n_chords=chords, seed=seed)
ambient = compare_tire(tire, standalone_boundary_faces=False)
ambient_cases += 1
ambient_reasons.update(ambient["reasons"])
if ambient["removed"]:
ambient_differ.append((m, k, chords, seed, tire, ambient))
standalone = compare_tire(tire, standalone_boundary_faces=True)
standalone_cases += 1
standalone_reasons.update(standalone["reasons"])
if standalone["removed"]:
standalone_differ.append((m, k, chords, seed, tire, standalone))
print("ambient tread-face model")
print(f" cases checked: {ambient_cases}")
print(f" cases where full != reduced: {len(ambient_differ)}")
print(f" removed-edge reasons: {dict(sorted(ambient_reasons.items()))}")
if ambient_differ:
m, k, chords, seed, tire, result = ambient_differ[0]
print(" first difference:")
print(f" m={m} k={k} requested_chords={chords} seed={seed}")
print(f" path={tire['lattice_path']} chords={tire['inner_chords']}")
print(f" removed examples={result['examples']}")
print()
print("standalone tire-with-boundary-faces model")
print(f" cases checked: {standalone_cases}")
print(f" cases where full != reduced: {len(standalone_differ)}")
print(f" removed-edge reasons: {dict(sorted(standalone_reasons.items()))}")
if standalone_differ:
m, k, chords, seed, tire, result = standalone_differ[0]
print(" first difference:")
print(f" m={m} k={k} requested_chords={chords} seed={seed}")
print(f" path={tire['lattice_path']} chords={tire['inner_chords']}")
print(f" full_edges={result['full_edges']} reduced_edges={result['reduced_edges']}")
print(f" removed examples={result['examples']}")
def run_exhaustive(args: argparse.Namespace) -> None:
ambient_cases = 0
ambient_differ = []
standalone_cases = 0
standalone_differ = []
for m in range(args.min_cycle, args.max_cycle + 1):
for k in range(args.min_cycle, args.max_cycle + 1):
for chords in chord_sets(k, args.max_chords):
for path in lattice_paths(m, k):
tire = tire_from_path(m, k, chords, path)
ambient = compare_tire(tire, standalone_boundary_faces=False)
ambient_cases += 1
if ambient["removed"]:
ambient_differ.append((m, k, chords, path, ambient))
standalone = compare_tire(tire, standalone_boundary_faces=True)
standalone_cases += 1
if standalone["removed"]:
standalone_differ.append((m, k, chords, path, standalone))
print("exhaustive ambient tread-face model")
print(f" cases checked: {ambient_cases}")
print(f" cases where full != reduced: {len(ambient_differ)}")
if ambient_differ:
m, k, chords, path, result = ambient_differ[0]
print(" first difference:")
print(f" m={m} k={k} chords={chords} path={path}")
print(f" removed examples={result['examples']}")
print()
print("exhaustive standalone tire-with-boundary-faces model")
print(f" cases checked: {standalone_cases}")
print(f" cases where full != reduced: {len(standalone_differ)}")
if standalone_differ:
m, k, chords, path, result = standalone_differ[0]
print(" first difference:")
print(f" m={m} k={k} chords={chords} path={path}")
print(f" full_edges={result['full_edges']} reduced_edges={result['reduced_edges']}")
print(f" removed examples={result['examples']}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--min-cycle", type=int, default=3)
parser.add_argument("--max-cycle", type=int, default=8)
parser.add_argument("--max-chords", type=int, default=3)
parser.add_argument("--seeds", type=int, default=50)
parser.add_argument("--exhaustive", action="store_true")
args = parser.parse_args()
if args.exhaustive:
run_exhaustive(args)
else:
run_sweep(args)
if __name__ == "__main__":
main()
@@ -0,0 +1,82 @@
# Full vs Reduced Medial Tire Findings
Question: do Definition 3.1 (full medial tire graph) and Definition 3.2
(reduced medial tire graph) differ?
## Experiment
Script:
```bash
python3 papers/medial_tire_decompositions_of_plane_triangulations/experiments/compare_full_reduced_medial_tires.py
```
The script compares two models.
- Ambient tread-face model: medial edges are contributed by annular
triangular faces of the tire tread inside the ambient triangulation.
- Standalone tire-with-boundary-faces model: the outer and inner
boundary walks are also treated as faces, as in the older drawing
script.
## Random Sweep
Command:
```bash
python3 papers/medial_tire_decompositions_of_plane_triangulations/experiments/compare_full_reduced_medial_tires.py
```
Result:
```text
ambient tread-face model
cases checked: 7200
cases where full != reduced: 0
removed-edge reasons: {}
standalone tire-with-boundary-faces model
cases checked: 7200
cases where full != reduced: 7200
removed-edge reasons: {'inner_boundary': 39600, 'outer_boundary': 39600}
first difference:
m=3 k=3 requested_chords=0 seed=0
path=IOOOII chords=[]
full_edges=24 reduced_edges=18
removed examples=[((0, 1), (0, 2)), ((0, 1), (1, 2)), ((0, 2), (1, 2)), ((3, 4), (3, 5)), ((3, 4), (4, 5))]
```
## Exhaustive Small Sweep
Command:
```bash
python3 papers/medial_tire_decompositions_of_plane_triangulations/experiments/compare_full_reduced_medial_tires.py --exhaustive --max-cycle 5 --max-chords 2
```
Result:
```text
exhaustive ambient tread-face model
cases checked: 5578
cases where full != reduced: 0
exhaustive standalone tire-with-boundary-faces model
cases checked: 5578
cases where full != reduced: 5578
first difference:
m=3 k=3 chords=() path=OOOIII
full_edges=24 reduced_edges=18
removed examples=[((0, 1), (0, 2)), ((0, 1), (1, 2)), ((0, 2), (1, 2)), ((3, 4), (3, 5)), ((3, 4), (4, 5))]
```
## Interpretation
For the intended ambient-triangulation definition, the experiments
support the suspicion that Definition 3.1 and Definition 3.2 coincide:
same-boundary medial edges do not arise from annular triangular tread
faces, and inner chords are not incident to tread triangles.
They differ only in the standalone tire-with-boundary-faces model,
where the artificial outer and inner boundary faces create medial edges
between consecutive boundary edges.