Adds src/homemaker_layout/cpsat.py (OR-Tools CP-SAT) as an exact alternative to operators._assign_adjacency_aware's greedy/beam room-code placement, wired in as assign_solver="greedy"|"cpsat" (EXPERIMENTAL, default "greedy", byte-identical to before) through constructive_topology/lift_base_to_storeys/ driver.search, plus a new operators.mutate_reassign in-search repair operator (driver.search's enable_reassign=False default, mirrors enable_ruin_recreate). Both found and fixed a resize-fragility bug (a second CP-SAT pass against settled geometry, operators._cpsat_relabel_settled) and a CP-SAT symmetry-blowup stall (explicit interchangeable-code grouping). Seeder-level A/B on harbor-house is a solid, low-noise positive (~13% fewer real fitness-scored secondary-adjacency fails, 10 seeds). Full driver.search A/B is only pilot-scale (budget=3000 vs the bead's own 20k target) and inconclusive -- both flags stay default-off pending a larger-N confirmation. Full writeup: DESIGN.md §37.7. Bead left in_progress (own acceptance criteria not fully met); homemaker-py-5bv tracks the deferred post-collapse repair item. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSwQwpEaHFBkeVSDDWd75S
84 lines
3.4 KiB
Python
84 lines
3.4 KiB
Python
"""A/B: does CP-SAT exact room-code labelling beat today's greedy/beam
|
|
heuristic seeder on the real ``driver.search`` loop (homemaker-py-2g7.5,
|
|
DESIGN.md §37.7)?
|
|
|
|
Three arms per programme/seed:
|
|
- baseline: assign_solver="greedy" (today's default)
|
|
- cpsat: assign_solver="cpsat" (seeder only, item (a))
|
|
- reassign: assign_solver="cpsat" + enable_reassign=True (adds item (b),
|
|
the periodic in-search re-labelling operator)
|
|
|
|
Metric: mean (n_hard, n_soft, fitness) of ``driver.search``'s best individual
|
|
at a FIXED budget across several seeds, same format as the shapecurve A/Bs
|
|
(``experiments/ab_shapecurve_warmstart.py``) -- plus a count of how many
|
|
runs the ``reassign`` operator actually fired+was-accepted in, per the
|
|
bead's acceptance criterion.
|
|
|
|
Usage: python experiments/ab_cpsat_assign.py [budget] [n_seeds] [programme]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from homemaker_layout import dom, driver
|
|
|
|
EXAMPLES = Path(__file__).parent.parent / "examples"
|
|
ARMS = {
|
|
"greedy": {"assign_solver": "greedy", "enable_reassign": False},
|
|
"cpsat": {"assign_solver": "cpsat", "enable_reassign": False},
|
|
"reassign": {"assign_solver": "cpsat", "enable_reassign": True},
|
|
}
|
|
|
|
|
|
def run_arm(seed_root: dom.Node, programme_dir: Path, budget: int, seed: int,
|
|
arm_kw: dict):
|
|
t0 = time.perf_counter()
|
|
r = driver.search(
|
|
seed_root, programme_dir, budget=budget, pop_size=8, child_budget=80,
|
|
seed_budget=200, seed=seed, **arm_kw,
|
|
)
|
|
elapsed = time.perf_counter() - t0
|
|
# "fired and accepted": a reassign-descended child survived tournament
|
|
# replacement into the FINAL population (lineage is per-generation, not
|
|
# cumulative -- see Individual/driver._evaluate -- so this only counts
|
|
# children born directly from a non-noop reassign, not their descendants).
|
|
fired = sum(1 for ind in r.population
|
|
if ind.lineage.startswith("reassign") and "noop" not in ind.lineage)
|
|
return r, elapsed, fired
|
|
|
|
|
|
def main() -> None:
|
|
budget = int(sys.argv[1]) if len(sys.argv) > 1 else 4000
|
|
n_seeds = int(sys.argv[2]) if len(sys.argv) > 2 else 3
|
|
programme_name = sys.argv[3] if len(sys.argv) > 3 else "harbor-house"
|
|
|
|
programme_dir = EXAMPLES / programme_name
|
|
seed_root = dom.load(str(programme_dir / "init.dom"))
|
|
|
|
rows: dict[str, list[tuple]] = {name: [] for name in ARMS}
|
|
for seed in range(n_seeds):
|
|
line = [f"seed {seed}:"]
|
|
for name, kw in ARMS.items():
|
|
r, elapsed, fired = run_arm(seed_root, programme_dir, budget, seed, kw)
|
|
rows[name].append((r.best.n_hard, r.best.n_soft, r.best.fitness, elapsed, fired))
|
|
line.append(f"{name} hard={r.best.n_hard} soft={r.best.n_soft} "
|
|
f"fit={r.best.fitness:.4g} {elapsed:.1f}s"
|
|
+ (f" reassign_fired={fired}" if name == "reassign" else ""))
|
|
print(" | ".join(line), flush=True)
|
|
|
|
print()
|
|
print(f"budget={budget} n_seeds={n_seeds} programme={programme_dir.name}")
|
|
def mean(data: list[tuple], idx: int) -> float:
|
|
return sum(d[idx] for d in data) / len(data)
|
|
|
|
for name, data in rows.items():
|
|
extra = f" mean_reassign_fired={mean(data, 4):.1f}" if name == "reassign" else ""
|
|
print(f"{name:9s}: mean hard={mean(data, 0):.3f} soft={mean(data, 1):.3f} "
|
|
f"fitness={mean(data, 2):.6g} wall={mean(data, 3):.1f}s{extra}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|