2026-08-04 09:19:36 +01:00
|
|
|
"""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,
|
§39.4 completion + §39.5 retraction + §39.6: the usage namespace is NOT clean
Answering "are we clean". Generic namespace: yes. Usage namespace: no.
FINISH §39.4. The first sweep missed sites, found by a full re-grep:
graph.py's free-area budget, operators.py host-preference / keep-type /
repair-candidate, fitness.py's ("l","c","k") public-access test, bubble.py's
generic adjacency reference, and -- the important one -- cpsat.py, which was
still matching adjacency by raw startswith. graph.code_matches_requirement is
now the single public answer to "does this leaf count as the thing the
programme asked to be next to", shared by has_adjacency, has_vertical_connection
and cpsat.
RETRACT §39.5. It concluded 2g7.5's CP-SAT seeder win did not survive the
correction. That was wrong. The cause was the missed cpsat matcher above: the
exact solver was optimising a different relation than the scorer checked, so a
failing test reporting an incomplete sweep was misread as a baseline shift.
Re-measured over 6 seeds, cpsat now wins on both programmes (harbor 102/92,
maple 156/154). xfail removed.
REAL BUG UNDERNEATH: CP-SAT was never deterministic despite
num_search_workers=1 and a comment claiming it. neighbors[slot] is a set of
dom.Node, which hashes by id() -- a memory address -- so raw iteration made the
model-build order vary and CP-SAT returned a different equally-optimal
assignment each run (measured 194/180/171/182 over four identical aggregates).
sorted() on the slot indices fixes it. Also paired the wall-clock cap with
max_deterministic_time (solves run ~124ms against a 2s cap, so nothing was
timing out -- latent hazard, not the cause). solve_room_labels is now
reproducible on every captured instance; constructive_topology on the cpsat
path still is not, filed as homemaker-py-fdp (plausible contributor to b8g).
§39.6 THE SECOND NAMESPACE. Usage prefixes b/t/l/k (bedroom/toilet/living/
kitchen) classify programme codes by first letter and stay prefix-based by
design, but they are not inert: has_circulation deletes graph edges from them.
Four corpus rooms are misclassified by spelling -- la1 "Laundry Room" and li1
"Library Corner" as living, br1 "Staff Room" as bedroom, tr1 "Treatment Room"
as toilet. Measured on a health-centre seed: tr1 loses its edge to the adjacent
O, br1 loses its edge to t10 "Staff WC" -- both feed the connectivity fails §38
found persisting. Filed homemaker-py-sel; an explicit usage: key is the fix,
but it changes fitness for correctly-spelled programmes too so it needs its own
A/B.
DOCS. README gains a "Room codes and reserved names" section; CLAUDE.md and
AGENTS.md gain the same summary for agents. audit_programme_config.py now
reports the usage class each code picks up alongside the namespace and
satisfiability checks. DESIGN §37.2's note calling the c/o/s quirk "existing
product behaviour, not a bug" is annotated as superseded.
Corpus audit: zero generic-namespace violations across all ten example
programmes. 346 passed, same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 10:09:14 +00:00
|
|
|
deterministic_limit: float = 4.0,
|
2026-08-04 09:19:36 +01:00
|
|
|
) -> 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 completion + §39.5 retraction + §39.6: the usage namespace is NOT clean
Answering "are we clean". Generic namespace: yes. Usage namespace: no.
FINISH §39.4. The first sweep missed sites, found by a full re-grep:
graph.py's free-area budget, operators.py host-preference / keep-type /
repair-candidate, fitness.py's ("l","c","k") public-access test, bubble.py's
generic adjacency reference, and -- the important one -- cpsat.py, which was
still matching adjacency by raw startswith. graph.code_matches_requirement is
now the single public answer to "does this leaf count as the thing the
programme asked to be next to", shared by has_adjacency, has_vertical_connection
and cpsat.
RETRACT §39.5. It concluded 2g7.5's CP-SAT seeder win did not survive the
correction. That was wrong. The cause was the missed cpsat matcher above: the
exact solver was optimising a different relation than the scorer checked, so a
failing test reporting an incomplete sweep was misread as a baseline shift.
Re-measured over 6 seeds, cpsat now wins on both programmes (harbor 102/92,
maple 156/154). xfail removed.
REAL BUG UNDERNEATH: CP-SAT was never deterministic despite
num_search_workers=1 and a comment claiming it. neighbors[slot] is a set of
dom.Node, which hashes by id() -- a memory address -- so raw iteration made the
model-build order vary and CP-SAT returned a different equally-optimal
assignment each run (measured 194/180/171/182 over four identical aggregates).
sorted() on the slot indices fixes it. Also paired the wall-clock cap with
max_deterministic_time (solves run ~124ms against a 2s cap, so nothing was
timing out -- latent hazard, not the cause). solve_room_labels is now
reproducible on every captured instance; constructive_topology on the cpsat
path still is not, filed as homemaker-py-fdp (plausible contributor to b8g).
§39.6 THE SECOND NAMESPACE. Usage prefixes b/t/l/k (bedroom/toilet/living/
kitchen) classify programme codes by first letter and stay prefix-based by
design, but they are not inert: has_circulation deletes graph edges from them.
Four corpus rooms are misclassified by spelling -- la1 "Laundry Room" and li1
"Library Corner" as living, br1 "Staff Room" as bedroom, tr1 "Treatment Room"
as toilet. Measured on a health-centre seed: tr1 loses its edge to the adjacent
O, br1 loses its edge to t10 "Staff WC" -- both feed the connectivity fails §38
found persisting. Filed homemaker-py-sel; an explicit usage: key is the fix,
but it changes fitness for correctly-spelled programmes too so it needs its own
A/B.
DOCS. README gains a "Room codes and reserved names" section; CLAUDE.md and
AGENTS.md gain the same summary for agents. audit_programme_config.py now
reports the usage class each code picks up alongside the namespace and
satisfiability checks. DESIGN §37.2's note calling the c/o/s quirk "existing
product behaviour, not a bug" is annotated as superseded.
Corpus audit: zero generic-namespace violations across all ten example
programmes. 346 passed, same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 10:09:14 +00:00
|
|
|
# §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)
|
2026-08-04 09:19:36 +01:00
|
|
|
|
|
|
|
|
# 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:
|
§39.4 completion + §39.5 retraction + §39.6: the usage namespace is NOT clean
Answering "are we clean". Generic namespace: yes. Usage namespace: no.
FINISH §39.4. The first sweep missed sites, found by a full re-grep:
graph.py's free-area budget, operators.py host-preference / keep-type /
repair-candidate, fitness.py's ("l","c","k") public-access test, bubble.py's
generic adjacency reference, and -- the important one -- cpsat.py, which was
still matching adjacency by raw startswith. graph.code_matches_requirement is
now the single public answer to "does this leaf count as the thing the
programme asked to be next to", shared by has_adjacency, has_vertical_connection
and cpsat.
RETRACT §39.5. It concluded 2g7.5's CP-SAT seeder win did not survive the
correction. That was wrong. The cause was the missed cpsat matcher above: the
exact solver was optimising a different relation than the scorer checked, so a
failing test reporting an incomplete sweep was misread as a baseline shift.
Re-measured over 6 seeds, cpsat now wins on both programmes (harbor 102/92,
maple 156/154). xfail removed.
REAL BUG UNDERNEATH: CP-SAT was never deterministic despite
num_search_workers=1 and a comment claiming it. neighbors[slot] is a set of
dom.Node, which hashes by id() -- a memory address -- so raw iteration made the
model-build order vary and CP-SAT returned a different equally-optimal
assignment each run (measured 194/180/171/182 over four identical aggregates).
sorted() on the slot indices fixes it. Also paired the wall-clock cap with
max_deterministic_time (solves run ~124ms against a 2s cap, so nothing was
timing out -- latent hazard, not the cause). solve_room_labels is now
reproducible on every captured instance; constructive_topology on the cpsat
path still is not, filed as homemaker-py-fdp (plausible contributor to b8g).
§39.6 THE SECOND NAMESPACE. Usage prefixes b/t/l/k (bedroom/toilet/living/
kitchen) classify programme codes by first letter and stay prefix-based by
design, but they are not inert: has_circulation deletes graph edges from them.
Four corpus rooms are misclassified by spelling -- la1 "Laundry Room" and li1
"Library Corner" as living, br1 "Staff Room" as bedroom, tr1 "Treatment Room"
as toilet. Measured on a health-centre seed: tr1 loses its edge to the adjacent
O, br1 loses its edge to t10 "Staff WC" -- both feed the connectivity fails §38
found persisting. Filed homemaker-py-sel; an explicit usage: key is the fix,
but it changes fitness for correctly-spelled programmes too so it needs its own
A/B.
DOCS. README gains a "Room codes and reserved names" section; CLAUDE.md and
AGENTS.md gain the same summary for agents. audit_programme_config.py now
reports the usage class each code picks up alongside the namespace and
satisfiability checks. DESIGN §37.2's note calling the c/o/s quirk "existing
product behaviour, not a bug" is annotated as superseded.
Corpus audit: zero generic-namespace violations across all ten example
programmes. 346 passed, same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 10:09:14 +00:00
|
|
|
from .graph import code_matches_requirement
|
|
|
|
|
return any(code_matches_requirement(code, r) for r in referenced)
|
2026-08-04 09:19:36 +01:00
|
|
|
|
|
|
|
|
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
|
§39.4 completion + §39.5 retraction + §39.6: the usage namespace is NOT clean
Answering "are we clean". Generic namespace: yes. Usage namespace: no.
FINISH §39.4. The first sweep missed sites, found by a full re-grep:
graph.py's free-area budget, operators.py host-preference / keep-type /
repair-candidate, fitness.py's ("l","c","k") public-access test, bubble.py's
generic adjacency reference, and -- the important one -- cpsat.py, which was
still matching adjacency by raw startswith. graph.code_matches_requirement is
now the single public answer to "does this leaf count as the thing the
programme asked to be next to", shared by has_adjacency, has_vertical_connection
and cpsat.
RETRACT §39.5. It concluded 2g7.5's CP-SAT seeder win did not survive the
correction. That was wrong. The cause was the missed cpsat matcher above: the
exact solver was optimising a different relation than the scorer checked, so a
failing test reporting an incomplete sweep was misread as a baseline shift.
Re-measured over 6 seeds, cpsat now wins on both programmes (harbor 102/92,
maple 156/154). xfail removed.
REAL BUG UNDERNEATH: CP-SAT was never deterministic despite
num_search_workers=1 and a comment claiming it. neighbors[slot] is a set of
dom.Node, which hashes by id() -- a memory address -- so raw iteration made the
model-build order vary and CP-SAT returned a different equally-optimal
assignment each run (measured 194/180/171/182 over four identical aggregates).
sorted() on the slot indices fixes it. Also paired the wall-clock cap with
max_deterministic_time (solves run ~124ms against a 2s cap, so nothing was
timing out -- latent hazard, not the cause). solve_room_labels is now
reproducible on every captured instance; constructive_topology on the cpsat
path still is not, filed as homemaker-py-fdp (plausible contributor to b8g).
§39.6 THE SECOND NAMESPACE. Usage prefixes b/t/l/k (bedroom/toilet/living/
kitchen) classify programme codes by first letter and stay prefix-based by
design, but they are not inert: has_circulation deletes graph edges from them.
Four corpus rooms are misclassified by spelling -- la1 "Laundry Room" and li1
"Library Corner" as living, br1 "Staff Room" as bedroom, tr1 "Treatment Room"
as toilet. Measured on a health-centre seed: tr1 loses its edge to the adjacent
O, br1 loses its edge to t10 "Staff WC" -- both feed the connectivity fails §38
found persisting. Filed homemaker-py-sel; an explicit usage: key is the fix,
but it changes fitness for correctly-spelled programmes too so it needs its own
A/B.
DOCS. README gains a "Room codes and reserved names" section; CLAUDE.md and
AGENTS.md gain the same summary for agents. audit_programme_config.py now
reports the usage class each code picks up alongside the namespace and
satisfiability checks. DESIGN §37.2's note calling the c/o/s quirk "existing
product behaviour, not a bug" is annotated as superseded.
Corpus audit: zero generic-namespace violations across all ten example
programmes. 346 passed, same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 10:09:14 +00:00
|
|
|
# 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)
|
2026-08-04 09:19:36 +01:00
|
|
|
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()
|
§39.4 completion + §39.5 retraction + §39.6: the usage namespace is NOT clean
Answering "are we clean". Generic namespace: yes. Usage namespace: no.
FINISH §39.4. The first sweep missed sites, found by a full re-grep:
graph.py's free-area budget, operators.py host-preference / keep-type /
repair-candidate, fitness.py's ("l","c","k") public-access test, bubble.py's
generic adjacency reference, and -- the important one -- cpsat.py, which was
still matching adjacency by raw startswith. graph.code_matches_requirement is
now the single public answer to "does this leaf count as the thing the
programme asked to be next to", shared by has_adjacency, has_vertical_connection
and cpsat.
RETRACT §39.5. It concluded 2g7.5's CP-SAT seeder win did not survive the
correction. That was wrong. The cause was the missed cpsat matcher above: the
exact solver was optimising a different relation than the scorer checked, so a
failing test reporting an incomplete sweep was misread as a baseline shift.
Re-measured over 6 seeds, cpsat now wins on both programmes (harbor 102/92,
maple 156/154). xfail removed.
REAL BUG UNDERNEATH: CP-SAT was never deterministic despite
num_search_workers=1 and a comment claiming it. neighbors[slot] is a set of
dom.Node, which hashes by id() -- a memory address -- so raw iteration made the
model-build order vary and CP-SAT returned a different equally-optimal
assignment each run (measured 194/180/171/182 over four identical aggregates).
sorted() on the slot indices fixes it. Also paired the wall-clock cap with
max_deterministic_time (solves run ~124ms against a 2s cap, so nothing was
timing out -- latent hazard, not the cause). solve_room_labels is now
reproducible on every captured instance; constructive_topology on the cpsat
path still is not, filed as homemaker-py-fdp (plausible contributor to b8g).
§39.6 THE SECOND NAMESPACE. Usage prefixes b/t/l/k (bedroom/toilet/living/
kitchen) classify programme codes by first letter and stay prefix-based by
design, but they are not inert: has_circulation deletes graph edges from them.
Four corpus rooms are misclassified by spelling -- la1 "Laundry Room" and li1
"Library Corner" as living, br1 "Staff Room" as bedroom, tr1 "Treatment Room"
as toilet. Measured on a health-centre seed: tr1 loses its edge to the adjacent
O, br1 loses its edge to t10 "Staff WC" -- both feed the connectivity fails §38
found persisting. Filed homemaker-py-sel; an explicit usage: key is the fix,
but it changes fitness for correctly-spelled programmes too so it needs its own
A/B.
DOCS. README gains a "Room codes and reserved names" section; CLAUDE.md and
AGENTS.md gain the same summary for agents. audit_programme_config.py now
reports the usage class each code picks up alongside the namespace and
satisfiability checks. DESIGN §37.2's note calling the c/o/s quirk "existing
product behaviour, not a bug" is annotated as superseded.
Corpus audit: zero generic-namespace violations across all ten example
programmes. 346 passed, same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 10:09:14 +00:00
|
|
|
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.
|
|
|
|
|
solver.parameters.max_deterministic_time = deterministic_limit
|
2026-08-04 09:19:36 +01:00
|
|
|
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
|