Handle compound medial tires in cut labelling

This commit is contained in:
2026-06-15 21:46:56 -04:00
parent 37a7ff0b00
commit 1e8bee04ce
5 changed files with 296 additions and 110 deletions
@@ -57,12 +57,11 @@ sys.path.insert(0, _MTD)
sys.path.insert(0, _CUT_LIB) sys.path.insert(0, _CUT_LIB)
from tire_realization_analysis import ( # noqa: E402 from tire_realization_analysis import ( # noqa: E402
ekey, extract_tread, medial_graph, medial_tire_facemodel, ekey, medial_graph, triangular_faces,
recognise, triangular_faces,
) )
from run_medial_tire_cut_experiment import ( # noqa: E402 from run_medial_tire_cut_experiment import ( # noqa: E402
_assemble_cut_graph, _cap_cut, _label_treads, _assemble_cut_graph, _build_treads, _cap_cut, _label_treads,
random_maximal_planar_min_degree, random_maximal_planar_5_connected,
) )
from medial_tire_cut_labelling import up_apex_cuts # noqa: E402 from medial_tire_cut_labelling import up_apex_cuts # noqa: E402
@@ -71,31 +70,6 @@ from medial_tire_cut_labelling import up_apex_cuts # noqa: E402
# Tread recognition and the source-dual graph. # Tread recognition and the source-dual graph.
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _build_treads(faces, levels):
"""Recognise the full medial tire graph(s) of every BFS-level tread.
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())):
tread = extract_tread(faces, levels, d)
if tread is None:
skipped.append((d, "no tread faces"))
continue
if len(tread["up"]) < 3:
skipped.append((d, f"only {len(tread['up'])} up teeth"))
continue
tires = recognise(medial_tire_facemodel(tread["tread_faces"]), tread)
if not tires:
skipped.append((d, "no annular cycle recognised as a tire"))
continue
for c, gb in enumerate(tires):
treads[(d, c)] = gb
return treads, skipped
def root_entry_choices(G, source): def root_entry_choices(G, source):
"""Edge indices of the root tread's up teeth -- the eligible entry teeth. """Edge indices of the root tread's up teeth -- the eligible entry teeth.
@@ -104,7 +78,7 @@ def root_entry_choices(G, source):
""" """
faces, _ = triangular_faces(G) faces, _ = triangular_faces(G)
levels = nx.single_source_shortest_path_length(G, source) levels = nx.single_source_shortest_path_length(G, source)
treads, _ = _build_treads(faces, levels) treads, _skipped, _meta = _build_treads(faces, levels)
if not treads: if not treads:
return [] return []
g, _bij = treads[min(treads)] g, _bij = treads[min(treads)]
@@ -209,12 +183,28 @@ def annular_cut_edges(results, cap_cuts):
def up_apex_cut_edges(results): def up_apex_cut_edges(results):
"""Primal edges whose dual edge the apex duplications remove: the apex """Primal edges whose dual edge the apex duplications remove: the apex
medial vertex of every up tooth across all treads, except each tread's medial vertex of every up tooth across all treads, except each tread's
entry tooth and any vertex that is the shared apex of two up teeth.""" entry tooth and any vertex that is the shared apex of two up teeth within
that recognised annular cycle. In a compound component, an entry apex is
treated as an entry seam for every cycle sharing that apex.
"""
entry_apexes = defaultdict(set)
for key in sorted(results):
rec = results[key]
compound = rec.get("compound", key)
entry_apexes[compound].add(rec["bij"][f"u{rec['entry_edge']}"])
removed = set() removed = set()
for key in sorted(results): for key in sorted(results):
g, bij = results[key]["g"], results[key]["bij"] rec = results[key]
entry = results[key]["entry_edge"] g, bij = rec["g"], rec["bij"]
removed.update(up_apex_cuts(g, entry, bij=bij).values()) entry = rec["entry_edge"]
compound = rec.get("compound", key)
removed.update(
up_apex_cuts(
g, entry, bij=bij,
shared_apexes=entry_apexes.get(compound, set()),
).values()
)
return removed return removed
@@ -245,7 +235,7 @@ def medial_tire_dual_cut(G, source, entry_edge):
faces, emb = triangular_faces(G) faces, emb = triangular_faces(G)
M = medial_graph(G) M = medial_graph(G)
levels = nx.single_source_shortest_path_length(G, source) levels = nx.single_source_shortest_path_length(G, source)
treads, skipped = _build_treads(faces, levels) treads, skipped, tread_meta = _build_treads(faces, levels)
if not treads: if not treads:
raise ValueError(f"level source {source} induces no recognised tread") raise ValueError(f"level source {source} induces no recognised tread")
@@ -256,7 +246,8 @@ def medial_tire_dual_cut(G, source, entry_edge):
f"(choices: {sorted(g_root.up_edges)})") f"(choices: {sorted(g_root.up_edges)})")
results = {} results = {}
_label_treads(treads, results, root_entry_edge=entry_edge) _label_treads(
treads, results, root_entry_edge=entry_edge, tread_meta=tread_meta)
cap_cuts = _cap_cut(G, emb, source, levels, results) cap_cuts = _cap_cut(G, emb, source, levels, results)
cut_graph, labels, warnings = _assemble_cut_graph(M, results, cap_cuts=cap_cuts) cut_graph, labels, warnings = _assemble_cut_graph(M, results, cap_cuts=cap_cuts)
@@ -276,7 +267,8 @@ def medial_tire_dual_cut(G, source, entry_edge):
"G": G, "M": M, "source": source, "entry_edge": entry_edge, "G": G, "M": M, "source": source, "entry_edge": entry_edge,
"entry_medial_vertex": entry_medial, "entry_medial_vertex": entry_medial,
"faces": faces, "outer_face": 0, "faces": faces, "outer_face": 0,
"levels": levels, "treads": treads, "skipped": skipped, "levels": levels, "treads": treads, "tread_meta": tread_meta,
"skipped": skipped,
"results": results, "cap_cuts": cap_cuts, "cut_graph": cut_graph, "results": results, "cap_cuts": cap_cuts, "cut_graph": cut_graph,
"labels": labels, "warnings": warnings, "labels": labels, "warnings": warnings,
"dual": dual, "removed_dual_edges": removed, "dual": dual, "removed_dual_edges": removed,
@@ -319,8 +311,11 @@ def dual_cut_random_face(G, rng=None):
raise ValueError("no face's cap vertex induces a recognised root tread") 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): def random_dual_cut(
"""Find a random maximal planar graph of minimum degree ``min_degree``, then n=20, seed=0, rng=None, min_connectivity=5, flips=400, attempts=1000,
min_degree=None,
):
"""Find a random maximal planar graph of node connectivity 5, then
``dual_cut_random_face``. ``dual_cut_random_face``.
``seed`` drives the graph sample; ``rng`` (defaulting to ``Random(seed)``) ``seed`` drives the graph sample; ``rng`` (defaulting to ``Random(seed)``)
@@ -328,12 +323,16 @@ def random_dual_cut(n=20, seed=0, rng=None, min_degree=5, flips=400, attempts=10
pipeline is reproducible from ``(n, seed)``. pipeline is reproducible from ``(n, seed)``.
""" """
rng = rng or random.Random(seed) rng = rng or random.Random(seed)
G, graph_seed = random_maximal_planar_min_degree( if min_degree is not None:
n, seed, flips=flips, min_degree=min_degree, attempts=attempts) min_connectivity = max(min_connectivity, min_degree)
G, graph_seed = random_maximal_planar_5_connected(
n, seed, flips=flips, min_connectivity=min_connectivity, attempts=attempts)
result = dual_cut_random_face(G, rng=rng) result = dual_cut_random_face(G, rng=rng)
result["graph_seed"] = graph_seed result["graph_seed"] = graph_seed
result["base_min_degree"] = min(dict(G.degree()).values()) result["base_min_degree"] = min(dict(G.degree()).values())
result["base_connectivity"] = nx.node_connectivity(G)
result["min_degree"] = min(dict(result["G"].degree()).values()) result["min_degree"] = min(dict(result["G"].degree()).values())
result["connectivity"] = nx.node_connectivity(result["G"])
return result return result
@@ -353,7 +352,8 @@ def summary(result):
f"(deep embedding of base n=" f"(deep embedding of base n="
f"{base.number_of_nodes() if base is not None else '?'}) " f"{base.number_of_nodes() if base is not None else '?'}) "
f"graph_seed={result.get('graph_seed', '?')} " f"graph_seed={result.get('graph_seed', '?')} "
f"min_degree={result.get('min_degree', min(dict(G.degree()).values()))}", f"base_connectivity={result.get('base_connectivity', '?')} "
f"deep_connectivity={result.get('connectivity', nx.node_connectivity(G))}",
f"chosen face: {result.get('chosen_face', '?')} " f"chosen face: {result.get('chosen_face', '?')} "
f"-> cap vertex x*={result.get('cap_vertex', result['source'])}", f"-> cap vertex x*={result.get('cap_vertex', result['source'])}",
f"level source: cap vertex {result['source']} " f"level source: cap vertex {result['source']} "
@@ -828,7 +828,9 @@ def main():
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("-n", type=int, default=20, help="number of vertices") 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("--seed", type=int, default=0, help="graph sample seed")
parser.add_argument("--min-degree", type=int, default=5) parser.add_argument("--min-connectivity", type=int, default=5)
parser.add_argument("--min-degree", type=int, default=None,
help="compatibility alias; also raises min-connectivity")
parser.add_argument("--face", type=str, default=None, parser.add_argument("--face", type=str, default=None,
help="fix the chosen face as 'a,b,c' (default: random " help="fix the chosen face as 'a,b,c' (default: random "
"via rng); the deep embedding's cap vertex is the " "via rng); the deep embedding's cap vertex is the "
@@ -846,8 +848,11 @@ def main():
rng = random.Random(args.seed) rng = random.Random(args.seed)
if args.face is not None: if args.face is not None:
G, graph_seed = random_maximal_planar_min_degree( min_connectivity = args.min_connectivity
args.n, args.seed, min_degree=args.min_degree) if args.min_degree is not None:
min_connectivity = max(min_connectivity, args.min_degree)
G, graph_seed = random_maximal_planar_5_connected(
args.n, args.seed, min_connectivity=min_connectivity)
face = tuple(int(x) for x in args.face.split(",")) face = tuple(int(x) for x in args.face.split(","))
G_prime, cap, depth = deep_embedding(G, face) G_prime, cap, depth = deep_embedding(G, face)
if args.entry is not None: if args.entry is not None:
@@ -860,10 +865,14 @@ def main():
result["deep_depth"] = depth result["deep_depth"] = depth
result["graph_seed"] = graph_seed result["graph_seed"] = graph_seed
result["base_min_degree"] = min(dict(G.degree()).values()) result["base_min_degree"] = min(dict(G.degree()).values())
result["base_connectivity"] = nx.node_connectivity(G)
result["min_degree"] = min(dict(G_prime.degree()).values()) result["min_degree"] = min(dict(G_prime.degree()).values())
result["connectivity"] = nx.node_connectivity(G_prime)
else: else:
result = random_dual_cut(n=args.n, seed=args.seed, result = random_dual_cut(n=args.n, seed=args.seed,
rng=rng, min_degree=args.min_degree) rng=rng,
min_connectivity=args.min_connectivity,
min_degree=args.min_degree)
print(summary(result)) print(summary(result))
if args.png: if args.png:
@@ -2,10 +2,8 @@
End-to-end experiment for the *Medial Tire Cuts* paper: End-to-end experiment for the *Medial Tire Cuts* paper:
1. Generate a random maximal planar graph G on n vertices (stacked seed plus 1. Generate a 5-connected maximal planar graph G on n vertices, using
random diagonal flips; ``random_maximal_planar`` from the medial tire ``plantri -c5`` when available and verifying node connectivity.
decompositions experiments), optionally rejecting samples below a requested
minimum degree.
2. Build its medial graph M(G). 2. Build its medial graph M(G).
3. Take the nested tire decomposition at one random vertex level source: the 3. Take the nested tire decomposition at one random vertex level source: the
BFS-level treads, each realized as a FullMedialTireGraph. BFS-level treads, each realized as a FullMedialTireGraph.
@@ -40,6 +38,7 @@ import os
import random import random
import subprocess import subprocess
import sys import sys
from collections import defaultdict
import networkx as nx import networkx as nx
@@ -56,12 +55,125 @@ sys.path.insert(0, _MTD)
sys.path.insert(0, _CUT_LIB) sys.path.insert(0, _CUT_LIB)
from tire_realization_analysis import ( # noqa: E402 from tire_realization_analysis import ( # noqa: E402
ekey, extract_tread, medial_graph, medial_tire_facemodel, ekey, medial_graph, medial_tire_facemodel,
random_maximal_planar, recognise, triangular_faces, random_maximal_planar, recognise, triangular_faces,
) )
from medial_tire_cut_labelling import door_bite, label_and_cut # noqa: E402 from medial_tire_cut_labelling import door_bite, label_and_cut # noqa: E402
# --------------------------------------------------------------------------- #
# Component-based tread recognition.
# --------------------------------------------------------------------------- #
def _edge_face_data(faces):
edge_faces = defaultdict(list)
for i, face in enumerate(faces):
for x, y in ((face[0], face[1]), (face[1], face[2]), (face[2], face[0])):
edge_faces[ekey(x, y)].append(i)
return edge_faces
def _depth_components(faces, edge_faces, levels):
depths = [min(levels[v] for v in face) for face in faces]
dual_adj = defaultdict(set)
for incident in edge_faces.values():
for a in range(len(incident)):
for b in range(a + 1, len(incident)):
dual_adj[incident[a]].add(incident[b])
dual_adj[incident[b]].add(incident[a])
comps = []
seen = [False] * len(faces)
for start in range(len(faces)):
if seen[start]:
continue
depth = depths[start]
stack = [start]
comp = []
seen[start] = True
while stack:
face = stack.pop()
comp.append(face)
for other in dual_adj[face]:
if not seen[other] and depths[other] == depth:
seen[other] = True
stack.append(other)
comps.append((depth, tuple(sorted(comp))))
return comps
def _tread_from_component(faces, levels, face_indices):
tread_faces = [faces[i] for i in face_indices]
if not tread_faces:
return None
depth = min(min(levels[v] for v in face) for face in tread_faces)
annular, up, down = set(), set(), set()
face_of_down = defaultdict(int)
for face in tread_faces:
for x, y in ((face[0], face[1]), (face[1], face[2]), (face[2], face[0])):
e = ekey(x, y)
lx, ly = levels[x], levels[y]
if {lx, ly} == {depth, depth + 1}:
annular.add(e)
elif lx == ly == depth:
up.add(e)
elif lx == ly == depth + 1:
down.add(e)
face_of_down[e] += 1
if len(annular) < 3:
return None
return {
"tread_faces": tread_faces,
"annular": annular,
"up": up,
"down": down,
"bites": {e for e in down if face_of_down[e] == 2},
}
def _build_treads(faces, levels):
"""Recognise simple cycles inside connected depth components.
The returned ``treads`` keeps the existing simple-tire interface used by
the labelling code. ``tread_meta`` records the connected depth component
each simple cycle came from, so compound tires can be chained through
shared up apexes rather than seeded as unrelated roots.
"""
treads, skipped, tread_meta = {}, [], {}
edge_faces = _edge_face_data(faces)
comps = sorted(_depth_components(faces, edge_faces, levels),
key=lambda item: (item[0], item[1]))
component_count = defaultdict(int)
tire_count = defaultdict(int)
for depth, face_indices in comps:
component = component_count[depth]
component_count[depth] += 1
tread = _tread_from_component(faces, levels, face_indices)
if tread is None:
skipped.append(((depth, component), "no tread faces"))
continue
if len(tread["up"]) < 3:
skipped.append(((depth, component), f"only {len(tread['up'])} up teeth"))
continue
tires = recognise(medial_tire_facemodel(tread["tread_faces"]), tread)
if not tires:
skipped.append(((depth, component), "no annular cycle recognised as a tire"))
continue
compound = (depth, component)
cycle_count = len(tires)
for cycle, gb in enumerate(tires):
key = (depth, tire_count[depth])
tire_count[depth] += 1
treads[key] = gb
tread_meta[key] = {
"compound": compound,
"cycle": cycle,
"cycle_count": cycle_count,
"face_indices": face_indices,
}
return treads, skipped, tread_meta
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# 4. Walk-depth labelling and cut, chained down the tire tree. # 4. Walk-depth labelling and cut, chained down the tire tree.
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -71,7 +183,7 @@ def _apex_vertex(g, bij, edge):
return bij[g.apex_of_edge(edge)] return bij[g.apex_of_edge(edge)]
def _label_treads(treads, results, root_entry_edge=None): def _label_treads(treads, results, root_entry_edge=None, tread_meta=None):
"""Fill ``results[(d, c)]`` with the walk-depth labelling and cuts for every """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 recognised tire ``c`` of every tread depth ``d``, chaining child entries to
parent down teeth. parent down teeth.
@@ -99,28 +211,85 @@ def _label_treads(treads, results, root_entry_edge=None):
if apex not in parent_down or value < parent_down[apex]: if apex not in parent_down or value < parent_down[apex]:
parent_down[apex] = value parent_down[apex] = value
has_parent = any(k[0] == d - 1 for k in treads) has_parent = any(k[0] == d - 1 for k in treads)
for key in sorted(k for k in treads if k[0] == d): pending = sorted(k for k in treads if k[0] == d)
g, bij = treads[key] while pending:
if not has_parent: progressed = False
if (key == (root_d, 0) and root_entry_edge is not None deferred = []
and root_entry_edge in g.up_edges): use_sibling_entries = has_parent and not any(
entry_edge, start_depth = root_entry_edge, 0 parent_down.keys() & {treads[key][1][f"u{m}"]
else: for m in treads[key][0].up_edges}
entry_edge, start_depth = g.up_edges[0], 0 # arbitrary entry for key in pending
else: if key not in results
)
for key in pending:
if key in results:
continue
g, bij = treads[key]
child_up_apex = {bij[f"u{m}"]: m for m in g.up_edges} child_up_apex = {bij[f"u{m}"]: m for m in g.up_edges}
best = None entry = None
for apex, value in parent_down.items(): if not has_parent:
if apex in child_up_apex and (best is None or value < best[1]): if (key == (root_d, 0) and root_entry_edge is not None
best = (child_up_apex[apex], value) and root_entry_edge in g.up_edges):
if best is not None: # chains to a parent down tooth entry = (root_entry_edge, 0)
entry_edge, start_depth = best else:
else: # no shared apex (degenerate); root-style entry = (g.up_edges[0], 0) # arbitrary entry
entry_edge, start_depth = g.up_edges[0], 0 else:
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 = best
elif use_sibling_entries:
compound = (
tread_meta.get(key, {}).get("compound")
if tread_meta is not None else None
)
sibling_best = None
if compound is not None:
for sk, srec in results.items():
if sk[0] != d or srec.get("compound") != compound:
continue
sg, sbij = srec["g"], srec["bij"]
for edge in sg.up_edges:
apex = sbij[f"u{edge}"]
if apex not in child_up_apex:
continue
value = srec["depth"][edge] + 1
if (sibling_best is None
or value > sibling_best[1]):
sibling_best = (child_up_apex[apex], value)
if sibling_best is not None:
entry = sibling_best
if entry is None:
deferred.append(key)
continue
entry_edge, start_depth = entry
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}
if tread_meta is not None and key in tread_meta:
results[key].update(tread_meta[key])
progressed = True
if progressed:
pending = deferred
continue
# Degenerate component: no parent or labelled sibling gives an
# entry. Seed it so any remaining sibling cycles can chain to it.
key = deferred[0]
g, bij = treads[key]
entry_edge, start_depth = g.up_edges[0], 0
depth, cuts = label_and_cut(g, entry_edge, start_depth=start_depth) depth, cuts = label_and_cut(g, entry_edge, start_depth=start_depth)
results[key] = {"g": g, "bij": bij, "entry_edge": entry_edge, results[key] = {"g": g, "bij": bij, "entry_edge": entry_edge,
"start_depth": start_depth, "depth": depth, "start_depth": start_depth, "depth": depth,
"cuts": cuts} "cuts": cuts}
if tread_meta is not None and key in tread_meta:
results[key].update(tread_meta[key])
pending = deferred[1:]
def _cap_cut(G, emb, source, levels, results): def _cap_cut(G, emb, source, levels, results):
@@ -252,39 +421,46 @@ def _assemble_cut_graph(M, results, cap_cuts=None):
# Driver. # Driver.
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def random_maximal_planar_min_degree(n: int, seed: int, flips: int = 400, def random_maximal_planar_5_connected(n: int, seed: int, flips: int = 400,
min_degree: int = 0, min_connectivity: int = 5,
attempts: int = 1000) -> tuple[nx.Graph, int]: attempts: int = 1000) -> tuple[nx.Graph, int]:
"""Generate a random maximal planar graph with minimum degree at least """Generate a maximal planar graph with node connectivity at least
``min_degree``. The returned seed is the actual sample seed used.""" ``min_connectivity``. The returned seed is the actual sample seed used."""
if min_degree <= 0: if min_connectivity <= 0:
return random_maximal_planar(n, seed, flips=flips), seed return random_maximal_planar(n, seed, flips=flips), seed
if min_degree >= 5: if min_connectivity >= 5:
plantri = os.path.normpath(os.path.join(_HERE, "..", "..", "..", plantri = os.path.normpath(os.path.join(_HERE, "..", "..", "..",
"plantri", "plantri")) "plantri", "plantri"))
if os.path.exists(plantri): if os.path.exists(plantri):
data = subprocess.check_output( data = subprocess.check_output(
[plantri, f"-m{min_degree}", "-g", str(n)], [plantri, "-c5", "-g", str(n)],
stderr=subprocess.DEVNULL) stderr=subprocess.DEVNULL)
graphs = [line for line in data.splitlines() if line] graphs = [
line for line in data.splitlines()
if line and not line.startswith(b">>")
]
if graphs: if graphs:
G = nx.from_graph6_bytes(graphs[seed % len(graphs)]) for offset in range(len(graphs)):
return nx.convert_node_labels_to_integers(G), seed G = nx.from_graph6_bytes(graphs[(seed + offset) % len(graphs)])
G = nx.convert_node_labels_to_integers(G)
if nx.node_connectivity(G) >= min_connectivity:
return G, seed + offset
for offset in range(attempts): for offset in range(attempts):
sample_seed = seed + offset sample_seed = seed + offset
G = random_maximal_planar(n, sample_seed, flips=flips) G = random_maximal_planar(n, sample_seed, flips=flips)
if min(dict(G.degree()).values()) >= min_degree: if nx.node_connectivity(G) >= min_connectivity:
return G, sample_seed return G, sample_seed
raise RuntimeError( raise RuntimeError(
f"no random maximal planar graph on {n} vertices with " f"no random maximal planar graph on {n} vertices with "
f"minimum degree >= {min_degree} found in {attempts} attempts " f"node connectivity >= {min_connectivity} found in {attempts} attempts "
f"starting at seed {seed}") f"starting at seed {seed}")
def run_experiment(n: int = 12, seed: int = 0, flips: int = 400, def run_experiment(n: int = 12, seed: int = 0, flips: int = 400,
min_degree: int = 5, attempts: int = 1000) -> dict: min_connectivity: int = 5, attempts: int = 1000,
min_degree: int | None = None) -> dict:
"""Run the full pipeline and return a structured result. """Run the full pipeline and return a structured result.
Result keys: ``n, seed, G, M, source, treads`` (dict depth -> (g, bij)), Result keys: ``n, seed, G, M, source, treads`` (dict depth -> (g, bij)),
@@ -292,38 +468,28 @@ def run_experiment(n: int = 12, seed: int = 0, flips: int = 400,
(depth, reason)), ``cut_graph`` (networkx graph), ``labels`` (list of tooth (depth, reason)), ``cut_graph`` (networkx graph), ``labels`` (list of tooth
records), ``warnings``. records), ``warnings``.
""" """
G, graph_seed = random_maximal_planar_min_degree( if min_degree is not None:
n, seed, flips=flips, min_degree=min_degree, attempts=attempts) min_connectivity = max(min_connectivity, min_degree)
G, graph_seed = random_maximal_planar_5_connected(
n, seed, flips=flips, min_connectivity=min_connectivity, attempts=attempts)
faces, emb = triangular_faces(G) faces, emb = triangular_faces(G)
M = medial_graph(G) M = medial_graph(G)
source = random.Random(f"source-{graph_seed}").choice(sorted(G.nodes())) source = random.Random(f"source-{graph_seed}").choice(sorted(G.nodes()))
levels = nx.single_source_shortest_path_length(G, source) levels = nx.single_source_shortest_path_length(G, source)
treads, skipped = {}, [] treads, skipped, tread_meta = _build_treads(faces, levels)
for d in range(max(levels.values())):
tread = extract_tread(faces, levels, d)
if tread is None:
skipped.append((d, "no tread faces"))
continue
if len(tread["up"]) < 3:
skipped.append((d, f"only {len(tread['up'])} up teeth"))
continue
tires = recognise(medial_tire_facemodel(tread["tread_faces"]), tread)
if not tires:
skipped.append((d, "no annular cycle recognised as a tire"))
continue
for c, gb in enumerate(tires):
treads[(d, c)] = gb
results = {} results = {}
_label_treads(treads, results) _label_treads(treads, results, tread_meta=tread_meta)
cap_cuts = _cap_cut(G, emb, source, levels, results) cap_cuts = _cap_cut(G, emb, source, levels, results)
cut_graph, labels, warnings = _assemble_cut_graph(M, results, cap_cuts=cap_cuts) cut_graph, labels, warnings = _assemble_cut_graph(M, results, cap_cuts=cap_cuts)
return { return {
"n": n, "seed": seed, "graph_seed": graph_seed, "n": n, "seed": seed, "graph_seed": graph_seed,
"min_degree": min(dict(G.degree()).values()), "min_degree": min(dict(G.degree()).values()),
"connectivity": nx.node_connectivity(G),
"G": G, "M": M, "source": source, "G": G, "M": M, "source": source,
"treads": treads, "results": results, "cap_cuts": cap_cuts, "treads": treads, "tread_meta": tread_meta,
"results": results, "cap_cuts": cap_cuts,
"skipped": skipped, "skipped": skipped,
"cut_graph": cut_graph, "labels": labels, "warnings": warnings, "cut_graph": cut_graph, "labels": labels, "warnings": warnings,
} }
@@ -370,6 +536,7 @@ def to_json(result: dict) -> dict:
return { return {
"n": result["n"], "seed": result["seed"], "n": result["n"], "seed": result["seed"],
"graph_seed": result["graph_seed"], "min_degree": result["min_degree"], "graph_seed": result["graph_seed"], "min_degree": result["min_degree"],
"connectivity": result["connectivity"],
"source": result["source"], "source": result["source"],
"graph_edges": sorted([int(u), int(v)] for u, v in result["G"].edges()), "graph_edges": sorted([int(u), int(v)] for u, v in result["G"].edges()),
"medial_vertices": result["M"].number_of_nodes(), "medial_vertices": result["M"].number_of_nodes(),
@@ -397,7 +564,8 @@ def summary(result: dict) -> str:
lines = [ lines = [
f"random maximal planar graph: n={result['n']} requested seed={result['seed']} " f"random maximal planar graph: n={result['n']} requested seed={result['seed']} "
f"graph seed={result['graph_seed']} " f"graph seed={result['graph_seed']} "
f"({result['G'].number_of_edges()} edges, min degree {result['min_degree']})", f"({result['G'].number_of_edges()} edges, "
f"connectivity {result['connectivity']}, min degree {result['min_degree']})",
f"medial graph M(G): {result['M'].number_of_nodes()} vertices", f"medial graph M(G): {result['M'].number_of_nodes()} vertices",
f"level source: vertex {result['source']}", f"level source: vertex {result['source']}",
f"recognised tires (depth, component): {sorted(res)}", f"recognised tires (depth, component): {sorted(res)}",
@@ -427,16 +595,20 @@ def main() -> None:
parser.add_argument("--seed", type=int, default=0) parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--flips", type=int, default=400, parser.add_argument("--flips", type=int, default=400,
help="number of random diagonal flips when building G") help="number of random diagonal flips when building G")
parser.add_argument("--min-degree", type=int, default=5, parser.add_argument("--min-connectivity", type=int, default=5,
help="reject random graphs below this minimum degree") help="reject random graphs below this node connectivity")
parser.add_argument("--min-degree", type=int, default=None,
help="compatibility alias; also raises min-connectivity")
parser.add_argument("--attempts", type=int, default=1000, parser.add_argument("--attempts", type=int, default=1000,
help="number of consecutive seeds to try for --min-degree") help="number of consecutive seeds to try for sampling")
parser.add_argument("--json", metavar="PATH", parser.add_argument("--json", metavar="PATH",
help="write the full result as JSON to PATH") help="write the full result as JSON to PATH")
args = parser.parse_args() args = parser.parse_args()
result = run_experiment(n=args.n, seed=args.seed, flips=args.flips, result = run_experiment(n=args.n, seed=args.seed, flips=args.flips,
min_degree=args.min_degree, attempts=args.attempts) min_connectivity=args.min_connectivity,
min_degree=args.min_degree,
attempts=args.attempts)
print(summary(result)) print(summary(result))
if args.json: if args.json:
with open(args.json, "w") as fh: with open(args.json, "w") as fh:
@@ -205,7 +205,8 @@ def label_and_cut(graph: FullMedialTireGraph, entry_edge: int,
def up_apex_cuts(graph: FullMedialTireGraph, entry_edge: int, def up_apex_cuts(graph: FullMedialTireGraph, entry_edge: int,
bij: Mapping[str, object] | None = None) -> dict[int, object]: bij: Mapping[str, object] | None = None,
shared_apexes: set[object] | None = None) -> dict[int, object]:
"""Up-tooth apex cuts prescribed after the walk-depth traversal. """Up-tooth apex cuts prescribed after the walk-depth traversal.
The returned dict maps each cut up-tooth edge to the apex vertex to The returned dict maps each cut up-tooth edge to the apex vertex to
@@ -218,11 +219,12 @@ def up_apex_cuts(graph: FullMedialTireGraph, entry_edge: int,
i: (bij[f"u{i}"] if bij is not None else graph.apex_of_edge(i)) i: (bij[f"u{i}"] if bij is not None else graph.apex_of_edge(i))
for i in graph.up_edges for i in graph.up_edges
} }
shared_apexes = shared_apexes or set()
multiplicity = Counter(apex_by_edge.values()) multiplicity = Counter(apex_by_edge.values())
return { return {
i: apex i: apex
for i, apex in apex_by_edge.items() for i, apex in apex_by_edge.items()
if i != entry_edge and multiplicity[apex] == 1 if i != entry_edge and multiplicity[apex] == 1 and apex not in shared_apexes
} }
@@ -812,7 +812,10 @@ def run(args: argparse.Namespace):
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
if args.graph6: if args.graph6:
graphs = [nx.from_graph6_bytes(args.graph6.encode())] graph = nx.from_graph6_bytes(args.graph6.encode())
if nx.node_connectivity(graph) < 5:
raise ValueError("--graph6 graph must be 5-connected")
graphs = [graph]
if args.source is None: if args.source is None:
raise ValueError("--source is required with --graph6") raise ValueError("--source is required with --graph6")
sources = [args.source] sources = [args.source]