nested_level_duals → coloring_nested_tire_graphs: rename + retitle + tire definition

Resurrects the shelved nested-level-duals paper under a new framing
focused on the tire graph: a triangulation of the annulus between an
outer cycle and the outer face of an inner outerplanar graph.

Paper changes:
- Title: "Nested Level Duals" → "Coloring Nested Tire Graphs"
- Adds Definition 1.5 (Tire graph) formalising (C_out, O, E_ann) with
  the annular-triangulation condition, plus a Remark on vertex/edge/
  face counts.
- Removes the 2026-05-22 "shelved" note.

Directory rename: papers/nested_level_duals/ → papers/coloring_nested_tire_graphs/.

New scaffolding:
- experiments/tire_graph.py — random tire generator using the lattice-
  path bijection (m O's + k I's anchored at one spoke), plus a
  planar-layout renderer (vertices placed at angular centroid of their
  incident spokes for crossing-free straight-line drawings).
- 6 example PNGs at varying (m, k, n_chords).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 14:28:22 -04:00
parent 04dd72076b
commit 6ca0c6dd15
15 changed files with 246 additions and 0 deletions
@@ -0,0 +1,246 @@
"""Tire graphs: triangulations of the annular region between an outer
cycle and the outer face of an inner outerplanar graph.
Definition.
A *tire graph* T = (C_out, O, Tann) consists of:
- an outer cycle C_out of length m, on vertices V_out,
- an outerplanar graph O whose outer-face boundary is a simple cycle
C_in of length k, on vertices V_in (V_in disjoint from V_out),
- a triangulation Tann of the closed annulus with outer boundary
C_out and inner boundary C_in, using ONLY V_out V_in (no Steiner
points).
As a plane graph: V(T) = V_out V_in,
E(T) = E(C_out) E(O) E(Tann).
Random generation.
Annular triangulations between C_m and C_k (no interior vertices) are
in bijection with sequences of m "O" moves and k "I" moves, once an
anchor spoke (V_out[0]V_in[0]) is fixed. We sample uniformly over
the C(m+k, m) such sequences. Inner-outerplanar-graph chords are
sampled by greedily adding non-crossing chords until n_chords are
placed (or no more fit).
Run:
python3 experiments/tire_graph.py
"""
import math
import os
import random
import matplotlib.pyplot as plt
import matplotlib.patches as patches
def random_tire(m, k, n_chords=0, seed=None):
"""Generate a random tire graph with outer cycle length m and inner
outerplanar graph whose outer face cycle has length k."""
rng = random.Random(seed)
outer = list(range(m)) # outer-cycle vertex labels
inner = list(range(m, m + k)) # inner-cycle vertex labels
edges = set()
# outer cycle
for i in range(m):
edges.add(frozenset({outer[i], outer[(i + 1) % m]}))
# inner cycle (= outer face of inner outerplanar graph)
for j in range(k):
edges.add(frozenset({inner[j], inner[(j + 1) % k]}))
# random non-crossing chords inside the inner cycle
inner_chords = set()
candidates = []
for a in range(k):
for b in range(a + 2, k):
if not (a == 0 and b == k - 1): # skip the cycle edge
candidates.append((a, b))
rng.shuffle(candidates)
for (a, b) in candidates:
if len(inner_chords) >= n_chords:
break
ok = True
for (a2, b2) in inner_chords:
# chords (a,b), (a2,b2) cross iff one strictly separates the
# other on the cycle
if (a < a2 < b < b2) or (a2 < a < b2 < b):
ok = False
break
if ok:
inner_chords.add((a, b))
edges.add(frozenset({inner[a], inner[b]}))
# anchor spoke
edges.add(frozenset({outer[0], inner[0]}))
# annular triangulation via random lattice path
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(frozenset({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(frozenset({outer[i % m], inner[(j + 1) % k]}))
j += 1
return {
'm': m, 'k': k, 'n_chords': len(inner_chords),
'outer': outer, 'inner': inner,
'edges': [tuple(sorted(e)) for e in edges],
'triangles': triangles,
'inner_chords': sorted(inner_chords),
'lattice_path': ''.join(moves),
'seed': seed,
}
def planar_positions(tire, R_out=1.0, R_in=0.45):
"""Compute crossing-free straight-line positions on concentric circles.
Walking spokes in cyclic order starting from the anchor, each outer
(resp. inner) vertex appears in a contiguous arc of spokes; we
place each vertex at the angular centroid of those spoke positions
on its circle, which preserves the cyclic order implied by the
planar embedding and so makes all annular edges crossing-free."""
m, k = tire['m'], tire['k']
outer, inner = tire['outer'], tire['inner']
moves = tire['lattice_path']
# Build spoke list in cyclic order: spoke t connects outer[i_t] to
# inner[j_t] where (i_t, j_t) is the lattice position after t moves.
spokes = [(outer[0], inner[0])] # anchor at t=0
i, j = 0, 0
for mv in moves:
if mv == 'O':
i += 1
else:
j += 1
spokes.append((outer[i % m], inner[j % k]))
spokes = spokes[:-1] # drop wrap-back duplicate
n = len(spokes) # = m + k
out_t = {v: [] for v in outer}
in_t = {v: [] for v in inner}
for t, (ov, iv) in enumerate(spokes):
out_t[ov].append(t)
in_t[iv].append(t)
def circ_mean(ts):
x = sum(math.cos(2 * math.pi * t / n) for t in ts)
y = sum(math.sin(2 * math.pi * t / n) for t in ts)
return math.atan2(y, x)
pos = {}
for v in outer:
theta = circ_mean(out_t[v])
pos[v] = (R_out * math.cos(theta), R_out * math.sin(theta))
for v in inner:
theta = circ_mean(in_t[v])
pos[v] = (R_in * math.cos(theta), R_in * math.sin(theta))
return pos
def draw_tire(tire, filename, title=None):
"""Draw the tire on concentric circles with annular triangulation as
straight-line edges. Outer cycle = blue, inner cycle = red, inner
chords = orange, annular spokes/chords = grey."""
m, k = tire['m'], tire['k']
outer, inner = tire['outer'], tire['inner']
edges = tire['edges']
R_out, R_in = 1.0, 0.45
pos = planar_positions(tire, R_out=R_out, R_in=R_in)
fig, ax = plt.subplots(figsize=(5.5, 5.5))
# faint guide circles
for r in (R_out + 0.05, R_in - 0.05):
ax.add_patch(patches.Circle((0, 0), r, fill=False,
edgecolor='lightgray',
linewidth=0.5, linestyle='--'))
outer_set = set(outer)
inner_set = set(inner)
C = {
'outer_cycle': '#1f77b4',
'inner_cycle': '#d62728',
'inner_chord': '#ff7f0e',
'spoke': '#7f7f7f',
}
for (u, v) in edges:
if u in outer_set and v in outer_set:
color, lw = C['outer_cycle'], 2.4
elif u in inner_set and v in inner_set:
ia, ib = u - m, v - m
d = abs(ia - ib)
d = min(d, k - d)
if d == 1:
color, lw = C['inner_cycle'], 2.4
else:
color, lw = C['inner_chord'], 1.6
else:
color, lw = C['spoke'], 1.0
x1, y1 = pos[u]; x2, y2 = pos[v]
ax.plot([x1, x2], [y1, y2], color=color, linewidth=lw, zorder=1)
for v in outer:
x, y = pos[v]
ax.plot(x, y, 'o', color=C['outer_cycle'], markersize=14, zorder=2)
ax.annotate(str(v), (x, y), color='white', ha='center',
va='center', fontsize=8, fontweight='bold', zorder=3)
for v in inner:
x, y = pos[v]
ax.plot(x, y, 'o', color=C['inner_cycle'], markersize=12, zorder=2)
ax.annotate(str(v), (x, y), color='white', ha='center',
va='center', fontsize=7, fontweight='bold', zorder=3)
ax.set_xlim(-1.18, 1.18)
ax.set_ylim(-1.18, 1.18)
ax.set_aspect('equal')
ax.axis('off')
if title is None:
title = (f"tire(m={m}, k={k}, chords={tire['n_chords']}, "
f"seed={tire['seed']})")
ax.set_title(title, fontsize=11)
plt.savefig(filename, dpi=140, bbox_inches='tight')
plt.close()
def main():
out_dir = os.path.dirname(os.path.abspath(__file__))
# (m, k, n_chords, seed) -- a mix of small/medium, no chords / chords
examples = [
(6, 3, 0, 1),
(8, 5, 0, 7),
(10, 4, 1, 11),
(12, 7, 3, 23),
(9, 9, 2, 42),
(14, 5, 0, 100),
]
paths = []
for (m, k, nc, seed) in examples:
tire = random_tire(m, k, n_chords=nc, seed=seed)
fn = os.path.join(out_dir, f"tire_m{m}_k{k}_c{nc}_s{seed}.png")
draw_tire(tire, fn)
paths.append(fn)
print(f" wrote {os.path.basename(fn)} "
f"path={tire['lattice_path']} "
f"chords={tire['inner_chords']}")
print(f"\nGenerated {len(paths)} tires.")
return paths
if __name__ == '__main__':
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 250 KiB