Native fitness: adjacency graph + merge_divided (homemaker-py-3y7)
Two bugs fixed in boundary_id / leaf_graph:
1. 'bid not in "abcd"' used Python substring check, silently dropping the
root-division boundary (empty-string id). Fixed to frozenset membership.
2. Upper-storey nodes store their own rotation in the YAML but Urb::Quad::Rotation
delegates to Below->Rotation. boundary_id now walks the below-chain to the
ground-floor rotation, matching Perl exactly.
After fixes all 35 corpus files produce edge counts matching Perl oracle.
Added:
- src/homemaker/graph.py: build_graphs (two-phase pattern), has_adjacency,
has_vertical_connection (faithful no-overlap stub per DESIGN §8.1),
find_missing_spaces, check_adjacency, check_level_constraints,
check_vertical_connectivity
- src/homemaker/dom.py: @dataclass(eq=False) on Node for NetworkX hashability;
is_outside, is_supported, is_unsupported, merge_divided
- tests/test_graph.py: 7 tests, edge counts vs Perl oracle on all 35 files,
exact widths for 2f45907, merge_divided smoke, two-phase independence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 21:57:45 +01:00
|
|
|
"""Tests for leaf-adjacency graph build, merge_divided, and pre-merge checks.
|
|
|
|
|
|
|
|
|
|
Oracle: edge counts verified against Perl urb (Urb::Quad::Graph, door_width=1.2)
|
|
|
|
|
across the 35-file corpus. Widths for 2f45907 verified edge-by-edge.
|
|
|
|
|
|
|
|
|
|
Fidelity decision (DESIGN.md §8.1): has_vertical_connection is a faithful stub —
|
|
|
|
|
any leaf of the target type on the level below counts; no spatial overlap check.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
2026-06-14 08:18:06 +01:00
|
|
|
from homemaker_layout import dom, geometry
|
|
|
|
|
from homemaker_layout.dom import merge_divided, levels
|
|
|
|
|
from homemaker_layout.graph import build_graphs
|
Native fitness: adjacency graph + merge_divided (homemaker-py-3y7)
Two bugs fixed in boundary_id / leaf_graph:
1. 'bid not in "abcd"' used Python substring check, silently dropping the
root-division boundary (empty-string id). Fixed to frozenset membership.
2. Upper-storey nodes store their own rotation in the YAML but Urb::Quad::Rotation
delegates to Below->Rotation. boundary_id now walks the below-chain to the
ground-floor rotation, matching Perl exactly.
After fixes all 35 corpus files produce edge counts matching Perl oracle.
Added:
- src/homemaker/graph.py: build_graphs (two-phase pattern), has_adjacency,
has_vertical_connection (faithful no-overlap stub per DESIGN §8.1),
find_missing_spaces, check_adjacency, check_level_constraints,
check_vertical_connectivity
- src/homemaker/dom.py: @dataclass(eq=False) on Node for NetworkX hashability;
is_outside, is_supported, is_unsupported, merge_divided
- tests/test_graph.py: 7 tests, edge counts vs Perl oracle on all 35 files,
exact widths for 2f45907, merge_divided smoke, two-phase independence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 21:57:45 +01:00
|
|
|
|
2026-06-13 23:39:20 +01:00
|
|
|
CORPUS = Path(__file__).parent.parent / "examples" / "programme-house"
|
Native fitness: adjacency graph + merge_divided (homemaker-py-3y7)
Two bugs fixed in boundary_id / leaf_graph:
1. 'bid not in "abcd"' used Python substring check, silently dropping the
root-division boundary (empty-string id). Fixed to frozenset membership.
2. Upper-storey nodes store their own rotation in the YAML but Urb::Quad::Rotation
delegates to Below->Rotation. boundary_id now walks the below-chain to the
ground-floor rotation, matching Perl exactly.
After fixes all 35 corpus files produce edge counts matching Perl oracle.
Added:
- src/homemaker/graph.py: build_graphs (two-phase pattern), has_adjacency,
has_vertical_connection (faithful no-overlap stub per DESIGN §8.1),
find_missing_spaces, check_adjacency, check_level_constraints,
check_vertical_connectivity
- src/homemaker/dom.py: @dataclass(eq=False) on Node for NetworkX hashability;
is_outside, is_supported, is_unsupported, merge_divided
- tests/test_graph.py: 7 tests, edge counts vs Perl oracle on all 35 files,
exact widths for 2f45907, merge_divided smoke, two-phase independence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 21:57:45 +01:00
|
|
|
|
2026-06-13 23:39:20 +01:00
|
|
|
pytestmark = pytest.mark.skipif(not CORPUS.is_dir(), reason="Corpus not available")
|
Native fitness: adjacency graph + merge_divided (homemaker-py-3y7)
Two bugs fixed in boundary_id / leaf_graph:
1. 'bid not in "abcd"' used Python substring check, silently dropping the
root-division boundary (empty-string id). Fixed to frozenset membership.
2. Upper-storey nodes store their own rotation in the YAML but Urb::Quad::Rotation
delegates to Below->Rotation. boundary_id now walks the below-chain to the
ground-floor rotation, matching Perl exactly.
After fixes all 35 corpus files produce edge counts matching Perl oracle.
Added:
- src/homemaker/graph.py: build_graphs (two-phase pattern), has_adjacency,
has_vertical_connection (faithful no-overlap stub per DESIGN §8.1),
find_missing_spaces, check_adjacency, check_level_constraints,
check_vertical_connectivity
- src/homemaker/dom.py: @dataclass(eq=False) on Node for NetworkX hashability;
is_outside, is_supported, is_unsupported, merge_divided
- tests/test_graph.py: 7 tests, edge counts vs Perl oracle on all 35 files,
exact widths for 2f45907, merge_divided smoke, two-phase independence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 21:57:45 +01:00
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Perl-oracle edge counts per file (generated from Urb::Quad::Graph, door=1.2)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
PERL_EDGE_COUNTS = {
|
|
|
|
|
"0aec8e39552ff3bc9f43e085912644c5.dom": [5, 3, 8],
|
|
|
|
|
"105ceaf8751377af694a5bb8854bed3e.dom": [3, 5, 8],
|
|
|
|
|
"155953b017c5c7b7559d5a2a1f22cd5f.dom": [7, 5, 3],
|
|
|
|
|
"21059668d774c17322e78931605080ad.dom": [8, 5],
|
|
|
|
|
"2c13a0816affe390130cb0ad24336d81.dom": [5, 10],
|
|
|
|
|
"2f45907abd9accac2a124d311732f749.dom": [9, 3, 6],
|
|
|
|
|
"3ec9e7f4785e3db8b71921444c8fd4fa.dom": [10, 3, 6],
|
|
|
|
|
"3ff824bf8e5e00f28a736fcb405a4702.dom": [6, 8],
|
|
|
|
|
"458aa8b8756bc099ce22f0ee92ce4c88.dom": [8, 3, 3],
|
|
|
|
|
"48ffdc096e07ba0bdc7b537485ce7a79.dom": [9, 3, 6],
|
|
|
|
|
"6cdced8e6d3aae40bdb7180d1a206729.dom": [10, 5],
|
|
|
|
|
"6fa7ebd4d570ebe48c8845fbc4e4ead0.dom": [4, 3, 4],
|
|
|
|
|
"77f9db022c0f124eef216ab3737edd3c.dom": [9, 3, 6],
|
|
|
|
|
"7d8269c8faa6adf23432243c13e90cb6.dom": [10, 6],
|
|
|
|
|
"7ef10b4dfe6bf7d9cee5bf0cbd7d67a9.dom": [3, 5, 5],
|
|
|
|
|
"82c7439d3098ad710fef7d8caa3e320d.dom": [7, 9],
|
|
|
|
|
"8fbadf0d7cd70ff6eb49c2dc978ed4e0.dom": [8, 3, 6],
|
|
|
|
|
"a82f07068e4408fdd0d5e3dc469a8dee.dom": [3, 9, 3],
|
|
|
|
|
"aa0dcab98927d2c933e8381d37734971.dom": [8, 8],
|
|
|
|
|
"aa4f7a9839a84435ac07fdf8111cce42.dom": [8, 5],
|
|
|
|
|
"b5d6002ff0b04f9269566b14d5e91f2a.dom": [6, 6],
|
|
|
|
|
"c074775d0660ba48fa607f4472c7e484.dom": [3, 3, 9],
|
|
|
|
|
"c436666f7cf1c24b20daaa625a01a071.dom": [8, 3, 6],
|
|
|
|
|
"c7fab3037ca6ff7a02a42553570b2aaa.dom": [9, 3, 6],
|
|
|
|
|
"c848f73cf1a847b8cb6583dbde94c633.dom": [8, 3, 6],
|
|
|
|
|
"c964435454c459f86c3ed9a5a7621132.dom": [3, 9, 3],
|
|
|
|
|
"ca9e80c5c1502f1050eaa548978dbb2d.dom": [6, 8],
|
|
|
|
|
"candidate-001.dom": [3, 9, 9],
|
|
|
|
|
"candidate-002.dom": [3, 9, 9],
|
|
|
|
|
"cb93a2d2de7f5d37af450a8ce7b681b1.dom": [6, 8],
|
|
|
|
|
"cd39357b1ba79aec4943411cdca51668.dom": [3, 5, 5],
|
|
|
|
|
"cf0b8a77e8b2325f92a7e7d150184a55.dom": [5, 6],
|
|
|
|
|
"de9468f607f5d0a88cc554ad1776b537.dom": [7, 5, 3],
|
|
|
|
|
"eebf1980a672d9ec11cfc69c029d6796.dom": [4, 5, 4],
|
|
|
|
|
"init.dom": [0],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_edge_counts_match_perl_corpus():
|
|
|
|
|
"""Edge counts per level must match Perl oracle across all 35 corpus files."""
|
|
|
|
|
for fname, expected in PERL_EDGE_COUNTS.items():
|
|
|
|
|
root = dom.load(str(CORPUS / fname))
|
|
|
|
|
actual = [
|
|
|
|
|
geometry.leaf_graph(lvl, 1.2).number_of_edges()
|
|
|
|
|
for lvl in dom.levels(root)
|
|
|
|
|
]
|
|
|
|
|
assert actual == expected, f"{fname}: got {actual}, expected {expected}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_edge_widths_2f45907():
|
|
|
|
|
"""Edge widths for 2f45907 must match Perl values to 3 d.p."""
|
|
|
|
|
root = dom.load(str(CORPUS / "2f45907abd9accac2a124d311732f749.dom"))
|
|
|
|
|
lvls = dom.levels(root)
|
|
|
|
|
|
|
|
|
|
def edges_as_dict(G):
|
|
|
|
|
return {
|
|
|
|
|
tuple(sorted((a.id, b.id))): round(d["width"], 3)
|
|
|
|
|
for a, b, d in G.edges(data=True)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Level 0 (Perl-verified widths)
|
|
|
|
|
g0 = edges_as_dict(geometry.leaf_graph(lvls[0], 1.2))
|
|
|
|
|
assert g0[("lll", "llr")] == pytest.approx(4.176, abs=0.001)
|
|
|
|
|
assert g0[("lll", "rl")] == pytest.approx(1.237, abs=0.001)
|
|
|
|
|
assert g0[("lll", "rr")] == pytest.approx(1.586, abs=0.001)
|
|
|
|
|
assert g0[("lll", "lrl")] == pytest.approx(2.175, abs=0.001)
|
|
|
|
|
assert g0[("lll", "lrr")] == pytest.approx(2.001, abs=0.001)
|
|
|
|
|
assert g0[("llr", "rr")] == pytest.approx(2.429, abs=0.001)
|
|
|
|
|
assert g0[("lrl", "lrr")] == pytest.approx(2.422, abs=0.001)
|
|
|
|
|
assert g0[("lrr", "rl")] == pytest.approx(2.306, abs=0.001)
|
|
|
|
|
assert g0[("rl", "rr")] == pytest.approx(2.639, abs=0.001)
|
|
|
|
|
|
|
|
|
|
# Level 1
|
|
|
|
|
g1 = edges_as_dict(geometry.leaf_graph(lvls[1], 1.2))
|
|
|
|
|
assert g1[("l", "rl")] == pytest.approx(3.543, abs=0.001)
|
|
|
|
|
assert g1[("l", "rr")] == pytest.approx(4.014, abs=0.001)
|
|
|
|
|
assert g1[("rl", "rr")] == pytest.approx(2.639, abs=0.001)
|
|
|
|
|
|
|
|
|
|
# Level 2
|
|
|
|
|
g2 = edges_as_dict(geometry.leaf_graph(lvls[2], 1.2))
|
|
|
|
|
assert g2[("ll", "rrr")] == pytest.approx(2.581, abs=0.001)
|
|
|
|
|
assert g2[("ll", "lr")] == pytest.approx(4.176, abs=0.001)
|
|
|
|
|
assert g2[("lr", "rl")] == pytest.approx(3.543, abs=0.001)
|
|
|
|
|
assert g2[("lr", "rrl")] == pytest.approx(1.252, abs=0.001)
|
|
|
|
|
assert g2[("rl", "rrl")] == pytest.approx(2.639, abs=0.001)
|
|
|
|
|
assert g2[("rrl", "rrr")] == pytest.approx(2.625, abs=0.001)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_all_edge_widths_at_or_above_door_width():
|
|
|
|
|
"""Every edge in every graph must have width >= 1.2 (the door_width threshold)."""
|
|
|
|
|
for fname in PERL_EDGE_COUNTS:
|
|
|
|
|
root = dom.load(str(CORPUS / fname))
|
|
|
|
|
for lvl in dom.levels(root):
|
|
|
|
|
G = geometry.leaf_graph(lvl, 1.2)
|
|
|
|
|
for _, _, d in G.edges(data=True):
|
|
|
|
|
assert d["width"] >= 1.2 - 1e-9, f"{fname}: edge width {d['width']} < 1.2"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_graph_nodes_equal_leaf_count():
|
|
|
|
|
"""Every graph must have exactly one vertex per leaf (isolated or connected)."""
|
|
|
|
|
for fname in PERL_EDGE_COUNTS:
|
|
|
|
|
root = dom.load(str(CORPUS / fname))
|
|
|
|
|
for lvl in dom.levels(root):
|
|
|
|
|
leaves = lvl.leaves()
|
|
|
|
|
G = geometry.leaf_graph(lvl, 1.2)
|
|
|
|
|
assert G.number_of_nodes() == len(leaves), fname
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_graphs_length_matches_level_count():
|
|
|
|
|
"""build_graphs must return one graph per storey."""
|
|
|
|
|
for fname in PERL_EDGE_COUNTS:
|
|
|
|
|
root = dom.load(str(CORPUS / fname))
|
|
|
|
|
gs = build_graphs(root)
|
|
|
|
|
assert len(gs) == len(dom.levels(root)), fname
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_merge_divided_candidate_002():
|
|
|
|
|
"""candidate-002 level 2 collapses 5 outdoor leaves into a single O node."""
|
|
|
|
|
root = dom.load(str(CORPUS / "candidate-002.dom"))
|
|
|
|
|
lvl2_before = levels(root)[2]
|
|
|
|
|
leaves_before = [leaf.type for leaf in lvl2_before.leaves()]
|
|
|
|
|
# level 2 has 7 leaves, of which 5 are O/S types in the l-subtree
|
|
|
|
|
assert len(leaves_before) == 7
|
|
|
|
|
|
|
|
|
|
merge_divided(root)
|
|
|
|
|
lvl2_after = levels(root)[2]
|
|
|
|
|
leaves_after = [(leaf.id, leaf.type) for leaf in lvl2_after.leaves()]
|
|
|
|
|
# After merge the 5 outdoor leaves in l collapse to a single O
|
|
|
|
|
assert len(leaves_after) == 3
|
|
|
|
|
assert ("l", "O") in leaves_after
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_two_phase_build_graphs_independent():
|
|
|
|
|
"""Pre-merge and post-merge graphs must be independent (different node counts
|
|
|
|
|
after merging a file that actually merges)."""
|
|
|
|
|
root = dom.load(str(CORPUS / "candidate-002.dom"))
|
|
|
|
|
graphs_pre = build_graphs(root)
|
|
|
|
|
pre_nodes_l2 = graphs_pre[2].number_of_nodes()
|
|
|
|
|
|
|
|
|
|
merge_divided(root)
|
|
|
|
|
graphs_post = build_graphs(root)
|
|
|
|
|
post_nodes_l2 = graphs_post[2].number_of_nodes()
|
|
|
|
|
|
|
|
|
|
assert pre_nodes_l2 == 7
|
|
|
|
|
assert post_nodes_l2 == 3
|
Missing-space cascade no longer weighted by YAML verbosity
check_space_counts emitted, per missing room instance, two base failures
plus one placeholder for each optional key the author happened to type --
has_size/has_width/has_proportion are literally "size" in c from the YAML.
So a missing room cost 3, 4 or 5 fails depending on nothing but how
verbosely its space was written, and under value *= 0.5 ** len(failures)
that is a 4x difference in penalty between two single rooms. The tiered
comparator inherits it directly, since n_hard is dominated by these
cascades -- the search's primary key was partly a measure of config style.
The two paths disagreed about the same room. A PRESENT room is checked on
all three qualities regardless of declaration: get_space_params fills width
and proportion from defaults, deriving width from size when absent, so
programme-house's t2 declares size: alone and still gets a real width
target of 1.633 it can fail on. Missing, it emitted one placeholder where
b1 emitted three. The cascade stands in for the checks that could not run,
and it stood in for the wrong number of them.
Fix: emit all three placeholders always -- a fixed 5 per missing instance,
mirroring the present-room path. 36 of 67 corpus codes were under-counted.
Max weight ratio between two single rooms 4x -> 1x (programme-house),
2x -> 1x (harbor, maple).
This makes fail counts LARGER and that is the point; it is a correctness
fix, not an improvement. harbor evolved-3M-nols-3 82 -> 84, generated
155 -> 174, evolved-3M 131 -> 144; maple generated unchanged (no missing
instances).
NOT taken: 1i8's other option, one fail per instance with the placeholders
informational. It fixes the verbosity dependence too but silently rescales
a missing room from 1/32 to 1/2, the same weight as one crinkliness fail.
Whether it SHOULD cost 1/32 is a real and separate question; bundling it
here would change the objective's priorities under cover of a bug fix.
Magnitude left exactly where it was, filed as homemaker-py-3i3.
Every historical corpus fail count is invalidated again, on top of 39.4 and
38.10/38.11 -- which is why the cold-start re-baseline belongs after the
objective work, not before it.
Closes homemaker-py-1i8.
Lint at parity (46); tests 379 passed (3 new), 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-29 09:33:40 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# homemaker-py-1i8 / DESIGN.md §38.12 — the missing-space cascade must not be
|
|
|
|
|
# weighted by how verbosely the programme was written.
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
def _missing_fails(declared: dict) -> list[str]:
|
|
|
|
|
"""Fails for a bare plot that declares one required room, absent."""
|
|
|
|
|
from homemaker_layout.graph import check_space_counts
|
|
|
|
|
from homemaker_layout.programme import _parse_spaces
|
|
|
|
|
|
|
|
|
|
reqs = _parse_spaces({"spaces": {"x1": dict({"usage": "living"}, **declared)}})
|
|
|
|
|
root = dom.Node(node=[[0, 0], [6, 0], [6, 6], [0, 6]], type="O")
|
|
|
|
|
fails, missing = check_space_counts(root, reqs)
|
|
|
|
|
assert missing, "the room should be reported missing"
|
|
|
|
|
return fails
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_space_cost_is_independent_of_declared_keys():
|
|
|
|
|
"""A missing room costs the same whether or not the author typed the
|
|
|
|
|
optional keys.
|
|
|
|
|
|
|
|
|
|
It used to cost 2 base + one placeholder per key PRESENT IN THE YAML, so a
|
|
|
|
|
room declaring size/width/proportion cost 5 fails and one declaring size
|
|
|
|
|
alone cost 3. Under `value *= 0.5 ** len(failures)` that is a 4x difference
|
|
|
|
|
in penalty between two single rooms, decided by verbosity -- and the tiered
|
|
|
|
|
comparator inherits it, since n_hard is dominated by these cascades.
|
|
|
|
|
"""
|
|
|
|
|
verbose = _missing_fails({"size": [16.0, 4.0], "width": [4.0, 1.0],
|
|
|
|
|
"proportion": [1.5, 0.5]})
|
|
|
|
|
terse = _missing_fails({"size": [16.0, 4.0]})
|
|
|
|
|
assert len(verbose) == len(terse) == 5
|
|
|
|
|
assert set(verbose) == set(terse)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_space_placeholders_mirror_the_checks_a_present_room_faces():
|
|
|
|
|
"""All three, always -- because a present room is checked on all three.
|
|
|
|
|
|
|
|
|
|
`get_space_params` fills width and proportion from defaults (deriving width
|
|
|
|
|
from size when absent), so the requirement exists however the config is
|
|
|
|
|
spelled. The placeholder count has to mirror that or the two paths
|
|
|
|
|
disagree.
|
|
|
|
|
"""
|
|
|
|
|
fails = _missing_fails({"size": [16.0, 4.0]})
|
|
|
|
|
for check in ("size", "width", "proportion"):
|
|
|
|
|
assert f"missing x1: would need {check} check" in fails
|
|
|
|
|
assert sum(1 for f in fails if f.startswith("missing required space")) == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_space_cascade_scales_per_instance_not_per_code():
|
|
|
|
|
fails = _missing_fails({"size": [16.0, 4.0], "count": 3})
|
|
|
|
|
assert len(fails) == 15
|
|
|
|
|
for i in (1, 2, 3):
|
|
|
|
|
assert f"missing required space: x1#{i}" in fails
|