Phase 6 §11.3: staged per-floor search (c4c.3)
Search the genome in causal dependency order. Stage 1 evolves a single-storey
base over the level-0 room set (programme auto-derived to a tempdir), ranked
with a substrate-readiness bonus (reserved core × divisible capacity) so the
base is selected as a good substrate, not just a good ground floor (anti-§4.2).
Stage 2 lifts the best base into a full multi-storey design — preserving the
inherited core, instantiating each upper storey's required set by construction —
and searches the deltas with the base mutable at low probability (base_p=0.15).
New: programme.{n_storeys_required,partition_rooms_by_storey,write_stage1_programme},
graph.substrate_readiness, operators.{lift_base_to_storeys,_pick_weighted_by_storey},
base_p threading, driver.search rank_bonus_fn/seed_factory/base_p hooks +
search_staged orchestrator, experiments/run_staged_search.py, tests/test_staging.py.
Result (harbor, 20000 evals, seed 0): staged 95 fails vs single-stage 105
(-10, -9.5%), gain in crinkliness 27->18 + edge 12->8. Anti-bungalow confirmed
(Stage-2 core moves all noop — core inherited, not carved). Programme-house
regression PASS (warmstart-2f4 still reaches whole-pop 1-fail).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 06:05:53 +01:00
|
|
|
"""Staged per-floor search tests (DESIGN.md §11.3, homemaker-py-c4c.3).
|
|
|
|
|
|
|
|
|
|
Cover the building blocks: programme partition, derived single-storey base
|
|
|
|
|
programme, substrate-readiness score, base-lift seeder, and the storey-weighted
|
|
|
|
|
mutation pick. Oracle-free; harbor-house is the multi-storey fixture.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import collections
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from homemaker_layout import dom, fitness, genome, graph, operators, programme
|
|
|
|
|
|
|
|
|
|
CORPUS = Path(__file__).parent.parent / "examples" / "harbor-house"
|
|
|
|
|
|
|
|
|
|
pytestmark = pytest.mark.skipif(not CORPUS.is_dir(), reason="harbor-house not available")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def reqs():
|
|
|
|
|
return programme.load_programme_dir(CORPUS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _required_counts(reqs):
|
§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
|
|
|
# §39.4: every declared code is a real requirement — a programme code
|
|
|
|
|
# starting with c/o/s is no longer swallowed by the generic-type rule.
|
|
|
|
|
return {c: r.count for c, r in reqs.items() if not dom.is_generic(c)}
|
Phase 6 §11.3: staged per-floor search (c4c.3)
Search the genome in causal dependency order. Stage 1 evolves a single-storey
base over the level-0 room set (programme auto-derived to a tempdir), ranked
with a substrate-readiness bonus (reserved core × divisible capacity) so the
base is selected as a good substrate, not just a good ground floor (anti-§4.2).
Stage 2 lifts the best base into a full multi-storey design — preserving the
inherited core, instantiating each upper storey's required set by construction —
and searches the deltas with the base mutable at low probability (base_p=0.15).
New: programme.{n_storeys_required,partition_rooms_by_storey,write_stage1_programme},
graph.substrate_readiness, operators.{lift_base_to_storeys,_pick_weighted_by_storey},
base_p threading, driver.search rank_bonus_fn/seed_factory/base_p hooks +
search_staged orchestrator, experiments/run_staged_search.py, tests/test_staging.py.
Result (harbor, 20000 evals, seed 0): staged 95 fails vs single-stage 105
(-10, -9.5%), gain in crinkliness 27->18 + edge 12->8. Anti-bungalow confirmed
(Stage-2 core moves all noop — core inherited, not carved). Programme-house
regression PASS (warmstart-2f4 still reaches whole-pop 1-fail).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 06:05:53 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_n_storeys_required(reqs):
|
|
|
|
|
assert programme.n_storeys_required(reqs) == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_sums_to_required_counts(reqs):
|
|
|
|
|
n = programme.n_storeys_required(reqs)
|
|
|
|
|
buckets = programme.partition_rooms_by_storey(reqs, n, np.random.default_rng(0))
|
|
|
|
|
assert len(buckets) == n
|
|
|
|
|
tot = collections.Counter()
|
|
|
|
|
for b in buckets:
|
|
|
|
|
tot.update(b)
|
|
|
|
|
assert dict(tot) == _required_counts(reqs)
|
|
|
|
|
# level-constrained rooms land on their required storey
|
|
|
|
|
assert buckets[1].get("r") == 10 # r is level: 1
|
|
|
|
|
assert "r" not in buckets[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_write_stage1_programme_single_storey(reqs):
|
|
|
|
|
n = programme.n_storeys_required(reqs)
|
|
|
|
|
buckets = programme.partition_rooms_by_storey(reqs, n, np.random.default_rng(0))
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
|
|
|
programme.write_stage1_programme(CORPUS, tmp, buckets[0])
|
|
|
|
|
s1 = programme.load_programme_dir(tmp)
|
|
|
|
|
conf, _ = fitness.load_config(tmp)
|
|
|
|
|
# exactly the base-floor codes, levels dropped, single-storey constraints
|
|
|
|
|
assert set(s1) == set(buckets[0])
|
|
|
|
|
assert all(r.level is None for r in s1.values())
|
|
|
|
|
assert conf["storey_limit"] == 1 and conf["storey_minimum"] == 1
|
|
|
|
|
assert conf["staircase_min"] == 1 and conf["staircase_max"] == 1
|
|
|
|
|
# adjacency pruned to surviving / generic refs only
|
|
|
|
|
for r in s1.values():
|
|
|
|
|
for ref in r.adjacency:
|
|
|
|
|
assert ref in s1 or ref[0].lower() in "cos"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_substrate_readiness_range_and_core(reqs):
|
|
|
|
|
n = programme.n_storeys_required(reqs)
|
|
|
|
|
rng = np.random.default_rng(0)
|
|
|
|
|
buckets = programme.partition_rooms_by_storey(reqs, n, rng)
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
|
|
|
programme.write_stage1_programme(CORPUS, tmp, buckets[0])
|
|
|
|
|
s1 = programme.load_programme_dir(tmp)
|
|
|
|
|
base = operators.constructive_topology(
|
|
|
|
|
dom.load(str(CORPUS / "init.dom")), s1, rng, sorted(s1) + ["C", "O"])
|
|
|
|
|
r = graph.substrate_readiness(base, reqs, n)
|
|
|
|
|
assert 0.0 <= r <= 1.0
|
|
|
|
|
|
|
|
|
|
# stripping every C leaf to a non-core type drops the core factor
|
|
|
|
|
for leaf in dom.levels(base)[0].leaves():
|
|
|
|
|
if leaf.type and leaf.type[0].lower() == "c":
|
|
|
|
|
leaf.type = "m"
|
2026-08-03 11:03:02 +01:00
|
|
|
dom.link(base)
|
Phase 6 §11.3: staged per-floor search (c4c.3)
Search the genome in causal dependency order. Stage 1 evolves a single-storey
base over the level-0 room set (programme auto-derived to a tempdir), ranked
with a substrate-readiness bonus (reserved core × divisible capacity) so the
base is selected as a good substrate, not just a good ground floor (anti-§4.2).
Stage 2 lifts the best base into a full multi-storey design — preserving the
inherited core, instantiating each upper storey's required set by construction —
and searches the deltas with the base mutable at low probability (base_p=0.15).
New: programme.{n_storeys_required,partition_rooms_by_storey,write_stage1_programme},
graph.substrate_readiness, operators.{lift_base_to_storeys,_pick_weighted_by_storey},
base_p threading, driver.search rank_bonus_fn/seed_factory/base_p hooks +
search_staged orchestrator, experiments/run_staged_search.py, tests/test_staging.py.
Result (harbor, 20000 evals, seed 0): staged 95 fails vs single-stage 105
(-10, -9.5%), gain in crinkliness 27->18 + edge 12->8. Anti-bungalow confirmed
(Stage-2 core moves all noop — core inherited, not carved). Programme-house
regression PASS (warmstart-2f4 still reaches whole-pop 1-fail).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 06:05:53 +01:00
|
|
|
r_nocore = graph.substrate_readiness(base, reqs, n)
|
|
|
|
|
assert r_nocore < r # losing the reserved core lowers readiness
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_lift_preserves_base_and_builds_upper(reqs):
|
|
|
|
|
n = programme.n_storeys_required(reqs)
|
|
|
|
|
rng = np.random.default_rng(1)
|
|
|
|
|
buckets = programme.partition_rooms_by_storey(reqs, n, rng)
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
|
|
|
programme.write_stage1_programme(CORPUS, tmp, buckets[0])
|
|
|
|
|
s1 = programme.load_programme_dir(tmp)
|
|
|
|
|
base = operators.constructive_topology(
|
|
|
|
|
dom.load(str(CORPUS / "init.dom")), s1, rng, sorted(s1) + ["C", "O"])
|
|
|
|
|
|
|
|
|
|
base_leaf_types = collections.Counter(l.type for l in dom.levels(base)[0].leaves())
|
|
|
|
|
lifted = operators.lift_base_to_storeys(
|
|
|
|
|
base, buckets[1:], rng, sorted(reqs) + ["C", "O"])
|
|
|
|
|
lv = dom.levels(lifted)
|
|
|
|
|
assert len(lv) == n
|
|
|
|
|
# base storey untouched
|
|
|
|
|
assert collections.Counter(l.type for l in lv[0].leaves()) == base_leaf_types
|
|
|
|
|
# upper storey instantiates its required room set + keeps a circulation core
|
|
|
|
|
up = collections.Counter(l.type for l in lv[1].leaves())
|
|
|
|
|
for code, cnt in buckets[1].items():
|
|
|
|
|
assert up.get(code, 0) >= cnt
|
|
|
|
|
assert up.get("C", 0) >= 1
|
|
|
|
|
# result is a canonical genome
|
|
|
|
|
g = genome.encode(lifted)
|
|
|
|
|
assert genome.encode(genome.decode(g)) == g
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_pick_weighted_by_storey_biases_base():
|
|
|
|
|
items = [(0, "base"), (1, "upper")]
|
|
|
|
|
rng = np.random.default_rng(0)
|
|
|
|
|
picks = [operators._pick_weighted_by_storey(rng, items, base_p=0.0)[0]
|
|
|
|
|
for _ in range(50)]
|
|
|
|
|
assert all(li == 1 for li in picks) # base_p=0 ⇒ never pick the base storey
|
|
|
|
|
# base_p=1.0 is the plain uniform pick (no bias)
|
|
|
|
|
rng2 = np.random.default_rng(0)
|
|
|
|
|
p1 = operators._pick_weighted_by_storey(rng2, items, base_p=1.0)
|
|
|
|
|
rng3 = np.random.default_rng(0)
|
|
|
|
|
p2 = operators._pick(rng3, items)
|
|
|
|
|
assert p1 == p2
|