homemaker-layout/tests/test_compose.py
Claude 7ec4e5d121
§39.4: tighten generic-type matching, reverting the harbor rename
Supersedes the previous commit's approach. Renaming harbor's four colliding
codes fixed one programme; tightening the matching rule fixes the rule, so a
room may be called anything. cr1/of/st1/st2 are restored and the examples are
byte-identical to their pre-§39 state -- which also means existing .dom
artefacts (evolved-3M*) stay valid, so migrate_ju3_rename.py is deleted.

The rule: Urb has exactly three GENERIC structural types (get_space_types:
qw/C O S/), the leaves the search creates. Measured across the corpus: 154 C,
110 O, 1 S, not one lowercase generic -- while every programme code is
lowercase, including single-character ones (r, t, m, n). Case is the
discriminator, not length. Every generic test was type[0].lower() in (...), a
case-insensitive PREFIX that swept up any programme code starting with those
letters; they now match the generic set exactly. 30 sites across dom, fitness,
graph, operators, programme, shapecurve and bubble.

NOT applied to the SEMANTIC prefixes: l/k/b/t classify programme codes by first
letter (graph.py builds bedroom<->toilet and kitchen<->living relations from
them) and stay prefix-based. Where the namespaces were mixed in one expression
they were split -- has_circulation's ("b","l","k","c") is three semantic
prefixes plus dom.is_circulation; access()'s ("l","c","s") is semantic l plus
the generic circulation set.

New: dom.GENERIC_{CIRCULATION,OUTSIDE,TYPES} + is_generic(); fitness.
_generic_class(), replacing the _t0 dispatch in quality_size/quality_width/
quality_proportion/value_rate -- the four terms that mattered most and that a
first sweep missed, since they dispatch through a t0 variable rather than an
inline test. graph._adjacency_target resolves a generic adjacency requirement
(programmes write "adjacency: [c, o]") to the generic set while every other
requirement keeps Perl's prefix semantics.

Two subtleties: S is in both generic sets but takes the OUTSIDE parameter
families -- a first translation tested circulation first and silently gave S
the circulation params, caught by test_get_space_params_sahn_proportion. And
validate_codes survives, narrowed to a code spelled exactly C/O/S, which is a
genuine ambiguity; merely starting with c/o/s is now fine.

Invariant asserted as a test: test_scoring_is_invariant_under_programme_code_
spelling relabels one tree and its config together and re-scores. Bit-identical
across 12 comparisons (6 seeds x collapse on/off).

Re-baseline (seed 1, 20k, original names): 58 fails (15 hard / 43 soft) against
the real 37-instance programme, with cr1 at 79.1 m2 vs declared 80 (was 32.9
and 17.1), of/st1/st2 all present and in band, and one fail naming any of them.
57 -> 58 on a 5-instance-harder programme is within noise: "did not regress".

Fallout (§39.5): 2g7.5's CP-SAT seeder win does not survive. Over 6 seeds --
harbor real 102/114 (cpsat loses), harbor old-effective 98/99 (tie, so the win
was already marginal), maple-court 156/144 (cpsat wins). maple is the control:
the solver did not regress, harbor's programme changed. Test xfail'd with that
reason plus a maple companion; both assign_solver flags stay default off.
Filed homemaker-py-w6x to re-check other narrow-margin harbor A/Bs.

345 passed, 1 xfailed, 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:45:28 +00:00

192 lines
6.4 KiB
Python

"""homemaker-py-2g7.1: plan->dom composer tests.
Synthetic fixtures only -- no real human trace exists yet (see DESIGN.md sec
37.x). These exercise the two acceptance-criteria halves: a slicible
partition round-trips to a scoring .dom, and a non-slicible one is reported
with the offending region rather than mis-parsed.
"""
from __future__ import annotations
import textwrap
import pytest
from homemaker_layout import dom, geometry
from homemaker_layout.compose import LabelError, NonSlicible, StoreyTrace, compose, parse_svg
BOUNDARY_YAML = textwrap.dedent(
"""\
node: [[0.0, 0.0], [10.0, 0.0], [10.0, 8.0], [0.0, 8.0]]
perimeter: {a: null, b: null, c: null, d: null}
height: 3.0
elevation: 0.0
wall_inner: 0.08
wall_outer: 0.25
rotation: 0
"""
)
# Plot is 10x8. Cut 1 (axis 0, vertical) at x=4 splits into a left column
# (x:0-4, full height) and a right column (x:4-10). Cut 2 (axis 1,
# horizontal) at y=5 splits the right column into a bottom room (y:0-5) and
# a top room (y:5-8) -- exercises both axes and depth-2 recursion.
GOOD_SVG = textwrap.dedent(
"""\
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">
<g inkscape:groupmode="layer" inkscape:label="storey-0">
<path d="M 4,0 L 4,8"/>
<path d="M 4,5 L 10,5"/>
<text x="2" y="4">cr1</text>
<text x="7" y="2.5">k1</text>
<text x="7" y="6.5">b1</text>
</g>
</svg>
"""
)
# Same partition, endpoints perturbed by < 0.15 (default tol) to exercise
# snapping: a hand-drawn line that overlaps/undershoots slightly.
SLOPPY_SVG = textwrap.dedent(
"""\
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">
<g inkscape:groupmode="layer" inkscape:label="storey-0">
<path d="M 4.06,-0.05 L 3.95,8.07"/>
<path d="M 3.96,5.04 L 10.06,4.93"/>
<text x="2" y="4">cr1</text>
<text x="7" y="2.5">k1</text>
<text x="7" y="6.5">b1</text>
</g>
</svg>
"""
)
# One dangling interior line that touches neither pair of opposite edges --
# not a guillotine cut of the plot, and there is no other line to try.
NON_SLICIBLE_SVG = textwrap.dedent(
"""\
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">
<g inkscape:groupmode="layer" inkscape:label="storey-0">
<path d="M 3,3 L 7,3"/>
</g>
</svg>
"""
)
def _write(tmp_path, name, content):
p = tmp_path / name
p.write_text(content)
return p
def test_composes_synthetic_partition_and_scores(tmp_path):
boundary_path = _write(tmp_path, "boundary.dom", BOUNDARY_YAML)
svg_path = _write(tmp_path, "plan.svg", GOOD_SVG)
boundary = dom.load(str(boundary_path))
storeys = parse_svg(str(svg_path))
root = compose(boundary, storeys)
leaves = root.leaves()
assert sorted(leaf.type for leaf in leaves) == ["b1", "cr1", "k1"]
# round-trips through the .dom text format
out_path = tmp_path / "plan.dom"
dom.dump(root, str(out_path))
reloaded = dom.load(str(out_path))
reloaded_types = sorted(leaf.type for leaf in reloaded.leaves())
assert reloaded_types == ["b1", "cr1", "k1"]
# geometry is sane: leaf areas sum to the (wall-inset) plot area
total = sum(geometry.area(leaf) for leaf in reloaded.leaves())
assert total == pytest.approx(geometry.area(reloaded), rel=1e-9)
# scores cleanly through the native fitness engine (no config on disk:
# unconstrained score, just confirms it runs end-to-end without raising)
from homemaker_layout.fitness import Fitness
fitness = Fitness({}, {})
score, failures = fitness.score_with_fails(reloaded)
assert isinstance(score, float)
assert len(failures) > 0 # unconstrained rooms + no programme: expected fails
def test_snaps_sloppy_hand_traced_lines(tmp_path):
boundary_path = _write(tmp_path, "boundary.dom", BOUNDARY_YAML)
svg_path = _write(tmp_path, "plan.svg", SLOPPY_SVG)
boundary = dom.load(str(boundary_path))
storeys = parse_svg(str(svg_path))
root = compose(boundary, storeys, tol=0.15)
assert sorted(leaf.type for leaf in root.leaves()) == ["b1", "cr1", "k1"]
# a tighter tolerance than the sketch's slop should fail to find the cuts
boundary2 = dom.load(str(boundary_path))
with pytest.raises((NonSlicible, LabelError)):
compose(boundary2, storeys, tol=0.01)
def test_non_slicible_region_is_reported(tmp_path):
boundary_path = _write(tmp_path, "boundary.dom", BOUNDARY_YAML)
svg_path = _write(tmp_path, "plan.svg", NON_SLICIBLE_SVG)
boundary = dom.load(str(boundary_path))
storeys = parse_svg(str(svg_path))
with pytest.raises(NonSlicible) as excinfo:
compose(boundary, storeys)
exc = excinfo.value
assert exc.storey == 0
# names the offending region: should be the whole plot (wall-inset
# corners), since the dangling line doesn't localise to a sub-quad
assert len(exc.corners) == 4
assert "region around" in str(exc)
def test_label_count_mismatch_is_reported(tmp_path):
svg = textwrap.dedent(
"""\
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">
<g inkscape:groupmode="layer" inkscape:label="storey-0">
</g>
</svg>
"""
)
boundary_path = _write(tmp_path, "boundary.dom", BOUNDARY_YAML)
svg_path = _write(tmp_path, "plan.svg", svg)
boundary = dom.load(str(boundary_path))
storeys = parse_svg(str(svg_path))
with pytest.raises(LabelError) as excinfo:
compose(boundary, storeys)
assert excinfo.value.storey == 0
assert excinfo.value.labels == []
def test_parse_svg_rejects_missing_storey_layers(tmp_path):
svg = textwrap.dedent(
"""\
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">
<g inkscape:groupmode="layer" inkscape:label="not-a-storey"/>
</svg>
"""
)
svg_path = _write(tmp_path, "plan.svg", svg)
with pytest.raises(ValueError):
parse_svg(str(svg_path))
def test_compose_rejects_storey_count_mismatch(tmp_path):
boundary_path = _write(tmp_path, "boundary.dom", BOUNDARY_YAML)
boundary = dom.load(str(boundary_path))
with pytest.raises(ValueError):
compose(boundary, [StoreyTrace(), StoreyTrace()])