Treat each disjoint annular cycle as its own full medial tire graph

A tread's annular frontier can split into several disjoint cycles; each
is now recognised as a separate full medial tire graph instead of
disqualifying the whole tread.

- recognise() returns a list of (g, bij), one per annular cycle
  component; add annular_cycle_components() and _recognise_one(), and
  iterate components in iter_pieces().
- Key tires/results by (depth, component) throughout both experiment
  drivers: _label_treads chains each tire to a parent-depth down tooth
  sharing its apex; _cap_cut/_assemble_cut_graph/to_json/summary and the
  dual-cut collectors/draws follow suit.

Source vertex selection for the dual-cut experiment now deep-embeds a
random face and roots at the outer-cap vertex. The source-dual figure
labels the source-graph vertices, highlights the entry medial vertex,
and uses a cap-rooted concentric layout.

For seed 7 / face (14,15,19) this recognises treads 3 and 4 as two
tires each (3.0,3.1,4.0,4.1), so every dual face is now cut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 12:01:36 -04:00
parent 7554582056
commit b605931678
6 changed files with 423 additions and 188 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

@@ -14,15 +14,21 @@ the interesting quantity is how many of those removed (``missing``) dual edges
surround each dual face (vertex of G). For ``seed59`` at source 5 the maximum
is 3, around the degree-9 vertex 3.
The level source is chosen by deep embedding: pick a random face of G, take the
deep embedding G' relative to that face (subdividing every neutral face,
including the chosen one), and use the outer-cap vertex x* placed inside the
chosen face as the source. The whole dual cut is then read off G'.
Four chained entry points (broad to narrow control):
* ``random_dual_cut(n, ...)`` -- find a random maximal planar graph of a given
minimum degree, then defer to ``dual_cut_random_source``.
* ``dual_cut_random_source(G, ...)`` -- choose a random level source, then
defer to ``dual_cut_random_entry``.
* ``dual_cut_random_entry(G, source, ...)`` -- choose a random root entry
minimum degree, then defer to ``dual_cut_random_face``.
* ``dual_cut_random_face(G, ...)`` -- choose a random face, deep-embed
relative to it, and use the cap vertex as the source, then defer to
``dual_cut_random_entry``.
* ``dual_cut_random_entry(G', cap, ...)`` -- choose a random root entry
tooth, then defer to ``medial_tire_dual_cut``.
* ``medial_tire_dual_cut(G, source, entry_edge)`` -- the worker: chain the
* ``medial_tire_dual_cut(G', source, entry_edge)`` -- the worker: chain the
walk-depth labelling/cut from the given root entry tooth and assemble the
source-dual cut.
@@ -62,10 +68,12 @@ from run_medial_tire_cut_experiment import ( # noqa: E402
# --------------------------------------------------------------------------- #
def _build_treads(faces, levels):
"""Recognise the full medial tire graph of every BFS-level tread.
"""Recognise the full medial tire graph(s) of every BFS-level tread.
Returns ``(treads, skipped)`` where ``treads`` maps depth ``d`` to the
recognised ``(g, bij)`` and ``skipped`` lists ``(d, reason)`` for the rest.
A tread depth whose annular frontier splits into several disjoint cycles
yields one tire per cycle. Returns ``(treads, skipped)`` where ``treads``
maps ``(depth, component)`` to the recognised ``(g, bij)`` and ``skipped``
lists ``(d, reason)`` for the depths that produced no tire.
"""
treads, skipped = {}, []
for d in range(max(levels.values())):
@@ -76,11 +84,12 @@ def _build_treads(faces, levels):
if len(tread["up"]) < 3:
skipped.append((d, f"only {len(tread['up'])} up teeth"))
continue
rec = recognise(medial_tire_facemodel(tread["tread_faces"]), tread)
if rec is None:
skipped.append((d, "not a valid full medial tire graph"))
tires = recognise(medial_tire_facemodel(tread["tread_faces"]), tread)
if not tires:
skipped.append((d, "no annular cycle recognised as a tire"))
continue
treads[d] = rec
for c, gb in enumerate(tires):
treads[(d, c)] = gb
return treads, skipped
@@ -98,6 +107,72 @@ def root_entry_choices(G, source):
return sorted(g.up_edges)
# --------------------------------------------------------------------------- #
# Deep embedding (relative to a chosen face) and its outer-cap vertex.
# --------------------------------------------------------------------------- #
def _plane_depth(G, outer_cycle):
"""Plane depth of every vertex of ``G`` relative to ``outer_cycle``: the
graph distance to the nearest outer-cycle vertex (outer cycle = depth 0).
Mirrors ``plane_depth_sequencing.get_plane_depth_labelling`` -- attach a
temporary super-source to the outer cycle, BFS, and subtract one."""
tmp = G.copy()
s = max(G.nodes()) + 1
tmp.add_node(s)
tmp.add_edges_from((s, v) for v in outer_cycle)
dist = nx.single_source_shortest_path_length(tmp, s)
return {v: dist[v] - 1 for v in G.nodes()}
def deep_embedding(G, face):
"""Deep embedding of maximal planar ``G`` relative to triangular ``face``.
Networkx port of ``plane_depth_sequencing.extended_deep_embedding``: with
``face`` taken as the outer face, subdivide every *neutral* triangular face
(all three vertices at equal plane depth) -- including ``face`` itself -- by
inserting a new vertex adjacent to its three corners. The vertex inserted
inside ``face`` is the outer-cap vertex x* (depth -1); the rest sit one
level deeper than the face they cap.
Returns ``(G_prime, cap_vertex, depth)``.
"""
faces, _ = triangular_faces(G)
outer = frozenset(face)
depth = _plane_depth(G, face)
G_prime = G.copy()
nxt = max(G.nodes()) + 1
cap_vertex = None
for f in faces:
assert len(f) == 3, f"non-triangular face {f} (graph not maximal planar?)"
a, b, c = f
if depth[a] == depth[b] == depth[c]:
x = nxt
nxt += 1
G_prime.add_node(x)
G_prime.add_edges_from([(x, a), (x, b), (x, c)])
if frozenset(f) == outer:
cap_vertex = x
depth[x] = -1
else:
depth[x] = depth[a] + 1
if cap_vertex is None:
raise ValueError(f"face {face} is not a face of G")
return G_prime, cap_vertex, depth
def deep_embed_random_face(G, rng=None):
"""Pick a random triangular face of ``G`` and deep-embed relative to it.
Returns ``(G_prime, cap_vertex, face)``; ``cap_vertex`` is the outer-cap
vertex used as the level source."""
rng = rng or random.Random()
faces, _ = triangular_faces(G)
face = rng.choice(faces)
G_prime, cap, _depth = deep_embedding(G, face)
return G_prime, cap, face
def source_dual(G, faces):
"""The planar dual of triangulation ``G``: one node per face, one edge per
primal edge (tagged ``primal``). Faces are indexed as in ``faces``."""
@@ -119,9 +194,9 @@ def annular_cut_edges(results, cap_cuts):
removed = set()
for c in cap_cuts or []:
removed.add(c["medial_vertex"])
for d in sorted(results):
g, bij = results[d]["g"], results[d]["bij"]
for c in results[d]["cuts"]:
for key in sorted(results):
bij = results[key]["bij"]
for c in results[key]["cuts"]:
if c.vertex is not None:
removed.add(bij[f"a{c.vertex}"])
return removed
@@ -132,9 +207,9 @@ def up_apex_cut_edges(results):
medial vertex of every (singleton) up tooth across all treads, except the
entry tooth of each tread (its apex is not duplicated)."""
removed = set()
for d in sorted(results):
g, bij = results[d]["g"], results[d]["bij"]
entry = results[d]["entry_edge"]
for key in sorted(results):
g, bij = results[key]["g"], results[key]["bij"]
entry = results[key]["entry_edge"]
for i in g.up_edges:
if i == entry:
continue
@@ -190,8 +265,15 @@ def medial_tire_dual_cut(G, source, entry_edge):
removed = annular | apex
missing = dual_face_missing(G, removed)
# The first entry: the medial vertex (primal edge) of the root tread's
# entry up-tooth apex. This apex is *not* duplicated, so it is the seam the
# chained walk starts from rather than a removed edge.
root = min(results)
entry_medial = results[root]["bij"][f"u{entry_edge}"]
return {
"G": G, "M": M, "source": source, "entry_edge": entry_edge,
"entry_medial_vertex": entry_medial,
"faces": faces, "outer_face": 0,
"levels": levels, "treads": treads, "skipped": skipped,
"results": results, "cap_cuts": cap_cuts, "cut_graph": cut_graph,
@@ -212,34 +294,45 @@ def dual_cut_random_entry(G, source, rng=None):
return medial_tire_dual_cut(G, source, rng.choice(choices))
def dual_cut_random_source(G, rng=None):
"""Pick a random level source, then ``dual_cut_random_entry``.
def dual_cut_random_face(G, rng=None):
"""Pick a random face of ``G``, deep-embed relative to it, and cut from the
resulting outer-cap vertex, then ``dual_cut_random_entry``.
Sources are tried in random order; the first one inducing a recognised root
tread is used (a maximal planar graph always has at least one)."""
Faces are tried in random order; the first whose cap vertex induces a
recognised root tread is used. The dual cut is then read off the deep
embedding ``G'`` (stored as ``result["G"]``); the original triangulation is
kept as ``result["base_graph"]``."""
rng = rng or random.Random()
sources = sorted(G.nodes())
rng.shuffle(sources)
for source in sources:
if root_entry_choices(G, source):
return dual_cut_random_entry(G, source, rng=rng)
raise ValueError("no level source induces a recognised root tread")
faces, _ = triangular_faces(G)
order = list(faces)
rng.shuffle(order)
for face in order:
G_prime, cap, depth = deep_embedding(G, face)
if root_entry_choices(G_prime, cap):
result = dual_cut_random_entry(G_prime, cap, rng=rng)
result["base_graph"] = G
result["chosen_face"] = tuple(face)
result["cap_vertex"] = cap
result["deep_depth"] = depth
return result
raise ValueError("no face's cap vertex induces a recognised root tread")
def random_dual_cut(n=20, seed=0, rng=None, min_degree=5, flips=400, attempts=1000):
"""Find a random maximal planar graph of minimum degree ``min_degree``, then
``dual_cut_random_source``.
``dual_cut_random_face``.
``seed`` drives the graph sample; ``rng`` (defaulting to ``Random(seed)``)
drives the random source and entry choices, so the whole pipeline is
reproducible from ``(n, seed)``.
drives the random face, deep embedding, and entry choices, so the whole
pipeline is reproducible from ``(n, seed)``.
"""
rng = rng or random.Random(seed)
G, graph_seed = random_maximal_planar_min_degree(
n, seed, flips=flips, min_degree=min_degree, attempts=attempts)
result = dual_cut_random_source(G, rng=rng)
result = dual_cut_random_face(G, rng=rng)
result["graph_seed"] = graph_seed
result["min_degree"] = min(dict(G.degree()).values())
result["base_min_degree"] = min(dict(G.degree()).values())
result["min_degree"] = min(dict(result["G"].degree()).values())
return result
@@ -253,13 +346,19 @@ def summary(result):
hist = defaultdict(int)
for k in missing.values():
hist[k] += 1
base = result.get("base_graph")
lines = [
f"source-dual cut: n={G.number_of_nodes()} "
f"(deep embedding of base n="
f"{base.number_of_nodes() if base is not None else '?'}) "
f"graph_seed={result.get('graph_seed', '?')} "
f"min_degree={result.get('min_degree', min(dict(G.degree()).values()))}",
f"level source: vertex {result['source']} "
f"chosen face: {result.get('chosen_face', '?')} "
f"-> cap vertex x*={result.get('cap_vertex', result['source'])}",
f"level source: cap vertex {result['source']} "
f"root entry tooth: e{result['entry_edge']}",
f"recognised treads: {sorted(result['treads'])} "
f"recognised tires (depth.component): "
f"{[f'{d}.{c}' for d, c in sorted(result['treads'])]} "
f"skipped: {result['skipped']}",
f"removed source-dual edges ({len(removed)}): "
f"{len(result['annular_cut_edges'])} annular/cap + "
@@ -277,19 +376,58 @@ def summary(result):
return "\n".join(lines)
def _radial_source_layout(G, source, levels):
"""Concentric ('onion') layout rooted at the cap ``source``: radius grows
with BFS level so the depth rings are actual circles, and each ring's
angular order is inherited from its lower-level neighbours to keep the
nesting legible. This matches the cap-source construction, where the BFS
rings are exactly the plane-depth rings."""
import math
max_level = max(levels.values()) or 1
ring = defaultdict(list)
for v, d in levels.items():
ring[d].append(v)
angle = {source: 0.0}
pos = {source: (0.0, 0.0)}
for d in range(1, max_level + 1):
verts = ring[d]
prov = {}
for v in verts:
pa = [angle[w] for w in G.neighbors(v)
if levels.get(w) == d - 1 and w in angle]
if pa:
sx = sum(math.cos(a) for a in pa)
sy = sum(math.sin(a) for a in pa)
prov[v] = math.atan2(sy, sx)
else:
prov[v] = 0.0
verts.sort(key=lambda v: prov[v] % (2 * math.pi))
k = len(verts)
base = prov[verts[0]] if verts else 0.0
r = d / max_level
for i, v in enumerate(verts):
a = base + 2 * math.pi * i / k
angle[v] = a
pos[v] = (r * math.cos(a), r * math.sin(a))
return pos
def draw_png(result, path, scale=6.0):
"""Render the source-dual cut: dual nodes at face centroids, dual edges
drawn light gray where the cut removed them, labelled by missing count."""
drawn light gray where the cut removed them, labelled by missing count.
The source graph is laid out concentrically around the cap source so the
BFS/plane-depth rings read as nested circles."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from draw_medial_tire_cut import _source_layout # local import; needs numpy
G, faces, dual = result["G"], result["faces"], result["dual"]
removed = result["removed_dual_edges"]
missing = result["dual_face_missing"]
pos_v = _source_layout(G)
source = result["source"]
entry_medial = result.get("entry_medial_vertex")
pos_v = _radial_source_layout(G, source, result["levels"])
def centroid(fi):
xs = [pos_v[u][0] for u in faces[fi]]
@@ -297,33 +435,59 @@ def draw_png(result, path, scale=6.0):
return (sum(xs) / 3.0, sum(ys) / 3.0)
pos = {fi: centroid(fi) for fi in dual.nodes()}
fig, ax = plt.subplots(figsize=(7, 7))
# primal graph, faint, for orientation
fig, ax = plt.subplots(figsize=(7.6, 7.6))
# primal (source) graph, faint, for orientation
for u, v in G.edges():
ax.plot([pos_v[u][0], pos_v[v][0]], [pos_v[u][1], pos_v[v][1]],
color="0.85", lw=0.5, zorder=0)
# the entry medial vertex = a primal edge; highlight that primal edge and
# the dual edge crossing it.
if entry_medial is not None:
eu, ev = entry_medial
ax.plot([pos_v[eu][0], pos_v[ev][0]], [pos_v[eu][1], pos_v[ev][1]],
color="#1b9e44", lw=2.6, zorder=2, solid_capstyle="round")
for u, v, data in dual.edges(data=True):
cut = data["primal"] in removed
is_entry = entry_medial is not None and data["primal"] == entry_medial
if is_entry:
ax.plot([pos[u][0], pos[v][0]], [pos[u][1], pos[v][1]],
color="#1b9e44", lw=2.6, zorder=4, solid_capstyle="round")
mx, my = (pos[u][0] + pos[v][0]) / 2, (pos[u][1] + pos[v][1]) / 2
ax.plot(mx, my, "*", ms=15, mfc="#1b9e44", mec="white",
mew=0.7, zorder=5)
ax.text(mx, my - 0.06, "entry", color="#1b9e44", fontsize=8,
fontweight="bold", ha="center", va="top", zorder=5)
else:
ax.plot([pos[u][0], pos[v][0]], [pos[u][1], pos[v][1]],
color="0.80" if cut else "0.25",
lw=1.0 if cut else 1.3,
linestyle=(0, (2, 2)) if cut else "solid", zorder=1)
for fi in dual.nodes():
x, y = pos[fi]
# label each dual face's source vertex by its missing count instead:
ax.plot(x, y, "o", ms=4, color="#3a6ea5", zorder=2)
# annotate dual faces (vertices of G) with their missing count
ax.plot(x, y, "o", ms=4, color="#3a6ea5", zorder=3)
# label each source-graph vertex by its id; the cap source is flagged.
for v in G.nodes():
x, y = pos_v[v]
is_src = v == source
ax.text(x, y, str(v),
color="#0b6", fontsize=8 if not is_src else 9,
fontweight="bold" if is_src else "normal",
ha="center", va="center", zorder=6,
bbox=dict(boxstyle="round,pad=0.12",
fc="#eafff2" if is_src else "white",
ec="#1b9e44" if is_src else "0.6", lw=0.7))
# missing-edge count, offset above-right of the vertex label.
m = missing[v]
if m:
x, y = pos_v[v]
ax.text(x, y, str(m), color="#b03030", fontsize=8,
ha="center", va="center", zorder=3,
bbox=dict(boxstyle="circle,pad=0.1", fc="white",
ax.text(x + 0.045, y + 0.045, str(m), color="#b03030", fontsize=7,
ha="left", va="bottom", zorder=7,
bbox=dict(boxstyle="circle,pad=0.05", fc="white",
ec="#b03030", lw=0.6))
ax.set_title(f"source-dual cut (source {result['source']}, entry "
f"e{result['entry_edge']}); gray = edges missing after cuts\n"
f"red numbers = #missing dual edges around each dual face; "
ax.set_title(f"source-dual cut (cap source {source}, entry "
f"e{result['entry_edge']} = medial vtx {entry_medial}); "
f"gray = edges missing after cuts\n"
f"green star = first entry medial vertex; red numbers = "
f"#missing dual edges around each dual face; "
f"max {result['max_missing']}", fontsize=9)
ax.set_aspect("equal")
ax.axis("off")
@@ -441,22 +605,26 @@ def _draw_tread(ax, g, depth, cuts, entry_edge, title):
def draw_tire_cuts_png(result, path):
"""Render every recognised tread's full medial tire cut, one panel each."""
"""Render every recognised tire's full medial tire cut, one panel each.
A tread depth with several disjoint annular cycles contributes one panel
per cycle, labelled ``tread d.c``."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
res = result["results"]
depths = sorted(res)
if not depths:
raise ValueError("no recognised treads to draw")
fig, axes = plt.subplots(1, len(depths), figsize=(5.2 * len(depths), 5.4))
if len(depths) == 1:
keys = sorted(res)
if not keys:
raise ValueError("no recognised tires to draw")
fig, axes = plt.subplots(1, len(keys), figsize=(5.2 * len(keys), 5.4))
if len(keys) == 1:
axes = [axes]
for ax, d in zip(axes, depths):
rec = res[d]
for ax, key in zip(axes, keys):
d, comp = key
rec = res[key]
g = rec["g"]
title = (f"tread {d}: |A(T)|={g.n} word={g.tooth_word}\n"
title = (f"tread {d}.{comp}: |A(T)|={g.n} word={g.tooth_word}\n"
f"bites={sorted(g.bites)} entry=e{rec['entry_edge']} "
f"start_depth={rec['start_depth']} cuts={len(rec['cuts'])}")
_draw_tread(ax, g, rec["depth"], rec["cuts"], rec["entry_edge"], title)
@@ -547,10 +715,12 @@ def main():
parser.add_argument("-n", type=int, default=20, help="number of vertices")
parser.add_argument("--seed", type=int, default=0, help="graph sample seed")
parser.add_argument("--min-degree", type=int, default=5)
parser.add_argument("--source", type=int, default=None,
help="fix the level source (default: random via rng)")
parser.add_argument("--face", type=str, default=None,
help="fix the chosen face as 'a,b,c' (default: random "
"via rng); the deep embedding's cap vertex is the "
"source")
parser.add_argument("--entry", type=int, default=None,
help="fix the root entry tooth (requires --source)")
help="fix the root entry tooth (requires --face)")
parser.add_argument("--png", metavar="PATH", help="render the dual cut to PNG")
parser.add_argument("--tire-png", metavar="PATH",
help="render each full medial tire cut to PNG")
@@ -559,18 +729,22 @@ def main():
args = parser.parse_args()
rng = random.Random(args.seed)
if args.source is not None and args.entry is not None:
if args.face is not None:
G, graph_seed = random_maximal_planar_min_degree(
args.n, args.seed, min_degree=args.min_degree)
result = medial_tire_dual_cut(G, args.source, args.entry)
face = tuple(int(x) for x in args.face.split(","))
G_prime, cap, depth = deep_embedding(G, face)
if args.entry is not None:
result = medial_tire_dual_cut(G_prime, cap, args.entry)
else:
result = dual_cut_random_entry(G_prime, cap, rng=rng)
result["base_graph"] = G
result["chosen_face"] = face
result["cap_vertex"] = cap
result["deep_depth"] = depth
result["graph_seed"] = graph_seed
result["min_degree"] = min(dict(G.degree()).values())
elif args.source is not None:
G, graph_seed = random_maximal_planar_min_degree(
args.n, args.seed, min_degree=args.min_degree)
result = dual_cut_random_entry(G, args.source, rng=rng)
result["graph_seed"] = graph_seed
result["min_degree"] = min(dict(G.degree()).values())
result["base_min_degree"] = min(dict(G.degree()).values())
result["min_degree"] = min(dict(G_prime.degree()).values())
else:
result = random_dual_cut(n=args.n, seed=args.seed,
rng=rng, min_degree=args.min_degree)
@@ -69,39 +69,55 @@ def _apex_vertex(g, bij, edge):
def _label_treads(treads, results, root_entry_edge=None):
"""Fill ``results[d]`` with the walk-depth labelling and cuts for each
recognised tread ``d``, chaining child entries to parent down teeth.
"""Fill ``results[(d, c)]`` with the walk-depth labelling and cuts for every
recognised tire ``c`` of every tread depth ``d``, chaining child entries to
parent down teeth.
The root tread (lowest recognised depth) is entered at ``root_entry_edge``
when given -- it must be one of that tread's up teeth -- otherwise at an
arbitrary up tooth.
``treads`` maps ``(depth, component)`` -> ``(g, bij)``; a tread depth may
carry several tires (one per disjoint annular cycle). The root tire
``(root_d, 0)`` is entered at ``root_entry_edge`` when given -- it must be
one of that tire's up teeth -- otherwise at an arbitrary up tooth. Each
other tire chains to whichever parent-depth down tooth (across all parent
tires) shares its apex, at the lowest parent walk depth.
"""
root_d = min(treads) if treads else None
for d in sorted(treads):
g, bij = treads[d]
parent = treads.get(d - 1)
if parent is None:
if d == root_d and root_entry_edge is not None:
if not treads:
return
depths = sorted({k[0] for k in treads})
root_d = depths[0]
for d in depths:
# apex medial vertex -> child start depth, over all parent-depth tires
parent_down = {}
for pk in (k for k in treads if k[0] == d - 1):
pg, pbij = treads[pk]
pdepth = results[pk]["depth"]
for e in pg.down_edges:
apex = _apex_vertex(pg, pbij, e)
value = pdepth[e] + 1
if apex not in parent_down or value < parent_down[apex]:
parent_down[apex] = value
has_parent = any(k[0] == d - 1 for k in treads)
for key in sorted(k for k in treads if k[0] == d):
g, bij = treads[key]
if not has_parent:
if (key == (root_d, 0) and root_entry_edge is not None
and root_entry_edge in g.up_edges):
entry_edge, start_depth = root_entry_edge, 0
else:
entry_edge, start_depth = g.up_edges[0], 0 # arbitrary root entry
entry_edge, start_depth = g.up_edges[0], 0 # arbitrary entry
else:
pg, pbij = parent
pdepth = results[d - 1]["depth"]
# parent down teeth, lowest walk depth first
down = sorted((pdepth[k], _apex_vertex(pg, pbij, k))
for k in pg.down_edges)
child_up_apex = {bij[f"u{m}"]: m for m in g.up_edges}
entry_edge = start_depth = None
for value, apex in down:
if apex in child_up_apex:
entry_edge, start_depth = child_up_apex[apex], value + 1
break
if entry_edge is None: # no shared apex (degenerate); root-style
best = None
for apex, value in parent_down.items():
if apex in child_up_apex and (best is None or value < best[1]):
best = (child_up_apex[apex], value)
if best is not None: # chains to a parent down tooth
entry_edge, start_depth = best
else: # no shared apex (degenerate); root-style
entry_edge, start_depth = g.up_edges[0], 0
depth, cuts = label_and_cut(g, entry_edge, start_depth=start_depth)
results[d] = {"g": g, "bij": bij, "entry_edge": entry_edge,
"start_depth": start_depth, "depth": depth, "cuts": cuts}
results[key] = {"g": g, "bij": bij, "entry_edge": entry_edge,
"start_depth": start_depth, "depth": depth,
"cuts": cuts}
def _cap_cut(G, emb, source, levels, results):
@@ -182,21 +198,22 @@ def _assemble_cut_graph(M, results, cap_cuts=None):
**{v: copy_a for v in c["neighbours_a"]},
**{v: copy_b for v in c["neighbours_b"]},
}
for d in sorted(results):
g, bij = results[d]["g"], results[d]["bij"]
for key in sorted(results):
td = key[0]
g, bij = results[key]["g"], results[key]["bij"]
n = g.n
for c in results[d]["cuts"]:
for c in results[key]["cuts"]:
kk = c.vertex
if kk is None:
continue
mv = bij[f"a{kk}"]
if mv in split:
warnings.append(f"annular vertex a{kk} of tread {d} cut twice; "
warnings.append(f"annular vertex a{kk} of tread {key} cut twice; "
f"second cut not applied")
continue
e_prev, e_next = (kk - 1) % n, kk
copy_a = (mv, "A", d)
copy_b = (mv, "B", d)
copy_a = (mv, "A", td)
copy_b = (mv, "B", td)
split[mv] = {
bij[f"a{(kk - 1) % n}"]: copy_a,
_apex_vertex(g, bij, e_prev): copy_a,
@@ -215,13 +232,14 @@ def _assemble_cut_graph(M, results, cap_cuts=None):
H.add_edge(resolve(u, v), resolve(v, u))
label_records = []
for d in sorted(results):
g, bij, depth = results[d]["g"], results[d]["bij"], results[d]["depth"]
for key in sorted(results):
td = key[0]
g, bij, depth = results[key]["g"], results[key]["bij"], results[key]["depth"]
for k in range(g.n):
role = ("up" if g.tooth_word[k] == "U"
else "bite" if door_bite(g, k) is not None else "down")
label_records.append({
"tread": d, "edge": k, "role": role,
"tread": td, "comp": key[1], "edge": k, "role": role,
"apex": _apex_vertex(g, bij, k), "walk": depth[k],
})
return H, label_records, warnings
@@ -287,11 +305,12 @@ def run_experiment(n: int = 12, seed: int = 0, flips: int = 400,
if len(tread["up"]) < 3:
skipped.append((d, f"only {len(tread['up'])} up teeth"))
continue
rec = recognise(medial_tire_facemodel(tread["tread_faces"]), tread)
if rec is None:
skipped.append((d, "not a valid full medial tire graph"))
tires = recognise(medial_tire_facemodel(tread["tread_faces"]), tread)
if not tires:
skipped.append((d, "no annular cycle recognised as a tire"))
continue
treads[d] = rec
for c, gb in enumerate(tires):
treads[(d, c)] = gb
results = {}
_label_treads(treads, results)
@@ -322,9 +341,10 @@ def _vname(v) -> str:
def to_json(result: dict) -> dict:
res = result["results"]
treads_out = []
for d in sorted(res):
g, bij = res[d]["g"], res[d]["bij"]
depth, cuts = res[d]["depth"], res[d]["cuts"]
for key in sorted(res):
d, comp = key
g, bij = res[key]["g"], res[key]["bij"]
depth, cuts = res[key]["depth"], res[key]["cuts"]
teeth = [{
"edge": k,
"role": ("up" if g.tooth_word[k] == "U"
@@ -333,9 +353,9 @@ def to_json(result: dict) -> dict:
"walk": depth[k],
} for k in range(g.n)]
treads_out.append({
"depth": d, "n": g.n, "tooth_word": g.tooth_word,
"depth": d, "comp": comp, "n": g.n, "tooth_word": g.tooth_word,
"bites": sorted(list(b) for b in g.bites),
"entry_edge": res[d]["entry_edge"], "start_depth": res[d]["start_depth"],
"entry_edge": res[key]["entry_edge"], "start_depth": res[key]["start_depth"],
"teeth": teeth,
"cuts": [{
"annular_index": c.vertex,
@@ -361,7 +381,8 @@ def to_json(result: dict) -> dict:
"edges": sorted([_vname(u), _vname(v)] for u, v in H.edges()),
},
"labels": [{
"tread": r["tread"], "edge": r["edge"], "role": r["role"],
"tread": r["tread"], "comp": r.get("comp", 0),
"edge": r["edge"], "role": r["role"],
"apex": _vname(r["apex"]), "walk": r["walk"],
} for r in result["labels"]],
"warnings": result["warnings"],
@@ -376,16 +397,17 @@ def summary(result: dict) -> str:
f"({result['G'].number_of_edges()} edges, min degree {result['min_degree']})",
f"medial graph M(G): {result['M'].number_of_nodes()} vertices",
f"level source: vertex {result['source']}",
f"recognised treads: {sorted(res)}",
f"recognised tires (depth, component): {sorted(res)}",
f"skipped treads: {result['skipped']}",
]
for d in sorted(res):
g = res[d]["g"]
ncuts = len(res[d]["cuts"])
for key in sorted(res):
d, comp = key
g = res[key]["g"]
ncuts = len(res[key]["cuts"])
lines.append(
f" tread {d}: |A(T)|={g.n} word={g.tooth_word} "
f"bites={sorted(g.bites)} entry=e{res[d]['entry_edge']} "
f"start_depth={res[d]['start_depth']} cuts={ncuts}")
f" tread {d}.{comp}: |A(T)|={g.n} word={g.tooth_word} "
f"bites={sorted(g.bites)} entry=e{res[key]['entry_edge']} "
f"start_depth={res[key]['start_depth']} cuts={ncuts}")
lines.append(
f"final cut graph: {H.number_of_nodes()} vertices, "
f"{H.number_of_edges()} edges, "
@@ -178,19 +178,17 @@ def extract_tread(faces, levels, d):
}
def annular_cycle_order(M: nx.Graph, annular: set):
"""Cyclic order of the annular medial vertices (they induce a cycle)."""
sub = M.subgraph(annular)
if sub.number_of_nodes() == 0 or any(sub.degree(v) != 2 for v in sub):
def _cycle_order(sub: nx.Graph, comp):
"""Cyclic order of a 2-regular connected component ``comp`` of ``sub``;
None if it is not a single simple cycle of length >= 3."""
csub = sub.subgraph(comp)
if csub.number_of_nodes() < 3 or any(csub.degree(v) != 2 for v in csub):
return None
if not nx.is_connected(sub):
return None
start = next(iter(annular))
start = next(iter(comp))
order = [start]
prev = None
cur = start
prev, cur = None, start
while True:
nbrs = [w for w in sub.neighbors(cur) if w != prev]
nbrs = [w for w in csub.neighbors(cur) if w != prev]
if not nbrs:
break
nxt = nbrs[0]
@@ -198,7 +196,33 @@ def annular_cycle_order(M: nx.Graph, annular: set):
break
order.append(nxt)
prev, cur = cur, nxt
return order if len(order) == len(annular) else None
return order if len(order) == csub.number_of_nodes() else None
def annular_cycle_order(M: nx.Graph, annular: set):
"""Cyclic order of the annular medial vertices when they induce a *single*
cycle; None otherwise. See ``annular_cycle_components`` for the
multi-component case."""
sub = M.subgraph(annular)
if not annular or not nx.is_connected(sub):
return None
return _cycle_order(sub, set(annular))
def annular_cycle_components(M: nx.Graph, annular: set):
"""Cyclic orders of the annular medial vertices, one per connected
component of the annular subgraph.
A tread's annular frontier may split into several disjoint cycles (one per
boundary component); each is its own full medial tire graph. Components
that are not a single simple cycle of length >= 3 are skipped."""
sub = M.subgraph(annular)
orders = []
for comp in nx.connected_components(sub):
order = _cycle_order(sub, comp)
if order is not None:
orders.append(order)
return orders
# --------------------------------------------------------------------------- #
@@ -226,38 +250,35 @@ def _linear_cut(n, bite_pairs):
return None
def recognise(M, tread):
"""Return (FullMedialTireGraph, bijection fmt-name -> medial vertex) or None.
def _recognise_one(M, order, up, ann_global):
"""Recognise a single annular cycle (given as the cyclic order of its
medial vertices) as a ``FullMedialTireGraph``.
``M`` here is the tread-face model M(T) (cycle + teeth + bites)."""
annular = tread["annular"]
order = annular_cycle_order(M, annular)
if order is None or len(order) < 3:
return None
``up`` is the tread's up-edge medial-vertex set; ``ann_global`` is the full
annular set of the tread (used to exclude annular vertices, including those
of *other* components, when picking each cycle edge's apex). Returns
``(g, bij)`` or None."""
n = len(order)
ann_set = set(annular)
if n < 3:
return None
ann_set = set(order)
apex_of_edge = []
for i in range(n):
a, b = order[i], order[(i + 1) % n]
common = [w for w in set(M.neighbors(a)) & set(M.neighbors(b)) if w not in ann_set]
common = [w for w in set(M.neighbors(a)) & set(M.neighbors(b))
if w not in ann_global]
if len(common) != 1:
return None
apex_of_edge.append(common[0])
up = set(tread["up"])
# bite apex: serves two cycle edges (== adjacent to four annular vertices)
apex_positions = defaultdict(list)
for i, ap in enumerate(apex_of_edge):
apex_positions[ap].append(i)
tooth = []
bite_pairs = []
for ap, positions in apex_positions.items():
if len(positions) == 2:
bite_pairs.append(tuple(sorted(positions)))
for i, ap in enumerate(apex_of_edge):
tooth.append("U" if ap in up else "D")
bite_pairs = [tuple(sorted(positions))
for positions in apex_positions.values() if len(positions) == 2]
tooth = ["U" if ap in up else "D" for ap in apex_of_edge]
cut = _linear_cut(n, bite_pairs)
if cut is None:
@@ -279,14 +300,35 @@ def recognise(M, tread):
for (i, j) in sorted(g.bites):
bij[f"p{i}_{j}"] = apex_of_edge[(i + r) % n]
# verify the reconstructed graph is edge-faithful to the tread-face M(T)
mt_edges = {ekey(*e) for e in M.edges()}
# verify the reconstructed graph is edge-faithful to this cycle's sub-model
# (its annular vertices together with their tooth apexes).
sub_nodes = ann_set | set(apex_of_edge)
sub_edges = {ekey(*e) for e in M.subgraph(sub_nodes).edges()}
rec_edges = {ekey(bij[u], bij[v]) for u, v in g.edges()}
if rec_edges != mt_edges:
if rec_edges != sub_edges:
return None
return g, bij
def recognise(M, tread):
"""Recognise the tread's medial-tire structure.
A tread's annular frontier may be several disjoint cycles, each its own
full medial tire graph. Returns a list of ``(FullMedialTireGraph,
bijection fmt-name -> medial vertex)`` -- one per annular cycle component
that recognises -- or ``[]`` if none do.
``M`` here is the tread-face model M(T) (cycle(s) + teeth + bites)."""
up = set(tread["up"])
ann_global = set(tread["annular"])
tires = []
for order in annular_cycle_components(M, tread["annular"]):
rec = _recognise_one(M, order, up, ann_global)
if rec is not None:
tires.append(rec)
return tires
def canonical(coloring, ordered):
remap, out = {}, []
for v in ordered:
@@ -341,10 +383,7 @@ def iter_pieces(seed: int, color_limit: int = 400000):
if tread is None or len(tread["up"]) < 3:
continue
mt = medial_tire_facemodel(tread["tread_faces"])
rec = recognise(mt, tread)
if rec is None:
continue
g, bij = rec
for comp, (g, bij) in enumerate(recognise(mt, tread)):
mt_nodes = list(bij.values())
name_of = {v: k for k, v in bij.items()}
@@ -365,7 +404,7 @@ def iter_pieces(seed: int, color_limit: int = 400000):
cat = ("Invalid" if not balanced
else "Realized" if is_real else "Unrealized")
colorings.append((fmt_col, cat))
meta = {"source": s, "tread": d}
meta = {"source": s, "tread": d, "comp": comp}
yield (meta, g, colorings)