homemaker-layout/src/homemaker_layout/cpsat.py
Claude 03c1c5edcf
CP-SAT seeding re-measured deterministically: it loses
39.5 concluded the exact CP-SAT seeder beats greedy (harbor 102 -> 92,
maple 156 -> 154). Re-checked because fdp made the arms deterministic and
3qj made the model 7.5x slower. Three findings.

A live bug in the cap, found on the way. solve_room_labels sets a
deterministic work-unit budget (4.0) and a wall-clock backstop, commented
as "a pathological-case backstop only". At 2.0s it had become THE BINDING
CONSTRAINT: 2 of 24 harbor solves returned FEASIBLE not OPTIMAL, wall time
hit exactly 2010 ms, and the deterministic budget was never reached (max
2.483/4.0). Those labellings were suboptimal AND load-dependent -- the wall
clock is exactly the cap 39.5 added the deterministic one to escape. Cause:
38.14's t -> n adjacency makes the model much harder, and the 2s value
dated from when solves took ~124 ms. Raised to 30s; 24/24 harbor and 36/36
maple now OPTIMAL, deterministic budget still in headroom (3.569/4.0).

The verdict reverses. Deterministic, 12 seeds, scored canonically:
  harbor  greedy 1323 (722h)  0.079 s/seed    cpsat 1548 (908h)  1.623
  maple   greedy 1764 (777h)  0.063 s/seed    cpsat 2256 (1213h) 1.327
cpsat loses on both, +225 and +492 fails at ~21x the seeding time,
concentrated in hard fails.

Time and quality have different causes. Removing t -> n from harbor takes
cpsat 1.623 -> 0.193 s/seed (8.4x faster) but it is still +205 vs greedy
(was +225) -- so the adjacency explains the time blow-up and ~9% of the
quality gap; the regression is otherwise pre-existing.

Squaring with 39.5: that section records cpsat returning 194/180/171/182
over four identical 10-seed aggregates before the determinism work. Its
10-fail harbor margin sits well inside a noise band that wide, and was
measured with fdp's id()-ordered room_slots live. The seeder-level claim
was never established rather than overturned. 39.5 annotated in place.

Absolute totals are ~6x 39.5's because the objective has changed, so they
are not comparable to that table; the within-measurement comparison is
like-for-like and is what the verdict rests on.

No default changes: assign_solver was already greedy for 37.7's independent
reason. What changes is that "cpsat wins the seeder A/B" should no longer
be cited as a reason to pursue it.

The cap fix takes the suite from ~4.5 to ~10 min and the tests cannot opt
out, since constructive_topology does not thread the solver limits through.
Filed as homemaker-py-2xk.

Closes homemaker-py-vjd.

Lint at parity (46); tests 384 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-29 14:47:36 +00:00

225 lines
10 KiB
Python

"""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 = 30.0,
deterministic_limit: float = 4.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:
# §39.4: shared with graph.has_adjacency so the exact solve optimises
# exactly what the scorer checks. A generic requirement ("c"/"o"/"s" —
# how programmes name circulation/outside) matches only the generic
# types, not every programme code that starts with that letter.
from .graph import code_matches_requirement
return code_matches_requirement(code, prefix)
# 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:
from .graph import code_matches_requirement
return any(code_matches_requirement(code, 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
# sorted(): ``neighbors[slot]`` is a SET, and its members are usually
# ``dom.Node``s, which are ``@dataclass(eq=False)`` and therefore hash by
# id() -- a memory address. Iterating it raw makes the order the model is
# built in vary run to run, so among several equally-optimal assignments
# CP-SAT returns a different one each time. That, not the time limit, was
# the real source of the "non-deterministic, system-load dependent"
# behaviour §37.7 recorded (measured 194/180/171/182 over four identical
# 10-seed runs; solves finish in ~124 ms against a 2 s cap, so nothing was
# ever timing out). Sorting the integer indices makes the model canonical.
nbr_idxs = sorted(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.num_search_workers = 1
# Determinism (homemaker-py-b8g / DESIGN.md §39.5). ``num_search_workers=1``
# alone does NOT give it: a WALL-CLOCK cap makes the returned solution
# load-dependent, because a timeout returns whatever branch-and-bound had
# reached by then. That is exactly the "non-deterministic, system-load
# dependent" behaviour §37.7 observed, and it made the seeder A/B flaky
# (10-seed aggregate measured 194/180/171/182 across four identical runs).
# ``max_deterministic_time`` is a work-unit budget, independent of machine
# speed and load, so the same inputs give the same answer; the wall-clock
# cap stays as a pathological-case backstop only.
#
# homemaker-py-vjd: that backstop MUST stay well clear of what the
# deterministic budget costs in wall time, or it silently becomes the
# binding cap and takes the load-dependence back. It had. At
# ``time_limit_s=2.0`` harbor-house hit exactly 2010 ms and returned
# FEASIBLE (not OPTIMAL) on 2 of 24 solves, while the deterministic budget
# was never reached (max 2.483 of 4.0) -- so the cap doing the work was the
# wall clock, and those two labellings were both suboptimal AND
# load-dependent. The cause was §38.14's added `t -> n` adjacency, which
# makes the model markedly harder; the 2 s value dated from when solves
# finished in ~124 ms. Raised so `deterministic_limit` governs and the wall
# clock only stops a pathological hang.
solver.parameters.max_deterministic_time = deterministic_limit
solver.parameters.max_time_in_seconds = time_limit_s
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