homemaker-layout/experiments/ab_cpsat_assign.py
Bruno Postle cf634ae949 homemaker-py-2g7.5: CP-SAT exact room-code assignment (seeder + reassign op)
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
2026-08-04 09:19:36 +01:00

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()