homemaker-layout/tests/test_superposition.py

217 lines
7.7 KiB
Python
Raw Permalink Normal View History

"""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"]