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
103 lines
4.7 KiB
Python
103 lines
4.7 KiB
Python
"""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")
|