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>
This commit is contained in:
Bruno Postle 2026-06-18 06:05:53 +01:00
parent debb7dc26b
commit 6ed9e0b4b1
8 changed files with 680 additions and 31 deletions

View file

@ -1,4 +1,4 @@
{"id":"homemaker-py-c4c.3","title":"Staged per-floor search (curriculum: credible base floor, then upper floors as deltas)","description":"Search the genome in its causal dependency order. The base-floor tree is the master; upper storeys are deltas (Below-inheritance). The programme partitions rooms by required level (harbor: 10 L0, 4 L1, 2 free), so each floor's target room set is known up front. Today the search discovers both floors simultaneously via random typing + the rare/drastic level_add (weighted 0.2) — an uncontrolled, degenerate version of staging.\nStage 1 — base floor: search the single-storey tree over the level-0 room set, dimensionality reduced (one tree, no deltas).\nStage 2 — upper floors as deltas: seed each upper storey with ITS required room set (via the construction op, homemaker-py-c4c.2), search the deltas; keep the base MUTABLE at low probability so it can adapt to upper-floor pressure.\nCRITICAL non-goal: do NOT hard-freeze the base. A base optimised purely as ground floor is a §4.2-style partial objective and can be a bad SUBSTRATE. Stage 1 objective must include (a) a reserved, vertically-alignable circulation core and (b) a substrate-readiness term: enough divisible area/cut structure to host the level-1 room set later.","design":"Premise gated by homemaker-py-c4c.1: only high-value if single-storey construction already reaches low fails. Substrate-readiness proxy candidates: count of base leaves large enough to subdivide for L1 rooms; presence of a core node with vertical continuity. Stage transition: when stage-1 base hits a fails/score threshold or budget fraction, freeze-soft and open the delta genome. Composes with canonical encoding (homemaker-py-9gp) — deltas are where redundancy/coarse moves hurt most.","acceptance_criteria":"Staged search beats single-stage on harbor-house (best fails/score), measured at equal native-fitness budget and recorded in DESIGN.md §11.x + bead notes. Reserved-core + substrate-readiness shown to prevent the bungalow trap (stage-2 does not have to carve a core from scratch — track core-carving moves). No regression on programme-house.","status":"open","priority":1,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-17T19:01:01Z","created_by":"Bruno Postle","updated_at":"2026-06-17T19:01:01Z","dependencies":[{"issue_id":"homemaker-py-c4c.3","depends_on_id":"homemaker-py-c4c","type":"parent-child","created_at":"2026-06-17T20:01:00Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-c4c.3","depends_on_id":"homemaker-py-c4c.1","type":"blocks","created_at":"2026-06-17T20:01:00Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-c4c.3","depends_on_id":"homemaker-py-c4c.2","type":"blocks","created_at":"2026-06-17T20:01:01Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-c4c.3","title":"Staged per-floor search (curriculum: credible base floor, then upper floors as deltas)","description":"Search the genome in its causal dependency order. The base-floor tree is the master; upper storeys are deltas (Below-inheritance). The programme partitions rooms by required level (harbor: 10 L0, 4 L1, 2 free), so each floor's target room set is known up front. Today the search discovers both floors simultaneously via random typing + the rare/drastic level_add (weighted 0.2) — an uncontrolled, degenerate version of staging.\nStage 1 — base floor: search the single-storey tree over the level-0 room set, dimensionality reduced (one tree, no deltas).\nStage 2 — upper floors as deltas: seed each upper storey with ITS required room set (via the construction op, homemaker-py-c4c.2), search the deltas; keep the base MUTABLE at low probability so it can adapt to upper-floor pressure.\nCRITICAL non-goal: do NOT hard-freeze the base. A base optimised purely as ground floor is a §4.2-style partial objective and can be a bad SUBSTRATE. Stage 1 objective must include (a) a reserved, vertically-alignable circulation core and (b) a substrate-readiness term: enough divisible area/cut structure to host the level-1 room set later.","design":"Premise gated by homemaker-py-c4c.1: only high-value if single-storey construction already reaches low fails. Substrate-readiness proxy candidates: count of base leaves large enough to subdivide for L1 rooms; presence of a core node with vertical continuity. Stage transition: when stage-1 base hits a fails/score threshold or budget fraction, freeze-soft and open the delta genome. Composes with canonical encoding (homemaker-py-9gp) — deltas are where redundancy/coarse moves hurt most.","acceptance_criteria":"Staged search beats single-stage on harbor-house (best fails/score), measured at equal native-fitness budget and recorded in DESIGN.md §11.x + bead notes. Reserved-core + substrate-readiness shown to prevent the bungalow trap (stage-2 does not have to carve a core from scratch — track core-carving moves). No regression on programme-house.","notes":"DONE. Implemented driver.search_staged (Stage 1 single-storey base over level-0 set with substrate-readiness ranking bonus; Stage 2 upper floors lifted as constructed deltas, base mutable at 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}, driver.search rank_bonus_fn/seed_factory/base_p hooks, 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-\u003e18 + edge 12-\u003e8, small access cost +5. Anti-bungalow CONFIRMED: all Stage-2 core_divide/undivide in winning lineage are noops (core inherited, not carved). Regression PASS: programme-house warmstart-2f4 still reaches whole-pop 1-fail. DESIGN.md §11.3 filled. Remaining high-fail plateau is §11.4 (graded objective) territory.","status":"in_progress","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-17T19:01:01Z","created_by":"Bruno Postle","updated_at":"2026-06-18T05:04:32Z","started_at":"2026-06-18T04:25:07Z","dependencies":[{"issue_id":"homemaker-py-c4c.3","depends_on_id":"homemaker-py-c4c","type":"parent-child","created_at":"2026-06-17T20:01:00Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-c4c.3","depends_on_id":"homemaker-py-c4c.1","type":"blocks","created_at":"2026-06-17T20:01:00Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-c4c.3","depends_on_id":"homemaker-py-c4c.2","type":"blocks","created_at":"2026-06-17T20:01:01Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
{"id":"homemaker-py-c4c.2","title":"Programme-aware construction + missing-room repair operator","description":"Highest-leverage fix for the epic's diagnosis. Today mutate_divide (operators.py:71) types new leaves at RANDOM, so required programme spaces go missing -\u003e 'missing' stacking dominates fitness on full programmes (harbor: 6 missing-room records stacking critical+size+width+adjacency+level). Make the required room set a constructive invariant rather than something the search must stumble onto.\nTwo parts:\n1. Constructive seeder: generate initial topologies that instantiate each required space (respecting count/level/type) by construction, instead of random divide+retype chains.\n2. Repair operator mutate_place_missing: detect a required-but-absent space and insert it (divide a compatible leaf, type the new leaf to the missing code, prefer a slot satisfying its adjacency). Complements mutate_level_compound_fix (which repairs level, not presence).\nWire the seeder into driver bootstrap and the repair op into mutate() weights.","design":"Seeder must place generic C (circulation/core) and O (outside) too, not just programme codes. Keep it stochastic (diverse population) but biased to cover the required set + correct levels. Repair op should be lex-safe: prefer insertions that don't create more new fails than the missing-stack it removes (cf. the §4.10 deceptive-valley lesson — a naive insert dumps a room into a bad slot and nets worse).","acceptance_criteria":"On harbor-house, 'missing'-type failures collapse to ~0 across the population (record before/after fail histograms); measured net-fail improvement vs current 74-fail out1.dom baseline, recorded in DESIGN.md §11.x + bead notes. No regression on seeded programme-house (still reaches 1-fail optimum, §4.10).","notes":"DONE 2026-06-17. Implemented constructive_topology seeder + mutate_place_missing repair op (operators.py), wired into driver bootstrap + mutate weights. A/B on harbor (20k evals, seed 0, identical config): old random-bootstrap 133 fails (103 missing, 77%) -\u003e new constructive 105 fails (12 missing, 11%); missing-records 22-\u003e2; -21% total. Seed head-start 163-\u003e139. §4.10 regression PASS: warmstart-2f4 still reaches 1-fail population at 50k. Verdict: construction necessary, reframes bottleneck to quality-fail packing (crinkliness/size/access/edge) of complete dense design -\u003e unblocks §11.3 staging, motivates §11.4 graded objective. Follow-up filed: adjacency-aware seeding. Full numbers in DESIGN.md §11.2. 186 tests pass.","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-17T18:51:21Z","created_by":"Bruno Postle","updated_at":"2026-06-17T21:50:34Z","started_at":"2026-06-17T20:19:39Z","closed_at":"2026-06-17T21:50:34Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-c4c.2","depends_on_id":"homemaker-py-c4c","type":"parent-child","created_at":"2026-06-17T19:51:20Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0} {"id":"homemaker-py-c4c.2","title":"Programme-aware construction + missing-room repair operator","description":"Highest-leverage fix for the epic's diagnosis. Today mutate_divide (operators.py:71) types new leaves at RANDOM, so required programme spaces go missing -\u003e 'missing' stacking dominates fitness on full programmes (harbor: 6 missing-room records stacking critical+size+width+adjacency+level). Make the required room set a constructive invariant rather than something the search must stumble onto.\nTwo parts:\n1. Constructive seeder: generate initial topologies that instantiate each required space (respecting count/level/type) by construction, instead of random divide+retype chains.\n2. Repair operator mutate_place_missing: detect a required-but-absent space and insert it (divide a compatible leaf, type the new leaf to the missing code, prefer a slot satisfying its adjacency). Complements mutate_level_compound_fix (which repairs level, not presence).\nWire the seeder into driver bootstrap and the repair op into mutate() weights.","design":"Seeder must place generic C (circulation/core) and O (outside) too, not just programme codes. Keep it stochastic (diverse population) but biased to cover the required set + correct levels. Repair op should be lex-safe: prefer insertions that don't create more new fails than the missing-stack it removes (cf. the §4.10 deceptive-valley lesson — a naive insert dumps a room into a bad slot and nets worse).","acceptance_criteria":"On harbor-house, 'missing'-type failures collapse to ~0 across the population (record before/after fail histograms); measured net-fail improvement vs current 74-fail out1.dom baseline, recorded in DESIGN.md §11.x + bead notes. No regression on seeded programme-house (still reaches 1-fail optimum, §4.10).","notes":"DONE 2026-06-17. Implemented constructive_topology seeder + mutate_place_missing repair op (operators.py), wired into driver bootstrap + mutate weights. A/B on harbor (20k evals, seed 0, identical config): old random-bootstrap 133 fails (103 missing, 77%) -\u003e new constructive 105 fails (12 missing, 11%); missing-records 22-\u003e2; -21% total. Seed head-start 163-\u003e139. §4.10 regression PASS: warmstart-2f4 still reaches 1-fail population at 50k. Verdict: construction necessary, reframes bottleneck to quality-fail packing (crinkliness/size/access/edge) of complete dense design -\u003e unblocks §11.3 staging, motivates §11.4 graded objective. Follow-up filed: adjacency-aware seeding. Full numbers in DESIGN.md §11.2. 186 tests pass.","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-17T18:51:21Z","created_by":"Bruno Postle","updated_at":"2026-06-17T21:50:34Z","started_at":"2026-06-17T20:19:39Z","closed_at":"2026-06-17T21:50:34Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-c4c.2","depends_on_id":"homemaker-py-c4c","type":"parent-child","created_at":"2026-06-17T19:51:20Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0}
{"id":"homemaker-py-c4c.1","title":"Experiment: single-storey harbor premise test (per-floor construction vs multi-storey coupling)","description":"De-risk the staged-search and construction work BEFORE building either. Strip harbor-house to its 10 level-0 rooms as a single-storey programme; run the current memetic search from a bare plot; record best fails/score and the fail-type histogram. This isolates the question: is the bottleneck per-floor CONSTRUCTION (placing the right room set on one floor) or the multi-storey COUPLING (deltas, core alignment, level constraints)?\n- If single-storey 10-room reaches near-zero fails: the difficulty is coupling -\u003e staged per-floor search (homemaker-py-\u003cstaging\u003e) is the high-value lever.\n- If it still stalls at many fails (esp. 'missing'): per-floor construction itself is the bottleneck -\u003e programme-aware construction (homemaker-py-\u003cconstruction\u003e) is required first and staging alone won't rescue it.\nRun from blank-slate (init.dom equivalent) AND from a bootstrap population; report both.","design":"Build examples/harbor-house-l0/ from harbor's level-0 spaces only (drop level: keys or set all to 0; keep adjacency among the retained codes). Reuse experiments/run_search_scaled.py harness. Cheap (~minutes at native-fitness throughput).","acceptance_criteria":"Single-storey 10-room harbor variant created and committed under examples/; current search run and best fails/score + fail histogram recorded in DESIGN.md (new §11.x) and bead notes; explicit verdict on construction-vs-coupling.","notes":"VERDICT: per-floor CONSTRUCTION is the bottleneck, not multi-storey coupling.\nBuilt examples/harbor-house-l0/ (10 explicit level:0 codes = 13 room instances, single-storey constraints), seeded from bare init.dom.\nRun: URB_NO_OCCLUSION=1 python3 experiments/run_search_scaled.py examples/harbor-house-l0 20000 0 examples/harbor-house-l0/init.dom examples/harbor-house-l0/generated.dom\nResult: 20000 evals / 250 topologies / 234s. Best 33 fails (fitness 2.25e-12, deep in 0.5^n regime); whole pop stuck 33-35. 40-\u003e33 over full budget. NOT near zero.\nFail histogram: 13 missing (all 3 m meeting rooms never built) + 6 adjacency + 4 access + 4 size + 2 edge-too-long + 2 crinkliness + 1 proportion + 1 too-few-stairs(single-storey artifact). Missing = 39% — matches the 'still stalls esp. missing' branch.\n=\u003e c4c.2 (programme-aware construction + missing-room repair) is the prerequisite; staging (c4c.3) alone won't rescue it. c4c.3 already correctly depends on both. Full writeup in DESIGN.md §11.1.","status":"closed","priority":1,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-17T18:49:43Z","created_by":"Bruno Postle","updated_at":"2026-06-17T20:15:36Z","started_at":"2026-06-17T19:25:49Z","closed_at":"2026-06-17T20:15:36Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-c4c.1","depends_on_id":"homemaker-py-c4c","type":"parent-child","created_at":"2026-06-17T19:49:43Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-c4c.1","title":"Experiment: single-storey harbor premise test (per-floor construction vs multi-storey coupling)","description":"De-risk the staged-search and construction work BEFORE building either. Strip harbor-house to its 10 level-0 rooms as a single-storey programme; run the current memetic search from a bare plot; record best fails/score and the fail-type histogram. This isolates the question: is the bottleneck per-floor CONSTRUCTION (placing the right room set on one floor) or the multi-storey COUPLING (deltas, core alignment, level constraints)?\n- If single-storey 10-room reaches near-zero fails: the difficulty is coupling -\u003e staged per-floor search (homemaker-py-\u003cstaging\u003e) is the high-value lever.\n- If it still stalls at many fails (esp. 'missing'): per-floor construction itself is the bottleneck -\u003e programme-aware construction (homemaker-py-\u003cconstruction\u003e) is required first and staging alone won't rescue it.\nRun from blank-slate (init.dom equivalent) AND from a bootstrap population; report both.","design":"Build examples/harbor-house-l0/ from harbor's level-0 spaces only (drop level: keys or set all to 0; keep adjacency among the retained codes). Reuse experiments/run_search_scaled.py harness. Cheap (~minutes at native-fitness throughput).","acceptance_criteria":"Single-storey 10-room harbor variant created and committed under examples/; current search run and best fails/score + fail histogram recorded in DESIGN.md (new §11.x) and bead notes; explicit verdict on construction-vs-coupling.","notes":"VERDICT: per-floor CONSTRUCTION is the bottleneck, not multi-storey coupling.\nBuilt examples/harbor-house-l0/ (10 explicit level:0 codes = 13 room instances, single-storey constraints), seeded from bare init.dom.\nRun: URB_NO_OCCLUSION=1 python3 experiments/run_search_scaled.py examples/harbor-house-l0 20000 0 examples/harbor-house-l0/init.dom examples/harbor-house-l0/generated.dom\nResult: 20000 evals / 250 topologies / 234s. Best 33 fails (fitness 2.25e-12, deep in 0.5^n regime); whole pop stuck 33-35. 40-\u003e33 over full budget. NOT near zero.\nFail histogram: 13 missing (all 3 m meeting rooms never built) + 6 adjacency + 4 access + 4 size + 2 edge-too-long + 2 crinkliness + 1 proportion + 1 too-few-stairs(single-storey artifact). Missing = 39% — matches the 'still stalls esp. missing' branch.\n=\u003e c4c.2 (programme-aware construction + missing-room repair) is the prerequisite; staging (c4c.3) alone won't rescue it. c4c.3 already correctly depends on both. Full writeup in DESIGN.md §11.1.","status":"closed","priority":1,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-17T18:49:43Z","created_by":"Bruno Postle","updated_at":"2026-06-17T20:15:36Z","started_at":"2026-06-17T19:25:49Z","closed_at":"2026-06-17T20:15:36Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-c4c.1","depends_on_id":"homemaker-py-c4c","type":"parent-child","created_at":"2026-06-17T19:49:43Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"id":"homemaker-py-c4c","title":"Phase 6: topology-search quality for full/multi-storey programmes","description":"Diagnosis (survey 2026-06-17): the delivered speedups (native fitness ~140x, geometry inner loop ~1.6x) landed in the two layers that were never the bottleneck. The geometry inner loop polishes WITHIN a failure tier (DESIGN.md §4.5/§4.7: 0 fail changes, by design — the 0.5^n cliff protects it). But final design quality is dominated by FAILURE COUNT, which is almost entirely a topology property. Topology search on full programmes is the weakness:\n- blank-slate programme-house (init.dom): memetic stalls at 18 fails vs urb-evolve 6 (§7 Phase 2 verdict);\n- harbor-house (16 rooms): out1.dom=74 fails, generated.dom=130 fails, both at ~machine-epsilon score; fails dominated by 'missing' room stacking (each missing room stacks critical+size+width+adjacency+level, §6).\nSmoking gun: operators.mutate_divide (operators.py:71) assigns each new leaf a RANDOM type from programme-codes+C+O. Nothing guarantees the required programme spaces are instantiated, so on a large programme required rooms go missing -\u003e catastrophic 0.5^n stacking, and the search is a random walk over type assignments with a flat/catastrophic gradient in the high-fail regime.\nThis epic groups the topology-search-quality work: programme-aware construction, staged per-floor search, graded high-fail objective, topology diversity, then the canonical-encoding capstone. Everything experiment-driven; results recorded in DESIGN.md sections + bead notes.","design":"Causal frame: base-floor tree is the master genome; upper storeys are divide/undivide deltas (Below-inheritance); the programme partitions rooms by required level (harbor: 10 on L0, 4 on L1, 2 free). So construction and search should follow the genome's dependency order: credible base floor first, upper floors as deltas, with required-room sets known per floor from the programme. Do NOT hard-freeze the base when adding floors — that recreates the §4.2 partial-objective trap at the topology level (a base optimised purely as ground floor can be a bad SUBSTRATE: vertical core must stay aligned, load-bearing walls must stack). Curriculum, not freeze.","acceptance_criteria":"Memetic search reaches a competitive low-fail design on harbor-house (16 rooms, multi-storey) and on blank-slate programme-house, beating the current 74/18-fail plateaus; each child bead lands its experiment with results recorded in DESIGN.md.","status":"open","priority":1,"issue_type":"epic","owner":"bruno@postle.net","created_at":"2026-06-17T18:45:39Z","created_by":"Bruno Postle","updated_at":"2026-06-17T18:45:39Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-c4c","title":"Phase 6: topology-search quality for full/multi-storey programmes","description":"Diagnosis (survey 2026-06-17): the delivered speedups (native fitness ~140x, geometry inner loop ~1.6x) landed in the two layers that were never the bottleneck. The geometry inner loop polishes WITHIN a failure tier (DESIGN.md §4.5/§4.7: 0 fail changes, by design — the 0.5^n cliff protects it). But final design quality is dominated by FAILURE COUNT, which is almost entirely a topology property. Topology search on full programmes is the weakness:\n- blank-slate programme-house (init.dom): memetic stalls at 18 fails vs urb-evolve 6 (§7 Phase 2 verdict);\n- harbor-house (16 rooms): out1.dom=74 fails, generated.dom=130 fails, both at ~machine-epsilon score; fails dominated by 'missing' room stacking (each missing room stacks critical+size+width+adjacency+level, §6).\nSmoking gun: operators.mutate_divide (operators.py:71) assigns each new leaf a RANDOM type from programme-codes+C+O. Nothing guarantees the required programme spaces are instantiated, so on a large programme required rooms go missing -\u003e catastrophic 0.5^n stacking, and the search is a random walk over type assignments with a flat/catastrophic gradient in the high-fail regime.\nThis epic groups the topology-search-quality work: programme-aware construction, staged per-floor search, graded high-fail objective, topology diversity, then the canonical-encoding capstone. Everything experiment-driven; results recorded in DESIGN.md sections + bead notes.","design":"Causal frame: base-floor tree is the master genome; upper storeys are divide/undivide deltas (Below-inheritance); the programme partitions rooms by required level (harbor: 10 on L0, 4 on L1, 2 free). So construction and search should follow the genome's dependency order: credible base floor first, upper floors as deltas, with required-room sets known per floor from the programme. Do NOT hard-freeze the base when adding floors — that recreates the §4.2 partial-objective trap at the topology level (a base optimised purely as ground floor can be a bad SUBSTRATE: vertical core must stay aligned, load-bearing walls must stack). Curriculum, not freeze.","acceptance_criteria":"Memetic search reaches a competitive low-fail design on harbor-house (16 rooms, multi-storey) and on blank-slate programme-house, beating the current 74/18-fail plateaus; each child bead lands its experiment with results recorded in DESIGN.md.","status":"open","priority":1,"issue_type":"epic","owner":"bruno@postle.net","created_at":"2026-06-17T18:45:39Z","created_by":"Bruno Postle","updated_at":"2026-06-17T18:45:39Z","dependency_count":0,"dependent_count":0,"comment_count":0}
@ -38,16 +38,16 @@
{"id":"homemaker-py-can","title":"Programme width defaults: t3 contradiction (impossible width_inside default)","description":"DESIGN.md §8.2, confirmed in source. t3 (3 m2 WC) has no width spec so inherits width_inside [4.0, 1.0] (Fitness/Base.pm:60) — geometrically impossible; designs 'pass' only by failing size instead. Fix AFTER faithful-port validation (port-faithfully-first policy, §8.1): a sane width default scaled to area (e.g. sqrt(area/proportion)) or per-room widths in patterns.config. Applies to native fitness; optionally upstream to Urb.","acceptance_criteria":"No programme space has a default width incompatible with its target area; corpus re-scored and effect documented","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:01Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:21:37Z","started_at":"2026-06-13T21:16:11Z","closed_at":"2026-06-13T21:21:37Z","close_reason":"Fixed in get_space_params: when a programme space has no explicit 'width', derive target from sqrt(size/proportion) instead of falling back to width_inside [4.0, 1.0]. Re-scored 35-file corpus: 32 files improved (+1-121%), 5 files lost spurious width fails. All 109 tests pass. Upstream Perl fix tracked as homemaker-py-8fe.","dependencies":[{"issue_id":"homemaker-py-can","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:47Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-can","title":"Programme width defaults: t3 contradiction (impossible width_inside default)","description":"DESIGN.md §8.2, confirmed in source. t3 (3 m2 WC) has no width spec so inherits width_inside [4.0, 1.0] (Fitness/Base.pm:60) — geometrically impossible; designs 'pass' only by failing size instead. Fix AFTER faithful-port validation (port-faithfully-first policy, §8.1): a sane width default scaled to area (e.g. sqrt(area/proportion)) or per-room widths in patterns.config. Applies to native fitness; optionally upstream to Urb.","acceptance_criteria":"No programme space has a default width incompatible with its target area; corpus re-scored and effect documented","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:01Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:21:37Z","started_at":"2026-06-13T21:16:11Z","closed_at":"2026-06-13T21:21:37Z","close_reason":"Fixed in get_space_params: when a programme space has no explicit 'width', derive target from sqrt(size/proportion) instead of falling back to width_inside [4.0, 1.0]. Re-scored 35-file corpus: 32 files improved (+1-121%), 5 files lost spurious width fails. All 109 tests pass. Upstream Perl fix tracked as homemaker-py-8fe.","dependencies":[{"issue_id":"homemaker-py-can","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:47Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"homemaker-py-yg5","title":"Penalty reshaping: replace 0.5^n while preserving inner-loop protection","description":"DESIGN.md §4.7, §5.4, §7 Phase 4, §8.5. The 0.5^n cliff gives the outer search no gradient and rewards flag-count over geometry, but it also PROTECTS the inner loop from trading into new failures (§4.5). One fitness shape cannot naively be both soft outside and cliff-protected inside. Candidates: cliff-inside-inner-loop only, lexicographic (failure count first, score second), additive/soft, multi-objective Pareto. Must preserve the missing-space failure hierarchy (worse to drop a room than to have a poor one). Measure landscape + search outcomes; this helps Urb today too.","acceptance_criteria":"Chosen scheme documented with measurements: search improves while inner loop still never trades into new failures","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:00Z","created_by":"Bruno Postle","updated_at":"2026-06-14T08:16:14Z","started_at":"2026-06-14T07:55:32Z","closed_at":"2026-06-14T08:16:14Z","close_reason":"Implemented lexicographic outer-search comparison (-n_fails, fitness). Inner loop unchanged (0.5^n cliff protection preserved). Experiment penalty_reshape.py confirms 0/9 fail regressions in inner loop and shows lex avoids the 3-fail trap that scalar hits 1/3 of the time. Fixed stale _CHILD_INNER_KW sigmas entry.","dependencies":[{"issue_id":"homemaker-py-yg5","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-yg5","title":"Penalty reshaping: replace 0.5^n while preserving inner-loop protection","description":"DESIGN.md §4.7, §5.4, §7 Phase 4, §8.5. The 0.5^n cliff gives the outer search no gradient and rewards flag-count over geometry, but it also PROTECTS the inner loop from trading into new failures (§4.5). One fitness shape cannot naively be both soft outside and cliff-protected inside. Candidates: cliff-inside-inner-loop only, lexicographic (failure count first, score second), additive/soft, multi-objective Pareto. Must preserve the missing-space failure hierarchy (worse to drop a room than to have a poor one). Measure landscape + search outcomes; this helps Urb today too.","acceptance_criteria":"Chosen scheme documented with measurements: search improves while inner loop still never trades into new failures","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:00Z","created_by":"Bruno Postle","updated_at":"2026-06-14T08:16:14Z","started_at":"2026-06-14T07:55:32Z","closed_at":"2026-06-14T08:16:14Z","close_reason":"Implemented lexicographic outer-search comparison (-n_fails, fitness). Inner loop unchanged (0.5^n cliff protection preserved). Experiment penalty_reshape.py confirms 0/9 fail regressions in inner loop and shows lex avoids the 3-fail trap that scalar hits 1/3 of the time. Fixed stale _CHILD_INNER_KW sigmas entry.","dependencies":[{"issue_id":"homemaker-py-yg5","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"homemaker-py-2g5","title":"Rebuild occlusion/daylight/sun subsystem in Python (post-Phase-5, after optimisation fully native)","description":"DESIGN.md §6 port scope — a whole subsystem, not a term. quality_daylight (Leaf.pm:281-296) needs Urb::Misc::Sun + Urb::Field::Occlusion (+CIESky); quality_uncrinkliness also takes the occlusion object. Indoor spaces return 1 for daylight; cost is outdoor spaces + crinkliness. Port Sun_horizontal (262980-minute normalisation) and the occlusion wall set from Dom-\u003eWalls.","acceptance_criteria":"Daylight and crinkliness factors match Perl (float tolerance) across the corpus, including multi-storey cases","notes":"Re-scoped 2026-06-12: occlusion disabled in the Urb oracle instead of ported (see homemaker-py-gp2). Native fitness ships with simple crinkliness (illumination factor = 1, in homemaker-py-gnw). This issue is now the eventual Python occlusion rebuild, only after optimisation works entirely in Python. Restores outdoor-daylight and shaded-wall selection pressure.\nReframed 2026-06-17: orthogonal to epic homemaker-py-c4c. This is fitness FIDELITY (restoring daylight + shaded-wall selection pressure to match Perl), not search CAPABILITY — it changes what 'good' means, not the search's ability to find good. It will NOT improve final designs in the sense currently sought. Stays P4, deferred until the topology-search-quality epic lands and optimisation is fully native.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:25Z","created_by":"Bruno Postle","updated_at":"2026-06-17T19:14:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-2g5","title":"Rebuild occlusion/daylight/sun subsystem in Python (post-Phase-5, after optimisation fully native)","description":"DESIGN.md §6 port scope — a whole subsystem, not a term. quality_daylight (Leaf.pm:281-296) needs Urb::Misc::Sun + Urb::Field::Occlusion (+CIESky); quality_uncrinkliness also takes the occlusion object. Indoor spaces return 1 for daylight; cost is outdoor spaces + crinkliness. Port Sun_horizontal (262980-minute normalisation) and the occlusion wall set from Dom-\u003eWalls.","acceptance_criteria":"Daylight and crinkliness factors match Perl (float tolerance) across the corpus, including multi-storey cases","notes":"Re-scoped 2026-06-12: occlusion disabled in the Urb oracle instead of ported (see homemaker-py-gp2). Native fitness ships with simple crinkliness (illumination factor = 1, in homemaker-py-gnw). This issue is now the eventual Python occlusion rebuild, only after optimisation works entirely in Python. Restores outdoor-daylight and shaded-wall selection pressure.\nReframed 2026-06-17: orthogonal to epic homemaker-py-c4c. This is fitness FIDELITY (restoring daylight + shaded-wall selection pressure to match Perl), not search CAPABILITY — it changes what 'good' means, not the search's ability to find good. It will NOT improve final designs in the sense currently sought. Stays P4, deferred until the topology-search-quality epic lands and optimisation is fully native.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:25Z","created_by":"Bruno Postle","updated_at":"2026-06-17T19:14:48Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"memory","key":"urb-fitness-bug-found-fixed-2026-06-12","value":"Urb fitness bug found+fixed 2026-06-12 (patch in /home/bruno/src/urb, uncommitted): ProgrammeDriven.pm ratio_o/ratio_type grepped case-insensitively over the ratios hash and took the FIRST key — nondeterministic (x4.5 score swings) for designs with mixed-case type classes (both 'c' circulation and 'C' covered). Fixed to SUM the class (matches Is_Circulation//Is_Outside semantics); 35/35 corpus scores unchanged. CRITICAL for homemaker-py-3y7/gnw: the native port must implement class-SUM ratios. Building.pm has the same unpatched pattern (site-driven path, not used by our oracle). Also: the memetic search reward-hacked this bug before the fix — search results predating it are noise artifacts."} {"_type":"memory","key":"deceptive-valleys-in-topology-search-when-every-single","value":"Deceptive valleys in topology search: when every single-step mutation from a target state passes through a high-fail intermediary (e.g. level_fix displaces a room into 5+ new fails), a compound operator that atomically applies two coordinated changes can escape. Design compound operators to land on the low-fail state directly, bypassing the deceptive gradient. Programme-house example: level_compound_fix atomically moves the level-constrained room AND re-inserts the displaced room adjacent to C in one step (operators.py, 2026-06-14)."}
{"_type":"memory","key":"urb-oracle-nondeterminism-urb-fitness-pl-output-varies","value":"Urb oracle nondeterminism: urb-fitness.pl output varies run-to-run from Perl hash-order randomisation — .fails line ORDER shuffles (compare sorted, use oracle.Score.fail_lines) and the score float can flip by ~1 ULP (compare with math.isclose rel_tol=1e-12, never ==). Not a batching artifact; affects single runs too. Matters for the Phase 3 native-fitness parity gate (homemaker-py-uxz)."}
{"_type":"memory","key":"multi-storey-staircase-consistency-when-dividing-or-retyping","value":"Multi-storey staircase consistency: when dividing or retyping a circulation (C) leaf at one level, the same structural change should be propagated to the matching leaf on ALL other storeys so the stair core path is maintained. The optimizer cannot fix staircase disruptions through trial-and-error geometry alone — it requires a synchronized multi-level operator that applies the same topology change to every storey simultaneously."} {"_type":"memory","key":"multi-storey-staircase-consistency-when-dividing-or-retyping","value":"Multi-storey staircase consistency: when dividing or retyping a circulation (C) leaf at one level, the same structural change should be propagated to the matching leaf on ALL other storeys so the stair core path is maintained. The optimizer cannot fix staircase disruptions through trial-and-error geometry alone — it requires a synchronized multi-level operator that applies the same topology change to every storey simultaneously."}
{"_type":"memory","key":"programme-house-optimisation-result-2026-06-14-15","value":"Programme-house optimisation result (2026-06-14/15): best achievable is 1 fail (l1 wrong level, score ~0.005). 0 fails is geometrically impossible: l1 (min 27m²) must occupy ll (~23m²) at level 0, which eliminates the t3-adj-C provider; dividing ll into lll(l1)+llr(C) gives llr proportion ~6:1 (fails). Python memetic optimizer achieves 1 fail in 50k evals vs Perl optimiser's 2-3 fails. Winning topology: TWO C nodes at level 0 — ll(C) for t3-adj-C via geometric contact, rl(C) for staircase via tree-sibling adjacency to rrr(O). Best .dom: scratch/from-warmstart-fixed.dom and scratch/from-compound3-fixed.dom."}
{"_type":"memory","key":"warm-x0-initialization-bug-pattern-when-a-topology","value":"warm_x0 initialization bug pattern: when a topology operator explicitly sets division ratios on a newly-created node (e.g. compound_fix sets node.division=[0.25,0.25] for t3), parent.ratios has no entry for that node (it was a leaf). warm_x0 defaults it to 0.5, corrupting the inner loop's starting point and making the operator invisible to lex comparison. Fix: only propagate child ratios for nodes where the parent node was NOT already divided; stale hidden nodes revealed by structural mutations (swap flipping b.below) must NOT contribute their pre-writeback values. See driver.py lines 259-267 (fixed 2026-06-14)."} {"_type":"memory","key":"warm-x0-initialization-bug-pattern-when-a-topology","value":"warm_x0 initialization bug pattern: when a topology operator explicitly sets division ratios on a newly-created node (e.g. compound_fix sets node.division=[0.25,0.25] for t3), parent.ratios has no entry for that node (it was a leaf). warm_x0 defaults it to 0.5, corrupting the inner loop's starting point and making the operator invisible to lex comparison. Fix: only propagate child ratios for nodes where the parent node was NOT already divided; stale hidden nodes revealed by structural mutations (swap flipping b.below) must NOT contribute their pre-writeback values. See driver.py lines 259-267 (fixed 2026-06-14)."}
{"_type":"memory","key":"adjacency-in-binary-slicing-tree-is-structural-not","value":"Adjacency in binary slicing tree is structural, not geometric: the inner-loop NM cannot fix topological adjacency failures. Two paths exist: (1) tree-sibling adjacency — a node is adjacent to its sibling in the tree; (2) cross-zone geometric adjacency — leaves from different subtrees that happen to share a boundary. Staircase/adjacency fails require a topology mutation that changes which nodes are siblings or which zones touch. This was proved empirically on programme-house: staircase fail from rot=0 layout could not be fixed by NM but was fixed by level_retype creating a two-C topology (2026-06-14/15)."} {"_type":"memory","key":"adjacency-in-binary-slicing-tree-is-structural-not","value":"Adjacency in binary slicing tree is structural, not geometric: the inner-loop NM cannot fix topological adjacency failures. Two paths exist: (1) tree-sibling adjacency — a node is adjacent to its sibling in the tree; (2) cross-zone geometric adjacency — leaves from different subtrees that happen to share a boundary. Staircase/adjacency fails require a topology mutation that changes which nodes are siblings or which zones touch. This was proved empirically on programme-house: staircase fail from rot=0 layout could not be fixed by NM but was fixed by level_retype creating a two-C topology (2026-06-14/15)."}
{"_type":"memory","key":"cli-tool-style-prefer-python-m-homemaker-module","value":"CLI tool style: prefer python -m homemaker.module --parameters pattern, installable via pip install -e . with pyproject.toml entry_points. Not standalone bin/ scripts."}
{"_type":"memory","key":"never-use-corpus-filenames-candidate-001-dom-candidate","value":"Never use corpus filenames (candidate-001.dom, candidate-002.dom, generated.dom, init.dom, etc.) as --output targets when running experiments. These are test fixtures. Always write experimental outputs to scratch/ or a timestamped path. Lesson from 2026-06-14: warm-start runs overwrote candidate-001/002.dom and broke graph tests."}
{"_type":"memory","key":"strategy-decision-2026-06-12-bruno-occlusion-daylight","value":"Strategy decision 2026-06-12 (Bruno): occlusion/daylight is ORTHOGONAL to building a scalable optimiser. Disable it in Urb (env flag, homemaker-py-gp2) rather than port it; native fitness uses simple crinkliness (illumination factor = 1); rebuild occlusion in Python only after optimisation is fully native (homemaker-py-2g5, now P4). Consequence: all scores change when the flag flips — re-baseline corpus/.score, DESIGN \\$4.5 gains, gate bars at one clean boundary AFTER homemaker-py-1p0 closes; Phase-2 urb-evolve benchmark must run with the same flag."}
{"_type":"memory","key":"programme-house-optimisation-result-2026-06-14-15","value":"Programme-house optimisation result (2026-06-14/15): best achievable is 1 fail (l1 wrong level, score ~0.005). 0 fails is geometrically impossible: l1 (min 27m²) must occupy ll (~23m²) at level 0, which eliminates the t3-adj-C provider; dividing ll into lll(l1)+llr(C) gives llr proportion ~6:1 (fails). Python memetic optimizer achieves 1 fail in 50k evals vs Perl optimiser's 2-3 fails. Winning topology: TWO C nodes at level 0 — ll(C) for t3-adj-C via geometric contact, rl(C) for staircase via tree-sibling adjacency to rrr(O). Best .dom: scratch/from-warmstart-fixed.dom and scratch/from-compound3-fixed.dom."}
{"_type":"memory","key":"user-preference-bruno-this-is-a-fedora-system","value":"User preference (Bruno): this is a Fedora system — NEVER install Python packages via pip without asking first; always ask whether to install the rpm via dnf (e.g. python3-cma) before considering pip. Applies to any dependency additions."} {"_type":"memory","key":"user-preference-bruno-this-is-a-fedora-system","value":"User preference (Bruno): this is a Fedora system — NEVER install Python packages via pip without asking first; always ask whether to install the rpm via dnf (e.g. python3-cma) before considering pip. Applies to any dependency additions."}
{"_type":"memory","key":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."} {"_type":"memory","key":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."}
{"_type":"memory","key":"never-use-corpus-filenames-candidate-001-dom-candidate","value":"Never use corpus filenames (candidate-001.dom, candidate-002.dom, generated.dom, init.dom, etc.) as --output targets when running experiments. These are test fixtures. Always write experimental outputs to scratch/ or a timestamped path. Lesson from 2026-06-14: warm-start runs overwrote candidate-001/002.dom and broke graph tests."}
{"_type":"memory","key":"cli-tool-style-prefer-python-m-homemaker-module","value":"CLI tool style: prefer python -m homemaker.module --parameters pattern, installable via pip install -e . with pyproject.toml entry_points. Not standalone bin/ scripts."}
{"_type":"memory","key":"deceptive-valleys-in-topology-search-when-every-single","value":"Deceptive valleys in topology search: when every single-step mutation from a target state passes through a high-fail intermediary (e.g. level_fix displaces a room into 5+ new fails), a compound operator that atomically applies two coordinated changes can escape. Design compound operators to land on the low-fail state directly, bypassing the deceptive gradient. Programme-house example: level_compound_fix atomically moves the level-constrained room AND re-inserts the displaced room adjacent to C in one step (operators.py, 2026-06-14)."}
{"_type":"memory","key":"homemaker-py-pythonpath-set-pythonpath-home-bruno-src","value":"homemaker-layout PYTHONPATH: package installed as 'homemaker-layout' via pip install -e . so 'import homemaker_layout' works from anywhere without PYTHONPATH. For running tests use 'python -m pytest' from project root /home/bruno/src/homemaker-layout (pyproject.toml adds src/ automatically). Never try pip show homemaker — that's the old homemaker-addon conflict."} {"_type":"memory","key":"homemaker-py-pythonpath-set-pythonpath-home-bruno-src","value":"homemaker-layout PYTHONPATH: package installed as 'homemaker-layout' via pip install -e . so 'import homemaker_layout' works from anywhere without PYTHONPATH. For running tests use 'python -m pytest' from project root /home/bruno/src/homemaker-layout (pyproject.toml adds src/ automatically). Never try pip show homemaker — that's the old homemaker-addon conflict."}
{"_type":"memory","key":"strategy-decision-2026-06-12-bruno-occlusion-daylight","value":"Strategy decision 2026-06-12 (Bruno): occlusion/daylight is ORTHOGONAL to building a scalable optimiser. Disable it in Urb (env flag, homemaker-py-gp2) rather than port it; native fitness uses simple crinkliness (illumination factor = 1); rebuild occlusion in Python only after optimisation is fully native (homemaker-py-2g5, now P4). Consequence: all scores change when the flag flips — re-baseline corpus/.score, DESIGN \\$4.5 gains, gate bars at one clean boundary AFTER homemaker-py-1p0 closes; Phase-2 urb-evolve benchmark must run with the same flag."} {"_type":"memory","key":"urb-fitness-bug-found-fixed-2026-06-12","value":"Urb fitness bug found+fixed 2026-06-12 (patch in /home/bruno/src/urb, uncommitted): ProgrammeDriven.pm ratio_o/ratio_type grepped case-insensitively over the ratios hash and took the FIRST key — nondeterministic (x4.5 score swings) for designs with mixed-case type classes (both 'c' circulation and 'C' covered). Fixed to SUM the class (matches Is_Circulation//Is_Outside semantics); 35/35 corpus scores unchanged. CRITICAL for homemaker-py-3y7/gnw: the native port must implement class-SUM ratios. Building.pm has the same unpatched pattern (site-driven path, not used by our oracle). Also: the memetic search reward-hacked this bug before the fix — search results predating it are noise artifacts."}
{"_type":"memory","key":"urb-oracle-nondeterminism-urb-fitness-pl-output-varies","value":"Urb oracle nondeterminism: urb-fitness.pl output varies run-to-run from Perl hash-order randomisation — .fails line ORDER shuffles (compare sorted, use oracle.Score.fail_lines) and the score float can flip by ~1 ULP (compare with math.isclose rel_tol=1e-12, never ==). Not a batching artifact; affects single runs too. Matters for the Phase 3 native-fitness parity gate (homemaker-py-uxz)."}

View file

@ -802,17 +802,72 @@ Two changes (`operators.py`, wired in `driver.py`):
identical-config A/B against the pre-change code (133 fails), not the stale identical-config A/B against the pre-change code (133 fails), not the stale
`out1.dom` number. `out1.dom` number.
### 11.3 Staged per-floor search (`homemaker-py-c4c.3`) ### 11.3 Staged per-floor search (`homemaker-py-c4c.3`) — DONE
*Stub.* Stage 1: base floor over the level-0 room set (one tree, no deltas) + Searches the genome in causal dependency order (`driver.search_staged`), two
reserved core + substrate-readiness term. Stage 2: upper floors as deltas seeded stages composed from the existing `driver.search`:
with their required room sets, base kept mutable at low probability. Gated by
§11.1 premise.
- *Gate:* staged beats single-stage on harbor at equal native-fitness budget; 1. **Stage 1 — base floor** (40 % of budget). A single-storey programme is
reserved-core + readiness shown to prevent the bungalow trap (stage 2 does not auto-derived to a tempdir (`programme.write_stage1_programme`): the full
carve a core from scratch); no programme-house regression. `patterns.config` filtered to the storey-0 room set
- *Result:* TODO. (`programme.partition_rooms_by_storey`), `level:` keys dropped, adjacencies
pruned to surviving refs, `storey_limit/staircase` forced to 1. The base is
searched on that reduced programme but **ranked** with a substrate-readiness
bonus — key `(-n_fails, fitness·(1 + W·readiness))`, `W=1` — so it is selected
as a good *substrate*, not merely a good ground floor (anti-§4.2).
`graph.substrate_readiness` = `core_factor · capacity`: full credit for a
reserved `C` leaf ≥ `STAIR_MIN_AREA` (vertically-alignable core), times
`min(1, usable_base_area / required_upper_area)` (enough divisible footprint
for the upper set).
2. **Stage 2 — upper floors as deltas** (remaining budget). The best base is
lifted (`operators.lift_base_to_storeys`) into a full multi-storey design that
**preserves the base storey and its inherited core** and instantiates each
upper storey's required room set by construction (the Stage-2 analog of §11.2
seeding). Deltas are searched with the base kept **mutable at low probability**
(`base_p=0.15`, threaded through the exploratory ops; `place_missing`/`core_*`
stay unbiased — repair and core-maintenance must reach the base).
- *Gate:* staged beats single-stage on harbor at equal budget; reserved-core +
readiness prevent the bungalow trap (stage 2 does not carve a core from
scratch); no programme-house regression.
- *Commands (reproduce, `URB_NO_OCCLUSION=1`, 20000 evals, seed 0):*
```bash
python3 experiments/run_search_scaled.py examples/harbor-house 20000 0 \
examples/harbor-house/init.dom scratch/ab_single.dom # single-stage
python3 experiments/run_staged_search.py examples/harbor-house 20000 0 \
examples/harbor-house/init.dom scratch/ab_staged.dom # staged
```
- *Result (harbor-house, 20000 native evals, seed 0, identical config):*
| metric | single-stage | **staged** |
|--------|-------------:|-----------:|
| total fails | 105 | **95** |
| crinkliness | 27 | 18 |
| edge too long | 12 | 8 |
| proportion | 6 | 4 |
| width | 4 | 2 |
| size | 25 | 26 |
| access | 13 | 18 |
| missing | 8 | 8 |
| adjacency | 2 | 2 |
Single-stage reproduces the §11.2 baseline **exactly (105 fails)**; staged ends
at **95 (10, 9.5 %)**. The gain is concentrated in the packing fails staging
targets — crinkliness 27→18 and edge-too-long 12→8 — at a small cost in access
(+5). **Anti-bungalow: confirmed.** Every `core_divide`/`core_undivide` in the
Stage-2 winning lineage is a *noop* — the core is inherited from Stage 1 and is
never carved from scratch. **Programme-house regression: PASS** — single-storey
programmes fall through to plain `search`; the warmstart-2f4 seed (50000 evals,
pop 8, 4 workers) still reaches a whole-population **1-fail** optimum (§4.10).
- *Verdict: staging helps, modestly, and is the right structural frame.* Building
one credible, substrate-ready floor first — then upper floors as constructed
deltas with an inherited core — beats cramming both floors simultaneously
(95 vs 105) without touching the inner loop. The remaining load is the dense
quality-fail regime (size/access/crinkliness on two fully-populated floors) that
**§11.4 (graded objective)** targets: with `missing` already collapsed (§11.2)
and the floors now assembled in dependency order, the lever left is navigation
*within* the high-fail plateau, where lex-by-count gives near-zero gradient.
### 11.4 Graded high-fail objective (`homemaker-py-c4c.4`) ### 11.4 Graded high-fail objective (`homemaker-py-c4c.4`)

View file

@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Staged per-floor topology search (homemaker-py-c4c.3, DESIGN.md §11.3).
Runs ``driver.search_staged`` (Stage 1: single-storey base over the level-0 room
set with a substrate-readiness ranking bonus; Stage 2: upper floors lifted as
deltas, base kept mutable at low probability) on a multi-storey programme. The
single-stage counterpart is ``run_search_scaled.py``; run both at the same budget
and seed for the §11.3 A/B.
Usage:
URB_NO_OCCLUSION=1 python3 experiments/run_staged_search.py \
[programme_dir] [budget] [rng_seed] [seed.dom] [out.dom]
Defaults: harbor-house, budget=20000, rng_seed=0, init.dom seed.
"""
from __future__ import annotations
import math
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import dom, driver, fitness # noqa: E402
REPO = Path(__file__).resolve().parents[1]
HARBOR = REPO / "examples" / "harbor-house"
def _native_score(root: dom.Node, programme_dir: Path) -> tuple[float, int]:
import copy
conf, cost = fitness.load_config(programme_dir)
fit = fitness.Fitness(conf, cost)
score, fails = fit.score_with_fails(copy.deepcopy(root))
return score, len(fails)
def main() -> int:
programme_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else HARBOR
budget = int(sys.argv[2]) if len(sys.argv) > 2 else 20000
rng_seed = int(sys.argv[3]) if len(sys.argv) > 3 else 0
seed_file = Path(sys.argv[4]) if len(sys.argv) > 4 else (programme_dir / "init.dom")
out = Path(sys.argv[5]) if len(sys.argv) > 5 else (
REPO / "scratch" / "staged_best.dom"
)
if not seed_file.exists():
print(f"ERROR: no seed .dom at {seed_file}", file=sys.stderr)
return 1
print(f"programme : {programme_dir.name}")
print(f"seed : {seed_file.name}")
print(f"budget : {budget} native evals (staged)")
print(f"rng seed : {rng_seed}")
print(flush=True)
seed_root = dom.load(str(seed_file))
t0 = time.perf_counter()
r = driver.search_staged(
seed_root,
programme_dir,
budget=budget,
pop_size=16,
child_budget=80,
seed_budget=300,
stage1_frac=0.4,
base_p=0.15,
p_crossover=0.2,
seed=rng_seed,
log=lambda m: print(m, flush=True),
)
elapsed = time.perf_counter() - t0
print(f"\n--- done ---")
print(f"elapsed : {elapsed:.1f}s ({r.n_evals / elapsed:.1f} evals/s)")
print(f"evals : {r.n_evals} across {r.n_topologies} topologies")
print(f"best : {r.best.fitness:.6g} ({r.best.n_fails} fails) via {r.best.lineage}")
print("population: " + ", ".join(
f"{p.fitness:.4g}/{p.n_fails}f" for p in r.population
))
if r.history:
print("\nimprovement history:")
for ev, fit_val, lin in r.history:
print(f" [{ev:6d}] {fit_val:.6g} ({lin})")
out.parent.mkdir(parents=True, exist_ok=True)
dom.dump(r.best.root, str(out))
rs, rf = _native_score(r.best.root, programme_dir)
ok = math.isclose(rs, r.best.fitness, rel_tol=1e-9)
print(f"\n{out.name} re-scored (native): {rs:.6g} ({rf} fails) "
f"{'OK' if ok else 'MISMATCH'}")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -121,6 +121,10 @@ def search(
log=None, log=None,
n_workers: int = 1, n_workers: int = 1,
use_lex: bool = True, use_lex: bool = True,
rank_bonus_fn=None,
rank_bonus_weight: float = 1.0,
seed_factory=None,
base_p: float = 1.0,
) -> SearchResult: ) -> SearchResult:
"""Run the memetic loop from ``seed_root`` until ``budget`` oracle """Run the memetic loop from ``seed_root`` until ``budget`` oracle
evaluations are consumed. Returns the best individual found; its ``root`` evaluations are consumed. Returns the best individual found; its ``root``
@ -144,7 +148,18 @@ def search(
urb_root = urb_root or DEFAULT_URB_ROOT urb_root = urb_root or DEFAULT_URB_ROOT
rng = np.random.default_rng(seed) rng = np.random.default_rng(seed)
inner_kw = dict(_CHILD_INNER_KW, **(inner_kw or {})) inner_kw = dict(_CHILD_INNER_KW, **(inner_kw or {}))
_key = (lambda ind: (-ind.n_fails, ind.fitness)) if use_lex else (lambda ind: ind.fitness) # Optional ranking bonus (DESIGN.md §11.3 Stage 1): bias selection toward
# individuals with high substrate-readiness via a multiplicative factor
# (1 + W·bonus) on fitness. The reported fitness/history stay the TRUE
# fitness; only the comparison key changes. rank_bonus_fn=None (default) ⇒
# the key is unchanged, so normal/Stage-2/programme-house runs are unaffected.
def _rank_fitness(ind: Individual) -> float:
if rank_bonus_fn is None:
return ind.fitness
return ind.fitness * (1.0 + rank_bonus_weight * rank_bonus_fn(ind.root))
_key = ((lambda ind: (-ind.n_fails, _rank_fitness(ind))) if use_lex
else (lambda ind: _rank_fitness(ind)))
# Always load reqs so bootstrap_n_leaves can be auto-derived from programme. # Always load reqs so bootstrap_n_leaves can be auto-derived from programme.
reqs = programme.load_programme_dir(programme_dir) reqs = programme.load_programme_dir(programme_dir)
if types is None: if types is None:
@ -229,7 +244,13 @@ def search(
n_target = bootstrap_n_leaves or max(len(reqs), 3) n_target = bootstrap_n_leaves or max(len(reqs), 3)
tasks = [] tasks = []
for i in range(pop_size): for i in range(pop_size):
if prog: if seed_factory is not None:
# Custom seed (DESIGN.md §11.3 Stage 2: lift the evolved base
# into a full multi-storey design with the upper room sets
# instantiated by construction).
topo = seed_factory(rng)
lineage = f"lift/{i}"
elif prog:
topo = operators.constructive_topology(seed_root, reqs, rng, types) topo = operators.constructive_topology(seed_root, reqs, rng, types)
lineage = f"construct/{i}" lineage = f"construct/{i}"
else: else:
@ -263,7 +284,7 @@ def search(
parent = _tournament(pop, rng, _key) parent = _tournament(pop, rng, _key)
child_root, desc = operators.mutate(parent.root, rng, types, child_root, desc = operators.mutate(parent.root, rng, types,
weights=_MUTATION_WEIGHTS, weights=_MUTATION_WEIGHTS,
reqs=reqs) reqs=reqs, base_p=base_p)
# Carry operator-specified ratios for nodes that are genuinely # Carry operator-specified ratios for nodes that are genuinely
# newly divided (existed as leaves in the parent, are now # newly divided (existed as leaves in the parent, are now
# divided in the child). Structural mutations (e.g. swap) can # divided in the child). Structural mutations (e.g. swap) can
@ -293,3 +314,110 @@ def search(
result.n_topologies = n_topologies result.n_topologies = n_topologies
result.interrupted = interrupted result.interrupted = interrupted
return result return result
def search_staged(
seed_root: dom.Node,
programme_dir: str | Path,
budget: int = 20000,
pop_size: int = 16,
child_budget: int = 80,
seed_budget: int = 300,
stage1_frac: float = 0.4,
base_p: float = 0.15,
rank_bonus_weight: float = 1.0,
p_crossover: float = 0.2,
seed: int = 0,
types: list[str] | None = None,
inner_kw: dict | None = None,
log=None,
n_workers: int = 1,
) -> SearchResult:
"""Staged per-floor topology search (DESIGN.md §11.3, ``homemaker-py-c4c.3``).
Searches the genome in causal dependency order:
- **Stage 1** (``stage1_frac`` of the budget): a single-storey base over the
level-0 room set (a programme auto-derived to a tempdir), ranked with a
substrate-readiness bonus so the base is selected as a good *substrate*
a reserved, vertically-alignable core and enough divisible footprint for the
upper floors not merely a good ground floor (anti-bungalow, §4.2).
- **Stage 2** (remaining budget): the best base is lifted into a full
multi-storey design with each upper storey's required room set instantiated
by construction (``operators.lift_base_to_storeys``); the deltas are searched
with the base kept mutable at low probability (``base_p``).
Single-storey programmes (e.g. programme-house) have no upper floors to stage,
so this falls through to a plain :func:`search` guaranteeing no regression.
"""
import shutil
import tempfile
from . import graph
reqs = programme.load_programme_dir(programme_dir)
n_storeys = programme.n_storeys_required(reqs)
def _log(msg: str) -> None:
if log:
log(msg)
if n_storeys < 2:
_log("[staged] single-storey programme — falling back to plain search")
return search(seed_root, programme_dir, budget=budget, pop_size=pop_size,
child_budget=child_budget, seed_budget=seed_budget,
p_crossover=p_crossover, seed=seed, types=types,
inner_kw=inner_kw, log=log, n_workers=n_workers)
if types is None:
types = sorted(reqs) + ["C", "O"]
rng = np.random.default_rng(seed)
buckets = programme.partition_rooms_by_storey(reqs, n_storeys, rng)
tmp = Path(tempfile.mkdtemp(prefix="homemaker_stage1_"))
try:
programme.write_stage1_programme(programme_dir, tmp, buckets[0])
# Stage 1 — single-storey base, readiness-biased ranking.
b1 = max(1, int(budget * stage1_frac))
_log(f"[staged] stage 1: base floor, budget {b1} "
f"(rooms {sum(buckets[0].values())}, +readiness bonus)")
r1 = search(
seed_root, tmp, budget=b1, pop_size=pop_size,
child_budget=child_budget, seed_budget=seed_budget,
p_crossover=p_crossover, seed=seed, types=None,
inner_kw=inner_kw, log=log, n_workers=n_workers,
rank_bonus_fn=lambda root: graph.substrate_readiness(root, reqs, n_storeys),
rank_bonus_weight=rank_bonus_weight,
)
best_base = r1.best.root
_log(f"[staged] stage 1 done: base {r1.best.fitness:.6g} "
f"({r1.best.n_fails} fails), readiness "
f"{graph.substrate_readiness(best_base, reqs, n_storeys):.3f}")
finally:
shutil.rmtree(tmp, ignore_errors=True)
# Stage 2 — lift base into full multi-storey, search deltas, base low-prob.
b2 = max(1, budget - r1.n_evals)
upper = buckets[1:]
def _seed_factory(rng2):
return operators.lift_base_to_storeys(best_base, upper, rng2, types)
_log(f"[staged] stage 2: upper floors as deltas, budget {b2}, base_p {base_p}")
r2 = search(
best_base, programme_dir, budget=b2, pop_size=pop_size,
child_budget=child_budget, seed_budget=seed_budget,
p_crossover=p_crossover, seed=seed, types=types,
inner_kw=inner_kw, log=log, n_workers=n_workers,
bootstrap=True, seed_factory=_seed_factory, base_p=base_p,
)
# Stitch the two stages into one accounting (total evals, tagged history).
r2.n_evals += r1.n_evals
r2.n_topologies += r1.n_topologies
r2.history = (
[(e, f, f"S1:{lin}") for e, f, lin in r1.history]
+ [(e + r1.n_evals, f, f"S2:{lin}") for e, f, lin in r2.history]
)
return r2

View file

@ -593,3 +593,62 @@ def check_vertical_connectivity(
f"{code} not connected to {req.requires_below} below" f"{code} not connected to {req.requires_below} below"
) )
return failures return failures
# --------------------------------------------------------------------------- #
# Substrate readiness (DESIGN.md §11.3 Stage 1 objective)
# --------------------------------------------------------------------------- #
STAIR_MIN_AREA = 6.0 # a C leaf must be at least this big to count as a core
def substrate_readiness(
base_root: Node,
reqs: dict[str, SpaceReq],
n_storeys: int,
) -> float:
"""Score in [0,1] of how well a single-storey base can HOST upper floors.
Stage 1 must optimise the base as a *substrate*, not merely as a ground floor
(the §4.2 partial-objective / bungalow trap). Two structural proxies from the
bead:
- **Reserved core**: a vertically-alignable circulation core must already
exist, so Stage 2 keeps it rather than carving one from scratch. Full credit
when at least one base ``C`` leaf is at least ``STAIR_MIN_AREA``; otherwise a
small floor (0.25) so the term still rewards adding/enlarging a core.
- **Capacity**: enough divisible base footprint to carve the upper-floor room
set above. ``min(1, usable_base_area / required_upper_area)`` where the
reserved core area is excluded from the usable footprint.
Returns ``core_factor * capacity`` (both in [0,1]).
"""
base_lvl = levels(base_root)[0]
base_leaves = base_lvl.leaves()
total_base_area = sum(geometry.area(lf) for lf in base_leaves)
core_leaves = [
lf for lf in base_leaves
if lf.type and lf.type[0].lower() == "c" and geometry.area(lf) >= STAIR_MIN_AREA
]
core_factor = 1.0 if core_leaves else 0.25
core_area = max((geometry.area(lf) for lf in core_leaves), default=0.0)
# Required floor area on storeys >= 1: level-constrained upper rooms plus the
# expected share of level-free rooms distributed to upper storeys.
upper_levels = sum(
req.size * req.count
for req in reqs.values()
if req.level is not None and req.level >= 1
)
free_area = sum(
req.size * req.count
for code, req in reqs.items()
if code[0].lower() not in ("c", "o", "s") and req.level is None
)
upper_free = free_area * (n_storeys - 1) / n_storeys if n_storeys > 0 else 0.0
required_upper_area = upper_levels + upper_free
usable = max(0.0, total_base_area - core_area)
capacity = 1.0 if required_upper_area <= 0 else min(1.0, usable / required_upper_area)
return core_factor * capacity

View file

@ -44,6 +44,22 @@ def _pick(rng: np.random.Generator, items: list):
return items[int(rng.integers(len(items)))] return items[int(rng.integers(len(items)))]
def _pick_weighted_by_storey(rng: np.random.Generator, items: list, base_p: float):
"""Pick one ``(level_index, node)`` tuple, downweighting the base storey.
Base-storey leaves/branches (``li == 0``) are sampled with relative weight
``base_p``; everything else with weight 1.0. ``base_p == 1.0`` (default)
reproduces a uniform pick exactly (DESIGN.md §11.3 Stage 2 keeps the base
mutable at low probability rather than freezing it).
"""
if base_p >= 1.0 or not items:
return _pick(rng, items)
w = np.array([base_p if li == 0 else 1.0 for li, _ in items], dtype=float)
if w.sum() == 0:
w[:] = 1.0
return items[int(rng.choice(len(items), p=w / w.sum()))]
def _owned_branches(root: dom.Node) -> list[tuple[int, dom.Node]]: def _owned_branches(root: dom.Node) -> list[tuple[int, dom.Node]]:
"""(level_index, node) for every divided node whose cut is live here.""" """(level_index, node) for every divided node whose cut is live here."""
out = [] out = []
@ -62,9 +78,9 @@ def _leaves(root: dom.Node) -> list[tuple[int, dom.Node]]:
# Mutations # Mutations
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def mutate_divide(root: dom.Node, rng: np.random.Generator, def mutate_divide(root: dom.Node, rng: np.random.Generator,
types: list[str]) -> tuple[dom.Node, str]: types: list[str], base_p: float = 1.0) -> tuple[dom.Node, str]:
child = copy.deepcopy(root) child = copy.deepcopy(root)
li, leaf = _pick(rng, _leaves(child)) li, leaf = _pick_weighted_by_storey(rng, _leaves(child), base_p)
leaf.division = [0.5, 0.5] leaf.division = [0.5, 0.5]
leaf.rotation = int(rng.integers(4)) leaf.rotation = int(rng.integers(4))
leaf.left = dom.Node(type=leaf.type) leaf.left = dom.Node(type=leaf.type)
@ -74,13 +90,13 @@ def mutate_divide(root: dom.Node, rng: np.random.Generator,
def mutate_undivide(root: dom.Node, rng: np.random.Generator, def mutate_undivide(root: dom.Node, rng: np.random.Generator,
types: list[str]) -> tuple[dom.Node, str]: types: list[str], base_p: float = 1.0) -> tuple[dom.Node, str]:
child = copy.deepcopy(root) child = copy.deepcopy(root)
cands = [(li, n) for li, n in _owned_branches(child) cands = [(li, n) for li, n in _owned_branches(child)
if not n.left.divided and not n.right.divided] if not n.left.divided and not n.right.divided]
if not cands: if not cands:
return _finalise(child), "undivide noop" return _finalise(child), "undivide noop"
li, n = _pick(rng, cands) li, n = _pick_weighted_by_storey(rng, cands, base_p)
# generic classes (circulation/outside/sahn) match case-insensitively, # generic classes (circulation/outside/sahn) match case-insensitively,
# cf. Urb Is_Circulation/Is_Outside # cf. Urb Is_Circulation/Is_Outside
keep = [t for t in (n.left.type, n.right.type) if t and t[0].lower() not in "cos"] keep = [t for t in (n.left.type, n.right.type) if t and t[0].lower() not in "cos"]
@ -91,33 +107,33 @@ def mutate_undivide(root: dom.Node, rng: np.random.Generator,
def mutate_retype(root: dom.Node, rng: np.random.Generator, def mutate_retype(root: dom.Node, rng: np.random.Generator,
types: list[str]) -> tuple[dom.Node, str]: types: list[str], base_p: float = 1.0) -> tuple[dom.Node, str]:
child = copy.deepcopy(root) child = copy.deepcopy(root)
li, leaf = _pick(rng, _leaves(child)) li, leaf = _pick_weighted_by_storey(rng, _leaves(child), base_p)
leaf.type = str(_pick(rng, [t for t in types if t != leaf.type] or types)) leaf.type = str(_pick(rng, [t for t in types if t != leaf.type] or types))
return _finalise(child), f"retype {li}/{leaf.id or 'root'}->{leaf.type}" return _finalise(child), f"retype {li}/{leaf.id or 'root'}->{leaf.type}"
def mutate_swap(root: dom.Node, rng: np.random.Generator, def mutate_swap(root: dom.Node, rng: np.random.Generator,
types: list[str]) -> tuple[dom.Node, str]: types: list[str], base_p: float = 1.0) -> tuple[dom.Node, str]:
child = copy.deepcopy(root) child = copy.deepcopy(root)
cands = _owned_branches(child) cands = _owned_branches(child)
if not cands: # undivided topology (e.g. a bare plot seed) if not cands: # undivided topology (e.g. a bare plot seed)
return _finalise(child), "swap noop" return _finalise(child), "swap noop"
li, n = _pick(rng, cands) li, n = _pick_weighted_by_storey(rng, cands, base_p)
n.left, n.right = n.right, n.left n.left, n.right = n.right, n.left
return _finalise(child), f"swap {li}/{n.id or 'root'}" return _finalise(child), f"swap {li}/{n.id or 'root'}"
def mutate_rotate(root: dom.Node, rng: np.random.Generator, def mutate_rotate(root: dom.Node, rng: np.random.Generator,
types: list[str]) -> tuple[dom.Node, str]: types: list[str], base_p: float = 1.0) -> tuple[dom.Node, str]:
# re-orient a live cut; live rotation = node without a below link (base # re-orient a live cut; live rotation = node without a below link (base
# storey or inside an upper-storey divide delta) # storey or inside an upper-storey divide delta)
child = copy.deepcopy(root) child = copy.deepcopy(root)
cands = [(li, n) for li, n in _owned_branches(child) if n.below is None] cands = [(li, n) for li, n in _owned_branches(child) if n.below is None]
if not cands: if not cands:
return _finalise(child), "rotate noop" return _finalise(child), "rotate noop"
li, n = _pick(rng, cands) li, n = _pick_weighted_by_storey(rng, cands, base_p)
n.rotation = (n.rotation + int(rng.integers(1, 4))) % 4 n.rotation = (n.rotation + int(rng.integers(1, 4))) % 4
return _finalise(child), f"rotate {li}/{n.id or 'root'}" return _finalise(child), f"rotate {li}/{n.id or 'root'}"
@ -424,6 +440,67 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
return _finalise(child) return _finalise(child)
def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]],
rng: np.random.Generator, types: list[str]) -> dom.Node:
"""Stack upper storeys onto an evolved single-storey base (DESIGN.md §11.3).
Stage 2 seeder: the Stage-1 base is the credible ground floor and is left
**untouched**; each upper storey is constructed as a delta that (a) inherits
and preserves the base's largest circulation ``C`` leaf as a vertically-aligned
core (so Stage 2 does not carve a core from scratch the anti-bungalow
invariant) and (b) instantiates its required room multiset (``upper_buckets``,
one dict per storey >= 1) by construction, plus one outside ``O``. Stochastic
splits/assignment keep a bootstrap batch diverse; ``mutate_place_missing``
repairs any residual gaps during the loop.
Returns a finalised deep copy; ``base_root`` is unchanged.
"""
from . import genome as _g, geometry as _geo
child = copy.deepcopy(base_root)
base = dom.levels(child)[0]
base.above = None # start from the single-storey base only
base_cs = [lf for lf in base.leaves()
if lf.type and lf.type[0].lower() == "c"]
core_path = max(base_cs, key=_geo.area).id if base_cs else None
prev = base
for bucket in upper_buckets:
dup = _g._copy_storey(prev)
dup.height = prev.height
core_node = dup.by_id(core_path) if core_path is not None else None
assign = [code for code, cnt in bucket.items() for _ in range(cnt)]
assign.append("O") # courtyard / outside on the upper floor
if core_node is None:
assign.append("C") # no inherited core to reuse — make one
def _free() -> list[dom.Node]:
return [lf for lf in dup.leaves() if lf is not core_node]
# grow only non-core leaves so the inherited core footprint is preserved
while len(_free()) < len(assign):
leaf = _pick(rng, _free())
leaf.division = [0.5, 0.5]
leaf.rotation = int(rng.integers(4))
leaf.left = dom.Node(type=leaf.type)
leaf.right = dom.Node(type=leaf.type)
leaf.type = None
frees = _free()
order = rng.permutation(len(frees))
for slot, leaf_idx in enumerate(order):
frees[int(leaf_idx)].type = assign[slot] if slot < len(assign) else "O"
if core_node is not None:
core_node.type = "C" # keep the inherited core as circulation
prev.above = dup
prev = dup
return _finalise(child)
def mutate_core_divide(root: dom.Node, rng: np.random.Generator, def mutate_core_divide(root: dom.Node, rng: np.random.Generator,
types: list[str]) -> tuple[dom.Node, str]: types: list[str]) -> tuple[dom.Node, str]:
"""Divide a circulation leaf at the same path across ALL storeys at once. """Divide a circulation leaf at the same path across ALL storeys at once.
@ -570,9 +647,16 @@ MUTATIONS = {
} }
# Exploratory ops that freely pick any leaf/branch; Stage 2 downweights the
# base storey for these via ``base_p`` (DESIGN.md §11.3). The repair op
# ``place_missing`` is deliberately excluded — a missing base room must still be
# repairable — as are the core_* ops, which exist to MAINTAIN the core.
_BASE_P_OPS = ("divide", "undivide", "retype", "swap", "rotate")
def mutate(root: dom.Node, rng: np.random.Generator, types: list[str], def mutate(root: dom.Node, rng: np.random.Generator, types: list[str],
weights: dict[str, float] | None = None, weights: dict[str, float] | None = None,
reqs=None) -> tuple[dom.Node, str]: reqs=None, base_p: float = 1.0) -> tuple[dom.Node, str]:
"""Apply one random mutation drawn from MUTATIONS.""" """Apply one random mutation drawn from MUTATIONS."""
names = sorted(MUTATIONS) names = sorted(MUTATIONS)
p = np.array([(weights or {}).get(n, 1.0) for n in names], dtype=float) p = np.array([(weights or {}).get(n, 1.0) for n in names], dtype=float)
@ -586,6 +670,8 @@ def mutate(root: dom.Node, rng: np.random.Generator, types: list[str],
name = str(rng.choice(names, p=p / p.sum())) name = str(rng.choice(names, p=p / p.sum()))
if name in reqs_ops: if name in reqs_ops:
return MUTATIONS[name](root, rng, types, reqs=reqs) return MUTATIONS[name](root, rng, types, reqs=reqs)
if name in _BASE_P_OPS:
return MUTATIONS[name](root, rng, types, base_p=base_p)
return MUTATIONS[name](root, rng, types) return MUTATIONS[name](root, rng, types)

View file

@ -75,6 +75,100 @@ def load_programme(path: str) -> dict[str, SpaceReq]:
return _parse_spaces(conf) return _parse_spaces(conf)
def n_storeys_required(reqs: dict[str, SpaceReq]) -> int:
"""Number of storeys the programme implies, from the highest ``level:`` key.
Level-free rooms (no ``level``) do not force extra storeys they are
distributed across whatever storeys the level-constrained rooms require.
"""
levels = [r.level for r in reqs.values() if r.level is not None]
return (max(levels) + 1) if levels else 1
def partition_rooms_by_storey(
reqs: dict[str, SpaceReq], n_storeys: int, rng,
) -> list[dict[str, int]]:
"""Per-storey required-room multisets (DESIGN.md §11.3 staging).
Level-constrained rooms land on their required storey; level-free rooms are
distributed round-robin over a shuffled order across all storeys. Generic
circulation/outside/sahn codes are excluded (they are added per storey at
construction time). Mirrors the inline partition in
``operators.constructive_topology`` so Stage 1 (base) and Stage 2 (upper
deltas) draw from one consistent partition.
Returns a list of length ``n_storeys``; each entry maps room code -> count.
"""
buckets: list[dict[str, int]] = [{} for _ in range(n_storeys)]
def _add(li: int, code: str) -> None:
buckets[li][code] = buckets[li].get(code, 0) + 1
free: list[str] = []
for code, req in reqs.items():
if code[0].lower() in ("c", "o", "s"):
continue
for _ in range(req.count):
if req.level is not None and req.level < n_storeys:
_add(req.level, code)
else:
free.append(code)
free = [free[i] for i in rng.permutation(len(free))]
for i, code in enumerate(free):
_add(i % n_storeys, code)
return buckets
def write_stage1_programme(
full_dir: str | Path, out_dir: str | Path, base_codes: dict[str, int],
) -> Path:
"""Derive a single-storey base-floor programme (DESIGN.md §11.3 Stage 1).
Filters the full merged ``patterns.config`` down to the rooms assigned to the
base floor (``base_codes``: code -> count), drops their ``level:`` keys,
prunes each kept space's ``adjacency`` to references that survive (retained
codes or generic c/o/s), and forces single-storey building constraints. The
result is written as a *self-contained* ``patterns.config`` in ``out_dir`` so
``fitness.load_config``'s parent-dir merge contributes nothing — keep
``out_dir`` outside the corpus tree (e.g. a tempdir).
Returns ``out_dir`` as a ``Path``.
"""
from pathlib import Path as _Path
from . import fitness as _fit
out_dir = _Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
conf, _cost = _fit.load_config(full_dir)
keep = set(base_codes)
src_spaces = conf.get("spaces") or {}
new_spaces: dict = {}
for code, count in base_codes.items():
if code not in src_spaces:
continue
spec = dict(src_spaces[code])
spec.pop("level", None)
spec["count"] = count
adj = spec.get("adjacency")
if adj is not None:
spec["adjacency"] = [
r for r in adj if r in keep or r[0].lower() in ("c", "o", "s")
]
new_spaces[code] = spec
new_conf = {k: v for k, v in conf.items() if k != "spaces"}
new_conf["spaces"] = new_spaces
new_conf.update(
storey_minimum=1, storey_limit=1, staircase_min=1, staircase_max=1,
)
with open(out_dir / "patterns.config", "w") as fh:
yaml.safe_dump(new_conf, fh, sort_keys=False, default_flow_style=False)
return out_dir
def load_programme_dir(directory: str | Path) -> dict[str, SpaceReq]: def load_programme_dir(directory: str | Path) -> dict[str, SpaceReq]:
"""Load programme from a directory, merging parent patterns.config as base. """Load programme from a directory, merging parent patterns.config as base.

125
tests/test_staging.py Normal file
View file

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