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:
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, "
|
||||
|
||||
+76
-37
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user