Owner's ruling: "as long as circulation is more expensive to build than it has value then we have a linear ramp. a gaussian ramp is probably not appropriate here as double the amount of corridor is simply twice as bad, so it should score the same as two half size corridors". Both halves check out. The linear ramp is already there -- value_circulation 50 against a build cost of 200, so every m2 of corridor is worth -150 and the objective pushes for less of it without needing a cap. And the AMOUNT of circulation is separately governed at building level by ratio_circulation [0.00, 0.20], a gaussian on the circulation fraction, which is where that question belongs. The per-leaf size gaussian was a third charge on the same thing. It was also the only one of the three that depended on how the corridor was cut up. One 20 m2 corridor scored gaussian(20,0,14) = 0.360 and contributed 360; two 10 m2 halves scored 0.775 each and contributed 775 between them. Splitting a corridor in half multiplied its value by 2.15x -- an artefact of where the tree happened to cut, rewarding the search for fragmenting its own spine. The ruling's test (one 2A leaf must score as two A leaves) is exactly what a gaussian on an amount cannot satisfy, and is now a test. size_circulation = None; quality_size returns 1.0 for circulation and shapecurve gives amin, amax = 0, inf. BUG this exposed: get_space_params falls through to a habitable default when a generic family key is missing and could not tell "missing" from "present but null", so a corridor silently inherited a room's 16 m2 size target. _generic_param now returns (found, value); pinned by a test. The same trap applied to 39.22's proportion_circulation. Fail-set effect of 39.22 and 39.23 together: 16 corridor size fails and 7 proportion fails removed, none added. harbor 33/43/42 -> 32/40/38, maple 54/73/55 -> 51/65/52, health-centre 4/9/5 -> 3/9/5, programme-house unchanged. The layouts are identical -- these are failures the objective should never have been reporting. Two shape-curve tests moved fixture: both built an infeasible upper storey from a 'C' leaf, infeasible precisely because of the bounds now removed. The fixture is a cr1 leaf, whose infeasibility is a contradiction between two of its own bounds (needs >= 180 m2 for its aspect bound, <= 101.5 m2 for its size bound across the box's fixed 23.52 m span) rather than a tight fit. The invariants they test are unchanged. Left open on hxi: the rate gap, value_circulation 50 against value_inside 300 on identical build cost. Whether a corridor is worth a sixth of a room per m2 is a design judgement, and the linear ramp is only as steep as that number. 419 passed. Refs homemaker-py-hxi. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
383 lines
16 KiB
Python
383 lines
16 KiB
Python
"""Tests for the shape-curve DP (homemaker-py-6xh, promoted from
|
|
experiments/shapecurve_spike.py; see DESIGN.md §37.2/§37.4 for the full
|
|
200-topology validation this unit scale is a fast smoke check of)."""
|
|
|
|
import copy
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from homemaker_layout import dom, driver, fitness as fit_mod, shapecurve, solver
|
|
|
|
HARBOR_L0 = Path(__file__).parent.parent / "examples" / "harbor-house-l0"
|
|
|
|
pytestmark = pytest.mark.skipif(not HARBOR_L0.is_dir(), reason="harbor-house-l0 not available")
|
|
|
|
|
|
def _fit():
|
|
conf, cost = fit_mod.load_config(str(HARBOR_L0))
|
|
return fit_mod.Fitness(conf, cost)
|
|
|
|
|
|
def _small_feasible_topology():
|
|
"""A tiny 2-leaf (C/O only, no size/adjacency constraints to speak of)
|
|
topology on harbor-house-l0's plot -- deterministically shape-feasible
|
|
(verified: driver.random_topology(seed, 2, rng(0), ['C', 'O']))."""
|
|
seed = dom.load(str(HARBOR_L0 / "init.dom"))
|
|
rng = np.random.default_rng(0)
|
|
return driver.random_topology(seed, 2, rng, ["C", "O"])
|
|
|
|
|
|
def _two_storey_feasible_topology():
|
|
"""Level 0: the whole plot as one undivided 'O' room (trivially
|
|
feasible, below is always None at level 0). Level 1: an independent
|
|
fresh 2-leaf 'C'/'O' topology whose root inherits the *whole plot* as
|
|
its fixed box (its below -- level 0's root -- exists but is undivided,
|
|
so the root itself is a free-region-root per ``shapecurve._region_roots``,
|
|
pinned to the same box ``_small_feasible_topology`` already validates as
|
|
shape-feasible for a single storey)."""
|
|
base_seed = dom.load(str(HARBOR_L0 / "init.dom"))
|
|
level0 = copy.deepcopy(base_seed)
|
|
level0.type = "O"
|
|
rng = np.random.default_rng(0)
|
|
level1 = driver.random_topology(dom.load(str(HARBOR_L0 / "init.dom")), 2, rng, ["C", "O"])
|
|
level0.above = level1
|
|
dom.link(level0)
|
|
return level0
|
|
|
|
|
|
def _two_storey_mixed_topology(child_types=("O", "O")):
|
|
"""Level 0: the same 2-leaf 'C'/'O' topology ``_small_feasible_topology``
|
|
validates. Level 1: an exact structural copy (so its root and both
|
|
leaves start out below-inherited/FIXED, wall-stacked on level 0), with
|
|
one of its leaves (``target``, id 'l') further divided into two brand
|
|
new leaves of ``child_types`` -- a genuine below-fixed-box/free-split
|
|
(case B) fringe node nested under a below-fixed-divided (case A) root,
|
|
the mixed scenario ``homemaker-py-koo`` adds support for. Returns
|
|
``(root, target)``."""
|
|
seed = dom.load(str(HARBOR_L0 / "init.dom"))
|
|
rng = np.random.default_rng(0)
|
|
level0 = driver.random_topology(seed, 2, rng, ["C", "O"])
|
|
level1 = copy.deepcopy(level0)
|
|
level0.above = level1
|
|
dom.link(level0)
|
|
|
|
target = level1.left
|
|
target.division = [0.5, 0.5]
|
|
target.rotation = 0
|
|
target.left = dom.Node(rotation=0, type=child_types[0])
|
|
target.right = dom.Node(rotation=0, type=child_types[1])
|
|
dom.link(level0)
|
|
return level0, target
|
|
|
|
|
|
def test_eligible_guards_superpose_not_storey_count_or_sharing():
|
|
"""`eligible` guards only what `leaf_constraints` cannot model.
|
|
|
|
homemaker-py-koo removed the storey-count guard (below-inherited fixed
|
|
splits, §37.6). homemaker-py-tym removed the leaf_sharing/max_share/
|
|
multi_use guards by MODELLING them: `leaf_constraints` now mirrors
|
|
`quality_size`'s k-scaling and co_type adjustment, reading the evaluator's
|
|
own Fitness so it cannot drift.
|
|
|
|
`superpose` remains excluded, for a different reason than the others: it
|
|
does not rescale a target, it changes WHICH TYPE the leaf is scored as, and
|
|
that collapse happens after the DP has read `leaf.type`.
|
|
"""
|
|
root = dom.load(str(HARBOR_L0 / "generated.dom"))
|
|
assert len(dom.levels(root)) == 1
|
|
assert shapecurve.eligible(root)
|
|
assert shapecurve.eligible(root, leaf_sharing=True)
|
|
assert shapecurve.eligible(root, max_share=3)
|
|
assert shapecurve.eligible(root, multi_use=True)
|
|
assert not shapecurve.eligible(root, superpose=True)
|
|
|
|
seed = dom.load(str(HARBOR_L0 / "init.dom"))
|
|
seed.above = dom.Node(rotation=0) # fake a second storey
|
|
assert len(dom.levels(seed)) == 2
|
|
assert shapecurve.eligible(seed)
|
|
assert shapecurve.eligible(seed, leaf_sharing=True)
|
|
assert not shapecurve.eligible(seed, superpose=True)
|
|
|
|
|
|
def test_solve_feasible_root_realises_zero_shape_fails(tmp_path):
|
|
"""A small, obviously-feasible topology's DP-realised ratios round-trip
|
|
through dom.dumps/dom.load and independently verify as zero shape fails
|
|
under the real Fitness scorer."""
|
|
root = _small_feasible_topology()
|
|
fit = _fit()
|
|
|
|
feasible, info = shapecurve.solve(root, fit)
|
|
assert feasible is True
|
|
assert info["w_plot"] > 0 and info["h_plot"] > 0
|
|
|
|
out_path = tmp_path / "realised.dom"
|
|
out_path.write_text(dom.dumps(root))
|
|
reloaded = dom.load(str(out_path))
|
|
|
|
_, fails = fit.score_with_fails(reloaded)
|
|
shape_fails = [f for f in fails if f.endswith((" size", " width", " proportion"))]
|
|
assert shape_fails == []
|
|
|
|
|
|
def test_solve_infeasible_topology_leaves_tree_untouched():
|
|
"""A topology with far more leaves than harbor-house-l0's plot can fit
|
|
(each needing its own min width/area) is infeasible; solve() must not
|
|
write partial/bogus ratios in that case."""
|
|
seed = dom.load(str(HARBOR_L0 / "init.dom"))
|
|
rng = np.random.default_rng(0)
|
|
root = driver.random_topology(seed, 60, rng, ["k1", "l1", "b1", "C", "O"])
|
|
fit = _fit()
|
|
|
|
feasible, info = shapecurve.solve(root, fit)
|
|
assert feasible is False
|
|
# infeasible: no realised point to check, but the call must not raise
|
|
# and must report the same plot dims as the feasible case's mechanism
|
|
assert info["w_plot"] > 0 and info["h_plot"] > 0
|
|
|
|
|
|
def test_solve_is_deterministic():
|
|
root = _small_feasible_topology()
|
|
fit = _fit()
|
|
f1, _ = shapecurve.solve(root, fit)
|
|
divisions_1 = [tuple(b.division) for b in solver.free_branches(root)]
|
|
f2, _ = shapecurve.solve(root, fit)
|
|
divisions_2 = [tuple(b.division) for b in solver.free_branches(root)]
|
|
assert f1 == f2 is True
|
|
assert divisions_1 == pytest.approx(divisions_2)
|
|
|
|
|
|
def test_is_feasible_agrees_with_solve_but_never_writes(monkeypatch):
|
|
"""homemaker-py-wkh: the hard-prune caller needs the boolean verdict
|
|
without solve()'s tree mutation, so ``is_feasible`` must (a) agree with
|
|
``solve``'s own verdict and (b) never write ``division`` -- verified on
|
|
both the feasible and infeasible fixtures already exercised above."""
|
|
fit = _fit()
|
|
|
|
feasible_root = _small_feasible_topology()
|
|
before = [tuple(b.division) for b in solver.free_branches(feasible_root)]
|
|
assert shapecurve.is_feasible(feasible_root, fit) is True
|
|
after = [tuple(b.division) for b in solver.free_branches(feasible_root)]
|
|
assert before == after
|
|
|
|
seed = dom.load(str(HARBOR_L0 / "init.dom"))
|
|
rng = np.random.default_rng(0)
|
|
infeasible_root = driver.random_topology(seed, 60, rng, ["k1", "l1", "b1", "C", "O"])
|
|
before = [tuple(b.division) for b in solver.free_branches(infeasible_root)]
|
|
assert shapecurve.is_feasible(infeasible_root, fit) is False
|
|
after = [tuple(b.division) for b in solver.free_branches(infeasible_root)]
|
|
assert before == after
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Multi-storey (homemaker-py-koo, DESIGN.md §37.6)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_solve_multistorey_feasible_realises_zero_shape_fails(tmp_path):
|
|
"""A 2-storey topology whose upper storey is a fresh, independently-free
|
|
2-leaf split (pinned to the whole plot, since the ground storey below it
|
|
is a single undivided room) round-trips to zero size/width/proportion
|
|
fails at every level, exactly like the single-storey case."""
|
|
root = _two_storey_feasible_topology()
|
|
fit = _fit()
|
|
|
|
feasible, info = shapecurve.solve(root, fit)
|
|
assert feasible is True
|
|
assert info["w_plot"] > 0 and info["h_plot"] > 0
|
|
assert info["n_levels"] == 2
|
|
|
|
out_path = tmp_path / "realised.dom"
|
|
out_path.write_text(dom.dumps(root))
|
|
reloaded = dom.load(str(out_path))
|
|
|
|
_, fails = fit.score_with_fails(reloaded)
|
|
shape_fails = [f for f in fails if f.endswith((" size", " width", " proportion"))]
|
|
assert shape_fails == []
|
|
|
|
|
|
def test_solve_multistorey_matches_free_branches(tmp_path):
|
|
"""Mixed fixture: level 1 is a structural copy of level 0 (so its root
|
|
and both original leaves are below-fixed) with one leaf further divided
|
|
into two brand new 'O' leaves (a below-fixed-box/free-split fringe node
|
|
nested under a below-fixed-divided root). ``solve`` must write ratios on
|
|
exactly ``solver.free_branches`` -- the pre-existing single-storey
|
|
invariant this generalises -- and leave every below-fixed node's own
|
|
``division`` byte-identical, even though it sits on a realised subtree."""
|
|
root, target = _two_storey_mixed_topology(child_types=("O", "O"))
|
|
level1 = root.above
|
|
fit = _fit()
|
|
|
|
all_nodes_before = [(n, list(n.division))
|
|
for lvl in dom.levels(root) for n in shapecurve._divided_nodes(lvl)]
|
|
free_before = [b for b in solver.free_branches(root)]
|
|
|
|
feasible, _ = shapecurve.solve(root, fit)
|
|
assert feasible is True
|
|
|
|
# level 1's own root is below-fixed (its below, level 0's root, is
|
|
# divided) so it must never appear as a free branch, and 'target' (a
|
|
# fresh split introduced only at level 1) must.
|
|
assert any(b is target for b in solver.free_branches(root))
|
|
assert not any(b is level1 for b in solver.free_branches(root))
|
|
|
|
for node, before in all_nodes_before:
|
|
if any(node is b for b in free_before):
|
|
continue
|
|
assert node.division == before, "below-fixed node's division must never be written"
|
|
|
|
out_path = tmp_path / "realised.dom"
|
|
out_path.write_text(dom.dumps(root))
|
|
reloaded = dom.load(str(out_path))
|
|
_, fails = fit.score_with_fails(reloaded)
|
|
shape_fails = [f for f in fails if f.endswith((" size", " width", " proportion"))]
|
|
assert shape_fails == []
|
|
|
|
|
|
def test_solve_multistorey_infeasible_restores_every_level():
|
|
"""When an upper-storey free split is infeasible, ``solve`` must roll back
|
|
ALL levels, including the ground storey it already realised earlier in the
|
|
same call -- not just the storey where infeasibility was detected.
|
|
|
|
The infeasibility is a `cr1` leaf in the below-fixed box, and it is a
|
|
contradiction between two of its own bounds rather than a tight fit: across
|
|
the box's fixed 23.52 m span, cr1 needs >= 180 m2 to satisfy its aspect
|
|
bound (3.07) and <= 101.5 m2 to satisfy its size bound -- a factor of 1.8
|
|
apart. Verified by inspection, not tuned to just barely fail.
|
|
|
|
This used to be a 'C' leaf, infeasible on circulation's own proportion and
|
|
size bounds. §39.22/§39.23 removed both, so a corridor can no longer be
|
|
shape-infeasible at all and the fixture had to move to a leaf that still
|
|
carries the constraints.
|
|
"""
|
|
root, target = _two_storey_mixed_topology(child_types=("cr1", "O"))
|
|
level1 = root.above
|
|
fit = _fit()
|
|
|
|
before = {
|
|
id(n): list(n.division)
|
|
for lvl in dom.levels(root) for n in shapecurve._divided_nodes(lvl)
|
|
}
|
|
|
|
feasible, _ = shapecurve.solve(root, fit)
|
|
assert feasible is False
|
|
|
|
after = {
|
|
id(n): list(n.division)
|
|
for lvl in dom.levels(root) for n in shapecurve._divided_nodes(lvl)
|
|
}
|
|
assert after == before, "an infeasible upper storey must not leave the ground storey mutated"
|
|
|
|
|
|
def test_is_feasible_multistorey_never_writes():
|
|
fit = _fit()
|
|
|
|
feasible_root = _two_storey_feasible_topology()
|
|
before = [tuple(b.division) for b in solver.free_branches(feasible_root)]
|
|
assert shapecurve.is_feasible(feasible_root, fit) is True
|
|
after = [tuple(b.division) for b in solver.free_branches(feasible_root)]
|
|
assert before == after
|
|
|
|
# 'C' is no longer shape-constrained (§39.22/§39.23); cr1 still is.
|
|
infeasible_root, _ = _two_storey_mixed_topology(child_types=("cr1", "O"))
|
|
before = [tuple(b.division) for b in solver.free_branches(infeasible_root)]
|
|
assert shapecurve.is_feasible(infeasible_root, fit) is False
|
|
after = [tuple(b.division) for b in solver.free_branches(infeasible_root)]
|
|
assert before == after
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# homemaker-py-tym / DESIGN.md §38.23 — leaf_sharing / co_type target modelling
|
|
# --------------------------------------------------------------------------- #
|
|
def _shared_seed():
|
|
"""A real harbor-house constructed seed, which stamps share>1 leaves."""
|
|
from homemaker_layout import geometry, operators, programme
|
|
|
|
d = "examples/harbor-house"
|
|
reqs = programme.load_programme_dir(d)
|
|
conf, cost = fit_mod.load_config(d, overrides={"leaf_sharing": True})
|
|
fit = fit_mod.Fitness(conf, cost)
|
|
root = operators.constructive_topology(
|
|
dom.load(f"{d}/init.dom"), reqs, np.random.default_rng(0),
|
|
sorted(reqs) + ["C", "O"], min_storeys=programme.storey_minimum(d),
|
|
adjacency_aware=True, proportion_aware=True, circ_divisor=3,
|
|
leaf_sharing=True, leaf_share_factor=3, depth_balanced=True,
|
|
interior_outside=True, outside_divisor=3)
|
|
geometry.clear_cache()
|
|
dom.canonicalize_shares(root)
|
|
return fit, root
|
|
|
|
|
|
@pytest.mark.skipif(not Path("examples/harbor-house").is_dir(),
|
|
reason="harbor-house not available")
|
|
def test_leaf_constraints_inverts_quality_size_for_shared_leaves():
|
|
"""The DP's (amin, amax) must be the exact FAIL_THRESHOLD inversion of
|
|
quality_size -- INCLUDING its k-scaling for a shared leaf.
|
|
|
|
quality_size centres the gaussian on k*target with sigma*k for a leaf
|
|
holding k same-code rooms. leaf_constraints ignored that, which is why
|
|
`eligible` excluded leaf_sharing outright -- and leaf_sharing defaults True
|
|
in driver.search, so the DP never fired on a real run.
|
|
"""
|
|
from homemaker_layout import geometry
|
|
|
|
fit, root = _shared_seed()
|
|
shared = [lf for lf in root.leaves() if (getattr(lf, "share", 1) or 1) > 1]
|
|
assert shared, "seed carries no shared leaves -- test would be vacuous"
|
|
|
|
orig_area = geometry.area
|
|
try:
|
|
for lf in shared:
|
|
b = shapecurve.leaf_constraints(fit, lf)
|
|
for bound in (b.amin, b.amax):
|
|
geometry.area = lambda _n, _a=bound: _a
|
|
assert fit.quality_size(lf) == pytest.approx(
|
|
fit_mod.FAIL_THRESHOLD, abs=1e-9), (
|
|
f"leaf {lf.id} (share={lf.share}): DP bound {bound} is not "
|
|
f"on the fail threshold of quality_size")
|
|
finally:
|
|
geometry.area = orig_area
|
|
|
|
|
|
@pytest.mark.skipif(not Path("examples/harbor-house").is_dir(),
|
|
reason="harbor-house not available")
|
|
def test_unscaled_bounds_would_reject_every_shared_leaf():
|
|
"""Guard the reason `eligible` could not simply be relaxed.
|
|
|
|
Without the k-scaling, a shared leaf's real area sits far outside the
|
|
single-room bounds, so the DP would call a feasible topology infeasible --
|
|
a false negative that prunes good topologies and misdirects the NM
|
|
warm-start. Measured: 100% of shared leaves, 6 seeds.
|
|
"""
|
|
from homemaker_layout import geometry
|
|
|
|
fit, root = _shared_seed()
|
|
K = shapecurve._K
|
|
checked = would_reject = 0
|
|
for lf in root.leaves():
|
|
if (getattr(lf, "share", 1) or 1) <= 1 or lf.type not in (fit._programme or {}):
|
|
continue
|
|
checked += 1
|
|
area = geometry.area(lf)
|
|
t, sg = fit.get_space_params(lf.type, "size")[:2]
|
|
b = shapecurve.leaf_constraints(fit, lf)
|
|
assert b.amin <= area <= b.amax, "scaled bounds should accept the real area"
|
|
if not (max(0.0, t - K * sg) <= area <= t + K * sg):
|
|
would_reject += 1
|
|
assert checked, "no shared leaves -- test would be vacuous"
|
|
assert would_reject == checked, (
|
|
f"expected the unscaled bounds to reject every shared leaf; "
|
|
f"{would_reject}/{checked}")
|
|
|
|
|
|
def test_eligible_admits_sharing_but_still_excludes_superpose():
|
|
"""superpose is excluded for a DIFFERENT reason than the others: it does
|
|
not rescale a target, it changes which TYPE is scored, and the collapse
|
|
happens after the DP has read leaf.type."""
|
|
assert shapecurve.eligible(None, leaf_sharing=True)
|
|
assert shapecurve.eligible(None, max_share=3)
|
|
assert shapecurve.eligible(None, multi_use=True)
|
|
assert shapecurve.eligible(None, leaf_sharing=True, max_share=4, multi_use=True)
|
|
assert not shapecurve.eligible(None, superpose=True)
|
|
assert not shapecurve.eligible(None, leaf_sharing=True, superpose=True)
|