94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
"""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 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": 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]}})
|
94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
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")
|
94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
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"]
|
94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# 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"]
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 20:59:07 +01:00
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 21:08:32 +01:00
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# 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"]
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 10:45:12 +01:00
|
|
|
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()]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
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
|
2026-08-05 07:52:44 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|