2026-06-12 14:07:35 +01:00
|
|
|
"""Operator tests (oracle-free): every child is a valid, canonical genome."""
|
|
|
|
|
|
2026-07-19 11:06:56 +01:00
|
|
|
import copy
|
2026-06-12 14:07:35 +01:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
import pytest
|
|
|
|
|
|
2026-06-14 08:18:06 +01:00
|
|
|
from homemaker_layout import dom, genome, operators
|
2026-06-12 14:07:35 +01:00
|
|
|
|
2026-06-13 23:39:20 +01:00
|
|
|
CORPUS = Path(__file__).parent.parent / "examples" / "programme-house"
|
2026-06-12 14:07:35 +01:00
|
|
|
FILES = ["2f45907abd9accac2a124d311732f749.dom", "candidate-002.dom",
|
|
|
|
|
"c964435454c459f86c3ed9a5a7621132.dom"]
|
2026-06-12 19:01:53 +01:00
|
|
|
TYPES = ["k1", "l1", "b1", "b2", "t1", "C", "O"]
|
2026-06-12 14:07:35 +01:00
|
|
|
|
2026-06-13 23:39:20 +01:00
|
|
|
pytestmark = pytest.mark.skipif(not CORPUS.is_dir(), reason="Corpus not available")
|
2026-06-12 14:07:35 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def canonical(root: dom.Node) -> None:
|
|
|
|
|
"""Child must encode to a genome that decode/encode holds fixed."""
|
|
|
|
|
g1 = genome.encode(root)
|
|
|
|
|
g2 = genome.encode(genome.decode(g1))
|
|
|
|
|
assert g2 == g1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("name", sorted(operators.MUTATIONS))
|
|
|
|
|
def test_mutations_yield_canonical_genomes(name):
|
|
|
|
|
op = operators.MUTATIONS[name]
|
|
|
|
|
for f in FILES:
|
|
|
|
|
root = genome.decode(genome.encode(dom.load(str(CORPUS / f))))
|
|
|
|
|
for seed in range(5):
|
|
|
|
|
child, desc = op(root, np.random.default_rng(seed), TYPES)
|
|
|
|
|
assert desc.startswith(name.split("_")[0]) or "noop" in desc
|
|
|
|
|
canonical(child)
|
|
|
|
|
# the parent must never be mutated in place
|
|
|
|
|
canonical(root)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_divide_grows_and_undivide_shrinks():
|
|
|
|
|
root = genome.decode(genome.encode(dom.load(str(CORPUS / FILES[0]))))
|
|
|
|
|
n_leaves = sum(len(lvl.leaves()) for lvl in dom.levels(root))
|
|
|
|
|
child, _ = operators.mutate_divide(root, np.random.default_rng(0), TYPES)
|
|
|
|
|
assert sum(len(lvl.leaves()) for lvl in dom.levels(child)) == n_leaves + 1
|
|
|
|
|
child, desc = operators.mutate_undivide(root, np.random.default_rng(0), TYPES)
|
|
|
|
|
if "noop" not in desc:
|
|
|
|
|
assert sum(len(lvl.leaves()) for lvl in dom.levels(child)) < n_leaves
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_level_add_delete():
|
|
|
|
|
root = genome.decode(genome.encode(dom.load(str(CORPUS / FILES[0]))))
|
|
|
|
|
n = len(dom.levels(root))
|
|
|
|
|
up, _ = operators.mutate_level_add(root, np.random.default_rng(0), TYPES)
|
|
|
|
|
assert len(dom.levels(up)) == n + 1
|
|
|
|
|
canonical(up)
|
|
|
|
|
down, _ = operators.mutate_level_delete(root, np.random.default_rng(0), TYPES)
|
|
|
|
|
assert len(dom.levels(down)) == n - 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_relink_clears_stale_below_after_base_undivide():
|
2026-08-03 11:03:02 +01:00
|
|
|
# regression: dom.link must clear below-links whose path vanished, or
|
2026-06-12 14:07:35 +01:00
|
|
|
# geometry on the mutated tree dereferences orphaned nodes
|
2026-06-14 08:18:06 +01:00
|
|
|
from homemaker_layout import geometry
|
2026-06-12 14:07:35 +01:00
|
|
|
|
|
|
|
|
root = genome.decode(genome.encode(dom.load(str(CORPUS / FILES[0]))))
|
|
|
|
|
# force an undivide on the BASE storey specifically
|
|
|
|
|
base = dom.levels(root)[0]
|
|
|
|
|
cands = [n for li, n in operators._owned_branches(root)
|
|
|
|
|
if li == 0 and not n.left.divided and not n.right.divided]
|
|
|
|
|
assert cands, "corpus design has no base leaf-pair branch"
|
|
|
|
|
import copy as _copy
|
|
|
|
|
|
|
|
|
|
child = _copy.deepcopy(root)
|
|
|
|
|
target = dom.levels(child)[0].by_id(cands[0].id)
|
|
|
|
|
target.division = None
|
|
|
|
|
target.left = target.right = None
|
|
|
|
|
target.type = "l1"
|
2026-08-03 11:03:02 +01:00
|
|
|
dom.link(child)
|
2026-06-12 14:07:35 +01:00
|
|
|
geometry.clear_cache()
|
|
|
|
|
for lvl in dom.levels(child):
|
|
|
|
|
for leaf in lvl.leaves():
|
|
|
|
|
for i in range(4):
|
|
|
|
|
geometry.coordinate(leaf, i) # must not raise
|
|
|
|
|
canonical(child)
|
|
|
|
|
assert base.by_id(cands[0].id) is not None # parent untouched
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 23:26:22 +01:00
|
|
|
def test_all_mutations_survive_undivided_tree():
|
|
|
|
|
# an undivided plot (init.dom-style seed) must never crash an operator
|
|
|
|
|
bare = dom.Node(type="O", node=[[0, 0], [10, 0], [10, 8], [0, 8]],
|
|
|
|
|
height=2.7, wall_outer=0.25, wall_inner=0.08)
|
2026-08-03 11:03:02 +01:00
|
|
|
dom.link(bare)
|
2026-06-12 23:26:22 +01:00
|
|
|
for name, op in operators.MUTATIONS.items():
|
|
|
|
|
for seed in range(3):
|
|
|
|
|
child, desc = op(bare, np.random.default_rng(seed), TYPES)
|
|
|
|
|
assert desc, name
|
|
|
|
|
canonical(child)
|
|
|
|
|
|
|
|
|
|
|
2026-07-12 16:34:33 +01:00
|
|
|
def test_unfold_shared_leaves_materialises_deficit():
|
|
|
|
|
# homemaker-py-yaa: a share=k leaf must unfold into k distinct same-code
|
|
|
|
|
# leaves (paying down the count deficit) with the share stamp cleared, while
|
|
|
|
|
# every non-shared leaf keeps its identity. Footprint is preserved: the k
|
|
|
|
|
# children tile the original leaf, so total plot area is unchanged.
|
|
|
|
|
from homemaker_layout import geometry
|
|
|
|
|
|
|
|
|
|
root = dom.Node(node=[[0, 0], [12, 0], [12, 8], [0, 8]],
|
|
|
|
|
height=2.7, wall_outer=0.25, wall_inner=0.08,
|
|
|
|
|
rotation=0, division=[0.5, 0.5])
|
|
|
|
|
root.left = dom.Node(type="n", share=3, share_type="n") # 3-room shared leaf
|
|
|
|
|
root.right = dom.Node(type="C") # untouched
|
2026-08-03 11:03:02 +01:00
|
|
|
dom.link(root)
|
2026-07-12 16:34:33 +01:00
|
|
|
geometry.clear_cache()
|
|
|
|
|
area_before = geometry.area(root)
|
|
|
|
|
|
|
|
|
|
created = operators.unfold_shared_leaves(root)
|
|
|
|
|
|
|
|
|
|
assert created == 2 # 3 rooms - 1 leaf
|
|
|
|
|
leaves = root.leaves()
|
|
|
|
|
assert sum(1 for lf in leaves if lf.type == "n") == 3 # three distinct n
|
|
|
|
|
assert sum(1 for lf in leaves if lf.type == "C") == 1 # C untouched
|
|
|
|
|
assert all(lf.share == 1 for lf in leaves) # stamps cleared
|
|
|
|
|
geometry.clear_cache()
|
|
|
|
|
assert geometry.area(root) == pytest.approx(area_before) # footprint kept
|
|
|
|
|
canonical(root) # genome round-trips
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 08:38:08 +01:00
|
|
|
def test_unfold_shared_leaves_above_grain_cap():
|
|
|
|
|
# homemaker-py-kpu (Schedule B): ``above=cap`` unfolds only leaves whose
|
|
|
|
|
# share EXCEEDS the grain cap, leaving smaller-share leaves collapsed for the
|
|
|
|
|
# next lower grain. A share=4 leaf unfolds under above=3; a share=3 leaf does
|
|
|
|
|
# not — it stays a single shared leaf.
|
|
|
|
|
from homemaker_layout import geometry
|
|
|
|
|
|
|
|
|
|
root = dom.Node(node=[[0, 0], [12, 0], [12, 8], [0, 8]],
|
|
|
|
|
height=2.7, wall_outer=0.25, wall_inner=0.08,
|
|
|
|
|
rotation=0, division=[0.5, 0.5])
|
|
|
|
|
root.left = dom.Node(type="n", share=4, share_type="n") # exceeds cap 3
|
|
|
|
|
root.right = dom.Node(type="m", share=3, share_type="m") # at cap 3, kept
|
2026-08-03 11:03:02 +01:00
|
|
|
dom.link(root)
|
2026-07-16 08:38:08 +01:00
|
|
|
geometry.clear_cache()
|
|
|
|
|
|
|
|
|
|
created = operators.unfold_shared_leaves(root, above=3)
|
|
|
|
|
|
|
|
|
|
assert created == 3 # only the share=4 leaf
|
|
|
|
|
leaves = root.leaves()
|
|
|
|
|
assert sum(1 for lf in leaves if lf.type == "n") == 4 # materialised
|
|
|
|
|
assert all(lf.share == 1 for lf in leaves if lf.type == "n")
|
|
|
|
|
m = [lf for lf in leaves if lf.type == "m"]
|
|
|
|
|
assert len(m) == 1 and m[0].share == 3 # kept collapsed
|
|
|
|
|
canonical(root)
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 22:51:58 +01:00
|
|
|
HARBOR = Path(__file__).parent.parent / "examples" / "harbor-house"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_constructive_topology_has_no_missing_spaces():
|
|
|
|
|
# §11.2: the constructive seeder must instantiate every required space by
|
|
|
|
|
# construction (count + level), so check_space_counts reports zero missing.
|
|
|
|
|
from homemaker_layout import graph, programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
seed = dom.load(str(HARBOR / "init.dom"))
|
|
|
|
|
for trial in range(5):
|
|
|
|
|
root = operators.constructive_topology(
|
|
|
|
|
seed, reqs, np.random.default_rng(trial), types)
|
|
|
|
|
_, missing = graph.check_space_counts(root, reqs)
|
|
|
|
|
assert missing == [], f"trial {trial} left {missing}"
|
|
|
|
|
# required level partition respected: level-N rooms land on storey N
|
|
|
|
|
lvls = dom.levels(root)
|
|
|
|
|
for code, req in reqs.items():
|
|
|
|
|
if code[0].lower() in "cos" or req.level is None:
|
|
|
|
|
continue
|
|
|
|
|
for li, lvl in enumerate(lvls):
|
|
|
|
|
for leaf in lvl.leaves():
|
|
|
|
|
if leaf.type == code:
|
|
|
|
|
assert li == req.level
|
|
|
|
|
canonical(root)
|
|
|
|
|
|
|
|
|
|
|
2026-06-24 18:16:17 +01:00
|
|
|
def test_leaf_share_explicit_and_type_guarded():
|
|
|
|
|
# erc.3 §13.3: explicit multiplicity, honoured only while type==share_type so
|
|
|
|
|
# a retype silently invalidates a stale share (no operator reset needed).
|
|
|
|
|
from homemaker_layout.graph import leaf_share
|
|
|
|
|
|
|
|
|
|
leaf = dom.Node(type="n", share=3, share_type="n")
|
|
|
|
|
assert leaf_share(leaf, 4) == 3
|
|
|
|
|
assert leaf_share(leaf, 2) == 2 # clamped at max_share
|
|
|
|
|
leaf.type = "ba" # retyped → share no longer matches
|
|
|
|
|
assert leaf_share(leaf, 4) == 1
|
|
|
|
|
plain = dom.Node(type="n") # default share 1
|
|
|
|
|
assert leaf_share(plain, 4) == 1
|
2026-06-24 08:30:26 +01:00
|
|
|
|
|
|
|
|
|
2026-06-28 22:04:35 +01:00
|
|
|
def _reqs(**share_kw):
|
|
|
|
|
"""Build a tiny programme: sized 'b' (share per kwarg), sized 'k', unsized 'C'."""
|
|
|
|
|
from homemaker_layout.programme import SpaceReq
|
|
|
|
|
|
|
|
|
|
b = SpaceReq(code="b", size=12.0, has_size=True, count=5)
|
|
|
|
|
if "b" in share_kw:
|
|
|
|
|
b.share, b.has_share = share_kw["b"], True
|
|
|
|
|
k = SpaceReq(code="k", size=20.0, has_size=True, count=4)
|
|
|
|
|
if "k" in share_kw:
|
|
|
|
|
k.share, k.has_share = share_kw["k"], True
|
|
|
|
|
c = SpaceReq(code="C", size=0.0, has_size=False, count=3) # unsized circulation
|
|
|
|
|
return {"b": b, "k": k, "C": c}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mults(plan_entry):
|
|
|
|
|
return sorted(plan_entry)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_share_grain_opt_in_mode():
|
|
|
|
|
# homemaker-py-x3b: factor 0 = per-code opt-in. A code shares iff it carries an
|
|
|
|
|
# explicit share:N>=2; sized codes without the key, and unsized codes, do not.
|
|
|
|
|
reqs = _reqs(b=3)
|
|
|
|
|
assert operators._share_grain(reqs["b"], 0) == 3 # explicit opt-in
|
|
|
|
|
assert operators._share_grain(reqs["k"], 0) == 1 # sized but no key → unshared
|
|
|
|
|
assert operators._share_grain(reqs["C"], 0) == 1 # unsized → never shareable
|
|
|
|
|
assert operators._share_grain(_reqs(b=1)["b"], 0) == 1 # share:1 stays unshared
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_share_grain_global_mode_with_per_code_override():
|
|
|
|
|
# factor>=2 = global: every sized code shares at the factor unless its entry
|
|
|
|
|
# overrides — share:1 opts OUT, share:N sets that code's grain to N.
|
|
|
|
|
reqs = _reqs(b=1, k=4)
|
|
|
|
|
assert operators._share_grain(reqs["b"], 3) == 1 # explicit share:1 → opt out
|
|
|
|
|
assert operators._share_grain(reqs["k"], 3) == 4 # explicit share:4 → grain 4
|
|
|
|
|
assert operators._share_grain(_reqs()["k"], 3) == 3 # no key → global factor 3
|
|
|
|
|
assert operators._share_grain(_reqs()["C"], 3) == 1 # unsized → never shareable
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_share_rooms_opt_in_groups_only_flagged_code():
|
|
|
|
|
# factor 0: only 'b' (share:3) collapses into runs of 3; 'k' and 'C' untouched.
|
|
|
|
|
rooms = ["b"] * 5 + ["k"] * 4 + ["C"] * 3
|
|
|
|
|
reduced, plan = operators._share_rooms(rooms, _reqs(b=3), 0)
|
|
|
|
|
assert _mults(plan["b"]) == [2, 3] # 5 rooms → runs of 3 + 2
|
|
|
|
|
assert plan["k"] == [1, 1, 1, 1] # no share key → unshared
|
|
|
|
|
assert plan["C"] == [1, 1, 1] # unsized → unshared
|
|
|
|
|
assert reduced.count("b") == 2 and reduced.count("k") == 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_share_rooms_global_with_opt_out():
|
|
|
|
|
# factor 3 global: 'k' shares at 3 (no key), 'b' opted OUT via share:1.
|
|
|
|
|
rooms = ["b"] * 5 + ["k"] * 4
|
|
|
|
|
reduced, plan = operators._share_rooms(rooms, _reqs(b=1), 3)
|
|
|
|
|
assert plan["b"] == [1, 1, 1, 1, 1] # share:1 → opt out, stays 5 leaves
|
|
|
|
|
assert _mults(plan["k"]) == [1, 3] # 4 rooms → run of 3 + 1
|
|
|
|
|
# multiplicities always sum back to the original room counts (no rooms lost)
|
|
|
|
|
assert sum(plan["b"]) == 5 and sum(plan["k"]) == 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_share_rooms_default_off_parity():
|
|
|
|
|
# Master switch off path: callers never invoke _share_rooms, but a single
|
|
|
|
|
# instance or grain<2 must yield the identity plan regardless of factor.
|
|
|
|
|
rooms = ["b", "k", "k", "C"]
|
|
|
|
|
reduced, plan = operators._share_rooms(rooms, _reqs(), 0) # opt-in, no keys
|
|
|
|
|
assert reduced == rooms and all(m == 1 for ms in plan.values() for m in ms)
|
|
|
|
|
|
|
|
|
|
|
2026-06-24 08:30:26 +01:00
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_leaf_sharing_reduces_leaves_and_covers_rooms():
|
|
|
|
|
# erc.3 §13.3: leaf_sharing builds fewer leaves, and coverage-counting lets
|
|
|
|
|
# the larger shared leaves satisfy several same-code rooms without missing.
|
|
|
|
|
from homemaker_layout import graph, programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
seed = dom.load(str(HARBOR / "init.dom"))
|
|
|
|
|
for trial in range(3):
|
|
|
|
|
plain = operators.constructive_topology(
|
|
|
|
|
seed, reqs, np.random.default_rng(trial), types)
|
|
|
|
|
shared = operators.constructive_topology(
|
|
|
|
|
seed, reqs, np.random.default_rng(trial), types,
|
|
|
|
|
leaf_sharing=True, leaf_share_factor=2)
|
|
|
|
|
|
|
|
|
|
n_plain = sum(len(l.leaves()) for l in dom.levels(plain))
|
|
|
|
|
n_shared = sum(len(l.leaves()) for l in dom.levels(shared))
|
|
|
|
|
assert n_shared < n_plain, f"trial {trial}: {n_shared} !< {n_plain}"
|
|
|
|
|
|
|
|
|
|
# Default-OFF parity: the flag defaults reproduce the strict count check.
|
|
|
|
|
assert (graph.check_space_counts(shared, reqs)
|
|
|
|
|
== graph.check_space_counts(shared, reqs, leaf_sharing=False))
|
|
|
|
|
|
|
|
|
|
# Coverage suppresses missings: the shared tree scored WITH leaf_sharing
|
|
|
|
|
# has fewer missing fails than the same tree scored without it.
|
|
|
|
|
_strict, miss_off = graph.check_space_counts(shared, reqs)
|
|
|
|
|
_cov, miss_on = graph.check_space_counts(shared, reqs, leaf_sharing=True)
|
|
|
|
|
assert len(miss_on) < len(miss_off), f"trial {trial}: sharing didn't cover"
|
|
|
|
|
|
|
|
|
|
|
2026-06-28 07:20:20 +01:00
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_interior_outside_seeds_landlocked_wells_and_scales_count():
|
|
|
|
|
# ld2 §13.6: interior_outside seeds O on the most landlocked leaves (lower
|
|
|
|
|
# external-perimeter exposure) instead of the most peripheral one, and scales
|
|
|
|
|
# the O count with the room count. Construction must still cover every room.
|
|
|
|
|
from homemaker_layout import graph, geometry, programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
seed = dom.load(str(HARBOR / "init.dom"))
|
|
|
|
|
|
|
|
|
|
def _outside_exposure(root):
|
|
|
|
|
geometry.clear_cache()
|
2026-08-03 11:03:02 +01:00
|
|
|
dom.link(root)
|
2026-06-28 07:20:20 +01:00
|
|
|
exps, n_o = [], 0
|
|
|
|
|
for lvl in dom.levels(root):
|
|
|
|
|
for leaf in lvl.leaves():
|
|
|
|
|
if leaf.type and leaf.type[0].lower() == "o":
|
|
|
|
|
n_o += 1
|
|
|
|
|
exps.append(operators._ext_exposure(leaf))
|
|
|
|
|
return n_o, (sum(exps) / len(exps) if exps else 0.0)
|
|
|
|
|
|
|
|
|
|
for trial in range(3):
|
|
|
|
|
peri = operators.constructive_topology(
|
2026-06-28 07:29:42 +01:00
|
|
|
seed, reqs, np.random.default_rng(trial), types,
|
|
|
|
|
interior_outside=False)
|
2026-06-28 07:20:20 +01:00
|
|
|
inter = operators.constructive_topology(
|
|
|
|
|
seed, reqs, np.random.default_rng(trial), types,
|
|
|
|
|
interior_outside=True, outside_divisor=3)
|
|
|
|
|
|
|
|
|
|
# no missing rooms either way
|
|
|
|
|
assert graph.check_space_counts(inter, reqs)[1] == []
|
|
|
|
|
|
|
|
|
|
n_peri, _exp_peri = _outside_exposure(peri)
|
|
|
|
|
n_inter, exp_inter = _outside_exposure(inter)
|
|
|
|
|
|
|
|
|
|
# the lever adds more outside leaves (scaled with room count)…
|
|
|
|
|
assert n_inter > n_peri, f"trial {trial}: {n_inter} !> {n_peri}"
|
|
|
|
|
# …and places them on landlocked leaves: a well averaging < 1 external
|
|
|
|
|
# plot edge is interior by construction (peripheral mode does not aim
|
|
|
|
|
# for this — its single O is chosen by circulation distance, so it can
|
|
|
|
|
# land anywhere — hence we assert the absolute landlocked property).
|
|
|
|
|
assert exp_inter < 1.0, (
|
|
|
|
|
f"trial {trial}: interior O wells not landlocked (mean exp {exp_inter})")
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 09:23:12 +01:00
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_adjacency_aware_seeding_cuts_adjacency_access_fails():
|
|
|
|
|
# s44: adjacency-aware construction clusters rooms around a connected
|
|
|
|
|
# circulation spine, cutting the adjacency-to-c + access fails that random
|
|
|
|
|
# type assignment leaves stranded. Compare like-for-like over several seeds.
|
|
|
|
|
import copy
|
|
|
|
|
|
|
|
|
|
from homemaker_layout import fitness, programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
conf, cost = fitness.load_config(str(HARBOR))
|
|
|
|
|
fit = fitness.Fitness(conf, cost)
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
seed = dom.load(str(HARBOR / "init.dom"))
|
|
|
|
|
|
|
|
|
|
def adj_access(aware: bool) -> float:
|
|
|
|
|
total = 0
|
|
|
|
|
for trial in range(6):
|
|
|
|
|
root = operators.constructive_topology(
|
|
|
|
|
seed, reqs, np.random.default_rng(trial), types,
|
|
|
|
|
adjacency_aware=aware)
|
|
|
|
|
_, fails = fit.score_with_fails(copy.deepcopy(root))
|
|
|
|
|
total += sum(1 for f in fails if "adjacent" in f or "access" in f
|
|
|
|
|
or "inaccessible" in f)
|
|
|
|
|
return total / 6
|
|
|
|
|
|
|
|
|
|
assert adj_access(True) < adj_access(False)
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 11:47:40 +01:00
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_adjacency_aware_lift_cuts_adjacency_access_fails():
|
|
|
|
|
# ld5: lift_base_to_storeys grows the upper-floor circulation spine off the
|
|
|
|
|
# inherited core and clusters rooms around it, cutting the same fail classes
|
|
|
|
|
# on the storeys above the base.
|
|
|
|
|
import copy
|
|
|
|
|
|
|
|
|
|
from homemaker_layout import fitness, programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
conf, cost = fitness.load_config(str(HARBOR))
|
|
|
|
|
fit = fitness.Fitness(conf, cost)
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
n_st = programme.n_storeys_required(reqs)
|
|
|
|
|
seed = dom.load(str(HARBOR / "init.dom"))
|
|
|
|
|
|
|
|
|
|
def adj_access(aware: bool) -> float:
|
|
|
|
|
total = 0
|
|
|
|
|
for trial in range(5):
|
|
|
|
|
rng = np.random.default_rng(trial)
|
|
|
|
|
buckets = programme.partition_rooms_by_storey(reqs, n_st, rng)
|
|
|
|
|
base = operators.constructive_topology(seed, reqs, rng, types)
|
|
|
|
|
base0 = dom.levels(base)[0]
|
|
|
|
|
base0.above = None
|
|
|
|
|
lifted = operators.lift_base_to_storeys(
|
|
|
|
|
base0, buckets[1:], rng, types, reqs=reqs, adjacency_aware=aware)
|
|
|
|
|
_, fails = fit.score_with_fails(copy.deepcopy(lifted))
|
|
|
|
|
total += sum(1 for f in fails if "adjacent" in f or "access" in f
|
|
|
|
|
or "inaccessible" in f)
|
|
|
|
|
return total / 5
|
|
|
|
|
|
|
|
|
|
assert adj_access(True) < adj_access(False)
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 00:00:51 +01:00
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_construction_beam_width_default_matches_greedy():
|
|
|
|
|
# homemaker-py-c94: beam_width=1 (the default, both explicit and implicit)
|
|
|
|
|
# must reproduce the prior one-shot greedy room placement byte-for-byte.
|
|
|
|
|
from homemaker_layout import programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
seed = dom.load(str(HARBOR / "init.dom"))
|
|
|
|
|
for trial in range(3):
|
|
|
|
|
plain = operators.constructive_topology(
|
|
|
|
|
seed, reqs, np.random.default_rng(trial), types)
|
|
|
|
|
explicit = operators.constructive_topology(
|
|
|
|
|
seed, reqs, np.random.default_rng(trial), types,
|
|
|
|
|
construction_beam_width=1)
|
|
|
|
|
assert genome.encode(plain) == genome.encode(explicit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_construction_beam_width_yields_valid_seed():
|
|
|
|
|
# A beam_width>1 seed must still satisfy the same construction invariants
|
|
|
|
|
# as the greedy path: every required space present, canonical genome.
|
|
|
|
|
from homemaker_layout import graph, programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
seed = dom.load(str(HARBOR / "init.dom"))
|
|
|
|
|
for trial in range(5):
|
|
|
|
|
root = operators.constructive_topology(
|
|
|
|
|
seed, reqs, np.random.default_rng(trial), types,
|
|
|
|
|
construction_beam_width=4)
|
|
|
|
|
_, missing = graph.check_space_counts(root, reqs)
|
|
|
|
|
assert missing == [], f"trial {trial} left {missing}"
|
|
|
|
|
canonical(root)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_construction_beam_width_lift_yields_valid_seed():
|
|
|
|
|
# Each upper storey's placed room multiset must match its requested
|
|
|
|
|
# bucket exactly (the invariant lift_base_to_storeys/_assign_adjacency_
|
|
|
|
|
# aware owns) and the whole tree must stay canonical. Unlike
|
|
|
|
|
# constructive_topology, a base built from the FULL reqs (as in
|
|
|
|
|
# test_adjacency_aware_lift_cuts_adjacency_access_fails above) need not
|
|
|
|
|
# sum with an independently-drawn upper bucket split to the whole-building
|
|
|
|
|
# total, so this checks the per-storey bucket invariant instead of
|
|
|
|
|
# graph.check_space_counts.
|
|
|
|
|
from collections import Counter
|
|
|
|
|
|
|
|
|
|
from homemaker_layout import programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
n_st = programme.n_storeys_required(reqs)
|
|
|
|
|
seed = dom.load(str(HARBOR / "init.dom"))
|
|
|
|
|
for trial in range(3):
|
|
|
|
|
rng = np.random.default_rng(trial)
|
|
|
|
|
buckets = programme.partition_rooms_by_storey(reqs, n_st, rng)
|
|
|
|
|
base = operators.constructive_topology(seed, reqs, rng, types)
|
|
|
|
|
base0 = dom.levels(base)[0]
|
|
|
|
|
base0.above = None
|
|
|
|
|
lifted = operators.lift_base_to_storeys(
|
|
|
|
|
base0, buckets[1:], rng, types, reqs=reqs,
|
|
|
|
|
construction_beam_width=4)
|
|
|
|
|
lvls = dom.levels(lifted)
|
|
|
|
|
for bucket, lvl in zip(buckets[1:], lvls[1:]):
|
|
|
|
|
placed = Counter(lf.type for lf in lvl.leaves() if lf.type in bucket)
|
|
|
|
|
assert placed == Counter(bucket), f"trial {trial}: {placed} != {bucket}"
|
|
|
|
|
canonical(lifted)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_beam_place_rooms_is_deterministic_given_inputs():
|
|
|
|
|
# _beam_place_rooms takes no rng — same inputs must give the same
|
|
|
|
|
# placement every call (only the caller's code-order shuffle is
|
|
|
|
|
# stochastic, already exercised via constructive_topology above).
|
|
|
|
|
class Req:
|
|
|
|
|
def __init__(self, adjacency):
|
|
|
|
|
self.adjacency = adjacency
|
|
|
|
|
|
|
|
|
|
reqs = {"a": Req([("c",)]), "b": Req([("a",)]), "c": Req([])}
|
|
|
|
|
slots = [dom.Node(type=None) for _ in range(3)]
|
|
|
|
|
idx = {L: i for i, L in enumerate(slots)}
|
|
|
|
|
deg = {L: 1 for L in slots}
|
|
|
|
|
dominated = set(slots)
|
|
|
|
|
|
|
|
|
|
def _nbrs(L):
|
|
|
|
|
return set(slots) - {L}
|
|
|
|
|
|
|
|
|
|
codes = ["a", "b"]
|
|
|
|
|
r1 = operators._beam_place_rooms(codes, slots, dominated, deg, idx,
|
|
|
|
|
_nbrs, reqs, beam_width=2)
|
|
|
|
|
r2 = operators._beam_place_rooms(codes, slots, dominated, deg, idx,
|
|
|
|
|
_nbrs, reqs, beam_width=2)
|
|
|
|
|
assert r1 == r2
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 09:19:36 +01:00
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_construction_assign_cpsat_yields_valid_seed():
|
|
|
|
|
# homemaker-py-2g7.5: the CP-SAT room-labelling path must satisfy the same
|
|
|
|
|
# construction invariants as the greedy path — every required space
|
|
|
|
|
# present, canonical genome.
|
|
|
|
|
from homemaker_layout import graph, programme
|
|
|
|
|
|
Recover most of the cpsat test-suite cost; keep the part that is real
38.20's cap fix took the suite from ~4.5 to ~10 min because the
assign_cpsat tests now solve to optimality. Recovered to ~6.8 min.
The bigger win was not the threading. The secondary-adjacency test ran the
cpsat arm THREE times and averaged, and its own comment says why: the cpsat
path "is not yet bit-reproducible (homemaker-py-fdp)", so one 10-seed
aggregate could straddle greedy's deterministic value and the test was
flaky by construction. fdp is fixed (38.15), so one pass says exactly what
three did -- that was work spent papering over a bug that no longer exists.
constructive_topology and _assign_adjacency_aware now forward an optional
cpsat_limits=(time_limit_s, deterministic_limit); default None keeps
solve_room_labels' defaults, so production is unchanged -- verified 24/24
harbor solves still OPTIMAL at the defaults. It is not a tuning knob: it
exists so a test whose claim does not depend on optimality can economise.
test_construction_assign_cpsat_yields_valid_seed asserts invariants only
and uses it, 91s -> 53s.
That test now also guards a real trap: too small a budget makes
solve_room_labels return None, _assign_adjacency_aware falls back to
greedy, and the test would pass while exercising nothing. It counts
fallbacks and fails if any occur.
The two quality comparisons keep the full budget deliberately -- their
claims are about the optimum, and cheapening them would weaken what they
assert. That is why the suite does not return to 4.5 min; the residue is
the honest price of optimal deterministic solves.
Also corrected a stale claim in the secondary-adjacency comment: it
measures only "not adjacent to" fails and is not a claim that cpsat seeds
better overall, which 38.20 measured markedly worse.
Closes homemaker-py-7t1.
Lint at parity (46); tests 384 passed, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-29 18:55:52 +00:00
|
|
|
from homemaker_layout import cpsat as cpsat_mod
|
|
|
|
|
|
2026-08-04 09:19:36 +01:00
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
seed = dom.load(str(HARBOR / "init.dom"))
|
Recover most of the cpsat test-suite cost; keep the part that is real
38.20's cap fix took the suite from ~4.5 to ~10 min because the
assign_cpsat tests now solve to optimality. Recovered to ~6.8 min.
The bigger win was not the threading. The secondary-adjacency test ran the
cpsat arm THREE times and averaged, and its own comment says why: the cpsat
path "is not yet bit-reproducible (homemaker-py-fdp)", so one 10-seed
aggregate could straddle greedy's deterministic value and the test was
flaky by construction. fdp is fixed (38.15), so one pass says exactly what
three did -- that was work spent papering over a bug that no longer exists.
constructive_topology and _assign_adjacency_aware now forward an optional
cpsat_limits=(time_limit_s, deterministic_limit); default None keeps
solve_room_labels' defaults, so production is unchanged -- verified 24/24
harbor solves still OPTIMAL at the defaults. It is not a tuning knob: it
exists so a test whose claim does not depend on optimality can economise.
test_construction_assign_cpsat_yields_valid_seed asserts invariants only
and uses it, 91s -> 53s.
That test now also guards a real trap: too small a budget makes
solve_room_labels return None, _assign_adjacency_aware falls back to
greedy, and the test would pass while exercising nothing. It counts
fallbacks and fails if any occur.
The two quality comparisons keep the full budget deliberately -- their
claims are about the optimum, and cheapening them would weaken what they
assert. That is why the suite does not return to 4.5 min; the residue is
the honest price of optimal deterministic solves.
Also corrected a stale claim in the secondary-adjacency comment: it
measures only "not adjacent to" fails and is not a claim that cpsat seeds
better overall, which 38.20 measured markedly worse.
Closes homemaker-py-7t1.
Lint at parity (46); tests 384 passed, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-29 18:55:52 +00:00
|
|
|
|
|
|
|
|
# This test asserts INVARIANTS -- every required space present, canonical
|
|
|
|
|
# genome -- which do not depend on the labelling being optimal. So it buys
|
|
|
|
|
# its runtime back with a reduced deterministic budget (homemaker-py-7t1);
|
|
|
|
|
# harbor's model needs ~3.6 work units for optimality since §38.14's added
|
|
|
|
|
# adjacency, and paying that here dominated the suite for no extra coverage.
|
|
|
|
|
#
|
|
|
|
|
# The trap: too small a budget makes solve_room_labels return None, and
|
|
|
|
|
# _assign_adjacency_aware then falls back to GREEDY -- the test would pass
|
|
|
|
|
# while exercising nothing. Guarded by counting fallbacks and requiring the
|
|
|
|
|
# solver to have answered every time.
|
|
|
|
|
fell_back = []
|
|
|
|
|
orig = cpsat_mod.solve_room_labels
|
|
|
|
|
|
|
|
|
|
def counting(*a, **k):
|
|
|
|
|
r = orig(*a, **k)
|
|
|
|
|
fell_back.append(r is None)
|
|
|
|
|
return r
|
|
|
|
|
|
|
|
|
|
cpsat_mod.solve_room_labels = counting
|
|
|
|
|
operators.cpsat = cpsat_mod
|
|
|
|
|
try:
|
|
|
|
|
for trial in range(5):
|
|
|
|
|
root = operators.constructive_topology(
|
|
|
|
|
seed, reqs, np.random.default_rng(trial), types,
|
|
|
|
|
assign_solver="cpsat", cpsat_limits=(30.0, 0.25))
|
|
|
|
|
_, missing = graph.check_space_counts(root, reqs)
|
|
|
|
|
assert missing == [], f"trial {trial} left {missing}"
|
|
|
|
|
canonical(root)
|
|
|
|
|
finally:
|
|
|
|
|
cpsat_mod.solve_room_labels = orig
|
|
|
|
|
operators.cpsat = cpsat_mod
|
|
|
|
|
|
|
|
|
|
assert fell_back and not any(fell_back), (
|
|
|
|
|
f"cpsat fell back to greedy on {sum(fell_back)}/{len(fell_back)} solves "
|
|
|
|
|
f"-- the reduced budget is too small and this test stopped testing cpsat")
|
2026-08-04 09:19:36 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_assign_cpsat_matches_or_beats_greedy_secondary_adjacency():
|
|
|
|
|
# homemaker-py-2g7.5: CP-SAT solves the same room-labelling decision the
|
|
|
|
|
# greedy/beam heuristic approximates exactly — its secondary-adjacency
|
|
|
|
|
# (not the access/adjacent-to-c fails the dominating-set step already
|
|
|
|
|
# solves) fail count must be strictly lower in aggregate.
|
|
|
|
|
import copy
|
|
|
|
|
|
|
|
|
|
from homemaker_layout import fitness, programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
conf, cost = fitness.load_config(str(HARBOR))
|
|
|
|
|
fit = fitness.Fitness(conf, cost)
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
seed = dom.load(str(HARBOR / "init.dom"))
|
|
|
|
|
|
|
|
|
|
def secondary_fails(solver: str) -> list[int]:
|
|
|
|
|
counts = []
|
|
|
|
|
for trial in range(10):
|
|
|
|
|
root = operators.constructive_topology(
|
|
|
|
|
seed, reqs, np.random.default_rng(trial), types,
|
|
|
|
|
assign_solver=solver)
|
|
|
|
|
_, fails = fit.score_with_fails(copy.deepcopy(root))
|
|
|
|
|
counts.append(sum(1 for f in fails if "not adjacent to" in f))
|
|
|
|
|
return counts
|
|
|
|
|
|
|
|
|
|
# Per-seed outcomes are noisy (both solvers depend on the same random
|
|
|
|
|
# room-order shuffle before falling into their own placement logic), so
|
|
|
|
|
# the comparison is on the aggregate over several seeds, not every seed
|
Recover most of the cpsat test-suite cost; keep the part that is real
38.20's cap fix took the suite from ~4.5 to ~10 min because the
assign_cpsat tests now solve to optimality. Recovered to ~6.8 min.
The bigger win was not the threading. The secondary-adjacency test ran the
cpsat arm THREE times and averaged, and its own comment says why: the cpsat
path "is not yet bit-reproducible (homemaker-py-fdp)", so one 10-seed
aggregate could straddle greedy's deterministic value and the test was
flaky by construction. fdp is fixed (38.15), so one pass says exactly what
three did -- that was work spent papering over a bug that no longer exists.
constructive_topology and _assign_adjacency_aware now forward an optional
cpsat_limits=(time_limit_s, deterministic_limit); default None keeps
solve_room_labels' defaults, so production is unchanged -- verified 24/24
harbor solves still OPTIMAL at the defaults. It is not a tuning knob: it
exists so a test whose claim does not depend on optimality can economise.
test_construction_assign_cpsat_yields_valid_seed asserts invariants only
and uses it, 91s -> 53s.
That test now also guards a real trap: too small a budget makes
solve_room_labels return None, _assign_adjacency_aware falls back to
greedy, and the test would pass while exercising nothing. It counts
fallbacks and fails if any occur.
The two quality comparisons keep the full budget deliberately -- their
claims are about the optimum, and cheapening them would weaken what they
assert. That is why the suite does not return to 4.5 min; the residue is
the honest price of optimal deterministic solves.
Also corrected a stale claim in the secondary-adjacency comment: it
measures only "not adjacent to" fails and is not a claim that cpsat seeds
better overall, which 38.20 measured markedly worse.
Closes homemaker-py-7t1.
Lint at parity (46); tests 384 passed, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-29 18:55:52 +00:00
|
|
|
# individually.
|
|
|
|
|
#
|
|
|
|
|
# This used to run the cpsat arm THREE times and compare the mean, because
|
|
|
|
|
# `homemaker-py-fdp` left the cpsat path non-bit-reproducible -- a single
|
|
|
|
|
# 10-seed aggregate could straddle greedy's deterministic value, so the test
|
|
|
|
|
# was flaky by construction. fdp is fixed (§38.15: `noncirc` was ordered by
|
|
|
|
|
# `id()`), so cpsat is now deterministic and one pass says exactly as much
|
|
|
|
|
# as three did, at a third of the cost -- this test dominated the suite.
|
|
|
|
|
#
|
|
|
|
|
# NOTE this measures ONLY secondary-adjacency fails ("not adjacent to"),
|
|
|
|
|
# which is the decision cpsat actually solves. It is not a claim that cpsat
|
|
|
|
|
# seeds better overall: measured over 12 seeds it is markedly WORSE on total
|
|
|
|
|
# fails (§38.20), which is why `assign_solver` stays default greedy.
|
§39.4 completion + §39.5 retraction + §39.6: the usage namespace is NOT clean
Answering "are we clean". Generic namespace: yes. Usage namespace: no.
FINISH §39.4. The first sweep missed sites, found by a full re-grep:
graph.py's free-area budget, operators.py host-preference / keep-type /
repair-candidate, fitness.py's ("l","c","k") public-access test, bubble.py's
generic adjacency reference, and -- the important one -- cpsat.py, which was
still matching adjacency by raw startswith. graph.code_matches_requirement is
now the single public answer to "does this leaf count as the thing the
programme asked to be next to", shared by has_adjacency, has_vertical_connection
and cpsat.
RETRACT §39.5. It concluded 2g7.5's CP-SAT seeder win did not survive the
correction. That was wrong. The cause was the missed cpsat matcher above: the
exact solver was optimising a different relation than the scorer checked, so a
failing test reporting an incomplete sweep was misread as a baseline shift.
Re-measured over 6 seeds, cpsat now wins on both programmes (harbor 102/92,
maple 156/154). xfail removed.
REAL BUG UNDERNEATH: CP-SAT was never deterministic despite
num_search_workers=1 and a comment claiming it. neighbors[slot] is a set of
dom.Node, which hashes by id() -- a memory address -- so raw iteration made the
model-build order vary and CP-SAT returned a different equally-optimal
assignment each run (measured 194/180/171/182 over four identical aggregates).
sorted() on the slot indices fixes it. Also paired the wall-clock cap with
max_deterministic_time (solves run ~124ms against a 2s cap, so nothing was
timing out -- latent hazard, not the cause). solve_room_labels is now
reproducible on every captured instance; constructive_topology on the cpsat
path still is not, filed as homemaker-py-fdp (plausible contributor to b8g).
§39.6 THE SECOND NAMESPACE. Usage prefixes b/t/l/k (bedroom/toilet/living/
kitchen) classify programme codes by first letter and stay prefix-based by
design, but they are not inert: has_circulation deletes graph edges from them.
Four corpus rooms are misclassified by spelling -- la1 "Laundry Room" and li1
"Library Corner" as living, br1 "Staff Room" as bedroom, tr1 "Treatment Room"
as toilet. Measured on a health-centre seed: tr1 loses its edge to the adjacent
O, br1 loses its edge to t10 "Staff WC" -- both feed the connectivity fails §38
found persisting. Filed homemaker-py-sel; an explicit usage: key is the fix,
but it changes fitness for correctly-spelled programmes too so it needs its own
A/B.
DOCS. README gains a "Room codes and reserved names" section; CLAUDE.md and
AGENTS.md gain the same summary for agents. audit_programme_config.py now
reports the usage class each code picks up alongside the namespace and
satisfiability checks. DESIGN §37.2's note calling the c/o/s quirk "existing
product behaviour, not a bug" is annotated as superseded.
Corpus audit: zero generic-namespace violations across all ten example
programmes. 346 passed, same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 10:09:14 +00:00
|
|
|
greedy = sum(secondary_fails("greedy"))
|
Recover most of the cpsat test-suite cost; keep the part that is real
38.20's cap fix took the suite from ~4.5 to ~10 min because the
assign_cpsat tests now solve to optimality. Recovered to ~6.8 min.
The bigger win was not the threading. The secondary-adjacency test ran the
cpsat arm THREE times and averaged, and its own comment says why: the cpsat
path "is not yet bit-reproducible (homemaker-py-fdp)", so one 10-seed
aggregate could straddle greedy's deterministic value and the test was
flaky by construction. fdp is fixed (38.15), so one pass says exactly what
three did -- that was work spent papering over a bug that no longer exists.
constructive_topology and _assign_adjacency_aware now forward an optional
cpsat_limits=(time_limit_s, deterministic_limit); default None keeps
solve_room_labels' defaults, so production is unchanged -- verified 24/24
harbor solves still OPTIMAL at the defaults. It is not a tuning knob: it
exists so a test whose claim does not depend on optimality can economise.
test_construction_assign_cpsat_yields_valid_seed asserts invariants only
and uses it, 91s -> 53s.
That test now also guards a real trap: too small a budget makes
solve_room_labels return None, _assign_adjacency_aware falls back to
greedy, and the test would pass while exercising nothing. It counts
fallbacks and fails if any occur.
The two quality comparisons keep the full budget deliberately -- their
claims are about the optimum, and cheapening them would weaken what they
assert. That is why the suite does not return to 4.5 min; the residue is
the honest price of optimal deterministic solves.
Also corrected a stale claim in the secondary-adjacency comment: it
measures only "not adjacent to" fails and is not a claim that cpsat seeds
better overall, which 38.20 measured markedly worse.
Closes homemaker-py-7t1.
Lint at parity (46); tests 384 passed, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-29 18:55:52 +00:00
|
|
|
cpsat = sum(secondary_fails("cpsat"))
|
|
|
|
|
assert cpsat < greedy, f"cpsat {cpsat} vs greedy {greedy}"
|
2026-08-04 09:19:36 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reassign_noop_without_reqs():
|
|
|
|
|
root = genome.decode(genome.encode(dom.load(str(CORPUS / FILES[0]))))
|
|
|
|
|
child, desc = operators.mutate_reassign(root, np.random.default_rng(0), TYPES)
|
|
|
|
|
assert desc == "reassign noop"
|
|
|
|
|
canonical(child)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_reassign_fires_and_preserves_room_multiset():
|
|
|
|
|
# homemaker-py-2g7.5: the reassign operator must fire (find at least one
|
|
|
|
|
# wing to re-label) on a real seeded design, and it must preserve the
|
|
|
|
|
# wing's exact room-code multiset — only the leaf<->code labelling
|
|
|
|
|
# changes, never the topology or which codes are present.
|
|
|
|
|
from collections import Counter
|
|
|
|
|
|
|
|
|
|
from homemaker_layout import programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
seed = dom.load(str(HARBOR / "init.dom"))
|
|
|
|
|
|
Declare toilet-to-sleeping adjacency where the brief supports it
A toilet next to a sleeping room is a positive even with no door between
them (Brand): the adjacency is what makes a later knock-through possible.
The engine already scores it -- check_adjacency runs against the unfiltered
graph_base_pre -- but only where a programme declares it, and only
programme-house did.
Declared:
harbor-house t -> n bathrooms serve the Neighborhoods (communal
sleeping); both unpinned, 6 t / 5 n
maple-court tt -> r Upper Bathrooms among Individual Rooms, both
level 2, already 62% adjacent at seed time
NOT declared, and checking before declaring is what caught these:
maple t -> n is IMPOSSIBLE. Adjacency is evaluated per level, and maple
pins t to level 0, n to level 1. Declaring it would have added six
permanently unsatisfiable fails; the 0% seed-time rate was a hard
impossibility, not search difficulty. maple's ground floor has six
bathrooms and one sleeping room (Clinic Room x1) -- a ground-floor WC in
a communal building is public, so Brand does not apply anyway.
health-centre has no dedicated WC. The ruling was that a treatment room
"may give access to a toilet, but this would be a dedicated toilet"; t9
is a Public WC and t10 a Staff WC. Earning the credit needs a WC added to
the brief -- programme authoring, filed as homemaker-py-5nw.
Both declarations are reachable (best of 8 seeds 2/3 harbor, 2/2 maple), so
the search gets a gradient not a permanent penalty. evolved-3M-nols-3
84 -> 89 fails, all five the new requirement.
Cost: cpsat assignment ~7.5x slower on harbor (0.28 -> 2.11s per seed);
greedy, the default, unchanged at 0.06s. Ordinary runs pay nothing, but
39.5's cpsat-vs-greedy verdict was measured on a cheaper problem than the
corpus now poses -- filed as homemaker-py-vjd.
Two tests were over-fitted to the old seeds and are repaired to assert
their intent, not relaxed to pass: reassign now sweeps six constructive
seeds (seed 0's better-seeded design legitimately has nothing to improve,
5 of 6 others fire), and repair_circulation asserts that repair strictly
helps plus a >=85% bar rather than a sampled 100% hardened into a
guarantee (measured 25% -> 92%, stable over 6 and 12 seeds).
Closes homemaker-py-3qj.
Lint at parity (46); tests 379 passed, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-29 10:57:44 +00:00
|
|
|
# Sweep several constructive seeds rather than pinning seed 0. The operator
|
|
|
|
|
# only fires when it finds a wing worth re-labelling, so a seed that happens
|
|
|
|
|
# to be already-optimal is a legitimate noop, not a broken operator --
|
|
|
|
|
# declaring harbor's `t -> n` adjacency (homemaker-py-3qj) made the
|
|
|
|
|
# adjacency-aware seeder good enough that seed 0 became exactly that case,
|
|
|
|
|
# while 5 of 6 other seeds still fire. Pinning one seed was testing the
|
|
|
|
|
# seeder's luck, not the operator.
|
2026-08-04 09:19:36 +01:00
|
|
|
fired = False
|
Declare toilet-to-sleeping adjacency where the brief supports it
A toilet next to a sleeping room is a positive even with no door between
them (Brand): the adjacency is what makes a later knock-through possible.
The engine already scores it -- check_adjacency runs against the unfiltered
graph_base_pre -- but only where a programme declares it, and only
programme-house did.
Declared:
harbor-house t -> n bathrooms serve the Neighborhoods (communal
sleeping); both unpinned, 6 t / 5 n
maple-court tt -> r Upper Bathrooms among Individual Rooms, both
level 2, already 62% adjacent at seed time
NOT declared, and checking before declaring is what caught these:
maple t -> n is IMPOSSIBLE. Adjacency is evaluated per level, and maple
pins t to level 0, n to level 1. Declaring it would have added six
permanently unsatisfiable fails; the 0% seed-time rate was a hard
impossibility, not search difficulty. maple's ground floor has six
bathrooms and one sleeping room (Clinic Room x1) -- a ground-floor WC in
a communal building is public, so Brand does not apply anyway.
health-centre has no dedicated WC. The ruling was that a treatment room
"may give access to a toilet, but this would be a dedicated toilet"; t9
is a Public WC and t10 a Staff WC. Earning the credit needs a WC added to
the brief -- programme authoring, filed as homemaker-py-5nw.
Both declarations are reachable (best of 8 seeds 2/3 harbor, 2/2 maple), so
the search gets a gradient not a permanent penalty. evolved-3M-nols-3
84 -> 89 fails, all five the new requirement.
Cost: cpsat assignment ~7.5x slower on harbor (0.28 -> 2.11s per seed);
greedy, the default, unchanged at 0.06s. Ordinary runs pay nothing, but
39.5's cpsat-vs-greedy verdict was measured on a cheaper problem than the
corpus now poses -- filed as homemaker-py-vjd.
Two tests were over-fitted to the old seeds and are repaired to assert
their intent, not relaxed to pass: reassign now sweeps six constructive
seeds (seed 0's better-seeded design legitimately has nothing to improve,
5 of 6 others fire), and repair_circulation asserts that repair strictly
helps plus a >=85% bar rather than a sampled 100% hardened into a
guarantee (measured 25% -> 92%, stable over 6 and 12 seeds).
Closes homemaker-py-3qj.
Lint at parity (46); tests 379 passed, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-29 10:57:44 +00:00
|
|
|
for construct_seed in range(6):
|
|
|
|
|
root = operators.constructive_topology(
|
|
|
|
|
seed, reqs, np.random.default_rng(construct_seed), types)
|
|
|
|
|
before = Counter(lf.type for lf in root.leaves())
|
|
|
|
|
for trial in range(20):
|
|
|
|
|
child, desc = operators.mutate_reassign(
|
|
|
|
|
root, np.random.default_rng(trial), types, reqs=reqs)
|
|
|
|
|
canonical(child)
|
|
|
|
|
after = Counter(lf.type for lf in child.leaves())
|
|
|
|
|
assert after == before, (
|
|
|
|
|
f"construct seed {construct_seed}, trial {trial}: "
|
|
|
|
|
f"room multiset changed ({desc})")
|
|
|
|
|
if not desc.endswith("noop"):
|
|
|
|
|
fired = True
|
|
|
|
|
assert fired, "reassign never fired on any of 6 real seeded designs"
|
2026-08-04 09:19:36 +01:00
|
|
|
|
|
|
|
|
|
2026-06-17 22:51:58 +01:00
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_place_missing_repairs_deficient_tree():
|
|
|
|
|
# §11.2 repair: iterating mutate_place_missing drives a deficient design's
|
|
|
|
|
# missing-space count to zero, then noops once the required set is complete.
|
|
|
|
|
from homemaker_layout import graph, programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
rng = np.random.default_rng(0)
|
|
|
|
|
root = dom.load(str(HARBOR / "generated.dom"))
|
|
|
|
|
_, missing0 = graph.check_space_counts(root, reqs)
|
|
|
|
|
assert missing0, "fixture should start deficient"
|
|
|
|
|
for _ in range(len(missing0) + 5):
|
|
|
|
|
root, desc = operators.mutate_place_missing(root, rng, types, reqs=reqs)
|
|
|
|
|
canonical(root)
|
|
|
|
|
_, missing = graph.check_space_counts(root, reqs)
|
|
|
|
|
if not missing:
|
|
|
|
|
break
|
|
|
|
|
assert missing == []
|
|
|
|
|
_, desc = operators.mutate_place_missing(root, rng, types, reqs=reqs)
|
|
|
|
|
assert desc == "place_missing noop"
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 14:07:35 +01:00
|
|
|
def test_crossover_yields_canonical_pair():
|
|
|
|
|
a = genome.decode(genome.encode(dom.load(str(CORPUS / FILES[0]))))
|
|
|
|
|
b = genome.decode(genome.encode(dom.load(str(CORPUS / FILES[1]))))
|
|
|
|
|
for seed in range(5):
|
|
|
|
|
ca, cb, desc = operators.crossover(a, b, np.random.default_rng(seed))
|
|
|
|
|
assert desc.startswith("crossover")
|
|
|
|
|
canonical(ca)
|
|
|
|
|
canonical(cb)
|
2026-06-20 18:54:48 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# 9gp.2 — M3 re-association move
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
def _leaf_types(root: dom.Node) -> list[str]:
|
|
|
|
|
return sorted(lf.type or "." for lvl in dom.levels(root) for lf in lvl.leaves())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _same_axis_chain() -> dom.Node:
|
|
|
|
|
"""A 3-leaf ``(a|b)|c`` tree with two parallel (same-orientation) cuts."""
|
|
|
|
|
root = dom.Node(rotation=0, division=[0.4, 0.4])
|
|
|
|
|
root.left = dom.Node(rotation=0, division=[0.5, 0.5])
|
|
|
|
|
root.left.left = dom.Node(type="A")
|
|
|
|
|
root.left.right = dom.Node(type="B")
|
|
|
|
|
root.right = dom.Node(type="C")
|
2026-08-03 11:03:02 +01:00
|
|
|
dom.link(root)
|
2026-06-20 18:54:48 +01:00
|
|
|
return root
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reassociate_preserves_leaves_changes_shape():
|
|
|
|
|
root = _same_axis_chain()
|
|
|
|
|
before_types = _leaf_types(root)
|
|
|
|
|
before_sig = genome.signature(root)
|
|
|
|
|
child, desc = operators.mutate_reassociate(root, np.random.default_rng(0), TYPES)
|
|
|
|
|
assert "noop" not in desc
|
|
|
|
|
# leaf set + types are an invariant; only the tree shape changes
|
|
|
|
|
assert _leaf_types(child) == before_types
|
|
|
|
|
assert genome.signature(child) != before_sig
|
|
|
|
|
canonical(child)
|
|
|
|
|
# parent untouched in place
|
|
|
|
|
assert genome.signature(root) == before_sig
|
|
|
|
|
canonical(root)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reassociate_noop_on_perpendicular_cuts():
|
|
|
|
|
# Outer cut rotation 0, inner cut rotation 1 (perpendicular) → not the
|
|
|
|
|
# associativity precondition, so there is no candidate and it noops.
|
|
|
|
|
root = dom.Node(rotation=0, division=[0.4, 0.4])
|
|
|
|
|
root.left = dom.Node(rotation=1, division=[0.5, 0.5])
|
|
|
|
|
root.left.left = dom.Node(type="A")
|
|
|
|
|
root.left.right = dom.Node(type="B")
|
|
|
|
|
root.right = dom.Node(type="C")
|
2026-08-03 11:03:02 +01:00
|
|
|
dom.link(root)
|
2026-06-20 18:54:48 +01:00
|
|
|
_, desc = operators.mutate_reassociate(root, np.random.default_rng(0), TYPES)
|
|
|
|
|
assert desc == "reassociate noop"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_reassociate_on_corpus_is_canonical_and_total():
|
|
|
|
|
from homemaker_layout import programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
root = dom.load(str(HARBOR / "generated.dom"))
|
|
|
|
|
before = _leaf_types(root)
|
|
|
|
|
for seed in range(8):
|
|
|
|
|
child, desc = operators.mutate_reassociate(root, np.random.default_rng(seed), types)
|
|
|
|
|
canonical(child)
|
|
|
|
|
if "noop" not in desc:
|
|
|
|
|
# leaf multiset preserved even on a real multi-storey tree
|
|
|
|
|
assert _leaf_types(child) == before
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# 9gp.1 — shape-feasibility proxy
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_predicted_shape_fails_is_nonneg_and_pure():
|
|
|
|
|
from homemaker_layout import fitness, programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
conf, cost = fitness.load_config(str(HARBOR))
|
|
|
|
|
fit = fitness.Fitness(conf, cost)
|
|
|
|
|
root = dom.load(str(HARBOR / "generated.dom"))
|
|
|
|
|
n_leaves = sum(len(lvl.leaves()) for lvl in dom.levels(root))
|
|
|
|
|
|
|
|
|
|
pred = operators.predicted_shape_fails(root, reqs, fit)
|
|
|
|
|
assert isinstance(pred, int) and pred >= 0
|
|
|
|
|
# input root is untouched (a deep copy is laid out and scored)
|
|
|
|
|
assert sum(len(lvl.leaves()) for lvl in dom.levels(root)) == n_leaves
|
|
|
|
|
# deterministic
|
|
|
|
|
assert operators.predicted_shape_fails(root, reqs, fit) == pred
|
2026-07-19 11:06:56 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# 7fm — targeted shape repair (shape_rotate / deslim)
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_shape_failing_flags_known_fail_only():
|
|
|
|
|
from homemaker_layout import fitness, programme
|
|
|
|
|
|
|
|
|
|
conf, cost = fitness.load_config(str(HARBOR))
|
|
|
|
|
fit = fitness.Fitness(conf, cost)
|
|
|
|
|
root = dom.load(str(HARBOR / "generated.dom"))
|
|
|
|
|
lvl0 = dom.levels(root)[0]
|
|
|
|
|
|
|
|
|
|
# generated.dom/0/rr (type "r") has a real proportion fail (fixture,
|
|
|
|
|
# verified via homemaker-fitness); an outside leaf is never a candidate
|
|
|
|
|
# regardless of its geometry.
|
|
|
|
|
assert operators._shape_failing(lvl0.by_id("rr"), fit)
|
|
|
|
|
assert not operators._shape_failing(lvl0.by_id("lllrl"), fit) # type O
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_mutate_shape_rotate_noop_without_fit():
|
|
|
|
|
root = dom.load(str(HARBOR / "generated.dom"))
|
|
|
|
|
child, desc = operators.mutate_shape_rotate(root, np.random.default_rng(0), TYPES)
|
|
|
|
|
assert "noop" in desc
|
|
|
|
|
canonical(child)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _with_forced_slim_leaf(root: dom.Node, code: str = "r") -> tuple[dom.Node, str]:
|
|
|
|
|
"""Force a real, deterministic shape fail: divide the largest outside leaf
|
|
|
|
|
95/5 into (``code``, "C"). The 5% side is narrow/high-aspect on any real
|
|
|
|
|
plot, and both sides are fresh leaves (a valid deslim candidate too),
|
|
|
|
|
unlike the fixture's organic fails which may not have a mergeable sibling."""
|
|
|
|
|
from homemaker_layout import geometry
|
|
|
|
|
|
|
|
|
|
child = copy.deepcopy(root)
|
|
|
|
|
lvl0 = dom.levels(child)[0]
|
|
|
|
|
host = max((lf for lf in lvl0.leaves() if lf.type == "O"), key=geometry.area)
|
|
|
|
|
host_id = host.id
|
|
|
|
|
host.division = [0.05, 0.05]
|
|
|
|
|
host.rotation = 0
|
|
|
|
|
host.left = dom.Node(type=code)
|
|
|
|
|
host.right = dom.Node(type="C")
|
|
|
|
|
host.type = None
|
|
|
|
|
child = operators._finalise(child)
|
|
|
|
|
leaf_id = (host_id + "l") if host_id else "l"
|
|
|
|
|
return child, leaf_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_mutate_shape_rotate_targets_a_failing_cut():
|
|
|
|
|
from homemaker_layout import fitness, programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
conf, cost = fitness.load_config(str(HARBOR))
|
|
|
|
|
fit = fitness.Fitness(conf, cost)
|
|
|
|
|
root, leaf_id = _with_forced_slim_leaf(dom.load(str(HARBOR / "generated.dom")))
|
|
|
|
|
assert operators._shape_failing(dom.levels(root)[0].by_id(leaf_id), fit)
|
|
|
|
|
|
|
|
|
|
child, desc = operators.mutate_shape_rotate(root, np.random.default_rng(0), types, fit=fit)
|
|
|
|
|
assert "noop" not in desc
|
|
|
|
|
canonical(child)
|
|
|
|
|
# only the rotation of the targeted cut changes; leaf multiset preserved
|
|
|
|
|
assert _leaf_types(child) == _leaf_types(root)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_mutate_deslim_merges_failing_leaf_and_is_repairable():
|
|
|
|
|
from homemaker_layout import fitness, graph, programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
conf, cost = fitness.load_config(str(HARBOR))
|
|
|
|
|
fit = fitness.Fitness(conf, cost)
|
|
|
|
|
root, _leaf_id = _with_forced_slim_leaf(dom.load(str(HARBOR / "generated.dom")))
|
|
|
|
|
n_leaves = sum(len(lvl.leaves()) for lvl in dom.levels(root))
|
|
|
|
|
|
|
|
|
|
child, desc = operators.mutate_deslim(root, np.random.default_rng(0), types, fit=fit)
|
|
|
|
|
assert "noop" not in desc
|
|
|
|
|
canonical(child)
|
|
|
|
|
# a merge strictly reduces the leaf count...
|
|
|
|
|
assert sum(len(lvl.leaves()) for lvl in dom.levels(child)) == n_leaves - 1
|
|
|
|
|
# ...and the displaced room is repairable by the existing place_missing op
|
|
|
|
|
_, missing = graph.check_space_counts(child, reqs)
|
|
|
|
|
assert missing
|
|
|
|
|
rng = np.random.default_rng(0)
|
|
|
|
|
for _ in range(len(missing) + 5):
|
|
|
|
|
child, _ = operators.mutate_place_missing(child, rng, types, reqs=reqs)
|
|
|
|
|
_, missing = graph.check_space_counts(child, reqs)
|
|
|
|
|
if not missing:
|
|
|
|
|
break
|
|
|
|
|
assert missing == []
|
2026-07-24 19:48:13 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# 8sh — insert/relocate-circulation repair (mechanism (a) follow-on to qi6)
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
def _row_of_three(mid_type: str) -> dom.Node:
|
|
|
|
|
"""Three same-height leaves in a row: ``C | [mid_type | C]``. The two ``C``
|
|
|
|
|
leaves each share a full-height edge with the middle leaf but not with
|
|
|
|
|
each other, so their circulation components are disconnected — a 2-fail
|
|
|
|
|
``level 0 not connected`` fixture for a single ``mid_type`` leaf bridge."""
|
|
|
|
|
root = dom.Node(rotation=0, division=[1 / 3, 1 / 3],
|
|
|
|
|
node=[[0, 0], [12, 0], [12, 4], [0, 4]],
|
|
|
|
|
height=2.7, wall_outer=0.25, wall_inner=0.08)
|
|
|
|
|
root.left = dom.Node(type="C")
|
|
|
|
|
root.right = dom.Node(rotation=0, division=[0.5, 0.5])
|
|
|
|
|
root.right.left = dom.Node(type=mid_type)
|
|
|
|
|
root.right.right = dom.Node(type="C")
|
2026-08-03 11:03:02 +01:00
|
|
|
dom.link(root)
|
2026-07-24 19:48:13 +01:00
|
|
|
return root
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _diamond(top_right_type: str) -> dom.Node:
|
|
|
|
|
"""2x2 grid: ``C``/``O`` on the left column, ``top_right_type``/``C`` on
|
|
|
|
|
the right, so the two ``C`` corners have two equal-length bridge routes —
|
|
|
|
|
one through the free ``O`` leaf, one through ``top_right_type``."""
|
|
|
|
|
root = dom.Node(rotation=0, division=[0.5, 0.5],
|
|
|
|
|
node=[[0, 0], [8, 0], [8, 8], [0, 8]],
|
|
|
|
|
height=2.7, wall_outer=0.25, wall_inner=0.08)
|
|
|
|
|
root.left = dom.Node(rotation=1, division=[0.5, 0.5])
|
|
|
|
|
root.left.left = dom.Node(type="C")
|
|
|
|
|
root.left.right = dom.Node(type="O")
|
|
|
|
|
root.right = dom.Node(rotation=1, division=[0.5, 0.5])
|
|
|
|
|
root.right.left = dom.Node(type=top_right_type)
|
|
|
|
|
root.right.right = dom.Node(type="C")
|
2026-08-03 11:03:02 +01:00
|
|
|
dom.link(root)
|
2026-07-24 19:48:13 +01:00
|
|
|
return root
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _n_circ_components(root: dom.Node) -> int:
|
|
|
|
|
import networkx as nx
|
|
|
|
|
|
|
|
|
|
G = _geo_leaf_graph(root)
|
|
|
|
|
circ = [n for n in G.nodes() if dom.is_circulation(n)]
|
|
|
|
|
return len(list(nx.connected_components(G.subgraph(circ))))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _geo_leaf_graph(lvl: dom.Node):
|
|
|
|
|
from homemaker_layout import geometry, graph as _graph
|
|
|
|
|
return geometry.leaf_graph(lvl, _graph.DOOR_WIDTH)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mutate_bridge_circulation_noop_when_already_connected():
|
|
|
|
|
root = _row_of_three("C") # all three already circulation → one component
|
|
|
|
|
assert _n_circ_components(root) == 1
|
|
|
|
|
_, desc = operators.mutate_bridge_circulation(root, np.random.default_rng(0), TYPES)
|
|
|
|
|
assert desc == "bridge_circulation noop"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mutate_bridge_circulation_bridges_fragmented_level():
|
|
|
|
|
root = _row_of_three("O")
|
|
|
|
|
assert _n_circ_components(root) == 2
|
|
|
|
|
child, desc = operators.mutate_bridge_circulation(root, np.random.default_rng(0), TYPES)
|
|
|
|
|
assert "noop" not in desc
|
|
|
|
|
assert desc.startswith("bridge_circulation")
|
|
|
|
|
canonical(child)
|
|
|
|
|
assert _n_circ_components(child) == 1
|
|
|
|
|
# the free 'O' leaf was converted; the two original 'C' leaves untouched
|
|
|
|
|
mid = dom.levels(child)[0].by_id("rl")
|
|
|
|
|
assert mid.type == "C"
|
|
|
|
|
# parent left untouched
|
|
|
|
|
assert dom.levels(root)[0].by_id("rl").type == "O"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mutate_bridge_circulation_falls_back_to_required_room_if_only_route():
|
|
|
|
|
from homemaker_layout import programme
|
|
|
|
|
|
|
|
|
|
root = _row_of_three("b1")
|
|
|
|
|
reqs = {"b1": programme.SpaceReq(code="b1")}
|
|
|
|
|
assert _n_circ_components(root) == 2
|
|
|
|
|
child, desc = operators.mutate_bridge_circulation(
|
|
|
|
|
root, np.random.default_rng(0), TYPES + ["b1"], reqs=reqs)
|
|
|
|
|
assert "noop" not in desc
|
|
|
|
|
assert _n_circ_components(child) == 1
|
|
|
|
|
assert dom.levels(child)[0].by_id("rl").type == "C"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mutate_bridge_circulation_prefers_free_leaf_over_required_room():
|
|
|
|
|
from homemaker_layout import programme
|
|
|
|
|
|
|
|
|
|
root = _diamond("b1")
|
|
|
|
|
reqs = {"b1": programme.SpaceReq(code="b1")}
|
|
|
|
|
assert _n_circ_components(root) == 2
|
|
|
|
|
child, desc = operators.mutate_bridge_circulation(
|
|
|
|
|
root, np.random.default_rng(0), TYPES + ["b1"], reqs=reqs)
|
|
|
|
|
assert "noop" not in desc
|
|
|
|
|
canonical(child)
|
|
|
|
|
assert _n_circ_components(child) == 1
|
|
|
|
|
# bridges via the free 'O' leaf ('lr'), not the required 'b1' ('rl')
|
|
|
|
|
lvl0 = dom.levels(child)[0]
|
|
|
|
|
assert lvl0.by_id("lr").type == "C"
|
|
|
|
|
assert lvl0.by_id("rl").type == "b1"
|
§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
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not (HARBOR.parent / "maple-court").is_dir(),
|
|
|
|
|
reason="maple-court not available")
|
|
|
|
|
def test_assign_cpsat_beats_greedy_on_a_namespace_clean_programme():
|
§39.4 completion + §39.5 retraction + §39.6: the usage namespace is NOT clean
Answering "are we clean". Generic namespace: yes. Usage namespace: no.
FINISH §39.4. The first sweep missed sites, found by a full re-grep:
graph.py's free-area budget, operators.py host-preference / keep-type /
repair-candidate, fitness.py's ("l","c","k") public-access test, bubble.py's
generic adjacency reference, and -- the important one -- cpsat.py, which was
still matching adjacency by raw startswith. graph.code_matches_requirement is
now the single public answer to "does this leaf count as the thing the
programme asked to be next to", shared by has_adjacency, has_vertical_connection
and cpsat.
RETRACT §39.5. It concluded 2g7.5's CP-SAT seeder win did not survive the
correction. That was wrong. The cause was the missed cpsat matcher above: the
exact solver was optimising a different relation than the scorer checked, so a
failing test reporting an incomplete sweep was misread as a baseline shift.
Re-measured over 6 seeds, cpsat now wins on both programmes (harbor 102/92,
maple 156/154). xfail removed.
REAL BUG UNDERNEATH: CP-SAT was never deterministic despite
num_search_workers=1 and a comment claiming it. neighbors[slot] is a set of
dom.Node, which hashes by id() -- a memory address -- so raw iteration made the
model-build order vary and CP-SAT returned a different equally-optimal
assignment each run (measured 194/180/171/182 over four identical aggregates).
sorted() on the slot indices fixes it. Also paired the wall-clock cap with
max_deterministic_time (solves run ~124ms against a 2s cap, so nothing was
timing out -- latent hazard, not the cause). solve_room_labels is now
reproducible on every captured instance; constructive_topology on the cpsat
path still is not, filed as homemaker-py-fdp (plausible contributor to b8g).
§39.6 THE SECOND NAMESPACE. Usage prefixes b/t/l/k (bedroom/toilet/living/
kitchen) classify programme codes by first letter and stay prefix-based by
design, but they are not inert: has_circulation deletes graph edges from them.
Four corpus rooms are misclassified by spelling -- la1 "Laundry Room" and li1
"Library Corner" as living, br1 "Staff Room" as bedroom, tr1 "Treatment Room"
as toilet. Measured on a health-centre seed: tr1 loses its edge to the adjacent
O, br1 loses its edge to t10 "Staff WC" -- both feed the connectivity fails §38
found persisting. Filed homemaker-py-sel; an explicit usage: key is the fix,
but it changes fitness for correctly-spelled programmes too so it needs its own
A/B.
DOCS. README gains a "Room codes and reserved names" section; CLAUDE.md and
AGENTS.md gain the same summary for agents. audit_programme_config.py now
reports the usage class each code picks up alongside the namespace and
satisfiability checks. DESIGN §37.2's note calling the c/o/s quirk "existing
product behaviour, not a bug" is annotated as superseded.
Corpus audit: zero generic-namespace violations across all ten example
programmes. 346 passed, same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 10:09:14 +00:00
|
|
|
"""§39.5 companion: the same property on a second, namespace-clean
|
|
|
|
|
programme.
|
|
|
|
|
|
|
|
|
|
Kept because it was this pair that caught an incomplete §39.4 sweep:
|
|
|
|
|
``cpsat._matches`` was still matching adjacency by raw prefix after
|
|
|
|
|
``graph.has_adjacency`` had been tightened, so the exact solver was
|
|
|
|
|
optimising a different relation than the scorer checked. Two programmes
|
|
|
|
|
make that class of drift visible instead of looking like noise.
|
§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
|
|
|
"""
|
|
|
|
|
import copy
|
|
|
|
|
|
|
|
|
|
from homemaker_layout import fitness, programme
|
|
|
|
|
|
|
|
|
|
maple = HARBOR.parent / "maple-court"
|
|
|
|
|
reqs = programme.load_programme_dir(str(maple))
|
|
|
|
|
conf, cost = fitness.load_config(str(maple))
|
|
|
|
|
fit = fitness.Fitness(conf, cost)
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
seed = dom.load(str(maple / "init.dom"))
|
|
|
|
|
|
|
|
|
|
def secondary_fails(solver: str) -> int:
|
|
|
|
|
total = 0
|
|
|
|
|
for trial in range(6):
|
|
|
|
|
root = operators.constructive_topology(
|
|
|
|
|
seed, reqs, np.random.default_rng(trial), types,
|
|
|
|
|
assign_solver=solver)
|
|
|
|
|
_, fails = fit.score_with_fails(copy.deepcopy(root))
|
|
|
|
|
total += sum(1 for f in fails if "not adjacent to" in f)
|
|
|
|
|
return total
|
|
|
|
|
|
|
|
|
|
assert secondary_fails("cpsat") < secondary_fails("greedy")
|
§39.9: level-not-connected is destroyed by the resize, not by the search
Answers homemaker-py-yql. §39.8 established the search is not PAID to sever
circulation; this establishes where connectivity actually goes.
CONSTRUCTED, THEN LOST -- at construction time, in the resize.
_assign_adjacency_aware picks circulation as a CONNECTED dominating set and
succeeds every time. _size_divisions_from_targets then moves every wall to hit
the programme's area targets and destroys it.
Measured over 20 constructed seeds per programme, fully-connected seeds:
harbor-house 1/20, health-centre 1/20, maple-court 0/20. The control -- same
seeds with proportion_aware=False, i.e. no resize -- is 100% connected on all
three. Mechanism confirmed on health-centre: 41 of 49 circulation-to-circulation
edges destroyed by the resize, surviving shared walls squeezed to 0.54-1.11 m
against door_width=1.2, so they stop counting as edges. This is the failure mode
§37.7 recorded for CP-SAT assignment, never looked for in connectivity, where it
costs 35-95 points.
§39.7 COST CHECK: zero. Identical rates under prefix-inferred vs declared
usages -- has_circulation never trims C-C edges, so last commit's usage change
could not and did not make connectivity harder to achieve.
REPAIR MEASURED NEGATIVE. operators.repair_circulation_settled applies §37.7's
own alternating-minimisation fix (re-connect against the settled geometry by
retyping the cheapest bridging leaves to C). It restores 100% connectivity on
all three programmes -- and is still the wrong trade: connectivity fails fall
0.8-1.7 per seed while missing-room fails rise 5.0-8.5, because every retyped
leaf displaces a required room at a 3-5 fail cascade (§38.5). Kept default off
with the write-up, per house style for a null lever, plus a byte-identical
default test and a test asserting it does reconnect every storey.
NEXT LEVER, FILED: preserve the connection during the resize (constrain
_size_divisions_from_targets so a shared C-C boundary cannot fall below
door_width) rather than rebuild it afterwards at the programme's expense --
a constraint on an existing solve, not a new repair pass. solver.py's existing
min_width_generic is the same idea applied to leaf width rather than to a shared
boundary, so it may belong beside it.
Adds experiments/diag_connectivity_yql.py (construct / cost / survive reports).
355 passed (+2 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 14:39:07 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# homemaker-py-yql / DESIGN.md §39.9 — settled-geometry circulation repair
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_repair_circulation_default_off_reproduces_prior_seeds():
|
|
|
|
|
"""Default off must be byte-identical, like every other experimental flag."""
|
|
|
|
|
from homemaker_layout import programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
seed = dom.load(str(HARBOR / "init.dom"))
|
|
|
|
|
kw = dict(min_storeys=programme.storey_minimum(str(HARBOR)),
|
|
|
|
|
adjacency_aware=True, proportion_aware=True, circ_divisor=3)
|
|
|
|
|
|
|
|
|
|
def sig(**extra):
|
|
|
|
|
root = operators.constructive_topology(
|
|
|
|
|
seed, reqs, np.random.default_rng(3), types, **kw, **extra)
|
|
|
|
|
return tuple(lf.type for lvl in dom.levels(root) for lf in lvl.leaves())
|
|
|
|
|
|
|
|
|
|
assert sig() == sig(repair_circulation=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_repair_circulation_reconnects_every_storey():
|
|
|
|
|
"""§39.9: the constructed circulation dominating set is connected, but
|
|
|
|
|
_size_divisions_from_targets then moves every wall and the shared
|
|
|
|
|
boundaries it relied on drop below door_width. Repairing against the
|
|
|
|
|
SETTLED geometry restores connectivity — measured 52% -> 100% of levels on
|
|
|
|
|
harbor-house. (Whether that is a net WIN is a different question: it is
|
|
|
|
|
not, see §39.9 — it displaces required rooms. Hence default off.)
|
|
|
|
|
"""
|
|
|
|
|
import networkx as nx
|
|
|
|
|
|
|
|
|
|
from homemaker_layout import geometry, graph as graph_mod, programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
seed = dom.load(str(HARBOR / "init.dom"))
|
|
|
|
|
|
|
|
|
|
def levels_connected(repair: bool) -> tuple[int, int]:
|
|
|
|
|
ok = tot = 0
|
|
|
|
|
for s in range(6):
|
|
|
|
|
root = operators.constructive_topology(
|
|
|
|
|
seed, reqs, np.random.default_rng(s), types,
|
|
|
|
|
min_storeys=programme.storey_minimum(str(HARBOR)),
|
|
|
|
|
adjacency_aware=True, proportion_aware=True, circ_divisor=3,
|
|
|
|
|
repair_circulation=repair)
|
|
|
|
|
for lvl in dom.levels(root):
|
|
|
|
|
geometry.clear_cache()
|
|
|
|
|
G = geometry.leaf_graph(lvl, graph_mod.DOOR_WIDTH)
|
|
|
|
|
circ = [n for n in G.nodes() if dom.is_circulation(n)]
|
|
|
|
|
tot += 1
|
|
|
|
|
if circ and nx.is_connected(G.subgraph(circ)):
|
|
|
|
|
ok += 1
|
|
|
|
|
return ok, tot
|
|
|
|
|
|
|
|
|
|
off_ok, off_tot = levels_connected(False)
|
|
|
|
|
on_ok, on_tot = levels_connected(True)
|
Declare toilet-to-sleeping adjacency where the brief supports it
A toilet next to a sleeping room is a positive even with no door between
them (Brand): the adjacency is what makes a later knock-through possible.
The engine already scores it -- check_adjacency runs against the unfiltered
graph_base_pre -- but only where a programme declares it, and only
programme-house did.
Declared:
harbor-house t -> n bathrooms serve the Neighborhoods (communal
sleeping); both unpinned, 6 t / 5 n
maple-court tt -> r Upper Bathrooms among Individual Rooms, both
level 2, already 62% adjacent at seed time
NOT declared, and checking before declaring is what caught these:
maple t -> n is IMPOSSIBLE. Adjacency is evaluated per level, and maple
pins t to level 0, n to level 1. Declaring it would have added six
permanently unsatisfiable fails; the 0% seed-time rate was a hard
impossibility, not search difficulty. maple's ground floor has six
bathrooms and one sleeping room (Clinic Room x1) -- a ground-floor WC in
a communal building is public, so Brand does not apply anyway.
health-centre has no dedicated WC. The ruling was that a treatment room
"may give access to a toilet, but this would be a dedicated toilet"; t9
is a Public WC and t10 a Staff WC. Earning the credit needs a WC added to
the brief -- programme authoring, filed as homemaker-py-5nw.
Both declarations are reachable (best of 8 seeds 2/3 harbor, 2/2 maple), so
the search gets a gradient not a permanent penalty. evolved-3M-nols-3
84 -> 89 fails, all five the new requirement.
Cost: cpsat assignment ~7.5x slower on harbor (0.28 -> 2.11s per seed);
greedy, the default, unchanged at 0.06s. Ordinary runs pay nothing, but
39.5's cpsat-vs-greedy verdict was measured on a cheaper problem than the
corpus now poses -- filed as homemaker-py-vjd.
Two tests were over-fitted to the old seeds and are repaired to assert
their intent, not relaxed to pass: reassign now sweeps six constructive
seeds (seed 0's better-seeded design legitimately has nothing to improve,
5 of 6 others fire), and repair_circulation asserts that repair strictly
helps plus a >=85% bar rather than a sampled 100% hardened into a
guarantee (measured 25% -> 92%, stable over 6 and 12 seeds).
Closes homemaker-py-3qj.
Lint at parity (46); tests 379 passed, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-29 10:57:44 +00:00
|
|
|
|
|
|
|
|
# The claim is that repairing against the SETTLED geometry restores
|
|
|
|
|
# connectivity the wall-settling destroyed -- not that it never fails. It is
|
|
|
|
|
# a heuristic over already-placed walls; nothing makes it complete. The
|
|
|
|
|
# original `on_ok == on_tot` hardened a sampled 100% into a guarantee, and
|
|
|
|
|
# it broke the moment the seeds changed (homemaker-py-3qj's `t -> n`
|
|
|
|
|
# adjacency reseeds harbor): measured 25% -> 92%, stable across 6 and 12
|
|
|
|
|
# seeds. The bar below is a real regression detector, comfortably clear of
|
|
|
|
|
# 92% but well above the 25% baseline.
|
§39.9: level-not-connected is destroyed by the resize, not by the search
Answers homemaker-py-yql. §39.8 established the search is not PAID to sever
circulation; this establishes where connectivity actually goes.
CONSTRUCTED, THEN LOST -- at construction time, in the resize.
_assign_adjacency_aware picks circulation as a CONNECTED dominating set and
succeeds every time. _size_divisions_from_targets then moves every wall to hit
the programme's area targets and destroys it.
Measured over 20 constructed seeds per programme, fully-connected seeds:
harbor-house 1/20, health-centre 1/20, maple-court 0/20. The control -- same
seeds with proportion_aware=False, i.e. no resize -- is 100% connected on all
three. Mechanism confirmed on health-centre: 41 of 49 circulation-to-circulation
edges destroyed by the resize, surviving shared walls squeezed to 0.54-1.11 m
against door_width=1.2, so they stop counting as edges. This is the failure mode
§37.7 recorded for CP-SAT assignment, never looked for in connectivity, where it
costs 35-95 points.
§39.7 COST CHECK: zero. Identical rates under prefix-inferred vs declared
usages -- has_circulation never trims C-C edges, so last commit's usage change
could not and did not make connectivity harder to achieve.
REPAIR MEASURED NEGATIVE. operators.repair_circulation_settled applies §37.7's
own alternating-minimisation fix (re-connect against the settled geometry by
retyping the cheapest bridging leaves to C). It restores 100% connectivity on
all three programmes -- and is still the wrong trade: connectivity fails fall
0.8-1.7 per seed while missing-room fails rise 5.0-8.5, because every retyped
leaf displaces a required room at a 3-5 fail cascade (§38.5). Kept default off
with the write-up, per house style for a null lever, plus a byte-identical
default test and a test asserting it does reconnect every storey.
NEXT LEVER, FILED: preserve the connection during the resize (constrain
_size_divisions_from_targets so a shared C-C boundary cannot fall below
door_width) rather than rebuild it afterwards at the programme's expense --
a constraint on an existing solve, not a new repair pass. solver.py's existing
min_width_generic is the same idea applied to leaf width rather than to a shared
boundary, so it may belong beside it.
Adds experiments/diag_connectivity_yql.py (construct / cost / survive reports).
355 passed (+2 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 14:39:07 +00:00
|
|
|
assert on_ok > off_ok, f"repair did not help: {off_ok}/{off_tot} -> {on_ok}/{on_tot}"
|
Declare toilet-to-sleeping adjacency where the brief supports it
A toilet next to a sleeping room is a positive even with no door between
them (Brand): the adjacency is what makes a later knock-through possible.
The engine already scores it -- check_adjacency runs against the unfiltered
graph_base_pre -- but only where a programme declares it, and only
programme-house did.
Declared:
harbor-house t -> n bathrooms serve the Neighborhoods (communal
sleeping); both unpinned, 6 t / 5 n
maple-court tt -> r Upper Bathrooms among Individual Rooms, both
level 2, already 62% adjacent at seed time
NOT declared, and checking before declaring is what caught these:
maple t -> n is IMPOSSIBLE. Adjacency is evaluated per level, and maple
pins t to level 0, n to level 1. Declaring it would have added six
permanently unsatisfiable fails; the 0% seed-time rate was a hard
impossibility, not search difficulty. maple's ground floor has six
bathrooms and one sleeping room (Clinic Room x1) -- a ground-floor WC in
a communal building is public, so Brand does not apply anyway.
health-centre has no dedicated WC. The ruling was that a treatment room
"may give access to a toilet, but this would be a dedicated toilet"; t9
is a Public WC and t10 a Staff WC. Earning the credit needs a WC added to
the brief -- programme authoring, filed as homemaker-py-5nw.
Both declarations are reachable (best of 8 seeds 2/3 harbor, 2/2 maple), so
the search gets a gradient not a permanent penalty. evolved-3M-nols-3
84 -> 89 fails, all five the new requirement.
Cost: cpsat assignment ~7.5x slower on harbor (0.28 -> 2.11s per seed);
greedy, the default, unchanged at 0.06s. Ordinary runs pay nothing, but
39.5's cpsat-vs-greedy verdict was measured on a cheaper problem than the
corpus now poses -- filed as homemaker-py-vjd.
Two tests were over-fitted to the old seeds and are repaired to assert
their intent, not relaxed to pass: reassign now sweeps six constructive
seeds (seed 0's better-seeded design legitimately has nothing to improve,
5 of 6 others fire), and repair_circulation asserts that repair strictly
helps plus a >=85% bar rather than a sampled 100% hardened into a
guarantee (measured 25% -> 92%, stable over 6 and 12 seeds).
Closes homemaker-py-3qj.
Lint at parity (46); tests 379 passed, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-29 10:57:44 +00:00
|
|
|
assert on_ok / on_tot >= 0.85, (
|
|
|
|
|
f"repair reconnected only {on_ok}/{on_tot} storeys "
|
|
|
|
|
f"({100 * on_ok / on_tot:.0f}%), against ~92% expected")
|
§39.10: preserving constructed connectivity is NULL — and it reframes §39.9
§39.9 named the upstream fix: keep circulation connected DURING the resize
rather than rebuilding it after. Built and measured. It does not help, and the
reason matters more than the lever.
Both halves of the re-cut do damage, in different proportions per programme.
Freezing rotations and letting only ratios move (% levels connected, 12 seeds):
harbor 100 -> 71 -> 50, health-centre 100 -> 8 -> 8, maple 100 -> 92 -> 67. So
health-centre is destroyed entirely by the ratio and maple mostly by the
rotation; a fix must be able to give back either.
operators._size_divisions_preserving_circulation snapshots every cut, resizes,
then reverts the cuts on the tree path between each circulation pair the resize
broke -- programme fully intact, no retyping, only geometry given back. It works
on connectivity (harbor 50->92%, maple 67->97%, health-centre 8->17%) and costs
area accuracy: constructed-seed fails harbor 96.6->141.5, maple 141.8->175.8,
size fails roughly double. (A greedy single-cut revert barely moved -- it stalls
where no ONE revert helps though two would. Targeting the broken pairs is what
made connectivity work.)
The obvious defence -- raw constructed seeds understate it, the resize is only a
warm start, the inner loop should recover -- was TESTED AND FAILS. Full search,
harbor-house, 12000 evals, seed 1:
OFF 43 fails, 9 hard, 3 connectivity
ON 65 fails, 26 hard, 4 connectivity
Worse on every axis, including connectivity itself.
REFRAMING: §39.9's fact stands (the resize destroys 41 of 49 circulation edges)
but is NOT ACTIONABLE, because construction-time connectivity does not determine
final connectivity. The search discards and rebuilds the seeder's circulation
either way, and constraining the seed only spends area quality the search cannot
recover. Together with §39.8 (not an incentive problem) that retires the framing
this thread inherited from §38: connectivity is neither a construction problem
nor an incentive one.
Both flags (repair_circulation, preserve_circulation) stay default off with the
numbers recorded, plus byte-identical-default tests. Do not revisit either
without a new formulation -- the standing this document gives bubble.py.
356 passed (+1 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 15:11:46 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
def test_preserve_circulation_default_off_reproduces_prior_seeds():
|
|
|
|
|
"""§39.10 measured NULL, so the default must stay byte-identical."""
|
|
|
|
|
from homemaker_layout import geometry, programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
seed = dom.load(str(HARBOR / "init.dom"))
|
|
|
|
|
kw = dict(min_storeys=programme.storey_minimum(str(HARBOR)),
|
|
|
|
|
adjacency_aware=True, proportion_aware=True, circ_divisor=3)
|
|
|
|
|
|
|
|
|
|
def sig(**extra):
|
|
|
|
|
geometry.clear_cache()
|
|
|
|
|
root = operators.constructive_topology(
|
|
|
|
|
seed, reqs, np.random.default_rng(5), types, **kw, **extra)
|
|
|
|
|
geometry.clear_cache()
|
|
|
|
|
return tuple((lf.type, round(geometry.area(lf), 6))
|
|
|
|
|
for lvl in dom.levels(root) for lf in lvl.leaves())
|
|
|
|
|
|
|
|
|
|
assert sig() == sig(preserve_circulation=False)
|
|
|
|
|
# ...and it does change something when enabled, or the A/B measured nothing
|
|
|
|
|
assert sig() != sig(preserve_circulation=True)
|
constructive_topology was ordered by memory address on the cpsat path
assign_solver="cpsat" gave a different leaf-type signature on every run
from an identical seed, in the same process. One line:
assignable = scope if scope is not None else set(leaves)
noncirc = [L for L in assignable if L not in circ] # id() order
assignable is a set of dom.Node, and Node hashes by id() -- a memory
address -- so iterating it ordered noncirc, and hence room_slots, by where
the objects happened to land in memory. That shifts between calls within
one process as allocation patterns change, with no seed involved.
Only cpsat showed it. The greedy path re-sorts every slot list with -idx[L]
as a unique tiebreak and is immune to the incoming order; CP-SAT consumes
room_slots order as its model's variable order, and the labelling problem
has many equally-optimal solutions. Greedy was not more correct, it was
masking a defect that had been there all along.
Fix: iterate the tree-ordered list, use the set only for membership.
Verified on programme-house, harbor-house and maple-court: 1 distinct
signature over 5 runs on both solvers, and 1 across 4 processes started
with different PYTHONHASHSEED, so context_types' string sets are not a
second source. test_constructive_topology_is_bit_reproducible guards both.
Method: rather than guess which set was at fault, instrument
solve_room_labels with an id-free fingerprint of inputs and outputs and
isolate the FIRST call, since later calls legitimately depend on earlier
ones through leaf types. Five runs gave five distinct first-call inputs,
placing the fault upstream of the solver in one step.
Every A/B on the cpsat path was comparing arms that differed partly by
memory layout -- 39.5's cpsat-vs-greedy verdict included, already down for
re-measurement under homemaker-py-vjd. Same id()-keying hazard as the
documented geometry._cache issue and a plausible contributor to
homemaker-py-b8g, which stays open: n_workers>1 has its own BLAS mechanism
and is not addressed here.
Closes homemaker-py-fdp.
Lint at parity (46); tests 381 passed (2 new), 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-29 11:45:19 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# homemaker-py-fdp / DESIGN.md §38.15 — constructive_topology must be
|
|
|
|
|
# bit-reproducible on BOTH assignment solvers.
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
|
|
|
|
@pytest.mark.parametrize("solver", ["greedy", "cpsat"])
|
|
|
|
|
def test_constructive_topology_is_bit_reproducible(solver):
|
|
|
|
|
"""Same seed, same signature -- every time, on either solver.
|
|
|
|
|
|
|
|
|
|
`assignable` is a set of dom.Node, which hashes by id(), so deriving the
|
|
|
|
|
room-slot list by iterating it ordered the slots by memory address. That
|
|
|
|
|
varies between calls in ONE process, and cpsat consumes the slot order as
|
|
|
|
|
its model's variable order, so it returned a different equally-optimal
|
|
|
|
|
labelling each run. Greedy never noticed because it re-sorts with `-idx[L]`
|
|
|
|
|
as a unique tiebreak.
|
|
|
|
|
|
|
|
|
|
Repetition in-process is what catches this class: allocation patterns
|
|
|
|
|
differ between calls, so id()-derived order changes without any seed
|
|
|
|
|
changing.
|
|
|
|
|
"""
|
|
|
|
|
from homemaker_layout import programme
|
|
|
|
|
|
|
|
|
|
reqs = programme.load_programme_dir(str(HARBOR))
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
|
|
|
|
seed = dom.load(str(HARBOR / "init.dom"))
|
|
|
|
|
|
|
|
|
|
sigs = {
|
|
|
|
|
tuple(lf.type for lf in operators.constructive_topology(
|
|
|
|
|
seed, reqs, np.random.default_rng(0), types,
|
|
|
|
|
min_storeys=programme.storey_minimum(str(HARBOR)),
|
|
|
|
|
adjacency_aware=True, proportion_aware=True, circ_divisor=3,
|
|
|
|
|
assign_solver=solver).leaves())
|
|
|
|
|
for _ in range(4)
|
|
|
|
|
}
|
|
|
|
|
assert len(sigs) == 1, (
|
|
|
|
|
f"assign_solver={solver!r} produced {len(sigs)} distinct leaf-type "
|
|
|
|
|
f"signatures from one seed")
|