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
342 lines
15 KiB
Python
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
|