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 |