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
|
|
|
|
|
"""
|
|
|
|
|
|
§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
|
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
|
|
|
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}
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# 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
|