Interchangeable codes (similar size/width/proportion, compatible level/stack, no adjacency edge) form equivalence classes derived from the programme. With --superpose (default off), each fitness eval COLLAPSES every superposed leaf to its best in-class usage via an optimal supply->demand assignment (brute force <=C! within cap C=4, scipy Hungarian beyond), then scores the condensed types. Because collapse re-types on the unmerged tree before all checks, counts / adjacency / quality are unchanged downstream -- no Node field, no graph/operator changes -- and default OFF is bit-identical. - programme.py: derive_interchange_classes + interchangeable (S1-S4, locked thresholds R_SIZE=1.5/R_WIDTH=1.3/R_PROP=1.5, CLASS_CAP=4) - fitness.py: collapse_superposition, _best_assignment, _usage_quality; superpose/superpose_class_cap conf knobs; collapse hooked into _evaluate_full - driver.py/evolve.py: superpose flag plumbed beside leaf_sharing; --superpose - tests/test_superposition.py: 17 tests (derivation, assignment, end-to-end) Closes homemaker-py-9o5 (build); validation A/B is homemaker-py-xi7. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
216 lines
7.7 KiB
Python
216 lines
7.7 KiB
Python
"""Tests for type superposition + collapse (homemaker-py-9o5).
|
|
|
|
Covers the three layers of the feature:
|
|
1. interchange-class derivation (programme.derive_interchange_classes)
|
|
2. the per-eval collapse assignment (Fitness._best_assignment)
|
|
3. end-to-end collapse re-typing on a built tree (collapse_superposition)
|
|
|
|
plus the default-OFF guarantee.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from homemaker_layout import dom, geometry, programme
|
|
from homemaker_layout.dom import Node, _link_subtree
|
|
from homemaker_layout.fitness import Fitness
|
|
from homemaker_layout.programme import (
|
|
SpaceReq,
|
|
derive_interchange_classes,
|
|
interchangeable,
|
|
)
|
|
|
|
|
|
def _req(code, size, width=4.0, proportion=1.5, level=None,
|
|
requires_below=None, adjacency=None, count=1):
|
|
return SpaceReq(
|
|
code=code, size=size, width=width, proportion=proportion,
|
|
level=level, requires_below=requires_below,
|
|
adjacency=list(adjacency or []), count=count, has_size=True,
|
|
has_width=True, has_proportion=True,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Derivation
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_similar_pair_is_grouped():
|
|
# codes are first-letter-classed; c/o/s are generic and never participate,
|
|
# so use plain non-generic codes for the study/guest analogue
|
|
reqs = {"den": _req("den", 9.0), "guest": _req("guest", 12.0)}
|
|
assert derive_interchange_classes(reqs) == [frozenset({"den", "guest"})]
|
|
|
|
|
|
def test_dissimilar_size_not_grouped():
|
|
# 60 / 10 = 6x area, far outside R_SIZE
|
|
reqs = {"hall": _req("hall", 60.0), "wc": _req("wc", 10.0)}
|
|
assert derive_interchange_classes(reqs) == []
|
|
|
|
|
|
def test_width_band_is_tighter_than_size():
|
|
# sizes within R_SIZE but widths 4.0 vs 2.5 (1.6x) exceed R_WIDTH
|
|
reqs = {"a": _req("a", 10.0, width=4.0), "b": _req("b", 12.0, width=2.5)}
|
|
assert derive_interchange_classes(reqs) == []
|
|
|
|
|
|
def test_adjacency_pair_not_grouped():
|
|
# genuinely-similar requirements, but a required adjacency means they are
|
|
# two coexisting rooms, not one interchangeable leaf (S4)
|
|
reqs = {
|
|
"x": _req("x", 10.0, adjacency=["y"]),
|
|
"y": _req("y", 11.0),
|
|
}
|
|
assert derive_interchange_classes(reqs) == []
|
|
|
|
|
|
def test_service_stack_not_grouped_with_non_service():
|
|
# a wet-stack code (requires_below) never groups with a dry room (S3)
|
|
reqs = {
|
|
"bath": _req("bath", 10.0, requires_below="bath"),
|
|
"den": _req("den", 11.0),
|
|
}
|
|
assert derive_interchange_classes(reqs) == []
|
|
# ... but two matching-stack services do group
|
|
reqs2 = {
|
|
"bath1": _req("bath1", 10.0, requires_below="bath"),
|
|
"bath2": _req("bath2", 11.0, requires_below="bath"),
|
|
}
|
|
assert interchangeable(reqs2["bath1"], reqs2["bath2"])
|
|
|
|
|
|
def test_incompatible_levels_not_grouped():
|
|
reqs = {"a": _req("a", 10.0, level=0), "b": _req("b", 11.0, level=1)}
|
|
assert derive_interchange_classes(reqs) == []
|
|
# one level None is still compatible
|
|
reqs2 = {"a": _req("a", 10.0, level=0), "b": _req("b", 11.0, level=None)}
|
|
assert derive_interchange_classes(reqs2) == [frozenset({"a", "b"})]
|
|
|
|
|
|
def test_generic_codes_never_participate():
|
|
reqs = {"c": _req("c", 10.0), "o1": _req("o1", 11.0), "room": _req("room", 11.0)}
|
|
# c/o are circulation/outside — excluded; only one real code left -> no class
|
|
assert derive_interchange_classes(reqs) == []
|
|
|
|
|
|
def test_real_programme_house():
|
|
reqs = programme.load_programme_dir("examples/programme-house")
|
|
classes = {frozenset(c) for c in derive_interchange_classes(reqs)}
|
|
assert frozenset({"b1", "b2"}) in classes
|
|
assert frozenset({"t2", "t3"}) in classes
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Assignment (collapse core)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def _fit_super():
|
|
return Fitness(conf={"superpose": True})
|
|
|
|
|
|
def test_best_assignment_picks_max_diagonal():
|
|
fit = _fit_super()
|
|
# best matching is the diagonal (sum 27); any off-diagonal is worse
|
|
q = [[9, 1, 1], [1, 9, 1], [1, 1, 9]]
|
|
got = sorted(fit._best_assignment(q))
|
|
assert got == [(0, 0), (1, 1), (2, 2)]
|
|
|
|
|
|
def test_best_assignment_enumerates_all_permutations():
|
|
fit = _fit_super()
|
|
# the optimum is the anti-diagonal (10+10+10) — exercises the 3!=6 search
|
|
q = [[1, 2, 10], [2, 10, 2], [10, 2, 1]]
|
|
got = sorted(fit._best_assignment(q))
|
|
assert got == [(0, 2), (1, 1), (2, 0)]
|
|
|
|
|
|
def test_best_assignment_surplus_supply():
|
|
fit = _fit_super()
|
|
# 3 leaves, 2 demand slots -> only 2 pairs, drop the worst-fitting leaf row
|
|
q = [[10, 1], [1, 10], [0, 0]]
|
|
got = sorted(fit._best_assignment(q))
|
|
assert got == [(0, 0), (1, 1)]
|
|
|
|
|
|
def test_best_assignment_surplus_demand():
|
|
fit = _fit_super()
|
|
# 2 leaves, 3 demand slots -> 2 pairs covering the two best columns
|
|
q = [[10, 1, 0], [1, 10, 0]]
|
|
got = sorted(fit._best_assignment(q))
|
|
assert got == [(0, 0), (1, 1)]
|
|
|
|
|
|
def test_best_assignment_falls_back_to_hungarian_beyond_cap():
|
|
fit = Fitness(conf={"superpose": True, "superpose_class_cap": 1})
|
|
q = [[9, 1, 1], [1, 9, 1], [1, 1, 9]] # min dim 3 > cap 1 -> scipy path
|
|
got = sorted(fit._best_assignment(q))
|
|
assert got == [(0, 0), (1, 1), (2, 2)]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# End-to-end collapse on a built tree
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
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 _bedroom_conf(superpose=True):
|
|
return {
|
|
"superpose": superpose,
|
|
"spaces": {
|
|
"b1": {"size": [16.0, 4.0], "width": [4.0, 1.0],
|
|
"proportion": [1.5, 0.5], "count": 1},
|
|
"b2": {"size": [12.0, 3.0], "width": [3.5, 0.8],
|
|
"proportion": [1.5, 0.5], "count": 1},
|
|
},
|
|
}
|
|
|
|
|
|
def test_collapse_relabels_to_demand_set():
|
|
fit = Fitness(conf=_bedroom_conf())
|
|
# both leaves typed b1; areas 14.4 (left) and 21.6 (right)
|
|
root = _two_leaf_root("b1", "b1")
|
|
left, right = root.leaves()
|
|
assert geometry.area(right) > geometry.area(left)
|
|
|
|
fit.collapse_superposition(root)
|
|
|
|
# the demand set {b1, b2} is now covered, not two b1's
|
|
assert sorted(lf.type for lf in root.leaves()) == ["b1", "b2"]
|
|
# the larger leaf takes the larger target (b1=16), the smaller takes b2=12
|
|
assert right.type == "b1"
|
|
assert left.type == "b2"
|
|
|
|
|
|
def test_collapse_is_noop_without_a_class():
|
|
# only one real code -> no interchange class -> collapse must not touch types
|
|
conf = {"superpose": True,
|
|
"spaces": {"b1": {"size": [16.0, 4.0], "count": 2}}}
|
|
fit = Fitness(conf=conf)
|
|
root = _two_leaf_root("b1", "b1")
|
|
fit.collapse_superposition(root)
|
|
assert [lf.type for lf in root.leaves()] == ["b1", "b1"]
|
|
|
|
|
|
def test_superpose_default_off():
|
|
assert Fitness(conf=_bedroom_conf(superpose=False))._superpose is False
|
|
assert Fitness()._superpose is False
|
|
|
|
|
|
def test_superpose_off_does_not_relabel():
|
|
# with the flag off, _evaluate_full must never call collapse: a two-b1 tree
|
|
# keeps both labels through scoring (proxy: collapse only fires when on)
|
|
fit = Fitness(conf=_bedroom_conf(superpose=False))
|
|
root = _two_leaf_root("b1", "b1")
|
|
# collapse_superposition is gated by self._superpose in _evaluate_full; call
|
|
# the gate directly to document the contract
|
|
if fit._superpose:
|
|
fit.collapse_superposition(root)
|
|
assert [lf.type for lf in root.leaves()] == ["b1", "b1"]
|