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
This commit is contained in:
parent
01da0296f0
commit
cf634ae949
11 changed files with 832 additions and 31 deletions
File diff suppressed because one or more lines are too long
|
|
@ -71,6 +71,7 @@ Key modules:
|
|||
- `programme.py` — parse `patterns.config` space requirements
|
||||
- `solver.py` — bottom-up ratio solve (scipy)
|
||||
- `shapecurve.py` — Otten/Stockmeyer shape-curve DP: exact size/width/proportion feasibility for a frozen topology, any storey count (DESIGN.md §37.2/§37.4-§37.6); used as `driver._evaluate`'s NM warm-start/hard pre-filter
|
||||
- `cpsat.py` — exact room-code-to-leaf labelling via OR-Tools CP-SAT for a fixed topology (DESIGN.md §37.7); replaces `operators._assign_adjacency_aware`'s greedy/beam room placement behind `assign_solver="cpsat"`, and powers the `operators.mutate_reassign` in-search repair operator
|
||||
- `fitness.py` — native Python fitness evaluator (replaces Perl oracle)
|
||||
- `fitness_cmd.py` — `homemaker-fitness` CLI entry point
|
||||
- `collapse_cmd.py` — `homemaker-collapse` CLI: finish-time global cell→room collapse (94g)
|
||||
|
|
|
|||
135
DESIGN.md
135
DESIGN.md
|
|
@ -4581,3 +4581,138 @@ multi-storey fixture. `tests/test_driver.py`: the multi-storey warm-start
|
|||
test renamed and inverted (`test_shapecurve_warmstart_handles_multistorey`
|
||||
now asserts `shapecurve.solve` **is** called on a multi-storey child, where
|
||||
it previously asserted the opposite). Full suite: 397 passed.
|
||||
|
||||
## 37.7 CP-SAT type assignment for a fixed tree (`homemaker-py-2g7.5`) — PARTIAL, seeder-level positive, driver-level INCONCLUSIVE at pilot scale
|
||||
|
||||
`§11.6`/`§11.7`'s greedy connected-dominating-set + hardest-constrained-
|
||||
code-first room placement (`operators._assign_adjacency_aware`) was Phase
|
||||
6's single biggest fail-count win, but it is a one-shot heuristic: each
|
||||
room code is placed onto the locally-best open slot and never revisited.
|
||||
The bead's premise: for a FIXED topology, room-code-to-leaf assignment
|
||||
(~30-70 leaves, ~16-26 codes) is small enough for exact solve. DESIGN.md
|
||||
§25 (line ~3089) explicitly rejected adding OR-Tools for a harder, different
|
||||
problem (`Fitness.collapse_global`'s finish-time relabel) "because the
|
||||
project has no ortools" — that gap is now closed (`pyproject.toml`
|
||||
`ortools>=9.10`), but only for this bead's simpler fixed-topology labelling
|
||||
problem; `collapse_global` itself is untouched (deferred, see below).
|
||||
|
||||
**What shipped.** `src/homemaker_layout/cpsat.py`: a single pure function
|
||||
`solve_room_labels(slots, codes, reqs, neighbors, context_types)` — a
|
||||
boolean assignment ILP (`x[i,s]`, one code per slot) with a `sat[i,s,adj]`
|
||||
reified-AND term per (code, adjacency-requirement, slot), maximising total
|
||||
satisfied requirements. Matches `graph.check_adjacency`'s REAL semantics
|
||||
(full-code case-insensitive prefix match) rather than the existing greedy
|
||||
heuristic's first-character-only local approximation — a strictly closer
|
||||
proxy for what `homemaker-fitness` actually scores. Wired in as:
|
||||
|
||||
- (a) **seeder**: `_assign_adjacency_aware`/`constructive_topology`/
|
||||
`lift_base_to_storeys` gain `assign_solver: str = "greedy"|"cpsat"`
|
||||
(EXPERIMENTAL, default `"greedy"` — byte-identical to before). Circulation/
|
||||
outside placement (the dominating-set step, a graph-connectivity problem,
|
||||
not this bead's ~30-70-leaf combinatorial one) is unchanged either way;
|
||||
only the room-code-to-slot step is replaced. Falls through to the
|
||||
greedy/beam path on any solver failure (unavailable/infeasible/timeout).
|
||||
- (b) **`operators.mutate_reassign`** (new `MUTATIONS` entry, default weight
|
||||
0 unless `driver.search(..., enable_reassign=True)`): the "assignment
|
||||
analogue of ruin_recreate" (§23) the bead's own plan named — picks the
|
||||
same kind of wing `mutate_ruin_recreate` does, but does NOT un-divide or
|
||||
regrow it; only re-solves which leaf gets which code, preserving the
|
||||
wing's exact room-code multiset and topology.
|
||||
- (c) **post-collapse repair** — deferred, filed as `homemaker-py-5bv`
|
||||
(child of `2g7`/`2g7.5`): replacing/augmenting `Fitness.collapse_global`'s
|
||||
Jacobi+2-opt QAP relaxation is a materially separate, riskier change to a
|
||||
delicate routine that runs inside every in-search eval by default
|
||||
(`collapse_insearch=True`) — correctness/wall-clock regressions there
|
||||
would be felt everywhere, not just behind an opt-in flag.
|
||||
|
||||
**Two bugs found and fixed en route, both worth recording.**
|
||||
|
||||
1. *Resize fragility.* First measurement (seeder-only, `constructive_topology`,
|
||||
harbor-house, 6 seeds): with `proportion_aware=True` (the real default —
|
||||
target-size-based ratio resizing right after assignment), `cpsat` was
|
||||
WORSE than greedy on real fitness-scored secondary-adjacency fails (104
|
||||
vs 92 total) despite tying/slightly-beating it with `proportion_aware=False`
|
||||
(86 vs 87). Root cause: resizing can shrink a shared-wall segment below
|
||||
the door-width adjacency threshold, silently invalidating an edge the
|
||||
exact solve specifically relied on — it packs satisfaction tightly
|
||||
against the PRE-resize graph, leaving less slack than the greedy path's
|
||||
more conservative, degree-biased placement. Fix: `_cpsat_relabel_settled`
|
||||
re-runs the exact solve once more against the now-settled geometry,
|
||||
right after `_size_divisions_from_targets` — cheap (same small model),
|
||||
never worse (can only improve on wherever resizing left it). This is the
|
||||
bead's own "§11.2 lesson … re-run assignment after geometry settles
|
||||
(alternating minimization)" applied literally. After the fix: 82 vs 92
|
||||
(cpsat now ahead) at the same protocol; a wider 10-seed re-check
|
||||
(`tests/test_operators.py::test_assign_cpsat_matches_or_beats_greedy_secondary_adjacency`)
|
||||
holds: 13/20 seed-pairs cpsat-better, 4 ties, 3 cpsat-worse, net ~13%
|
||||
fewer total real fails.
|
||||
2. *Symmetry blowup.* Isolated solves on real harbor-house models (~15
|
||||
slots — trivial by variable count) occasionally stalled for multiple
|
||||
seconds against a 2s `time_limit_s`, non-deterministically (system-load
|
||||
dependent, since a timeout returns whatever CP-SAT's branch-and-bound
|
||||
had reached). Cause: several codes sharing an identical, unreferenced
|
||||
adjacency signature (e.g. four "t" bedroom instances all needing only
|
||||
"c") are fully interchangeable — CP-SAT's branch-and-bound was proving
|
||||
optimality across their entire permutation space. Fix: group codes that
|
||||
share their own adjacency-requirement set AND are never themselves a
|
||||
match target for any other code's requirement; force a canonical
|
||||
slot-index ordering within each group (never removes an achievable
|
||||
objective value, only the redundant permutations of it). All previously-
|
||||
slow captured instances now solve in <200ms. A naive first attempt at a
|
||||
fix (a lexicographic tie-break term folded into the objective) made
|
||||
things WORSE (more instances timed out) by widening the objective's
|
||||
coefficient range — reverted in favour of the explicit grouping
|
||||
constraint above.
|
||||
|
||||
**`driver.search`-level A/B: INCONCLUSIVE at pilot scale, NOT the bead's own
|
||||
20k-budget/harbor+maple/3-seed acceptance protocol.** Wall-clock budget for
|
||||
this session did not stretch to the bead's own acceptance criteria (~28min
|
||||
per arm at budget=20000 on real harbor-house, ×3 arms ×3 seeds ×2
|
||||
programmes ≈ 8+ hours). `experiments/ab_cpsat_assign.py`, harbor-house only,
|
||||
budget=3000, 3 seeds (greedy / cpsat-seed-only / cpsat+`enable_reassign`):
|
||||
|
||||
| arm | mean hard | mean soft | mean fitness | mean wall |
|
||||
|---|---|---|---|---|
|
||||
| greedy | 21.000 | 39.000 | 9.308e-19 | 237.2s |
|
||||
| cpsat | 24.000 | 36.667 | 5.313e-18 | 245.6s |
|
||||
| reassign | 19.667 | 44.667 | 3.930e-19 | 240.0s |
|
||||
|
||||
Mixed: `cpsat` is worse on mean HARD fails but better on soft fails and
|
||||
~5.7x the mean fitness; `reassign` has the best mean hard fails but the
|
||||
worst soft fails. The `reassign` arm's own mechanism was **never observed
|
||||
to fire** in any of the 3 seeds (`mean_reassign_fired=0.0`) — at
|
||||
budget=3000 the loop only generates ~15-20 children total, and `reassign`
|
||||
carries the implicit uniform mutation weight (no boost was added — DESIGN.md
|
||||
§22's `bridge_circulation` precedent measured that an un-A/B-tested weight
|
||||
boost can backfire, so none was assumed here without evidence) — so the
|
||||
`reassign` arm's difference from `cpsat` at this budget is attributable to
|
||||
RNG/exploration noise from the changed weight-normalisation denominator,
|
||||
not to the operator's own effect. `mutate_reassign` firing-and-being-
|
||||
accepted IS independently confirmed at the operator level
|
||||
(`tests/test_operators.py::test_reassign_fires_and_preserves_room_multiset`,
|
||||
20/20 direct trials on a real seeded harbor-house design) — the pilot budget
|
||||
was simply too small to give it enough draws in the full search loop.
|
||||
|
||||
**Verdict: ship as opt-in EXPERIMENTAL, both default off** (matching every
|
||||
other flag in this codebase) — the seeder-level win (item (a)) is real and
|
||||
measured with low noise; the full-search-level payoff (matching the bead's
|
||||
own acceptance criteria) is unconfirmed at pilot scale and needs a proper
|
||||
larger-budget/larger-N run, tracked as follow-up work on `2g7.5` itself
|
||||
(left `in_progress`, not closed — same pattern `6xh` used when its own
|
||||
acceptance bar wasn't fully met). `homemaker-py-5bv` (child of `2g7`/`2g7.5`)
|
||||
tracks the deferred item (c).
|
||||
|
||||
**Verification.** `tests/test_cpsat.py` (5 tests): a hand-built
|
||||
counter-example graph (hub + one non-hub edge) where the beam/greedy
|
||||
heuristic (`operators._beam_place_rooms`) provably strands two codes that
|
||||
need each other while CP-SAT finds the assignment satisfying all of them;
|
||||
fixed-context credit without a decision-neighbour; over-capacity code
|
||||
dropping (least-constrained first); determinism; empty-input degeneracy.
|
||||
`tests/test_operators.py` (+5): CP-SAT seed satisfies
|
||||
`graph.check_space_counts`/stays canonical; the 10-seed secondary-adjacency
|
||||
A/B above; `mutate_reassign` no-ops without `reqs`; fires-and-preserves-
|
||||
multiset over 20 trials. `tests/test_driver.py` (+2): `assign_solver`
|
||||
default and `enable_reassign` default both reproduce prior runs
|
||||
byte-for-byte (`sig`/`n_topologies`/`n_evals` equality), the same clean
|
||||
single-variable-toggle control every other experimental flag in `driver.
|
||||
search` uses. Full suite: 409 passed.
|
||||
|
|
|
|||
84
experiments/ab_cpsat_assign.py
Normal file
84
experiments/ab_cpsat_assign.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""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()
|
||||
|
|
@ -10,6 +10,7 @@ dependencies = [
|
|||
"shapely>=2.0",
|
||||
"networkx>=3.0",
|
||||
"cma>=3.0",
|
||||
"ortools>=9.10",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
|
|||
188
src/homemaker_layout/cpsat.py
Normal file
188
src/homemaker_layout/cpsat.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"""Exact room-code-to-leaf labelling via OR-Tools CP-SAT (homemaker-py-2g7.5).
|
||||
|
||||
For a FIXED topology (and a fixed circulation/outside placement — that part
|
||||
stays on ``operators._assign_adjacency_aware``'s existing connected-
|
||||
dominating-set heuristic, a graph-connectivity problem, not this module's
|
||||
concern), assigning the remaining room codes to the remaining leaf slots so
|
||||
that secondary adjacency requirements (``k1<->da1``, ``da1<->o``, ...) are
|
||||
satisfied is a small discrete optimisation: ~30-70 leaves, ~16-26 codes,
|
||||
well within CP-SAT's exact-solve range in milliseconds. This replaces the
|
||||
one-shot greedy/beam heuristic (``operators._assign_adjacency_aware``'s
|
||||
hardest-constrained-code-first placement, ``_beam_place_rooms``) with an
|
||||
exact solve of the same decision, usable as (a) a seeder, (b) the periodic
|
||||
in-search ``mutate_reassign`` repair operator (``operators.py``).
|
||||
|
||||
DESIGN.md §25 (line ~3089) rejected adding OR-Tools for a different,
|
||||
harder QAP-relaxation problem (``Fitness.collapse_global``'s finish-time
|
||||
relabelling) specifically because the project had no such dependency; that
|
||||
gap is now closed, but only for this module's simpler fixed-topology
|
||||
labelling problem — ``collapse_global`` itself is untouched (tracked as a
|
||||
separate follow-up, DESIGN.md §37.7).
|
||||
|
||||
Matches ``graph.check_adjacency``'s real semantics exactly (full-code,
|
||||
case-insensitive PREFIX match, ``graph._codes_match_prefix``/
|
||||
``has_adjacency``) rather than the existing greedy heuristic's
|
||||
first-character-only approximation (``_assign_adjacency_aware``'s local
|
||||
``_sat``), so the objective this module maximises is a strictly closer proxy
|
||||
for what ``homemaker-fitness`` actually scores.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Hashable
|
||||
from itertools import pairwise
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _n_secondary_adjacency(reqs: dict, code: str) -> int:
|
||||
r = reqs.get(code)
|
||||
return len(r.adjacency) if r else 0
|
||||
|
||||
|
||||
def solve_room_labels(
|
||||
slots: list[Hashable],
|
||||
codes: list[str],
|
||||
reqs: dict,
|
||||
neighbors: dict[Hashable, set],
|
||||
context_types: dict[Hashable, set[str]],
|
||||
time_limit_s: float = 2.0,
|
||||
) -> dict[Hashable, str] | None:
|
||||
"""Assign each of ``codes`` to one of ``slots``, maximising satisfied
|
||||
secondary adjacency requirements.
|
||||
|
||||
``slots``: the room slots to label (any hashable key — a ``dom.Node``,
|
||||
a plain string in tests, ...). ``codes``: one entry per required room
|
||||
instance; if longer than ``slots`` the least-constrained (fewest
|
||||
``reqs[code].adjacency`` entries) codes are dropped first; if shorter,
|
||||
the excess slots are simply left unassigned in the returned dict (the
|
||||
caller's existing leftover-handling applies, e.g. typing them ``"O"``).
|
||||
``reqs``: ``dict[code, SpaceReq]``. ``neighbors``: adjacency among
|
||||
``slots`` themselves (the room-slot subgraph). ``context_types``: for
|
||||
each slot, the set of (lowercase-comparable) type strings of any FIXED
|
||||
neighbour outside ``slots`` (e.g. circulation ``"C"``, outside ``"O"``,
|
||||
or — for :func:`operators.mutate_reassign`'s scoped re-solve — room
|
||||
codes just outside the re-solved wing).
|
||||
|
||||
Returns ``{slot: code}`` (covering ``min(len(slots), len(codes))``
|
||||
slots) or ``None`` if OR-Tools is unavailable, the model is infeasible,
|
||||
or no solution is found within ``time_limit_s`` — callers must always
|
||||
have a defined fallback (the existing greedy/beam path) for ``None``.
|
||||
"""
|
||||
if not slots or not codes:
|
||||
return {}
|
||||
try:
|
||||
from ortools.sat.python import cp_model
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
reqs = reqs or {}
|
||||
n = len(slots)
|
||||
if len(codes) > n:
|
||||
codes = sorted(codes, key=lambda c: -_n_secondary_adjacency(reqs, c))[:n]
|
||||
k = len(codes)
|
||||
idx = {slot: i for i, slot in enumerate(slots)}
|
||||
|
||||
model = cp_model.CpModel()
|
||||
x = {(i, s): model.NewBoolVar(f"x_{i}_{s}") for i in range(k) for s in range(n)}
|
||||
for i in range(k):
|
||||
model.AddExactlyOne(x[i, s] for s in range(n))
|
||||
for s in range(n):
|
||||
model.Add(sum(x[i, s] for i in range(k)) <= 1)
|
||||
|
||||
def _matches(code: str, prefix: str) -> bool:
|
||||
return code.lower().startswith(prefix.lower())
|
||||
|
||||
# Symmetry breaking (homemaker-py-2g7.5, measured necessary on
|
||||
# harbor-house: several unrelated same-requirement codes, e.g. four "t"
|
||||
# bedroom instances all needing only "c", turn any permutation among
|
||||
# them into an equally-optimal solution — CP-SAT's branch-and-bound can
|
||||
# spend seconds proving optimality across that permutation space on an
|
||||
# otherwise ~15-variable model). Two code instances are provably
|
||||
# interchangeable iff they share the same OWN adjacency requirement set
|
||||
# AND neither is ever required as a match target by any code (including
|
||||
# each other) — group those and force a canonical slot-index ordering
|
||||
# within each group; this never removes an achievable objective value,
|
||||
# only the redundant permutations of it.
|
||||
referenced = {a.lower() for c in codes for a in (reqs.get(c).adjacency if reqs.get(c) else [])}
|
||||
|
||||
def _is_referenced(code: str) -> bool:
|
||||
cl = code.lower()
|
||||
return any(cl.startswith(r) for r in referenced)
|
||||
|
||||
groups: dict[tuple, list[int]] = {}
|
||||
for i, code in enumerate(codes):
|
||||
req = reqs.get(code)
|
||||
own = frozenset(a.lower() for a in (req.adjacency if req else []))
|
||||
key = ("unique", i) if _is_referenced(code) else ("group", own)
|
||||
groups.setdefault(key, []).append(i)
|
||||
|
||||
slot_index: dict[int, Any] = {}
|
||||
for key, ids in groups.items():
|
||||
if key[0] != "group" or len(ids) < 2:
|
||||
continue
|
||||
for i in ids:
|
||||
if i not in slot_index:
|
||||
slot_index[i] = model.NewIntVar(0, n - 1, f"slotidx_{i}")
|
||||
model.Add(slot_index[i] == sum(s * x[i, s] for s in range(n)))
|
||||
for a, b in pairwise(ids):
|
||||
model.Add(slot_index[a] <= slot_index[b])
|
||||
|
||||
neighbor_ok_cache: dict[tuple[int, str], Any] = {}
|
||||
|
||||
def _neighbor_ok(s: int, adj_lower: str):
|
||||
key = (s, adj_lower)
|
||||
if key in neighbor_ok_cache:
|
||||
return neighbor_ok_cache[key]
|
||||
slot = slots[s]
|
||||
fixed = context_types.get(slot, ())
|
||||
if any(_matches(t, adj_lower) for t in fixed):
|
||||
neighbor_ok_cache[key] = 1
|
||||
return 1
|
||||
nbr_idxs = [idx[nb] for nb in neighbors.get(slot, ()) if nb in idx]
|
||||
matches = [x[j, ns] for ns in nbr_idxs
|
||||
for j, code in enumerate(codes) if _matches(code, adj_lower)]
|
||||
if not matches:
|
||||
neighbor_ok_cache[key] = 0
|
||||
return 0
|
||||
var = model.NewBoolVar(f"nbr_ok_{s}_{adj_lower}")
|
||||
model.AddMaxEquality(var, matches)
|
||||
neighbor_ok_cache[key] = var
|
||||
return var
|
||||
|
||||
sat_vars = []
|
||||
for i, code in enumerate(codes):
|
||||
req = reqs.get(code)
|
||||
if not req or not req.adjacency:
|
||||
continue
|
||||
seen_adj: set[str] = set()
|
||||
for adj_code in req.adjacency:
|
||||
adj_lower = adj_code.lower()
|
||||
if adj_lower in seen_adj:
|
||||
continue
|
||||
seen_adj.add(adj_lower)
|
||||
for s in range(n):
|
||||
ok = _neighbor_ok(s, adj_lower)
|
||||
if isinstance(ok, int) and ok == 0:
|
||||
continue # provably unsatisfiable here — no var needed
|
||||
sat = model.NewBoolVar(f"sat_{i}_{s}_{adj_lower}")
|
||||
model.Add(sat <= x[i, s])
|
||||
model.Add(sat <= ok)
|
||||
sat_vars.append(sat)
|
||||
|
||||
if sat_vars:
|
||||
model.Maximize(sum(sat_vars))
|
||||
|
||||
solver = cp_model.CpSolver()
|
||||
solver.parameters.max_time_in_seconds = time_limit_s
|
||||
solver.parameters.num_search_workers = 1 # determinism (same inputs -> same result)
|
||||
status = solver.Solve(model)
|
||||
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
|
||||
return None
|
||||
|
||||
result: dict[Hashable, str] = {}
|
||||
for i, code in enumerate(codes):
|
||||
for s in range(n):
|
||||
if solver.Value(x[i, s]) == 1:
|
||||
result[slots[s]] = code
|
||||
break
|
||||
return result
|
||||
|
|
@ -314,6 +314,8 @@ def search(
|
|||
collapse_insearch: bool = True,
|
||||
shapecurve_warmstart: bool = False,
|
||||
shapecurve_prune: bool = False,
|
||||
assign_solver: str = "greedy",
|
||||
enable_reassign: bool = False,
|
||||
) -> SearchResult:
|
||||
"""Run the memetic loop from ``seed_root`` until ``budget`` oracle
|
||||
evaluations are consumed. Returns the best individual found; its ``root``
|
||||
|
|
@ -401,6 +403,19 @@ def search(
|
|||
a width-K beam search over which leaf a room lands on during construction,
|
||||
instead of one irrevocable greedy pass. ``1`` (default) reproduces the
|
||||
prior greedy seeding exactly.
|
||||
|
||||
``assign_solver`` (homemaker-py-2g7.5, EXPERIMENTAL, default "greedy")
|
||||
forwarded to ``operators.constructive_topology``/``lift_base_to_storeys``'s
|
||||
same-named parameter: ``"cpsat"`` replaces the greedy/beam room-code
|
||||
placement with an exact OR-Tools CP-SAT solve (DESIGN.md §37.7),
|
||||
falling through to the greedy/beam path on any solver failure.
|
||||
``"greedy"`` (default) reproduces prior seeding exactly.
|
||||
|
||||
``enable_reassign`` (homemaker-py-2g7.5, EXPERIMENTAL, default off)
|
||||
un-mutes ``operators.mutate_reassign``: the CP-SAT analogue of
|
||||
``enable_ruin_recreate`` — re-solves one wing's room-code labelling
|
||||
exactly instead of un-dividing and regrowing it. Gated the same way as
|
||||
``ruin_recreate`` (zero mutation weight unless enabled).
|
||||
"""
|
||||
from .oracle import DEFAULT_URB_ROOT
|
||||
|
||||
|
|
@ -417,6 +432,8 @@ def search(
|
|||
mutation_weights["bridge_circulation"] = 0.0
|
||||
if not enable_ruin_recreate:
|
||||
mutation_weights["ruin_recreate"] = 0.0
|
||||
if not enable_reassign:
|
||||
mutation_weights["reassign"] = 0.0
|
||||
# homemaker-py-161: shape_rotate/deslim are gated by operators.mutate itself
|
||||
# (fit_ops go to zero probability when fit=None) — only build the Fitness
|
||||
# instance, and thus only let them fire, when explicitly enabled.
|
||||
|
|
@ -613,7 +630,7 @@ def search(
|
|||
depth_balanced=depth_balanced,
|
||||
interior_outside=interior_outside, outside_divisor=outside_divisor,
|
||||
construction_beam_width=construction_beam_width,
|
||||
multi_use=multi_use)
|
||||
multi_use=multi_use, assign_solver=assign_solver)
|
||||
return (topo, None, child_budget, {}, f"construct/{tag}")
|
||||
n = int(rng.integers(max(1, n_target - 1), n_target + 2))
|
||||
return (random_topology(seed_root, n, rng, types), None, child_budget,
|
||||
|
|
@ -1049,6 +1066,8 @@ def search_staged(
|
|||
interior_outside: bool = True,
|
||||
outside_divisor: int = 3,
|
||||
construction_beam_width: int = 1,
|
||||
assign_solver: str = "greedy",
|
||||
enable_reassign: bool = False,
|
||||
) -> SearchResult:
|
||||
"""Staged per-floor topology search (DESIGN.md §11.3, ``homemaker-py-c4c.3``).
|
||||
|
||||
|
|
@ -1108,7 +1127,9 @@ def search_staged(
|
|||
depth_balanced=depth_balanced,
|
||||
interior_outside=interior_outside,
|
||||
outside_divisor=outside_divisor,
|
||||
construction_beam_width=construction_beam_width)
|
||||
construction_beam_width=construction_beam_width,
|
||||
assign_solver=assign_solver,
|
||||
enable_reassign=enable_reassign)
|
||||
|
||||
if types is None:
|
||||
types = sorted(reqs) + ["C", "O"]
|
||||
|
|
@ -1150,6 +1171,8 @@ def search_staged(
|
|||
interior_outside=interior_outside,
|
||||
outside_divisor=outside_divisor,
|
||||
construction_beam_width=construction_beam_width,
|
||||
assign_solver=assign_solver,
|
||||
enable_reassign=enable_reassign,
|
||||
)
|
||||
best_base = r1.best.root
|
||||
_log(f"[staged] stage 1 done: base {r1.best.fitness:.6g} "
|
||||
|
|
@ -1172,7 +1195,7 @@ def search_staged(
|
|||
depth_balanced=depth_balanced,
|
||||
interior_outside=interior_outside, outside_divisor=outside_divisor,
|
||||
construction_beam_width=construction_beam_width,
|
||||
multi_use=multi_use)
|
||||
multi_use=multi_use, assign_solver=assign_solver)
|
||||
|
||||
_log(f"[staged] stage 2: upper floors as deltas, budget {b2}, base_p {base_p}")
|
||||
r2 = search(
|
||||
|
|
@ -1202,6 +1225,8 @@ def search_staged(
|
|||
interior_outside=interior_outside,
|
||||
outside_divisor=outside_divisor,
|
||||
construction_beam_width=construction_beam_width,
|
||||
assign_solver=assign_solver,
|
||||
enable_reassign=enable_reassign,
|
||||
)
|
||||
|
||||
# Stitch the two stages into one accounting (total evals, tagged history).
|
||||
|
|
|
|||
|
|
@ -914,7 +914,8 @@ def _assign_adjacency_aware(lvl: dom.Node, room_codes: list[str], reqs,
|
|||
interior_outside: bool = False,
|
||||
n_outside: int = 1,
|
||||
scope: "set[dom.Node] | None" = None,
|
||||
beam_width: int = 1) -> None:
|
||||
beam_width: int = 1,
|
||||
assign_solver: str = "greedy") -> None:
|
||||
"""Assign leaf types so rooms cluster around a connected circulation spine.
|
||||
|
||||
s44 (DESIGN.md §11.2 follow-up): random type assignment leaves rooms stranded
|
||||
|
|
@ -956,6 +957,15 @@ def _assign_adjacency_aware(lvl: dom.Node, room_codes: list[str], reqs,
|
|||
since circulation/outside are already fixed and shared across every beam
|
||||
branch), so a room whose best slot is later needed by a harder-to-place
|
||||
room is no longer locked in by one irrevocable greedy step.
|
||||
|
||||
``assign_solver`` (homemaker-py-2g7.5, EXPERIMENTAL, default "greedy"):
|
||||
"cpsat" replaces the room-placement pass above (both the plain-greedy
|
||||
and beam variants) with an exact solve of the same decision via
|
||||
:func:`cpsat.solve_room_labels` — see DESIGN.md §37.7. Circulation and
|
||||
outside placement (the connected-dominating-set step above) is
|
||||
unaffected either way. Falls through to the greedy/beam path on any
|
||||
solver failure (OR-Tools unavailable, infeasible, or timeout), so
|
||||
behaviour is always defined.
|
||||
"""
|
||||
from . import geometry
|
||||
|
||||
|
|
@ -1051,7 +1061,24 @@ def _assign_adjacency_aware(lvl: dom.Node, room_codes: list[str], reqs,
|
|||
|
||||
codes.sort(key=_n_secondary, reverse=True)
|
||||
|
||||
if beam_width <= 1:
|
||||
placed = None
|
||||
if assign_solver == "cpsat":
|
||||
# homemaker-py-2g7.5 (DESIGN.md §37.7): exact assignment via
|
||||
# OR-Tools CP-SAT, in place of this block's greedy/beam heuristics
|
||||
# below. Falls through to them on any solver failure (unavailable/
|
||||
# infeasible/timeout) — always a defined outcome.
|
||||
from . import cpsat
|
||||
room_set = set(room_slots)
|
||||
neighbors = {L: {nb for nb in _nbrs(L) if nb in room_set} for L in room_slots}
|
||||
context_types = {L: {nb.type for nb in _nbrs(L) if nb.type and nb not in room_set}
|
||||
for L in room_slots}
|
||||
placed = cpsat.solve_room_labels(room_slots, codes, reqs, neighbors, context_types)
|
||||
|
||||
if placed is not None:
|
||||
for leaf, code in placed.items():
|
||||
leaf.type = code
|
||||
leftover = [L for L in room_slots if L not in placed]
|
||||
elif beam_width <= 1:
|
||||
open_slots = sorted(room_slots,
|
||||
key=lambda L: (L in dominated, deg.get(L, 0), -idx[L]),
|
||||
reverse=True)
|
||||
|
|
@ -1069,7 +1096,7 @@ def _assign_adjacency_aware(lvl: dom.Node, room_codes: list[str], reqs,
|
|||
deg.get(L, 0), -idx[L]))
|
||||
best.type = code
|
||||
open_slots.remove(best)
|
||||
placed, leftover = None, open_slots
|
||||
leftover = open_slots
|
||||
else:
|
||||
placed = _beam_place_rooms(codes, room_slots, dominated, deg, idx, _nbrs,
|
||||
reqs, beam_width)
|
||||
|
|
@ -1132,6 +1159,43 @@ def _beam_place_rooms(codes: list[str], slots: list, dominated: set,
|
|||
return max(beam, key=lambda c: c[0])[1] if beam else {}
|
||||
|
||||
|
||||
def _cpsat_relabel_settled(lvl: dom.Node, reqs) -> None:
|
||||
"""Re-solve room-code labelling against the storey's SETTLED geometry
|
||||
(homemaker-py-2g7.5, DESIGN.md §37.7's "alternating minimization"
|
||||
follow-up, §11.2's lesson applied at seed time).
|
||||
|
||||
``assign_solver="cpsat"``'s exact solve is measured (A/B, harbor-house)
|
||||
to be MORE fragile than the greedy/beam heuristic to the proportion-
|
||||
aware target-size resizing that runs right after assignment
|
||||
(``_size_divisions_from_targets``): resizing can shrink a shared-wall
|
||||
segment below the door-width adjacency threshold, silently invalidating
|
||||
an edge the exact solve specifically relied on (it packs adjacency
|
||||
satisfaction tightly against the pre-resize graph, leaving less slack
|
||||
than the greedy path's more conservative, degree-biased placement).
|
||||
Re-running the exact solve once more against the now-settled geometry
|
||||
recovers this — cheap (same small model) and never worse (it can only
|
||||
IMPROVE satisfied-adjacency count from wherever resizing left it).
|
||||
"""
|
||||
from . import cpsat, geometry
|
||||
|
||||
geometry.clear_cache()
|
||||
G = geometry.leaf_graph(lvl)
|
||||
room_slots = [lf for lf in lvl.leaves() if lf.type in reqs]
|
||||
if not room_slots:
|
||||
return
|
||||
codes = [lf.type for lf in room_slots]
|
||||
room_set = set(room_slots)
|
||||
neighbors = {L: {nb for nb in G.neighbors(L) if nb in room_set}
|
||||
for L in room_slots if G.has_node(L)}
|
||||
context_types = {L: {nb.type for nb in G.neighbors(L)
|
||||
if nb.type and nb not in room_set}
|
||||
for L in room_slots if G.has_node(L)}
|
||||
result = cpsat.solve_room_labels(room_slots, codes, reqs, neighbors, context_types)
|
||||
if result:
|
||||
for leaf, code in result.items():
|
||||
leaf.type = code
|
||||
|
||||
|
||||
def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
|
||||
types: list[str], min_storeys: int = 1,
|
||||
adjacency_aware: bool = True,
|
||||
|
|
@ -1143,7 +1207,8 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
|
|||
interior_outside: bool = True,
|
||||
outside_divisor: int = 3,
|
||||
construction_beam_width: int = 1,
|
||||
multi_use: bool = False) -> dom.Node:
|
||||
multi_use: bool = False,
|
||||
assign_solver: str = "greedy") -> dom.Node:
|
||||
"""Build a seed that instantiates every required space by construction.
|
||||
|
||||
The §11.0 diagnosis: random divide+retype chains leave required programme
|
||||
|
|
@ -1159,6 +1224,11 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
|
|||
forwarded to ``_assign_adjacency_aware``'s ``beam_width`` — see there.
|
||||
``1`` reproduces the prior greedy room placement exactly.
|
||||
|
||||
``assign_solver`` (homemaker-py-2g7.5, EXPERIMENTAL, default "greedy"):
|
||||
forwarded to ``_assign_adjacency_aware``'s same-named parameter — see
|
||||
there. ``"greedy"`` reproduces the prior placement exactly regardless
|
||||
of ``construction_beam_width``.
|
||||
|
||||
Returns a finalised deep copy; ``seed_root`` is unchanged.
|
||||
"""
|
||||
from . import genome as _g
|
||||
|
|
@ -1223,7 +1293,8 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
|
|||
dom.link(child)
|
||||
_assign_adjacency_aware(lvl, rooms, reqs, rng,
|
||||
interior_outside=interior_outside, n_outside=n_o,
|
||||
beam_width=construction_beam_width)
|
||||
beam_width=construction_beam_width,
|
||||
assign_solver=assign_solver)
|
||||
else:
|
||||
assign = rooms + ["C", "O"] # +core circulation, +outside
|
||||
_grow_leaves(lvl, len(assign), rng, balance=depth_balanced)
|
||||
|
|
@ -1245,6 +1316,8 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
|
|||
_size_divisions_from_targets(
|
||||
lvl, reqs, leaf_mult=_leaf_mult_from_plan(lvl, share_plan),
|
||||
leaf_extra=leaf_extra)
|
||||
if adjacency_aware and assign_solver == "cpsat":
|
||||
_cpsat_relabel_settled(lvl, reqs)
|
||||
|
||||
return _finalise(child)
|
||||
|
||||
|
|
@ -1260,7 +1333,8 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
|
|||
interior_outside: bool = True,
|
||||
outside_divisor: int = 3,
|
||||
construction_beam_width: int = 1,
|
||||
multi_use: bool = False) -> dom.Node:
|
||||
multi_use: bool = False,
|
||||
assign_solver: str = "greedy") -> dom.Node:
|
||||
"""Stack upper storeys onto an evolved single-storey base (DESIGN.md §11.3).
|
||||
|
||||
Stage 2 seeder: the Stage-1 base is the credible ground floor and is left
|
||||
|
|
@ -1272,6 +1346,10 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
|
|||
splits/assignment keep a bootstrap batch diverse; ``mutate_place_missing``
|
||||
repairs any residual gaps during the loop.
|
||||
|
||||
``assign_solver`` (homemaker-py-2g7.5, EXPERIMENTAL, default "greedy"):
|
||||
forwarded to ``_assign_adjacency_aware``'s same-named parameter — see
|
||||
there.
|
||||
|
||||
Returns a finalised deep copy; ``base_root`` is unchanged.
|
||||
"""
|
||||
from . import genome as _g, geometry as _geo
|
||||
|
|
@ -1336,7 +1414,8 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
|
|||
dup, rooms, reqs, rng,
|
||||
fixed_circ=[core_node] if core_node is not None else None,
|
||||
interior_outside=interior_outside, n_outside=n_o,
|
||||
beam_width=construction_beam_width)
|
||||
beam_width=construction_beam_width,
|
||||
assign_solver=assign_solver)
|
||||
else:
|
||||
assign = rooms + ["O"] # courtyard / outside on the upper floor
|
||||
if core_node is None:
|
||||
|
|
@ -1375,6 +1454,8 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
|
|||
_size_divisions_from_targets(
|
||||
dup, reqs, leaf_mult=_leaf_mult_from_plan(dup, share_plan),
|
||||
leaf_extra=leaf_extra)
|
||||
if adjacency_aware and assign_solver == "cpsat":
|
||||
_cpsat_relabel_settled(dup, reqs)
|
||||
|
||||
prev = dup
|
||||
|
||||
|
|
@ -1453,6 +1534,65 @@ def mutate_ruin_recreate(root: dom.Node, rng: np.random.Generator,
|
|||
f"({len(rooms)} rooms, {len(border_circ)} anchors)")
|
||||
|
||||
|
||||
def mutate_reassign(root: dom.Node, rng: np.random.Generator,
|
||||
types: list[str], reqs=None) -> tuple[dom.Node, str]:
|
||||
"""CP-SAT re-labelling of one wing's room codes (homemaker-py-2g7.5).
|
||||
|
||||
The "assignment analogue of ruin_recreate" (§23/DESIGN.md §37.7): picks
|
||||
the same kind of wing ``mutate_ruin_recreate`` does (a divided,
|
||||
live-cut subtree of one storey holding a genuine partial neighbourhood,
|
||||
at least 2 leaves, at most half the storey), but does NOT un-divide or
|
||||
regrow it — the topology and the wing's room-code multiset are both
|
||||
preserved exactly. Only which leaf gets which code is re-decided, via
|
||||
an exact :func:`cpsat.solve_room_labels` solve over the wing's room
|
||||
leaves (circulation/outside leaves inside or bordering the wing stay
|
||||
fixed, contributing as context for adjacency-to-``c`` credit). Where
|
||||
``mutate_ruin_recreate`` repairs a badly-grown wing structurally, this
|
||||
move repairs a badly-LABELLED wing whose leaf structure is already
|
||||
fine — the seeder's own one-shot greedy assignment can be beaten by
|
||||
revisiting it once the wing's geometry (and therefore its adjacency
|
||||
graph) has settled during search, not just once at construction time.
|
||||
|
||||
No ``reqs``, no eligible wing, no room leaves in the chosen wing, or
|
||||
solver failure (unavailable/infeasible/no improvement) all no-op.
|
||||
"""
|
||||
if not reqs:
|
||||
return _finalise(copy.deepcopy(root)), "reassign noop"
|
||||
from . import cpsat, geometry
|
||||
|
||||
child = copy.deepcopy(root)
|
||||
_finalise(child)
|
||||
lvls = dom.levels(child)
|
||||
totals = {li: len(lvl.leaves()) for li, lvl in enumerate(lvls)}
|
||||
cands = [(li, n) for li, n in _owned_branches(child)
|
||||
if totals[li] >= 4 and 2 <= len(n.leaves()) <= max(2, totals[li] // 2)]
|
||||
if not cands:
|
||||
return _finalise(child), "reassign noop"
|
||||
li, wing = _pick(rng, cands)
|
||||
lvl = lvls[li]
|
||||
|
||||
room_slots = [lf for lf in wing.leaves() if lf.type in reqs]
|
||||
if not room_slots:
|
||||
return _finalise(child), "reassign noop"
|
||||
codes = [lf.type for lf in room_slots]
|
||||
|
||||
G = geometry.leaf_graph(lvl)
|
||||
room_set = set(room_slots)
|
||||
neighbors = {L: {nb for nb in G.neighbors(L) if nb in room_set}
|
||||
for L in room_slots if G.has_node(L)}
|
||||
context_types = {L: {nb.type for nb in G.neighbors(L)
|
||||
if nb.type and nb not in room_set}
|
||||
for L in room_slots if G.has_node(L)}
|
||||
result = cpsat.solve_room_labels(room_slots, codes, reqs, neighbors, context_types)
|
||||
if not result or all(leaf.type == code for leaf, code in result.items()):
|
||||
return _finalise(child), "reassign noop"
|
||||
|
||||
for leaf, code in result.items():
|
||||
leaf.type = code
|
||||
|
||||
return _finalise(child), f"reassign {li}/{wing.id or 'root'} ({len(room_slots)} rooms)"
|
||||
|
||||
|
||||
def mutate_reassociate(root: dom.Node, rng: np.random.Generator,
|
||||
types: list[str]) -> tuple[dom.Node, str]:
|
||||
"""Wong-Liu M3 associativity move: ``(a|b)|c <-> a|(b|c)`` on parallel cuts.
|
||||
|
|
@ -1676,6 +1816,7 @@ MUTATIONS = {
|
|||
"shape_rotate": mutate_shape_rotate,
|
||||
"deslim": mutate_deslim,
|
||||
"ruin_recreate": mutate_ruin_recreate,
|
||||
"reassign": mutate_reassign,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1693,7 +1834,8 @@ def mutate(root: dom.Node, rng: np.random.Generator, types: list[str],
|
|||
names = sorted(MUTATIONS)
|
||||
p = np.array([(weights or {}).get(n, 1.0) for n in names], dtype=float)
|
||||
# these operators need programme reqs; disable them when not available
|
||||
reqs_ops = ("level_fix", "level_compound_fix", "place_missing", "ruin_recreate")
|
||||
reqs_ops = ("level_fix", "level_compound_fix", "place_missing", "ruin_recreate",
|
||||
"reassign")
|
||||
# also takes reqs (to avoid displacing a required room) but works without
|
||||
# it — never zero-weighted, unlike reqs_ops above
|
||||
reqs_optional_ops = ("bridge_circulation",)
|
||||
|
|
|
|||
103
tests/test_cpsat.py
Normal file
103
tests/test_cpsat.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Tests for the exact CP-SAT room-code labelling solver (homemaker-py-2g7.5).
|
||||
|
||||
``cpsat.solve_room_labels`` is dom/geometry-independent (same decoupled-
|
||||
testability convention as ``operators._beam_place_rooms``, exercised in
|
||||
``test_operators.py::test_beam_place_rooms_is_deterministic_given_inputs``),
|
||||
so these tests use plain hashable keys except where a direct comparison
|
||||
against the existing beam/greedy heuristic requires real ``dom.Node``
|
||||
objects (``_beam_place_rooms`` reads a neighbour's ``.type`` attribute for
|
||||
already-fixed context).
|
||||
"""
|
||||
|
||||
from homemaker_layout import cpsat, dom, operators
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, adjacency):
|
||||
self.adjacency = adjacency
|
||||
|
||||
|
||||
def test_empty_inputs_return_empty_dict():
|
||||
assert cpsat.solve_room_labels([], [], {}, {}, {}) == {}
|
||||
assert cpsat.solve_room_labels(["s1"], [], {}, {}, {}) == {}
|
||||
assert cpsat.solve_room_labels([], ["a"], {}, {}, {}) == {}
|
||||
|
||||
|
||||
def test_determinism():
|
||||
slots = ["s1", "s2", "s3"]
|
||||
codes = ["a", "b", "c"]
|
||||
reqs = {"a": _Req(["b"]), "b": _Req(["a"]), "c": _Req([])}
|
||||
neighbors = {"s1": {"s2"}, "s2": {"s1", "s3"}, "s3": {"s2"}}
|
||||
r1 = cpsat.solve_room_labels(slots, codes, reqs, neighbors, {})
|
||||
r2 = cpsat.solve_room_labels(slots, codes, reqs, neighbors, {})
|
||||
assert r1 == r2
|
||||
|
||||
|
||||
def test_fixed_context_credits_adjacency_without_a_decision_neighbour():
|
||||
# a single slot with no room-slot neighbours at all, but a fixed
|
||||
# (already-typed) circulation neighbour "c" — the requirement must be
|
||||
# creditable purely from context_types, no decision variable involved.
|
||||
reqs = {"k1": _Req(["c"])}
|
||||
result = cpsat.solve_room_labels(
|
||||
["s1"], ["k1"], reqs, {"s1": set()}, {"s1": {"c"}})
|
||||
assert result == {"s1": "k1"}
|
||||
|
||||
|
||||
def test_drops_least_constrained_code_when_over_capacity():
|
||||
# more codes than slots: the code with a real adjacency requirement is
|
||||
# kept over the unconstrained one, same priority the greedy path's
|
||||
# hardest-first ordering uses (_n_secondary).
|
||||
reqs = {"a": _Req(["b"]), "b": _Req([])}
|
||||
result = cpsat.solve_room_labels(["s1"], ["b", "a"], reqs, {"s1": set()}, {})
|
||||
assert result == {"s1": "a"}
|
||||
|
||||
|
||||
def test_finds_globally_optimal_labelling_beam_search_misses():
|
||||
"""Hand-built counter-example (same "adversarial hand-built graph"
|
||||
convention as test_collapse_global.py's
|
||||
test_two_opt_polish_escapes_jacobi_plateau): a hub H (already typed
|
||||
"r") connects to four leaves L1-L4; L1-L2 also has its own direct
|
||||
edge. "s" and "t" each need only a "r" neighbour — satisfiable from
|
||||
ANY leaf, since every leaf touches the hub. "p" and "q" need EACH
|
||||
OTHER as a neighbour — only satisfiable via the one non-hub edge,
|
||||
L1-L2.
|
||||
|
||||
All four codes tie at exactly one secondary-adjacency requirement, so
|
||||
the beam/greedy heuristic (``operators._beam_place_rooms``,
|
||||
beam_width=1 reproduces the plain greedy pass) processes them in
|
||||
whatever order the caller's shuffle produced. Given the order
|
||||
s, t, p, q, the degree/id tie-break greedily claims the special L1-L2
|
||||
edge for s and t (who don't need it — they're satisfiable everywhere),
|
||||
stranding p and q on L3/L4 with no edge between them: 2 of their 4
|
||||
combined requirements met. CP-SAT reasons globally and finds the
|
||||
assignment that satisfies all 4/4, regardless of processing order.
|
||||
"""
|
||||
H = dom.Node(type="r")
|
||||
L1, L2, L3, L4 = (dom.Node(type=None) for _ in range(4))
|
||||
slots = [L1, L2, L3, L4]
|
||||
nbrs = {H: {L1, L2, L3, L4}, L1: {H, L2}, L2: {H, L1}, L3: {H}, L4: {H}}
|
||||
deg = {n: len(ns) for n, ns in nbrs.items()}
|
||||
idx = {L1: 0, L2: 1, L3: 2, L4: 3}
|
||||
dominated = set(slots)
|
||||
reqs = {"r": _Req(["s", "t"]), "s": _Req(["r"]), "t": _Req(["r"]),
|
||||
"p": _Req(["q"]), "q": _Req(["p"])}
|
||||
codes = ["s", "t", "p", "q"]
|
||||
|
||||
placed = operators._beam_place_rooms(
|
||||
codes, slots, dominated, deg, idx, lambda s: nbrs[s], reqs,
|
||||
beam_width=1)
|
||||
for leaf, code in placed.items():
|
||||
leaf.type = code
|
||||
p_leaf = next(leaf for leaf, code in placed.items() if code == "p")
|
||||
q_leaf = next(leaf for leaf, code in placed.items() if code == "q")
|
||||
assert q_leaf not in nbrs[p_leaf], (
|
||||
"expected the greedy heuristic to strand p/q apart in this setup")
|
||||
|
||||
neighbors_among_slots = {L1: {L2}, L2: {L1}, L3: set(), L4: set()}
|
||||
context_types = {s: {"r"} for s in slots} # every leaf touches the hub
|
||||
result = cpsat.solve_room_labels(
|
||||
slots, codes, reqs, neighbors_among_slots, context_types)
|
||||
p_slot = next(s for s, c in result.items() if c == "p")
|
||||
q_slot = next(s for s, c in result.items() if c == "q")
|
||||
assert q_slot in neighbors_among_slots[p_slot], (
|
||||
"CP-SAT should place p/q on the one edge that satisfies both")
|
||||
|
|
@ -388,6 +388,36 @@ def test_shapecurve_prune_off_matches_baseline(fake_inner):
|
|||
assert off.n_evals == base.n_evals
|
||||
|
||||
|
||||
def test_assign_solver_default_matches_greedy(fake_inner):
|
||||
"""homemaker-py-2g7.5: with assign_solver left at its default ("greedy"),
|
||||
the run is identical to one that passes it explicitly — the same clean
|
||||
A/B control as shapecurve_prune's."""
|
||||
init_root = dom.load(str(INIT_FILE))
|
||||
base = driver.search(init_root, CORPUS, budget=600, pop_size=4,
|
||||
child_budget=60, seed_budget=100, seed=9)
|
||||
explicit = driver.search(init_root, CORPUS, budget=600, pop_size=4,
|
||||
child_budget=60, seed_budget=100, seed=9,
|
||||
assign_solver="greedy")
|
||||
assert explicit.best.sig == base.best.sig
|
||||
assert explicit.n_topologies == base.n_topologies
|
||||
assert explicit.n_evals == base.n_evals
|
||||
|
||||
|
||||
def test_enable_reassign_default_off_matches_baseline(fake_inner):
|
||||
"""homemaker-py-2g7.5: with enable_reassign left at its default (off),
|
||||
the run is identical to one that passes it explicitly False — the
|
||||
reassign operator never fires (zero mutation weight)."""
|
||||
init_root = dom.load(str(INIT_FILE))
|
||||
base = driver.search(init_root, CORPUS, budget=600, pop_size=4,
|
||||
child_budget=60, seed_budget=100, seed=9)
|
||||
off = driver.search(init_root, CORPUS, budget=600, pop_size=4,
|
||||
child_budget=60, seed_budget=100, seed=9,
|
||||
enable_reassign=False)
|
||||
assert off.best.sig == base.best.sig
|
||||
assert off.n_topologies == base.n_topologies
|
||||
assert off.n_evals == base.n_evals
|
||||
|
||||
|
||||
def test_shapecurve_prune_vetoes_heuristic_when_dp_feasible(monkeypatch):
|
||||
"""homemaker-py-wkh (DESIGN.md §37.5): a DP-feasible verdict is a real
|
||||
certificate that some ratio point clears every leaf's shape threshold, so
|
||||
|
|
|
|||
|
|
@ -494,6 +494,97 @@ def test_beam_place_rooms_is_deterministic_given_inputs():
|
|||
assert r1 == r2
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
||||
def test_construction_assign_cpsat_yields_valid_seed():
|
||||
# homemaker-py-2g7.5: the CP-SAT room-labelling path must satisfy the same
|
||||
# construction invariants as the greedy path — every required space
|
||||
# present, canonical genome.
|
||||
from homemaker_layout import graph, programme
|
||||
|
||||
reqs = programme.load_programme_dir(str(HARBOR))
|
||||
types = sorted(reqs) + ["C", "O"]
|
||||
seed = dom.load(str(HARBOR / "init.dom"))
|
||||
for trial in range(5):
|
||||
root = operators.constructive_topology(
|
||||
seed, reqs, np.random.default_rng(trial), types,
|
||||
assign_solver="cpsat")
|
||||
_, missing = graph.check_space_counts(root, reqs)
|
||||
assert missing == [], f"trial {trial} left {missing}"
|
||||
canonical(root)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
||||
def test_assign_cpsat_matches_or_beats_greedy_secondary_adjacency():
|
||||
# homemaker-py-2g7.5: CP-SAT solves the same room-labelling decision the
|
||||
# greedy/beam heuristic approximates exactly — its secondary-adjacency
|
||||
# (not the access/adjacent-to-c fails the dominating-set step already
|
||||
# solves) fail count must be strictly lower in aggregate.
|
||||
import copy
|
||||
|
||||
from homemaker_layout import fitness, programme
|
||||
|
||||
reqs = programme.load_programme_dir(str(HARBOR))
|
||||
conf, cost = fitness.load_config(str(HARBOR))
|
||||
fit = fitness.Fitness(conf, cost)
|
||||
types = sorted(reqs) + ["C", "O"]
|
||||
seed = dom.load(str(HARBOR / "init.dom"))
|
||||
|
||||
def secondary_fails(solver: str) -> list[int]:
|
||||
counts = []
|
||||
for trial in range(10):
|
||||
root = operators.constructive_topology(
|
||||
seed, reqs, np.random.default_rng(trial), types,
|
||||
assign_solver=solver)
|
||||
_, fails = fit.score_with_fails(copy.deepcopy(root))
|
||||
counts.append(sum(1 for f in fails if "not adjacent to" in f))
|
||||
return counts
|
||||
|
||||
# Per-seed outcomes are noisy (both solvers depend on the same random
|
||||
# room-order shuffle before falling into their own placement logic), so
|
||||
# the comparison is on the aggregate over several seeds, not every seed
|
||||
# individually — measured on harbor-house (10 seeds): cpsat wins on
|
||||
# most, ties on a few, loses on rare ones, net ~13% fewer total fails.
|
||||
greedy = secondary_fails("greedy")
|
||||
cpsat = secondary_fails("cpsat")
|
||||
assert sum(cpsat) < sum(greedy)
|
||||
|
||||
|
||||
def test_reassign_noop_without_reqs():
|
||||
root = genome.decode(genome.encode(dom.load(str(CORPUS / FILES[0]))))
|
||||
child, desc = operators.mutate_reassign(root, np.random.default_rng(0), TYPES)
|
||||
assert desc == "reassign noop"
|
||||
canonical(child)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
||||
def test_reassign_fires_and_preserves_room_multiset():
|
||||
# homemaker-py-2g7.5: the reassign operator must fire (find at least one
|
||||
# wing to re-label) on a real seeded design, and it must preserve the
|
||||
# wing's exact room-code multiset — only the leaf<->code labelling
|
||||
# changes, never the topology or which codes are present.
|
||||
from collections import Counter
|
||||
|
||||
from homemaker_layout import programme
|
||||
|
||||
reqs = programme.load_programme_dir(str(HARBOR))
|
||||
types = sorted(reqs) + ["C", "O"]
|
||||
seed = dom.load(str(HARBOR / "init.dom"))
|
||||
root = operators.constructive_topology(
|
||||
seed, reqs, np.random.default_rng(0), types)
|
||||
before = Counter(lf.type for lf in root.leaves())
|
||||
|
||||
fired = False
|
||||
for trial in range(20):
|
||||
child, desc = operators.mutate_reassign(
|
||||
root, np.random.default_rng(trial), types, reqs=reqs)
|
||||
canonical(child)
|
||||
after = Counter(lf.type for lf in child.leaves())
|
||||
assert after == before, f"trial {trial}: room multiset changed ({desc})"
|
||||
if not desc.endswith("noop"):
|
||||
fired = True
|
||||
assert fired, "reassign never fired across 20 trials on a real seeded design"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
||||
def test_place_missing_repairs_deficient_tree():
|
||||
# §11.2 repair: iterating mutate_place_missing drives a deficient design's
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue