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>
@@ -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()
|
||||
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 250 KiB |