Add medial tire decomposition paper
This commit is contained in:
+350
@@ -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()
|
||||
+82
@@ -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.
|
||||
Reference in New Issue
Block a user