diff --git a/papers/medial_tire_decompositions_of_plane_triangulations/experiments/even_program_findings.md b/papers/medial_tire_decompositions_of_plane_triangulations/experiments/even_program_findings.md index 32dc052..dfb3283 100644 --- a/papers/medial_tire_decompositions_of_plane_triangulations/experiments/even_program_findings.md +++ b/papers/medial_tire_decompositions_of_plane_triangulations/experiments/even_program_findings.md @@ -42,12 +42,31 @@ consecutive around a vertex or face. ## Status (synthetic ring triangulations, the clean-level-structure domain) Pipeline runs end to end. Surgery, canonical colouring, and gadget removal all -work. The program now lands squarely on the **cycle layer**: +work. The program now lands squarely on the **cycle layer**. + +The original `60 random ring triangulations: 39 ok, 21 fail` figure was the +**first-match heuristic** — one diamond per odd seam, placed at the *first* +admissible seam edge, only the colouring phase varied (≤4 random tread phases). +That is one point in the insertion-site design space, not a sweep of it. + +**Site sweep (`run_graph` now enumerates every combination of insertion sites, +one per odd seam, ≤4 colour phases each; `--max-combos` caps the product).** +A graph counts `ok` iff *some* placement fully descends: ``` -60 random ring triangulations: 39 ok, 21 fail:diamond-switch +seed 1, 60 graphs: first-match 31 ok / 29 fail -> sweep 54 ok / 6 fail (rescued 23) +seed 2, 60 graphs: first-match 36 ok / 24 fail -> sweep 57 ok / 3 fail (rescued 21) ``` +(First-match is seed-sensitive — 31–39 depending on seed; the 39 was one such +seed. What is robust is the *gap*: sweeping insertion sites rescues ~20 of the +~24 first-match failures, leaving a small stubborn residue of ~3–6 +`fail:diamond-switch` graphs.) Design space is real but modest: ~50 graphs need +a diamond, ~2900 combos total, max ~900–1200 on a single graph (a handful hit +the cap). So the answer to "did we test every way of adding a diamond?" is: +**now yes** (per odd seam, up to the cap), and most of the apparent failures +were heuristic, not intrinsic. + **Crucial diagnostic:** for a failing case, a simultaneously-removable proper 3-colouring of `M(G')` was shown to **exist** (it must — `M(G)` is 3-colourable). So `fail:diamond-switch` is **not** non-existence; it is **Kempe-reachability** — diff --git a/papers/medial_tire_decompositions_of_plane_triangulations/experiments/kempe_even_program_harness.py b/papers/medial_tire_decompositions_of_plane_triangulations/experiments/kempe_even_program_harness.py index 1622d2f..2053164 100644 --- a/papers/medial_tire_decompositions_of_plane_triangulations/experiments/kempe_even_program_harness.py +++ b/papers/medial_tire_decompositions_of_plane_triangulations/experiments/kempe_even_program_harness.py @@ -38,7 +38,7 @@ import os import subprocess import sys from collections import defaultdict, deque -from itertools import combinations +from itertools import combinations, product HERE = os.path.dirname(os.path.abspath(__file__)) PLANTRI = os.path.join(HERE, "..", "..", "..", "plantri", "plantri") @@ -574,55 +574,127 @@ def verify_proper(g, col): return True -def run_graph(g0: Tri, outer=None, verbose=False, attempts=4): +def run_graph(g0: Tri, outer=None, verbose=False, attempts=4, max_combos=128): + """Run the program on g0, sweeping EVERY combination of diamond insertion + sites (one site per odd seam) rather than the old first-match heuristic. + + Returns a dict: + status -- 'ok' if SOME placement (over <=attempts colour phases) + succeeds, else a representative 'skip:'/'fail:' string. + first_status -- result of the first-match heuristic (old behaviour: + first valid seam edge per seam) for comparison. + n_diamond_seams -- number of odd seams needing a diamond. + n_combos -- size of the full insertion-site design space. + n_combos_tried -- combos actually run (<= max_combos; rest truncated). + n_combos_ok -- how many tried combos fully succeeded. + truncated -- True if n_combos > max_combos. + """ import random g0.check() if outer is None: outer = tuple(g0.faces()[0]) - an0 = Analysis(g0.copy(), outer) - if an0.degenerate: - return f"skip:{an0.degenerate}" - last = "fail:unknown" - for att in range(attempts): - rng = random.Random(1000 + att) - last = _attempt(g0, outer, rng, verbose) - if last == "ok" or last.startswith("skip"): - return last - return last + orig_vertices = set(g0.rot) + + prep = _prep_gadgets(g0, outer) + if isinstance(prep, str): + return {"status": prep, "first_status": prep, "n_diamond_seams": 0, + "n_combos": 0, "n_combos_tried": 0, "n_combos_ok": 0, + "truncated": False} + template, an_g, gadgets = prep + + sites = _candidate_sites(an_g) + if sites is None: + s = "skip:no-diamond-site" + return {"status": s, "first_status": s, "n_diamond_seams": 0, + "n_combos": 0, "n_combos_tried": 0, "n_combos_ok": 0, + "truncated": False} + + # product(*choices) yields the first-match combo (all first elements) first. + combos = list(product(*sites)) if sites else [()] + n_combos = len(combos) + truncated = n_combos > max_combos + if truncated: + combos = combos[:max_combos] + + def run_combo(combo): + last = "fail:unknown" + for att in range(attempts): + rng = random.Random(1000 + att) + last = _descend_with_sites(template, outer, orig_vertices, + gadgets, combo, rng) + if last == "ok" or last.startswith("skip"): + return last + return last + + first_status = run_combo(combos[0]) + n_ok = 1 if first_status == "ok" else 0 + swept_status = first_status + for combo in combos[1:]: + res = run_combo(combo) + if res == "ok": + n_ok += 1 + swept_status = "ok" + elif swept_status != "ok" and not swept_status.startswith("fail"): + # keep a 'fail:' over a 'skip:' as the representative non-ok status + swept_status = res + if swept_status != "ok" and first_status.startswith("fail"): + swept_status = first_status + + return {"status": swept_status, "first_status": first_status, + "n_diamond_seams": len(sites), "n_combos": n_combos, + "n_combos_tried": len(combos), "n_combos_ok": n_ok, + "truncated": truncated} -def _attempt(g0: Tri, outer, rng, verbose=False): +def _prep_gadgets(g0: Tri, outer): + """Insert the leaf gadget on every terminal triangle (deterministic), then + re-analyse. Returns (g_with_gadgets, Analysis, gadgets) or a 'skip:' str.""" g = g0.copy() an = Analysis(g, outer) if an.degenerate: return f"skip:{an.degenerate}" - - # --- surgeries ------------------------------------------------------- - diamonds = [] # (w, quad(a,b,c,d) with diagonal (u,v)) for removal - gadgets = [] # (y, z, u, v, x, t) + gadgets = [] for f in an.terminal: a, b, c = f y, z, u, v, x, t = g.insert_leaf_gadget(a, b, c) gadgets.append((y, z, u, v, x, t)) - # re-analyse (gadgets change seams) an = Analysis(g, outer) if an.degenerate: return f"skip:post-gadget-{an.degenerate}" + return g, an, gadgets + + +def _candidate_sites(an: Analysis): + """For each ODD seam (in an.seams order), the list of valid diamond edges + -- seam edges whose two apexes straddle levels k-1 and k+1. Returns a list + (one entry per odd seam) of edge lists, or None if some odd seam has no + valid site (the old 'skip:no-diamond-site').""" + g = an.g + choices = [] for k, cyc in an.seams: if len(cyc) % 2 == 0: continue - # choose a seam edge whose below-apex is strictly deeper - choice = None + edges = [] for i in range(len(cyc)): a, b = cyc[i], cyc[(i + 1) % len(cyc)] x, t = g.apexes_of(a, b) lx, lt = an.level[x], an.level[t] if {lx, lt} == {k - 1, k + 1}: - choice = (a, b) - break - if choice is None: - return "skip:no-diamond-site" - w, u, v, x, t = g.insert_diamond(*choice) + edges.append((a, b)) + if not edges: + return None + choices.append(edges) + return choices + + +def _descend_with_sites(template: Tri, outer, orig_vertices, gadgets, combo, + rng): + """One full run with diamonds inserted at the explicit sites in `combo` + (one (a,b) edge per odd seam), then canonical colour + Kempe descent.""" + g = template.copy() + diamonds = [] # (w, u, v, x, t) for removal + for (a, b) in combo: + w, u, v, x, t = g.insert_diamond(a, b) diamonds.append((w, u, v, x, t)) an = Analysis(g, outer) if an.degenerate: @@ -636,7 +708,6 @@ def _attempt(g0: Tri, outer, rng, verbose=False): return f"fail:canonical-{reason}" # --- descent ----------------------------------------------------------- - level = an.level # diamonds: remove each (diagonal uv) for w, u, v, x, t in diamonds: adj = medial_adj(g) @@ -682,7 +753,7 @@ def _attempt(g0: Tri, outer, rng, verbose=False): return "fail:gadget-removal" # --- final check -------------------------------------------------------- - if set(g.rot) != set(g0.rot): + if set(g.rot) != orig_vertices: return "fail:vertex-mismatch" if not verify_proper(g, col): return "fail:final-improper" @@ -792,6 +863,47 @@ def random_profile(rng): return sizes, leaf +def _record(res, swept_tally, first_tally, sweep): + """Fold one run_graph dict (or error string) into the running tallies. + `sweep` accumulates site-sweep diagnostics.""" + if isinstance(res, str): # error before the sweep + swept_tally[res] += 1 + first_tally[res] += 1 + return res + swept_tally[res["status"]] += 1 + first_tally[res["first_status"]] += 1 + if res["n_diamond_seams"]: + sweep["graphs_with_diamonds"] += 1 + sweep["total_combos"] += res["n_combos"] + sweep["total_combos_tried"] += res["n_combos_tried"] + sweep["max_combos_seen"] = max(sweep["max_combos_seen"], + res["n_combos"]) + if res["truncated"]: + sweep["truncated_graphs"] += 1 + # the headline: a graph the first-match heuristic FAILED but some + # placement RESCUED. + if res["status"] == "ok" and res["first_status"] != "ok": + sweep["rescued_by_sweep"] += 1 + return res["status"] + + +def _print_sweep(sweep, first_tally, swept_tally): + fline = " ".join(f"{k}={v}" for k, v in sorted(first_tally.items())) + sline = " ".join(f"{k}={v}" for k, v in sorted(swept_tally.items())) + print("\n--- first-match heuristic (old behaviour) ---") + print(" " + fline) + print("--- full site sweep (some-placement-works) ---") + print(" " + sline) + g = sweep["graphs_with_diamonds"] + print(f"\nsite-sweep stats: {g} graphs needed >=1 diamond; " + f"design space {sweep['total_combos']} combos total " + f"(max {sweep['max_combos_seen']} on one graph; " + f"{sweep['truncated_graphs']} graphs truncated at the cap), " + f"{sweep['total_combos_tried']} combos actually run.") + print(f"sweep rescued {sweep['rescued_by_sweep']} graph(s) that the " + f"first-match heuristic failed.") + + def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--min-n", type=int, default=6) @@ -800,47 +912,47 @@ def main(): ap.add_argument("--synthetic", type=int, default=0, help="number of random ring triangulations to test") ap.add_argument("--seed", type=int, default=1) + ap.add_argument("--max-combos", type=int, default=128, + help="cap on diamond-site combinations swept per graph") ap.add_argument("--verbose", action="store_true") args = ap.parse_args() import random - grand = defaultdict(int) + swept_tally = defaultdict(int) # status under the full sweep + first_tally = defaultdict(int) # status under the old first-match rule + sweep = defaultdict(int) if args.synthetic: rng = random.Random(args.seed) - tally = defaultdict(int) for idx in range(args.synthetic): sizes, leaf = random_profile(rng) g, outer = ring_triangulation(sizes, leaf, rng) if g is None: - tally["error:construction"] += 1 + swept_tally["error:construction"] += 1 + first_tally["error:construction"] += 1 continue try: - res = run_graph(g, outer=outer, verbose=args.verbose) + res = run_graph(g, outer=outer, verbose=args.verbose, + max_combos=args.max_combos) except Exception as ex: # noqa: BLE001 res = f"error:{type(ex).__name__}" - tally[res] += 1 - grand[res] += 1 - if args.verbose and not res.startswith("ok"): - print(f" synth #{idx} sizes={sizes} leaf={leaf}: {res}") - line = " ".join(f"{k}={v}" for k, v in sorted(tally.items())) - print(f"synthetic ({args.synthetic}): {line}") + status = _record(res, swept_tally, first_tally, sweep) + if args.verbose and status != "ok": + print(f" synth #{idx} sizes={sizes} leaf={leaf}: {status}") + print(f"synthetic ({args.synthetic}):") else: for n in range(args.min_n, args.max_n + 1): - tally = defaultdict(int) gs = plantri_triangulations(n, args.limit) for idx, g in enumerate(gs): try: - res = run_graph(g, verbose=args.verbose) + res = run_graph(g, verbose=args.verbose, + max_combos=args.max_combos) except Exception as ex: # noqa: BLE001 res = f"error:{type(ex).__name__}" - tally[res] += 1 - grand[res] += 1 - if args.verbose and not res.startswith(("ok", "skip")): - print(f" n={n} #{idx}: {res}") - line = " ".join(f"{k}={v}" for k, v in sorted(tally.items())) - print(f"n={n} ({len(gs)} graphs): {line}") + status = _record(res, swept_tally, first_tally, sweep) + if args.verbose and not status.startswith(("ok", "skip")): + print(f" n={n} #{idx}: {status}") sys.stdout.flush() - print("\nTOTAL: " + " ".join(f"{k}={v}" for k, v in sorted(grand.items()))) + _print_sweep(sweep, first_tally, swept_tally) if __name__ == "__main__":