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
225 lines
10 KiB
Python
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
|