homemaker-layout/tests/test_staging.py
Bruno Postle 9296f75cdb homemaker-py-2g7.1: plan->dom composer (SVG trace -> slicing-tree .dom)
Composer half of the ground-truth bead: examples/harbor-house/drawings/
harbor-house 1.svg turned out to be a Bonsai/Blender render of 3m.dom's own
IFC (32 IfcSpace paths == 3m.dom's upper-storey leaf count), not a human
trace, so no usable reference exists yet -- this builds and tests the
pipeline that will consume one once traced. compose.py parses storey-N
Inkscape layers of cut-lines + labels against a boundary-stub .dom (plot/
height/elevation only, no room shapes to keep aligned across storeys) and
recursively detects guillotine cuts, mirroring geometry.py's own
division-line algebra; non-slicible regions and label mismatches are
reported by location rather than guessed at. homemaker-compose CLI added.
Renamed dom._link to public dom.link since compose.py needs to re-link from
outside dom.py. Full design writeup in DESIGN.md sec 37.3; actual human
tracing of harbor-house/programme-house is tracked as follow-up under
2g7.1, still open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSwQwpEaHFBkeVSDDWd75S
2026-08-03 11:03:02 +01:00

125 lines
4.8 KiB
Python

"""Staged per-floor search tests (DESIGN.md §11.3, homemaker-py-c4c.3).
Cover the building blocks: programme partition, derived single-storey base
programme, substrate-readiness score, base-lift seeder, and the storey-weighted
mutation pick. Oracle-free; harbor-house is the multi-storey fixture.
"""
import collections
import tempfile
from pathlib import Path
import numpy as np
import pytest
from homemaker_layout import dom, fitness, genome, graph, operators, programme
CORPUS = Path(__file__).parent.parent / "examples" / "harbor-house"
pytestmark = pytest.mark.skipif(not CORPUS.is_dir(), reason="harbor-house not available")
@pytest.fixture
def reqs():
return programme.load_programme_dir(CORPUS)
def _required_counts(reqs):
return {c: r.count for c, r in reqs.items() if c[0].lower() not in "cos"}
def test_n_storeys_required(reqs):
assert programme.n_storeys_required(reqs) == 2
def test_partition_sums_to_required_counts(reqs):
n = programme.n_storeys_required(reqs)
buckets = programme.partition_rooms_by_storey(reqs, n, np.random.default_rng(0))
assert len(buckets) == n
tot = collections.Counter()
for b in buckets:
tot.update(b)
assert dict(tot) == _required_counts(reqs)
# level-constrained rooms land on their required storey
assert buckets[1].get("r") == 10 # r is level: 1
assert "r" not in buckets[0]
def test_write_stage1_programme_single_storey(reqs):
n = programme.n_storeys_required(reqs)
buckets = programme.partition_rooms_by_storey(reqs, n, np.random.default_rng(0))
with tempfile.TemporaryDirectory() as tmp:
programme.write_stage1_programme(CORPUS, tmp, buckets[0])
s1 = programme.load_programme_dir(tmp)
conf, _ = fitness.load_config(tmp)
# exactly the base-floor codes, levels dropped, single-storey constraints
assert set(s1) == set(buckets[0])
assert all(r.level is None for r in s1.values())
assert conf["storey_limit"] == 1 and conf["storey_minimum"] == 1
assert conf["staircase_min"] == 1 and conf["staircase_max"] == 1
# adjacency pruned to surviving / generic refs only
for r in s1.values():
for ref in r.adjacency:
assert ref in s1 or ref[0].lower() in "cos"
def test_substrate_readiness_range_and_core(reqs):
n = programme.n_storeys_required(reqs)
rng = np.random.default_rng(0)
buckets = programme.partition_rooms_by_storey(reqs, n, rng)
with tempfile.TemporaryDirectory() as tmp:
programme.write_stage1_programme(CORPUS, tmp, buckets[0])
s1 = programme.load_programme_dir(tmp)
base = operators.constructive_topology(
dom.load(str(CORPUS / "init.dom")), s1, rng, sorted(s1) + ["C", "O"])
r = graph.substrate_readiness(base, reqs, n)
assert 0.0 <= r <= 1.0
# stripping every C leaf to a non-core type drops the core factor
for leaf in dom.levels(base)[0].leaves():
if leaf.type and leaf.type[0].lower() == "c":
leaf.type = "m"
dom.link(base)
r_nocore = graph.substrate_readiness(base, reqs, n)
assert r_nocore < r # losing the reserved core lowers readiness
def test_lift_preserves_base_and_builds_upper(reqs):
n = programme.n_storeys_required(reqs)
rng = np.random.default_rng(1)
buckets = programme.partition_rooms_by_storey(reqs, n, rng)
with tempfile.TemporaryDirectory() as tmp:
programme.write_stage1_programme(CORPUS, tmp, buckets[0])
s1 = programme.load_programme_dir(tmp)
base = operators.constructive_topology(
dom.load(str(CORPUS / "init.dom")), s1, rng, sorted(s1) + ["C", "O"])
base_leaf_types = collections.Counter(l.type for l in dom.levels(base)[0].leaves())
lifted = operators.lift_base_to_storeys(
base, buckets[1:], rng, sorted(reqs) + ["C", "O"])
lv = dom.levels(lifted)
assert len(lv) == n
# base storey untouched
assert collections.Counter(l.type for l in lv[0].leaves()) == base_leaf_types
# upper storey instantiates its required room set + keeps a circulation core
up = collections.Counter(l.type for l in lv[1].leaves())
for code, cnt in buckets[1].items():
assert up.get(code, 0) >= cnt
assert up.get("C", 0) >= 1
# result is a canonical genome
g = genome.encode(lifted)
assert genome.encode(genome.decode(g)) == g
def test_pick_weighted_by_storey_biases_base():
items = [(0, "base"), (1, "upper")]
rng = np.random.default_rng(0)
picks = [operators._pick_weighted_by_storey(rng, items, base_p=0.0)[0]
for _ in range(50)]
assert all(li == 1 for li in picks) # base_p=0 ⇒ never pick the base storey
# base_p=1.0 is the plain uniform pick (no bias)
rng2 = np.random.default_rng(0)
p1 = operators._pick_weighted_by_storey(rng2, items, base_p=1.0)
rng3 = np.random.default_rng(0)
p2 = operators._pick(rng3, items)
assert p1 == p2