homemaker-layout/tests/test_collapse_insearch.py

144 lines
5.9 KiB
Python
Raw Normal View History

"""Tests for the IN-SEARCH global collapse (homemaker-py-qpk, DESIGN.md §17
follow-on): running Fitness.collapse_global inside every fitness eval instead
of once at finish time.
Covers:
- default-OFF guarantee + conf-driven knobs (adjacency, iters)
- _evaluate_full wiring: collapse_global is invoked (with the right kwargs)
when the flag is on, never when it is off
- end-to-end effect on a real evolved layout, cross-checked against the
documented 94g finish-time result (DESIGN.md §17: 15 -> 12 fails on
evolved-3M-nols-3.dom)
"""
import copy
from pathlib import Path
from unittest.mock import patch
import pytest
§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
from _helpers import with_usage
from homemaker_layout import dom as dom_mod
from homemaker_layout.dom import Node, _link_subtree
from homemaker_layout.fitness import Fitness, load_config
HARBOR = Path(__file__).parent.parent / "examples" / "harbor-house"
def _two_leaf_root(t_left: str, t_right: str, side: float = 6.0, div: float = 0.4):
from homemaker_layout import geometry
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
# --------------------------------------------------------------------------- #
# Defaults + conf-driven knobs
# --------------------------------------------------------------------------- #
def test_collapse_insearch_default_off():
fit = Fitness()
assert fit._collapse_insearch is False
assert fit._collapse_insearch_adjacency is True
assert fit._collapse_insearch_iters == 3
def test_collapse_insearch_flag_on():
fit = Fitness(conf={"collapse_insearch": True})
assert fit._collapse_insearch is True
def test_collapse_insearch_adjacency_knob_off():
fit = Fitness(conf={"collapse_insearch": True,
"collapse_insearch_adjacency": False})
assert fit._collapse_insearch_adjacency is False
def test_collapse_insearch_iters_knob():
fit = Fitness(conf={"collapse_insearch": True, "collapse_insearch_iters": 5})
assert fit._collapse_insearch_iters == 5
# --------------------------------------------------------------------------- #
# _evaluate_full wiring
# --------------------------------------------------------------------------- #
def test_evaluate_full_calls_collapse_global_when_on():
fit = Fitness(conf={"collapse_insearch": True, "collapse_insearch_iters": 2,
§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
"spaces": with_usage({"b1": {"size": [16.0, 4.0], "count": 2}})})
root = _two_leaf_root("b1", "b1")
with patch.object(Fitness, "collapse_global", wraps=fit.collapse_global) as m:
fit.score_with_fails(root)
m.assert_called_once()
_, kw = m.call_args
assert kw["adjacency"] is True
assert kw["objective"] == "threshold"
assert kw["preserve_public_access"] is True
assert kw["iters"] == 2
def test_evaluate_full_does_not_call_collapse_global_when_off():
§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
fit = Fitness(conf={"spaces": with_usage({"b1": {"size": [16.0, 4.0], "count": 2}})})
root = _two_leaf_root("b1", "b1")
with patch.object(Fitness, "collapse_global", wraps=fit.collapse_global) as m:
fit.score_with_fails(root)
m.assert_not_called()
# --------------------------------------------------------------------------- #
# End-to-end: matches the documented 94g finish-time result
# --------------------------------------------------------------------------- #
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house example absent")
Make both failing tests assert their intent, not stale artefacts The suite is green for the first time this session: 376 passed, 0 failed. test_collapse_insearch_reproduces_94g_finish_time_result hard-coded both endpoints of the 17 result -- 15 fails before collapse, 12 after. Those were measured before 39.4, when harbor's effective programme was silently 32 instances because codes like cr1 were read as generic circulation; the same layout now scores 82. But the guarantee the test exists to protect, per its own docstring, is that in-search collapse reaches the SAME layout as finish-time collapse on fixed geometry -- and two independent constants never checked that. They can both drift and stay equal, or both hold and mask an inequality. Rewritten to compute both sides live and assert they agree, plus that collapse does not make the layout worse. Measured: 82 -> 58 in-search, and finish-time collapse independently reaches 58 at iters=3 and iters=6. The invariant holds; only the constants were stale. Restating the reference figure itself remains homemaker-py-ut5. test_classify_fail_tier_covers_full_corpus globbed examples/**/*.fails and asserted checked > 0. Git tracks ZERO .fails -- they are artefacts the scorer writes beside a .dom -- so its docstring described files that by design never exist in the repo, and it passed only on a machine that had already run the scorer. Split into: a test that GENERATES fails by scoring three corpus layouts picked for breadth (requiring >= 8 distinct kinds so it cannot silently narrow); a test that an unclassifiable string actually raises; and an opportunistic .fails sweep that never requires them. Verified by moving every .fails out of the tree and re-running. Closes homemaker-py-1ue. Lint at parity (46). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-29 07:11:25 +00:00
def test_collapse_insearch_matches_finish_time_collapse():
"""in-search collapse must reach the SAME layout as finish-time collapse.
That equality is the actual guarantee: `collapse_insearch` runs the same
`collapse_global` earlier in the same pipeline (before the Phase-1 checks
rather than after the whole search), so on a FIXED geometry the two must
agree. Both sides are computed here rather than hard-coded.
This test previously asserted the §17 constants directly -- `15` fails
before collapse and `12` after. Those were measured before §39.4, when
harbor's effective programme was silently 32 instances because codes like
`cr1` were being read as generic circulation; the same layout now scores 82
-> 58. Pinning the endpoints made a live invariant fail whenever the
programme or the objective legitimately changed, while not actually
checking the invariant at all (two independent constants can both drift and
still be equal, or both hold and mask an inequality). See
`homemaker-py-ut5` for restating the reference figure itself.
"""
conf, cost = load_config(HARBOR)
conf_ci, _ = load_config(HARBOR, overrides={"collapse_insearch": True})
root = dom_mod.load(str(HARBOR / "evolved-3M-nols-3.dom"))
Make both failing tests assert their intent, not stale artefacts The suite is green for the first time this session: 376 passed, 0 failed. test_collapse_insearch_reproduces_94g_finish_time_result hard-coded both endpoints of the 17 result -- 15 fails before collapse, 12 after. Those were measured before 39.4, when harbor's effective programme was silently 32 instances because codes like cr1 were read as generic circulation; the same layout now scores 82. But the guarantee the test exists to protect, per its own docstring, is that in-search collapse reaches the SAME layout as finish-time collapse on fixed geometry -- and two independent constants never checked that. They can both drift and stay equal, or both hold and mask an inequality. Rewritten to compute both sides live and assert they agree, plus that collapse does not make the layout worse. Measured: 82 -> 58 in-search, and finish-time collapse independently reaches 58 at iters=3 and iters=6. The invariant holds; only the constants were stale. Restating the reference figure itself remains homemaker-py-ut5. test_classify_fail_tier_covers_full_corpus globbed examples/**/*.fails and asserted checked > 0. Git tracks ZERO .fails -- they are artefacts the scorer writes beside a .dom -- so its docstring described files that by design never exist in the repo, and it passed only on a machine that had already run the scorer. Split into: a test that GENERATES fails by scoring three corpus layouts picked for breadth (requiring >= 8 distinct kinds so it cannot silently narrow); a test that an unclassifiable string actually raises; and an opportunistic .fails sweep that never requires them. Verified by moving every .fails out of the tree and re-running. Closes homemaker-py-1ue. Lint at parity (46). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-29 07:11:25 +00:00
_, f_base = Fitness(conf, cost).score_with_fails(copy.deepcopy(root))
_, f_ci = Fitness(conf_ci, cost).score_with_fails(copy.deepcopy(root))
Make both failing tests assert their intent, not stale artefacts The suite is green for the first time this session: 376 passed, 0 failed. test_collapse_insearch_reproduces_94g_finish_time_result hard-coded both endpoints of the 17 result -- 15 fails before collapse, 12 after. Those were measured before 39.4, when harbor's effective programme was silently 32 instances because codes like cr1 were read as generic circulation; the same layout now scores 82. But the guarantee the test exists to protect, per its own docstring, is that in-search collapse reaches the SAME layout as finish-time collapse on fixed geometry -- and two independent constants never checked that. They can both drift and stay equal, or both hold and mask an inequality. Rewritten to compute both sides live and assert they agree, plus that collapse does not make the layout worse. Measured: 82 -> 58 in-search, and finish-time collapse independently reaches 58 at iters=3 and iters=6. The invariant holds; only the constants were stale. Restating the reference figure itself remains homemaker-py-ut5. test_classify_fail_tier_covers_full_corpus globbed examples/**/*.fails and asserted checked > 0. Git tracks ZERO .fails -- they are artefacts the scorer writes beside a .dom -- so its docstring described files that by design never exist in the repo, and it passed only on a machine that had already run the scorer. Split into: a test that GENERATES fails by scoring three corpus layouts picked for breadth (requiring >= 8 distinct kinds so it cannot silently narrow); a test that an unclassifiable string actually raises; and an opportunistic .fails sweep that never requires them. Verified by moving every .fails out of the tree and re-running. Closes homemaker-py-1ue. Lint at parity (46). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-29 07:11:25 +00:00
# the same collapse, applied once at finish time, scored canonically
finished = copy.deepcopy(root)
Fitness(conf, cost).collapse_global(
finished, adjacency=True, objective="threshold",
preserve_public_access=True, iters=3)
_, f_finish = Fitness(conf, cost).score_with_fails(finished)
assert len(f_ci) == len(f_finish), (
"in-search collapse diverged from finish-time collapse on fixed geometry")
assert len(f_ci) < len(f_base), "collapse must not make the layout worse"
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house example absent")
def test_collapse_insearch_off_reproduces_baseline():
conf, cost = load_config(HARBOR)
fit = Fitness(conf, cost)
root = dom_mod.load(str(HARBOR / "evolved-3M-nols-3.dom"))
s1, f1 = fit.score_with_fails(copy.deepcopy(root))
s2, f2 = Fitness(conf, cost).score_with_fails(copy.deepcopy(root))
assert s1 == pytest.approx(s2)
assert f1 == f2