diff --git a/papers/medial_tire_cuts/experiments/deepcap_seed7_cap.png b/papers/medial_tire_cuts/experiments/deepcap_seed7_cap.png new file mode 100644 index 0000000..d913a9c Binary files /dev/null and b/papers/medial_tire_cuts/experiments/deepcap_seed7_cap.png differ diff --git a/papers/medial_tire_cuts/experiments/deepcap_seed7_dual.png b/papers/medial_tire_cuts/experiments/deepcap_seed7_dual.png new file mode 100644 index 0000000..5cf37ac Binary files /dev/null and b/papers/medial_tire_cuts/experiments/deepcap_seed7_dual.png differ diff --git a/papers/medial_tire_cuts/experiments/deepcap_seed7_tires.png b/papers/medial_tire_cuts/experiments/deepcap_seed7_tires.png new file mode 100644 index 0000000..a03633c Binary files /dev/null and b/papers/medial_tire_cuts/experiments/deepcap_seed7_tires.png differ diff --git a/papers/medial_tire_cuts/experiments/medial_tire_dual_cut_experiment.py b/papers/medial_tire_cuts/experiments/medial_tire_dual_cut_experiment.py index f4801b6..a0d9d2d 100644 --- a/papers/medial_tire_cuts/experiments/medial_tire_dual_cut_experiment.py +++ b/papers/medial_tire_cuts/experiments/medial_tire_dual_cut_experiment.py @@ -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 - 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) + 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) diff --git a/papers/medial_tire_cuts/experiments/run_medial_tire_cut_experiment.py b/papers/medial_tire_cuts/experiments/run_medial_tire_cut_experiment.py index 75e5e49..e25f29a 100644 --- a/papers/medial_tire_cuts/experiments/run_medial_tire_cut_experiment.py +++ b/papers/medial_tire_cuts/experiments/run_medial_tire_cut_experiment.py @@ -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: - entry_edge, start_depth = root_entry_edge, 0 + 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 entry else: - entry_edge, start_depth = g.up_edges[0], 0 # arbitrary root 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 - 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} + child_up_apex = {bij[f"u{m}"]: m for m in g.up_edges} + 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[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, " diff --git a/papers/medial_tire_decompositions_of_plane_triangulations/experiments/tire_realization_analysis.py b/papers/medial_tire_decompositions_of_plane_triangulations/experiments/tire_realization_analysis.py index 66ee45c..3d3db8d 100644 --- a/papers/medial_tire_decompositions_of_plane_triangulations/experiments/tire_realization_analysis.py +++ b/papers/medial_tire_decompositions_of_plane_triangulations/experiments/tire_realization_analysis.py @@ -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,32 +383,29 @@ 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 - mt_nodes = list(bij.values()) - name_of = {v: k for k, v in bij.items()} + for comp, (g, bij) in enumerate(recognise(mt, tread)): + mt_nodes = list(bij.values()) + name_of = {v: k for k, v in bij.items()} - realized = set() - for col in global_colorings: - realized.add(canonical({v: col[v] for v in mt_nodes}, mt_nodes)) + realized = set() + for col in global_colorings: + realized.add(canonical({v: col[v] for v in mt_nodes}, mt_nodes)) - colorings = [] - seen = set() - for col in proper_3_colorings_subgraph(mt, mt_nodes): - key = canonical(col, mt_nodes) - if key in seen: - continue - seen.add(key) - fmt_col = {name_of[v]: c for v, c in col.items()} - balanced = kempe_classify(g, fmt_col).valid - is_real = key in realized - cat = ("Invalid" if not balanced - else "Realized" if is_real else "Unrealized") - colorings.append((fmt_col, cat)) - meta = {"source": s, "tread": d} - yield (meta, g, colorings) + colorings = [] + seen = set() + for col in proper_3_colorings_subgraph(mt, mt_nodes): + key = canonical(col, mt_nodes) + if key in seen: + continue + seen.add(key) + fmt_col = {name_of[v]: c for v, c in col.items()} + balanced = kempe_classify(g, fmt_col).valid + is_real = key in realized + cat = ("Invalid" if not balanced + else "Realized" if is_real else "Unrealized") + colorings.append((fmt_col, cat)) + meta = {"source": s, "tread": d, "comp": comp} + yield (meta, g, colorings) def analyse(seed: int, color_limit: int = 400000):