Sweep all diamond insertion sites; report first-match vs full sweep
run_graph no longer takes the first admissible seam edge per odd seam. It now
enumerates every valid diamond site per odd seam (_candidate_sites), sweeps the
full Cartesian product (capped by --max-combos), runs <=4 colour phases per
combination, and counts a graph ok iff SOME placement fully descends. Reports
both the old first-match tally and the swept tally, plus design-space stats and
how many graphs the sweep rescued.
Finding: most "fail:diamond-switch" cases were heuristic, not intrinsic. The
old 39/60 was the first-match heuristic (one point in the design space, and
seed-sensitive 31-39). Sweeping insertion sites rescues ~20 of ~24 first-match
failures:
seed 1: first-match 31 ok / 29 fail -> sweep 54 ok / 6 fail (rescued 23)
seed 2: first-match 36 ok / 24 fail -> sweep 57 ok / 3 fail (rescued 21)
Only ~3-6 fail:diamond-switch survive the full site sweep -- those are the real
obstruction targets for the joint {1,3}-cycle bipartiteness solver. The colour/
tread phase is still only randomized over 4 attempts, not enumerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+21
-2
@@ -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** —
|
||||
|
||||
+152
-40
@@ -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}"
|
||||
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 = _attempt(g0, outer, rng, verbose)
|
||||
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
|
||||
|
||||
def _attempt(g0: Tri, outer, rng, verbose=False):
|
||||
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 _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__":
|
||||
|
||||
Reference in New Issue
Block a user