homemaker-layout/tests/test_collapse_global.py

343 lines
15 KiB
Python
Raw Normal View History

"""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
"""
§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 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):
§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
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():
ju3: reject programme codes colliding with the c/o/s generic type prefixes Closes homemaker-py-ju3. DESIGN.md §39.3. The class: key from the bead's design was deliberately NOT built. Auditing every use of the prefix rule first showed it runs deeper than c/o/s -- l/k/b/t carry real adjacency semantics (graph.py builds bedroom<->toilet and kitchen<->living relations from first characters) -- so re-plumbing the type system would invalidate the whole corpus and every baseline, for a problem whose damage is the silence, not the convention. Two findings made the smaller fix sufficient: no corpus programme has ever declared a bare c/o/s code, so check_space_counts' skip only ever discarded declared rooms; and nothing references harbor's four codes in any adjacency or co_locate list. - programme.validate_codes raises on a reserved-prefix code, with the full explanation. Called from BOTH parse paths (programme._parse_spaces and fitness.Fitness._load_programme parse conf["spaces"] independently, so validating one would leave the other door open). l/k/b/t stay unreserved. - harbor-house and harbor-house-l0 renamed: cr1->fr1, of->ao, st1->gs1, st2->gs2. New prefixes are unused in harbor and semantically neutral, and the two storage codes still share a prefix, preserving the structure evaluate_building's per-code plot-ratio term depends on. name: unchanged. - experiments/migrate_ju3_rename.py migrates .dom files written before the rename (--check dry-runs). Pre-rename artefacts, notably evolved-3M*.dom, must be migrated or their leaves read as unmatched generics. - test_collapse_global's c/o/s exclusion test now uses a generic C leaf, which is what the exclusion is actually for; it previously relied on a programme code colliding, which is no longer possible. Re-baseline (seed 1, 20k evals, same settings as §38's run): 57 fails against the 32-instance effective programme -> 55 against the real 37-instance one, with all five previously-lost room instances now placed inside their declared sigma bands (fr1 87.2 vs declared 80, was 32.9/17.1; ao/gs1/gs2 were absent entirely) and no failure naming any of the four codes. At one seed each, 57 vs 55 is within noise -- the robust result is the room placement, not the count. Historical harbor numbers are not comparable to post-ju3 ones; filed homemaker-py-t3s to restate 2v1's acceptance figure once evolved-3M is migrated. 346 passed (+10 new), same 7 pre-existing fixture failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 09:06:08 +00:00
# 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)
ju3: reject programme codes colliding with the c/o/s generic type prefixes Closes homemaker-py-ju3. DESIGN.md §39.3. The class: key from the bead's design was deliberately NOT built. Auditing every use of the prefix rule first showed it runs deeper than c/o/s -- l/k/b/t carry real adjacency semantics (graph.py builds bedroom<->toilet and kitchen<->living relations from first characters) -- so re-plumbing the type system would invalidate the whole corpus and every baseline, for a problem whose damage is the silence, not the convention. Two findings made the smaller fix sufficient: no corpus programme has ever declared a bare c/o/s code, so check_space_counts' skip only ever discarded declared rooms; and nothing references harbor's four codes in any adjacency or co_locate list. - programme.validate_codes raises on a reserved-prefix code, with the full explanation. Called from BOTH parse paths (programme._parse_spaces and fitness.Fitness._load_programme parse conf["spaces"] independently, so validating one would leave the other door open). l/k/b/t stay unreserved. - harbor-house and harbor-house-l0 renamed: cr1->fr1, of->ao, st1->gs1, st2->gs2. New prefixes are unused in harbor and semantically neutral, and the two storage codes still share a prefix, preserving the structure evaluate_building's per-code plot-ratio term depends on. name: unchanged. - experiments/migrate_ju3_rename.py migrates .dom files written before the rename (--check dry-runs). Pre-rename artefacts, notably evolved-3M*.dom, must be migrated or their leaves read as unmatched generics. - test_collapse_global's c/o/s exclusion test now uses a generic C leaf, which is what the exclusion is actually for; it previously relied on a programme code colliding, which is no longer possible. Re-baseline (seed 1, 20k evals, same settings as §38's run): 57 fails against the 32-instance effective programme -> 55 against the real 37-instance one, with all five previously-lost room instances now placed inside their declared sigma bands (fr1 87.2 vs declared 80, was 32.9/17.1; ao/gs1/gs2 were absent entirely) and no failure naming any of the four codes. At one seed each, 57 vs 55 is within noise -- the robust result is the room placement, not the count. Historical harbor numbers are not comparable to post-ju3 ones; filed homemaker-py-t3s to restate 2v1's acceptance figure once evolved-3M is migrated. 346 passed (+10 new), same 7 pre-existing fixture failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 09:06:08 +00:00
root = _two_leaf_root("C", "b1")
fit.collapse_global(root)
ju3: reject programme codes colliding with the c/o/s generic type prefixes Closes homemaker-py-ju3. DESIGN.md §39.3. The class: key from the bead's design was deliberately NOT built. Auditing every use of the prefix rule first showed it runs deeper than c/o/s -- l/k/b/t carry real adjacency semantics (graph.py builds bedroom<->toilet and kitchen<->living relations from first characters) -- so re-plumbing the type system would invalidate the whole corpus and every baseline, for a problem whose damage is the silence, not the convention. Two findings made the smaller fix sufficient: no corpus programme has ever declared a bare c/o/s code, so check_space_counts' skip only ever discarded declared rooms; and nothing references harbor's four codes in any adjacency or co_locate list. - programme.validate_codes raises on a reserved-prefix code, with the full explanation. Called from BOTH parse paths (programme._parse_spaces and fitness.Fitness._load_programme parse conf["spaces"] independently, so validating one would leave the other door open). l/k/b/t stay unreserved. - harbor-house and harbor-house-l0 renamed: cr1->fr1, of->ao, st1->gs1, st2->gs2. New prefixes are unused in harbor and semantically neutral, and the two storage codes still share a prefix, preserving the structure evaluate_building's per-code plot-ratio term depends on. name: unchanged. - experiments/migrate_ju3_rename.py migrates .dom files written before the rename (--check dry-runs). Pre-rename artefacts, notably evolved-3M*.dom, must be migrated or their leaves read as unmatched generics. - test_collapse_global's c/o/s exclusion test now uses a generic C leaf, which is what the exclusion is actually for; it previously relied on a programme code colliding, which is no longer possible. Re-baseline (seed 1, 20k evals, same settings as §38's run): 57 fails against the 32-instance effective programme -> 55 against the real 37-instance one, with all five previously-lost room instances now placed inside their declared sigma bands (fr1 87.2 vs declared 80, was 32.9/17.1; ao/gs1/gs2 were absent entirely) and no failure naming any of the four codes. At one seed each, 57 vs 55 is within noise -- the robust result is the room placement, not the count. Historical harbor numbers are not comparable to post-ju3 ones; filed homemaker-py-t3s to restate 2v1's acceptance figure once evolved-3M is migrated. 346 passed (+10 new), same 7 pre-existing fixture failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 09:06:08 +00:00
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