homemaker-layout/tests/test_collapse_global.py
Claude 03aadec1ba
§39.7: access requirements become a declared usage: attribute (homemaker-py-sel)
Closes the second namespace sharing a first character with programme codes: the
usage prefixes b/t/l/k, under which a room silently inherited another room's
connectivity rules from its spelling.

usage is a plain, MANDATORY attribute of the space definition -- not a lookup
table. An interim design proposed a top-level usage_classes: table binding
author-coined names to behaviour; withdrawn, because an indirect name->behaviour
mapping living apart from the thing it describes is exactly the shape of the
prefix rule §39 exists to remove, it would be the only such table in a schema
where every other space property is a plain attribute, and the need it served
was already met -- "building specific" is about what a room is CALLED, and
name: is already free text.

Rule that settles it: a usage value exists iff the engine treats it differently
somewhere. Config selects among behaviours; it cannot invent them.

- programme.USAGES (living/kitchen/bedroom/toilet/utility/none) plus the
  behaviour groupings PRIVATE_USAGES / PRIVATE_STRIPS / TOILET_STRIPS /
  SOCIABLE_USAGES. Missing or unknown usage is a load error naming the code,
  from BOTH parse paths.
- Code-level, never leaf-level: usage_of(leaf.type) is looked up fresh, so a
  retype changes the class automatically. 51 sites assign leaf.type, and
  share/share_type plus the r5a resurrection are the precedent for why
  leaf-level attributes rot.
- graph.has_circulation takes the usage map and trims on declared class;
  fitness.access and the public-access check likewise. fitness._t0 is DELETED --
  no first-character type test remains anywhere in the codebase.
- utility is distinct from bedroom (same access requirements today) because it
  is a different use and gives derive_interchange_classes an axis to relax on.
- A toilet now keeps its edge to a terminal room -- the Brand adjacency, which
  the old b-before-t loop ordering severed.
- All 107 corpus entries migrated by experiments/migrate_usage_key.py, comments
  and layout preserved.

MEASURED -- the connectivity model was ~4x too permissive. `none` is not
neutral: nothing is trimmed, so the graph may route THROUGH the room, and 34 of
52 codes had no class (Dental Surgery, Records Room, Utilities Closet all served
as corridors). Edges trimmed, prefix-inferred vs declared, 3 seeds each:
  harbor-house   18 (9%)  -> 79 (39%)   inaccessible fails 0 -> 4
  health-centre  12 (8%)  -> 59 (40%)   inaccessible fails 2 -> 3
  maple-court    53 (17%) -> 123 (39%)  inaccessible fails 1 -> 5

Re-baseline (seed 1, 20k, harbor): 58 fails (15h/43s) -> 61 (16h/45s), now
reporting 1-inaccessible-usable-space x2 plus level 0 and level 1 not connected.
The count rose because the objective got honest -- those failures were always
true of the layout and the old model could not see them. Every harbor number
before this was measured against a graph crediting routes through store
cupboards.

Sharpens §38.2: the objective pays x60-85 to delete circulation, and until now
the deleted corridors were not missed because storage stood in for them. With
that substitution gone, homemaker-py-2v1 is the remaining half -- and now
measurable, because the fails it should prevent actually fire.

350 passed (+5 new), 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 13:39:41 +00:00

342 lines
15 KiB
Python

"""Tests for the finish-time global cell→room collapse (homemaker-py-94g).
Covers the contracts of Fitness.collapse_global / collapse_finish:
- global relabel to the demand set (larger cell → larger target)
- hard level constraint (never introduce a wrong-level fail)
- c/o/s partition exclusion (circulation/structure cells are never relabelled)
- no-op safety (no programme) and the keep-better wrapper
"""
from _helpers import with_usage
from homemaker_layout import geometry
from homemaker_layout.dom import Node, _link_subtree
from homemaker_layout.fitness import Fitness
def _two_leaf_root(t_left: str, t_right: str, side: float = 6.0, div: float = 0.4):
geometry.clear_cache()
root = Node(
node=[[0, 0], [side, 0], [side, side], [0, side]],
rotation=0, division=[div, div],
left=Node(type=t_left), right=Node(type=t_right),
)
_link_subtree(root, None, "")
return root
def _conf(spaces, **extra):
return {"spaces": with_usage(spaces), **extra}
# --------------------------------------------------------------------------- #
# Global relabel
# --------------------------------------------------------------------------- #
def test_relabels_to_demand_set():
# two leaves both typed b1; demand {b1, b2} — collapse spreads them so the
# larger cell takes the larger target (b1=16) and the smaller takes b2=12.
conf = _conf({
"b1": {"size": [16.0, 4.0], "width": [4.0, 1.0], "proportion": [1.5, 0.5]},
"b2": {"size": [12.0, 3.0], "width": [3.5, 0.8], "proportion": [1.5, 0.5]},
})
fit = Fitness(conf=conf)
root = _two_leaf_root("b1", "b1")
left, right = root.leaves()
assert geometry.area(right) > geometry.area(left)
fit.collapse_global(root)
assert sorted(lf.type for lf in root.leaves()) == ["b1", "b2"]
assert right.type == "b1"
assert left.type == "b2"
# --------------------------------------------------------------------------- #
# Hard level constraint
# --------------------------------------------------------------------------- #
def test_level_constraint_never_assigns_wrong_level():
# b2 requires level 1; a single-storey tree is all level 0, so no leaf may
# take b2 — both stay b1 rather than gaining a wrong-level fail.
conf = _conf({
"b1": {"size": [16.0, 4.0]},
"b2": {"size": [12.0, 3.0], "level": 1},
})
fit = Fitness(conf=conf)
root = _two_leaf_root("b1", "b1")
fit.collapse_global(root)
assert all(lf.type == "b1" for lf in root.leaves())
assert "b2" not in {lf.type for lf in root.leaves()}
# --------------------------------------------------------------------------- #
# c/o/s partition exclusion
# --------------------------------------------------------------------------- #
def test_cos_prefixed_cells_are_not_relabelled():
# A GENERIC circulation leaf is skeleton — never relabelled, never a demand
# slot. This used to be written with a programme code ("cr1") that collided
# with the c* prefix; programme codes can no longer do that at all, since
# programme.validate_codes rejects them at load (homemaker-py-ju3, DESIGN.md
# §39.2), so the exclusion is now tested with the generic type it is for.
conf = _conf({"b1": {"size": [16.0, 4.0]}})
fit = Fitness(conf=conf)
root = _two_leaf_root("C", "b1")
fit.collapse_global(root)
assert sorted(lf.type for lf in root.leaves()) == ["C", "b1"]
# --------------------------------------------------------------------------- #
# No-op safety + defaults
# --------------------------------------------------------------------------- #
def test_no_programme_is_noop():
fit = Fitness(conf={})
root = _two_leaf_root("b1", "b1")
fit.collapse_global(root)
assert [lf.type for lf in root.leaves()] == ["b1", "b1"]
def test_single_code_is_noop():
# one assignable code, count 2 → demand == supply of the same code → no change
conf = _conf({"b1": {"size": [16.0, 4.0], "count": 2}})
fit = Fitness(conf=conf)
root = _two_leaf_root("b1", "b1")
fit.collapse_global(root)
assert [lf.type for lf in root.leaves()] == ["b1", "b1"]
# --------------------------------------------------------------------------- #
# 2-opt local search beyond the Jacobi plateau (homemaker-py-9wi)
# --------------------------------------------------------------------------- #
def _four_leaf_chain(t1: str, t2: str, t3: str, t4: str, width: float = 1.0, height: float = 2.0):
# A 1x4 strip of equal cells split twice at 0.5: the leaf-adjacency graph
# is a chain (1-2, 2-3, 3-4) with no 1-3/2-4 edges -- see build_graphs.
geometry.clear_cache()
left = Node(rotation=0, division=[0.5, 0.5], left=Node(type=t1), right=Node(type=t2))
right = Node(rotation=0, division=[0.5, 0.5], left=Node(type=t3), right=Node(type=t4))
root = Node(
node=[[0, 0], [4 * width, 0], [4 * width, height], [0, height]],
rotation=0, division=[0.5, 0.5],
left=left, right=right,
)
_link_subtree(root, None, "")
return root
def test_two_opt_polish_escapes_jacobi_plateau():
# Two adjacency pairs (p1<->p2, q1<->q2) on a 4-cell chain p1-q1-p2-q2.
# Every code shares identical size/width/proportion targets (all four
# cells are geometrically identical), so the ONLY thing that can prefer
# one labelling over another is adjacency -- isolating the effect.
#
# Starting interleaved (p1,q1,p2,q2), the true optimum interleaves the
# OTHER way (p1,p2 adjacent + q1,q2 adjacent, 4 satisfied requirements),
# but the Jacobi relaxation (adjacency bonus computed from the PREVIOUS
# round's neighbour labels, re-solved synchronously) 2-cycles between two
# states that each satisfy 0 requirements and never reaches it -- a
# textbook case of the quadratic-assignment plateau the issue describes.
# 2-opt, tried after the Jacobi fixpoint, finds the escaping swap.
spec = {
"size": [2.0, 1.0], "width": [1.0, 1.0], "proportion": [2.0, 1.0], "count": 1,
}
conf = _conf({
"p1": {**spec, "adjacency": ["p2"]},
"p2": {**spec, "adjacency": ["p1"]},
"q1": {**spec, "adjacency": ["q2"]},
"q2": {**spec, "adjacency": ["q1"]},
})
fit = Fitness(conf=conf)
def satisfied(root):
from homemaker_layout import graph as graph_mod
G = graph_mod.build_graphs(root, 1.2)[0]
prog = fit._programme
return sum(
1
for lf in root.leaves()
for ac in prog[lf.type].adjacency
if graph_mod.has_adjacency(lf, ac, G)
)
root_jacobi = _four_leaf_chain("p1", "q1", "p2", "q2")
fit.collapse_global(root_jacobi, adjacency=True, local_search=False)
assert satisfied(root_jacobi) == 0 # the Jacobi-only plateau
root_polished = _four_leaf_chain("p1", "q1", "p2", "q2")
fit.collapse_global(root_polished, adjacency=True, local_search=True)
assert satisfied(root_polished) == 4 # 2-opt reaches the true optimum
# --------------------------------------------------------------------------- #
# Stale leaf-share must not leak into a hypothetical candidate (homemaker-py-iio)
# --------------------------------------------------------------------------- #
def test_collapse_value_ignores_stale_share_for_hypothetical_code():
# A leaf that once held a live share (share>1, share_type==its type at the
# time) but was since retyped away carries stale share/share_type
# metadata -- graph.leaf_share's docstring: any retype silently
# invalidates a stale share, guarded everywhere by share_type==type. But
# _collapse_value probes a hypothetical candidate by temporarily
# overwriting leaf.type, and graph.leaf_share reads that overwritten
# type -- so a stale share_type that happens to equal the CANDIDATE code
# must not spuriously reactivate; only the leaf's own real current type
# may legitimately carry a live share.
conf = _conf({"b1": {"size": [16.0, 4.0], "width": [4.0, 1.0], "proportion": [1.5, 0.5]}},
leaf_sharing=True)
fit = Fitness(conf=conf)
prog = fit._programme
forbid, fail_w = fit._COLLAPSE_FORBID, fit._COLLAPSE_FAIL_W
stale_leaf, _ = _two_leaf_root("other", "other").leaves()
stale_leaf.type = "other"
stale_leaf.share = 3
stale_leaf.share_type = "b1" # stale: leaf is not currently typed "b1"
val_stale = fit._collapse_value(stale_leaf, "b1", 0, prog, "quality", forbid, fail_w)
clean_leaf, _ = _two_leaf_root("other", "other").leaves()
clean_leaf.type = "other" # share stays at the default 1 / share_type None
val_clean = fit._collapse_value(clean_leaf, "b1", 0, prog, "quality", forbid, fail_w)
assert val_stale == val_clean
# But the leaf's OWN current type still legitimately carries a live share.
live_leaf, _ = _two_leaf_root("b1", "b1").leaves()
live_leaf.type = "b1"
live_leaf.share = 3
live_leaf.share_type = "b1"
val_live_self = fit._collapse_value(live_leaf, "b1", 0, prog, "quality", forbid, fail_w)
assert val_live_self != val_clean
def test_collapse_global_dump_reload_agree_with_stale_share(tmp_path):
# End-to-end regression for the bug: a stale share/share_type surviving
# in-memory but dropped by dom.dump/dom.load (dom._emit only serialises
# share while share_type==type) must not change collapse_global's
# relabelling -- before the fix it did, because the stale metadata leaked
# into the Hungarian assignment's candidate valuation and swayed it to
# relabel the WRONG leaf (right, physically a poor fit for "b2") instead
# of the size-appropriate one, purely because right's stale share_type
# happened to equal that candidate code.
from homemaker_layout import dom
conf = _conf({
"b1": {"size": [16.0, 4.0], "width": [4.0, 1.0], "proportion": [1.5, 0.5]},
"b2": {"size": [10.8, 2.0], "width": [3.5, 0.8], "proportion": [1.5, 0.5]},
}, leaf_sharing=True)
def _make_root():
root = _two_leaf_root("b1", "b1")
_left, right = root.leaves()
right.share = 2
right.share_type = "b2" # stale: right is currently typed "b1", not "b2"
return root
live = _make_root()
Fitness(conf=conf).collapse_global(live)
path = tmp_path / "stale_share.dom"
dumped = _make_root()
dom.dump(dumped, str(path))
reloaded = dom.load(str(path))
Fitness(conf=conf).collapse_global(reloaded)
live_types = [lf.type for lf in live.leaves()]
reloaded_types = [lf.type for lf in reloaded.leaves()]
assert live_types == reloaded_types
# And it's the size-consistent labelling in both cases (left, the smaller
# leaf, takes the smaller-target b2; not the stale-share-swayed choice).
assert live_types == ["b2", "b1"]
def test_collapse_global_commit_does_not_resurrect_stale_share(tmp_path):
# homemaker-py-r5a: unlike the iio bug above (a stale stamp swaying which
# CANDIDATE code wins), this is the COMMIT door -- collapse_global's own
# assignment relabels the leaf back to the code its stale share_type
# names, so share_type == type becomes true again "for real" and the
# leaf would resurrect a share=3 credit for area that was never sized
# for 3 rooms. Demand/sizes mirror test_relabels_to_demand_set (two
# identically-typed leaves of different area spread across two demand
# codes by size fit) so collapse is EXPECTED to move the smaller (left)
# leaf onto "n" -- exactly the stale share_type stamped on it below.
from homemaker_layout import dom
conf = _conf({
"b1": {"size": [16.0, 4.0], "width": [4.0, 1.0], "proportion": [1.5, 0.5]},
"n": {"size": [12.0, 3.0], "width": [3.5, 0.8], "proportion": [1.5, 0.5]},
}, leaf_sharing=True)
def _make_root():
root = _two_leaf_root("b1", "b1")
left, _right = root.leaves()
left.share = 3
left.share_type = "n" # stale: left is currently typed "b1", not "n"
return root
live = _make_root()
Fitness(conf=conf).collapse_global(live)
left, right = live.leaves()
# Collapse did relabel left back onto "n" (the scenario the bug needs)...
assert left.type == "n"
# ...but the resurrected-looking match must not carry a share credit --
# the stamp predates this assignment and was never re-verified.
assert not (left.share > 1 and left.share_type == left.type)
path = tmp_path / "stale_share_commit.dom"
dumped = _make_root()
dom.dump(dumped, str(path))
reloaded = dom.load(str(path))
Fitness(conf=conf).collapse_global(reloaded)
assert [lf.type for lf in live.leaves()] == [lf.type for lf in reloaded.leaves()]
assert [lf.share for lf in live.leaves()] == [lf.share for lf in reloaded.leaves()]
assert (
[lf.share_type for lf in live.leaves()]
== [lf.share_type for lf in reloaded.leaves()]
)
def test_collapse_finish_is_keep_better_and_unmerged():
# collapse_finish returns (tree, base, collapsed, applied); the tree it hands
# back is unmerged (leaves still carry their divisions), and collapsed<=base.
conf = _conf({
"b1": {"size": [16.0, 4.0], "width": [4.0, 1.0], "proportion": [1.5, 0.5]},
"b2": {"size": [12.0, 3.0], "width": [3.5, 0.8], "proportion": [1.5, 0.5]},
})
fit = Fitness(conf=conf)
root = _two_leaf_root("b1", "b1")
tree, base_f, coll_f, applied = fit.collapse_finish(root)
assert coll_f <= base_f
assert applied == (coll_f < base_f) or coll_f == base_f
assert len(tree.leaves()) == 2 # unmerged: both room leaves intact
def test_collapse_finish_guard_is_canonical_even_with_insearch_collapse_on():
# homemaker-py-sd3 regression: score_with_fails auto-collapses BEFORE
# counting fails whenever the Fitness instance itself is configured with
# collapse_insearch=True (as driver.collapse_best used to build its
# evaluator). That silently made base_fails equal the ALREADY-collapsed
# count, so the 94g keep-better guard compared a collapsed tree against a
# collapsed tree and could never see collapse_global's true effect.
# collapse_finish must force canonical (collapse_insearch=False) scoring
# for its own base/collapsed measurement regardless of self's conf.
conf = _conf({
"b1": {"size": [16.0, 4.0], "width": [4.0, 1.0], "proportion": [1.5, 0.5]},
"b2": {"size": [12.0, 3.0], "width": [3.5, 0.8], "proportion": [1.5, 0.5]},
}, collapse_insearch=True)
fit = Fitness(conf=conf)
assert fit._collapse_insearch is True
root = _two_leaf_root("b1", "b1") # both b1 -> missing b2 is a real fail
tree, base_f, coll_f, applied = fit.collapse_finish(root)
# canonical base: the pre-collapse tree really is missing b2 -- if the
# guard were still vacuous, base_f would already equal coll_f (both
# silently pre-collapsed) instead of reporting the true starting fail.
assert base_f >= 1
assert coll_f < base_f
assert applied
assert sorted(lf.type for lf in tree.leaves()) == ["b1", "b2"]
# collapse_finish must not leak its temporary override back onto self.
assert fit._collapse_insearch is True