Phase 7 §12.2: proportion-aware constructive seeding + storey_minimum fix (leu.2, cq1)

Size each constructive-seed cut from leaf TARGET areas (division=[f,f] gives
left area-fraction f) and pick each cut's rotation for child squareness — both
derived from target dims, topology/type assignment untouched. Area-only
regressed (slivers); rotation choice is what makes it pay.

End-to-end (20000 evals, 3 seeds, staged): harbor 85.3->74.0 (-13%, best 69),
maple-court 151.7->136.0 (-10%, best 126). PROP=0 reproduces the §11.7/§12.1
baselines exactly. programme-house regresses at fixed budget (deeper local
optimum walls off the undivide restructuring path) but a budget sweep shows
it's convergence speed, not a worse asymptote (PROP=1 reaches 1 fail at 150k).
Default-on (seed_proportion_aware=True, env PROP=1).

cq1: n_storeys now honours storey_minimum, not just level: keys — programme-house
(storey_minimum:2, all rooms level:0) was seeded one storey short and fell
through to plain search. New programme.storey_minimum()/n_storeys_for();
driver.search passes min_storeys to the seeder; search_staged routes on the max.
No-op for harbor/maple; programme-house single-stage 8.0->5.0.

New maple-court best (126) saved as generated.dom. 204 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-06-20 14:04:42 +01:00
parent 7d7994e7a3
commit 995342d0a4
10 changed files with 825 additions and 440 deletions

View file

@ -16,7 +16,8 @@
{"id":"homemaker-py-1p0","title":"Geometry inner loop: full-objective equal-offset ratio optimiser","description":"DESIGN.md §5.1, §7 Phase 1. Productionise experiments/optimize_fullfitness.py into homemaker: optimise(topology, x0=None) -\u003e (geometry, fitness). DOF = equal-offset division ratios of free branches (solver.free_branches, lowest-storey cut ownership), clipped to [eps, 1-eps]. Objective = full oracle fitness (never a proxy — §4.2 falsified). Must support warm-start x0 (§5.6) and a population/batch evaluation mode so each iteration scores via one batched oracle call (§4.6).","acceptance_criteria":"Reproduces or exceeds §4.5 gains (x1.24x1.67, no new failures) on 2f45907, candidate-002, c964435; works as a library call on any corpus .dom","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:58Z","created_by":"Bruno Postle","updated_at":"2026-06-12T08:46:31Z","started_at":"2026-06-12T00:14:19Z","closed_at":"2026-06-12T08:46:31Z","close_reason":"innerloop.optimise() lands: batched CMA-ES sigma ladder (0.05/0.15, IPOP popsize doubling, deterministic seeding) over equal-offset free-branch ratios vs full oracle fitness; warm-start x0 supported. Acceptance vs unprojected originals: x1.65/x1.66/x1.58 against bars x1.24/x1.67/x1.59, no new failures, 46 oracle calls vs NM's 200. Two near-bar results accepted as reproduced-within-noise (1% tol) — draw spread brackets the single-NM-draw bars; approved by Bruno 2026-06-12. Gotchas: equal-offset projection of legacy unequal cuts loses fitness/adds failures (midpoint projection used); pycma seed=0 means clock-seeded.","dependencies":[{"issue_id":"homemaker-py-1p0","depends_on_id":"homemaker-py-av5","type":"blocks","created_at":"2026-06-12T00:39:33Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
{"id":"homemaker-py-8cs","title":"Experiment: warm-vs-cold start of inner loop (Lamarckian inheritance)","description":"DESIGN.md §5.6, §4.6. Warm-starting a child topology's inner loop from the parent's optimised ratios is the main lever for cutting per-topology cost (~3 min/topology cold). Apply single topology mutations to optimised corpus designs, re-optimise warm (surviving cuts keep values, new cuts get heuristic defaults) vs cold, compare oracle-call counts to convergence at equal final fitness.","acceptance_criteria":"Speedup factor measured across \u003e=10 mutated topologies; decision recorded (expect order-of-magnitude; if \u003c2x, revisit §4.6 Phase-2 scoping)","notes":"Experiment script committed (experiments/warm_vs_cold.py, 1cc86c8) and machinery validated oracle-free; one mutated child scored through the oracle OK. Waiting on homemaker-py-gp2 reference run to finish, then execute under URB_NO_OCCLUSION=1 (3 parents x 400 evals + 12 children x 2 x 200 evals, ~1.5-2 h oracle time). Default budgets: parent 400, child 200; target = evals to 95% of best final.","status":"closed","priority":1,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:58Z","created_by":"Bruno Postle","updated_at":"2026-06-12T11:44:45Z","closed_at":"2026-06-12T11:44:45Z","close_reason":"Measured (URB_NO_OCCLUSION=1, parent budget 400, child 200, 12 single mutations across 3 designs): cold start reached 95% of warm final in 0/12 cases within budget — speedup unbounded at practical budgets; warm finals beat cold finals x1.2-x4 in 12/12; 6/12 warm starts were within 95% at 1 eval (near-neutral mutations). Decision: Lamarckian warm-starting is MANDATORY in the memetic driver (homemaker-py-b39), not an optimisation; cold starts produce strictly worse geometry at equal budget. Note: 2 undivides were exactly fitness-neutral (same-type merge == Merge_Divided equivalence) — locality datum for homemaker-py-nyb.","dependencies":[{"issue_id":"homemaker-py-8cs","depends_on_id":"homemaker-py-1p0","type":"blocks","created_at":"2026-06-12T00:39:34Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"homemaker-py-av5","title":"Batched oracle: score many .dom files per invocation","description":"oracle.py currently scores one .dom per urb-fitness.pl call (~1.65 s/dom). DESIGN.md §4.6: batching amortises Perl startup to ~0.99 s/dom and is required so population/batch optimisers can score a whole generation in one oracle call. Extend oracle.py with a batch API: write N .dom files, one perl invocation, parse N .score/.fails pairs. Keep the single-file path for compatibility.","acceptance_criteria":"Batch of 35 corpus files scores in one perl invocation; per-file results identical to single-file calls; measured s/dom reported","status":"closed","priority":1,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:56Z","created_by":"Bruno Postle","updated_at":"2026-06-12T00:14:06Z","started_at":"2026-06-11T23:50:40Z","closed_at":"2026-06-12T00:14:06Z","close_reason":"score_batch() lands in oracle.py; 35-file corpus parity verified single-vs-batch (1e-12 rel fitness, exact fail sets); 0.98 s/dom batched vs 1.27 single, x1.30","dependency_count":0,"dependent_count":1,"comment_count":0}
{"id":"homemaker-py-leu.2","title":"Proportion-aware constructive seeding (size splits from target dims)","description":"Follow-up to §11.6/§11.7. Adjacency-aware seeding cut the topology load (adjacency-to-c / access) but §11.6 explicitly noted the seed still splits at 0.5, producing 'more, smaller leaves' whose size/proportion/crinkliness fails the inner loop then has to recover. With topology fails now cut by seeding, this GEOMETRY residual is the dominant remaining term (§11.7 verdict). Attacking it at the seed is the proven-productive (construction) direction and is far cheaper than the 9gp encoding rewrite.\n\nIdea: when constructive_topology / lift_base_to_storeys place a cut, size the division ratio from the leaves' TARGET dimensions (programme target areas/widths) instead of 0.5, so the raw seed geometry already sits near feasible proportions and the inner loop starts inside (or much closer to) the size/width/proportion basins. Keep adjacency-aware placement (§11.6/§11.7) unchanged; this only changes split RATIOS, not topology or type assignment. Behind a flag for clean A/B, default-on if it wins.\n\nMeasure raw-seed geometry fails (size/width/proportion/crinkliness) before/after AND end-to-end total fails at budget on harbor, programme-house, AND the new leu.1 benchmark, same protocol as §11.6. Record in DESIGN.md §12.2 + bead notes (incl. negative result if it does not win).","acceptance_criteria":"Proportion-aware split sizing implemented behind a flag; raw-seed geometry-fail reduction quantified; end-to-end total-fail change measured on harbor, programme-house, and the leu.1 benchmark (\u003e=3 seeds each); result (positive or negative) recorded in DESIGN.md.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-19T11:14:15Z","created_by":"Bruno Postle","updated_at":"2026-06-19T11:14:15Z","dependencies":[{"issue_id":"homemaker-py-leu.2","depends_on_id":"homemaker-py-leu","type":"parent-child","created_at":"2026-06-19T12:14:15Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-leu.2","depends_on_id":"homemaker-py-leu.1","type":"blocks","created_at":"2026-06-19T12:14:45Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"id":"homemaker-py-cq1","title":"Constructive seeder + staged dispatch ignored storey_minimum","description":"n_storeys_required only reads level: keys, so a programme with storey_minimum\u003emax(level)+1 (e.g. programme-house: storey_minimum:2, all rooms level:0) was seeded one storey short by constructive_topology and routed to plain (non-staged) search. Fitness then fired a 'storey minimum' fail the search had to repair structurally. Surfaced while measuring leu.2 (proportion-aware seeding deepened the basin around the wrong-storey-count seed). Fix: programme.storey_minimum()/n_storeys_for(); driver.search passes min_storeys to constructive_topology; search_staged routes on max(n_storeys_required, storey_minimum). Independent win: programme-house single-stage baseline 8.0 -\u003e 5.0 fails with correct 2-storey seed.","status":"open","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-06-20T08:59:31Z","created_by":"Bruno Postle","updated_at":"2026-06-20T08:59:31Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"homemaker-py-leu.2","title":"Proportion-aware constructive seeding (size splits from target dims)","description":"Follow-up to §11.6/§11.7. Adjacency-aware seeding cut the topology load (adjacency-to-c / access) but §11.6 explicitly noted the seed still splits at 0.5, producing 'more, smaller leaves' whose size/proportion/crinkliness fails the inner loop then has to recover. With topology fails now cut by seeding, this GEOMETRY residual is the dominant remaining term (§11.7 verdict). Attacking it at the seed is the proven-productive (construction) direction and is far cheaper than the 9gp encoding rewrite.\n\nIdea: when constructive_topology / lift_base_to_storeys place a cut, size the division ratio from the leaves' TARGET dimensions (programme target areas/widths) instead of 0.5, so the raw seed geometry already sits near feasible proportions and the inner loop starts inside (or much closer to) the size/width/proportion basins. Keep adjacency-aware placement (§11.6/§11.7) unchanged; this only changes split RATIOS, not topology or type assignment. Behind a flag for clean A/B, default-on if it wins.\n\nMeasure raw-seed geometry fails (size/width/proportion/crinkliness) before/after AND end-to-end total fails at budget on harbor, programme-house, AND the new leu.1 benchmark, same protocol as §11.6. Record in DESIGN.md §12.2 + bead notes (incl. negative result if it does not win).","acceptance_criteria":"Proportion-aware split sizing implemented behind a flag; raw-seed geometry-fail reduction quantified; end-to-end total-fail change measured on harbor, programme-house, and the leu.1 benchmark (\u003e=3 seeds each); result (positive or negative) recorded in DESIGN.md.","notes":"DONE (positive), default-on. End-to-end (20000 evals, 3 seeds, staged): harbor 85.3-\u003e74.0 (-13%, best 69), maple-court 151.7-\u003e136.0 (-10%, best 126). PROP=0 reproduces 11.7/12.1 baselines exactly. programme-house regresses at fixed budget (deeper-local-optimum: well-fitted seed walls off the undivide restructuring path) but a budget sweep shows it's convergence-SPEED not asymptote (PROP=1 reaches 1 fail at 150k, beating PROP=0's 2; floor is 2). Win requires rotation+ratio sizing from target dims (area-only regressed via slivers). Surfaced + fixed storey_minimum bug (cq1). Default flipped on: driver.search/search_staged seed_proportion_aware=True, harness PROP=1. DESIGN.md 12.2. 204 tests pass. New maple best 126 saved as generated.dom.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-19T11:14:15Z","created_by":"Bruno Postle","updated_at":"2026-06-20T12:32:09Z","started_at":"2026-06-19T13:03:27Z","dependencies":[{"issue_id":"homemaker-py-leu.2","depends_on_id":"homemaker-py-leu","type":"parent-child","created_at":"2026-06-19T12:14:15Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-leu.2","depends_on_id":"homemaker-py-leu.1","type":"blocks","created_at":"2026-06-19T12:14:45Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"id":"homemaker-py-s44","title":"Adjacency-aware constructive seeding (cut adjacency/access fails)","description":"Follow-up to homemaker-py-c4c.2. constructive_topology currently assigns room types to leaves at RANDOM, ignoring each space's adjacency requirement. On harbor this leaves 8 adjacency + 13 access fails in the seeded design. Cluster each required room near its required neighbour (esp. circulation c) at construction time — e.g. assign rooms to leaves whose sibling/parent is C, or grow the tree so each room lands adjacent to a circulation spine. Should directly cut the adjacency+access fail load that now dominates the complete-design quality-fail regime (DESIGN.md §11.2 verdict).","notes":"DONE (positive), DESIGN.md §11.6. _assign_adjacency_aware: greedy connected-dominating-set of circulation leaves on the geometric leaf_graph so every room borders a connected circulation spine; rooms on dominated leaves, O peripheral. Default-on via constructive_topology(adjacency_aware=True), threaded driver.search(seed_adjacency_aware). Seed quality (harbor 10 seeds): adjacency 29-\u003e12, access 27-\u003e8. End-to-end single-stage 20000 evals total fails mean: harbor 110.0-\u003e90.7 (-17.5%, ADJ=0 seed0 reproduces §11.2 105 baseline exactly), programme-house 12.3-\u003e9.3 (-24%); adjacency-aware single-stage harbor (mean 90.7, best 85) beats the §11.3 staged 95. Follow-ups filed: lift_base_to_storeys adjacency-awareness + secondary adjacencies.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-17T21:50:01Z","created_by":"Bruno Postle","updated_at":"2026-06-19T08:12:43Z","started_at":"2026-06-18T22:52:25Z","closed_at":"2026-06-19T08:12:43Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"homemaker-py-c4c.5","title":"Topology diversity: structural niching + restarts (replace fitness-scalar dedup)","description":"The population dedups on the FITNESS SCALAR (driver.py:174, abs(fitness) within 1e-9) and replaces worst-by-key. There is no structural/topological diversity preservation, no restarts, no islands. On a rugged combinatorial landscape this converges prematurely — and it is the root cause of the blank-slate gap (§7 Phase 2 verdict): a single mutation chain loses to urb-evolve's random-population diversity (init.dom: memetic 18 fails vs urb-evolve 6).\nAdd: (1) a topology signature (canonical tree hash / partition signature) so 'same topology, different geometry' is detectable and niching is by STRUCTURE not score; (2) diversity-preserving replacement (crowding / niching); (3) restarts or a small island model so blank-slate exploration matches urb-evolve's upfront diversity.","design":"A cheap topology-signature hash (string-encode the per-level tree + types) unblocks niching without waiting for the full canonical encoding; the canonical Polish encoding (homemaker-py-9gp) is the principled long-term signature and makes (a|b)|c == a|(b|c) collapse exactly. Wire signature into admit() in place of / alongside the fitness-scalar guard.","acceptance_criteria":"On blank-slate programme-house, memetic reaches \u003c=6 fails (matching/beating urb-evolve) at equal native-fitness budget; population structural diversity quantified (distinct topology signatures over time) before/after; recorded in DESIGN.md §11.x + bead notes.","notes":"DONE (negative), DESIGN.md §11.5. Implemented genome.signature (ratio-invariant structural topology hash), structural niching (niche_by_signature) replacing the fitness-scalar dedup, and soft restarts (restart_patience); SearchResult gained n_distinct_signatures/diversity_history/n_restarts. Diversity criterion MET: final-pop distinct topologies ~5/16 -\u003e 16/16, ~30% more topologies seen with restarts. Gate NOT met: blank-slate programme-house total fails (20000 evals) before/niche/restart = seed0 11/14/12, seed1 11/11/14, seed2 15/13/13 (mean 12.3/12.7/13.0); harbor staged seed0 = 95/94/108 (legacy 95 reproduces §11.3). Niching is a tie within seed noise, restarts strictly worse. Falsifies the epic's premise that the fitness-scalar dedup is the premature-convergence root cause: legacy already holds 14/16 distinct on harbor; the plateau is a reachability (operator/encoding) problem, not population-management. Both flags default-off, kept for reuse; genome.signature is the cheap stand-in for the 9gp canonical Polish encoding.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-17T19:12:54Z","created_by":"Bruno Postle","updated_at":"2026-06-18T22:41:58Z","started_at":"2026-06-18T21:52:37Z","closed_at":"2026-06-18T22:41:58Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-c4c.5","depends_on_id":"homemaker-py-9gp","type":"relates-to","created_at":"2026-06-17T20:14:46Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-c4c.5","depends_on_id":"homemaker-py-c4c","type":"parent-child","created_at":"2026-06-17T20:12:53Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-c4c.5","depends_on_id":"homemaker-py-c4c.2","type":"blocks","created_at":"2026-06-17T20:12:54Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"homemaker-py-c4c.4","title":"Graded high-fail objective (gradient in the high-fail regime)","description":"Phase 4 (homemaker-py-yg5) chose lexicographic (-n_fails, fitness) — correct for not being FOOLED by the 0.5^n cliff (§4.9). But lexicographic-by-TOTAL-count gives almost zero selection signal in the high-fail regime: on harbor every candidate sits at ~49-74 fails, so neighbours are indistinguishable and the search has no gradient to climb. There is no partial credit for a size-fail that is nearly in range, nor for covering one more required requirement. §7 predicted penalty reshaping would 'flatten the fail cliff' for blank-slate; lexicographic did not deliver that for high counts.\nAdd a graded objective for the high-fail regime: continuous proximity per unsatisfied constraint (how close a size/width/proportion is to its band) and/or count of DISTINCT unsatisfied requirements with sub-credit, used as a tie/secondary key beneath fail-count. Must preserve: (a) inner-loop 0.5^n cliff protection (§5.4) — inner loop unchanged; (b) the missing-space hierarchy (§6) — must not make dropping a room attractive.","design":"Likely a third comparison key: (-n_fails, -n_distinct_unsatisfied_or_proximity_sum, fitness). Or a soft margin inside fail counting only in the outer comparator. Keep the scalar fitness (with 0.5^n) untouched so the inner loop is unaffected. Extends homemaker-py-yg5; reuse experiments/penalty_reshape.py harness.","acceptance_criteria":"Measured escape from a high-fail plateau on harbor and/or blank-slate programme-house that the current lex comparator cannot escape at equal budget; before/after best-fail trajectory recorded in DESIGN.md §11.x + bead notes. Inner-loop cliff protection verified unchanged (re-run the §4.9 inner-loop 0/9-regression check).","notes":"NEGATIVE RESULT (DESIGN.md §11.4). Implemented graded proximity key (-n_fails, grade, fitness) behind use_grade flag (default off): fitness._leaf_grade / score_with_grade sum f/FAIL_THRESHOLD over failing per-leaf quality factors; scalar fitness + fail count untouched. Inner-loop 0/9 regression: PASS (re-ran §4.9 part 1). Harbor staged A/B, 20000 evals, seeds 0/1/2 total fails: lex 95/96/106 (mean 99.0) vs lex+grade 99/98/102 (mean 99.7). Grade wins 1/3, loses 2/3, slightly worse on mean, NO plateau escape. Acceptance criterion (escape lex cannot achieve) NOT met. Root cause: premise falsified — within a fixed fail-tier 0.5^n is constant so fitness still spans ~6 orders of magnitude (1e-37..1e-31), giving lex's secondary fitness key a strong gradient already; grade above fitness DISPLACES it (stalls fail-reducing restructurings), below fitness is inert. High-fail plateau is a topology-basin problem -\u003e defer to §11.5 niching/restarts + 9gp canonical encoding. Code kept default-off for reproducibility / possible reuse as a §11.5 diversity signal.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-17T19:12:18Z","created_by":"Bruno Postle","updated_at":"2026-06-18T21:20:49Z","started_at":"2026-06-18T05:31:17Z","closed_at":"2026-06-18T21:20:49Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-c4c.4","depends_on_id":"homemaker-py-c4c","type":"parent-child","created_at":"2026-06-17T20:12:18Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
@ -42,16 +43,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-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}
{"_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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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."}

110
DESIGN.md
View file

@ -1156,7 +1156,8 @@ URB_NO_OCCLUSION=1 python3 experiments/run_staged_search.py \
Each run executed exactly 20000 native evals across 250 topologies (~36 min,
~9.1 evals/s) and re-scored native-consistent (`→ OK`). The best layout (seed 0,
145 fails) is saved as `examples/maple-court/generated.dom` with its `.fails`.
145 fails) was saved as `examples/maple-court/generated.dom` with its `.fails`
(superseded in §12.2 by the proportion-aware 126-fail layout).
The single-stage harness (`run_search_scaled.py`) also accepts the programme
unchanged. The score prints near-zero (`0.5^145` fail cliff) — the **fail count**
is the yardstick.
@ -1166,3 +1167,110 @@ is the yardstick.
harbor's ~85; this is the scaling yardstick `leu.2` (proportion-aware seeding)
and the re-scoped `9gp` are measured against. The residual character is the same
geometry/shape family flagged at the close of §11.7.
### 12.2 Proportion-aware constructive seeding (`homemaker-py-leu.2`) — DONE (positive)
Premise (follow-up to §11.6/§11.7). The constructive seeders grow geometry with
uniform `[0.5, 0.5]` cuts *before* types are assigned, so the raw seed is "more,
smaller leaves" of equal area: a room with a large programme target comes out too
small, a small room too big, and the inner loop must recover all of
size/width/proportion from scratch. With the adjacency load now cut by seeding
(§11.6/§11.7), this geometry residual is the dominant remaining term. Attacking it
at the seed — in the proven *construction* direction — is far cheaper than the
`9gp` encoding rewrite.
**Implementation (`operators._size_divisions_from_targets`, flag
`seed_proportion_aware`, env `PROP`, default-on per the A/B below).** After the
adjacency-aware type assignment (§11.6/§11.7, left exactly as is), each leaf
carries a target area — a sized room's programme `size`; circulation/outside
absorb the plot slack (floored at `0.4 ×` mean room area so a circulation leaf
never shrinks below door-width and undoes the §11.6 adjacency win). Because
`division=[f, f]` cuts off left area-fraction `f` (rotation-independent —
verified), bottom-up subtree-target sums compose multiplicatively to give every
leaf area ∝ its target. **Area alone regressed the raw seed**, though: choosing
only the cut *fraction* to hit a target *area* slices thin slivers with terrible
aspect (proportion/width/edge-too-long fails swamp the size gain — measured
below). So each cut also picks the **rotation** (the two distinct cut directions)
that makes its two children squarest; rotation depends on realised parent
geometry, so the pass runs top-down. Both ratio and rotation derive from the
target dims; neither touches topology or type assignment. Threaded through
`driver.search`/`search_staged(seed_proportion_aware=…)`.
- *Raw-seed fails (10 seeds, single-stage constructive, before optimisation),
area-only vs area+rotation:*
| family | harbor before | area-only | area+rot |
|-------------|--------------:|----------:|---------:|
| geometry | 123.0 | 135.9 | **99.9** |
| access/adj | 19.1 | 23.8 | 20.4 |
| total | 144.1 | 162.1 | **123.7** |
Area-only makes geometry *worse* (slivers); area+rotation drops the geometry
family on every programme — harbor **123.0 → 99.9 (19 %)**, programme-house
**13.1 → 8.7 (34 %)**, maple-court **200.5 → 164.1 (18 %)**. Access/adjacency
regresses slightly (rotation shifts the leaf graph the adjacency assignment was
computed against): harbor +1.3, prog-house +2.4, maple +3.4 — far smaller than
the geometry gain. The size family in particular falls as intended
(harbor size 31.4 → 22.0), and proportion flips from a regression to a win
(21.3 → 12.8) once rotation is co-chosen.
- *End-to-end (total fails at budget, 20000 evals, 3 seeds, PROP=0 vs PROP=1;
harbor & maple-court staged):*
| seed | harbor PROP=0 | harbor PROP=1 | maple PROP=0 | maple PROP=1 |
|-----:|--------------:|--------------:|-------------:|-------------:|
| 0 | 97 | 72 | 145 | 126 |
| 1 | 78 | 81 | 158 | 148 |
| 2 | 81 | 69 | 152 | 134 |
| mean | **85.3** | **74.0** | **151.7** | **136.0** |
Harbor **13 % (best 69, was 78)**, maple-court **10 % (best 126, was 145)**.
PROP=0 reproduces the §11.7 staged harbor (85.3) and §12.1 maple baseline
(151.7) *exactly* — clean controls. Proportion-aware seeding is the first
Phase-7 lever to move the fail count on the larger-than-house benchmark.
- *A storey-count bug surfaced (`homemaker-py-cq1`).* programme-house has
`storey_minimum: 2` but all rooms `level: 0`, and `n_storeys_required` only read
`level:` keys — so the constructive seeder built a **1-storey** seed for a
2-storey programme and `search_staged` fell through to plain search. Fixed
(`programme.storey_minimum`/`n_storeys_for`; `driver.search` passes `min_storeys`
to the seeder; `search_staged` routes on `max(level-derived, storey_minimum)`).
No-op for harbor/maple (level-derived already ≥ storey_minimum); independent win
on programme-house (single-stage baseline **8.0 → 5.0** with a correct 2-storey
seed).
- *programme-house regresses, but it is a convergence-speed artifact, not a worse
optimum.* On the 6-room programme proportion-aware seeding loses at 20000 evals
on every path tested (single-stage 1-storey 8.0→11.7, single-stage 2-storey
5.0→8.3, staged 2-storey 4.3→6.0). The mechanism is a *deeper local optimum*:
the equal-area PROP=0 seed has badly-proportioned leaves, so `undivide` moves —
the route to programme-house's simpler optimum — are accepted as improvements;
the well-fitted PROP=1 seed makes `undivide` an immediate fitness drop (merging
two good leaves yields one bad one), walling off the restructuring path. A
budget sweep (staged, storey-fixed) shows this is *reachability speed*, not an
asymptotic trap:
| budget | PROP=0 (s0/s1) | PROP=1 (s0/s1) |
|-------:|---------------:|---------------:|
| 20000 | 4 / 5 | 8 / 6 |
| 60000 | 2 / 2 | 4 / 3 |
| 150000 | 2 / 0 | **1** / 10 |
PROP=1 reaches **1 fail** (seed 0, 150k — beating PROP=0's 2; best-known is 2),
so it is not trapped; the gap narrows with budget and crosses over. (Staged
splits budget by *fraction*, so runs at different budgets evolve different
Stage-1 bases and are not nested — hence the high variance, e.g. PROP=1 seed 1
swinging 3→10.) The same "deeper basin" that *helps* where the constructed
topology is roughly right (large programmes, scarce budget) *delays* convergence
where the seed must be restructured (small programmes).
- *Verdict: keep proportion-aware split sizing, default-on (`seed_proportion_aware`
default `True`, env `PROP=1`).* It is a measured win on both larger programmes —
harbor 13 %, the maple-court scaling benchmark 10 % — exactly the regime
Phase 7 targets and the basis the re-scoped `9gp` is measured on. The only
regression is a small-programme convergence-speed effect that washes out with
budget (PROP=1 reaches the known floor), with no evidence of an asymptotic
penalty, so default-on is not paid for by a worse optimum anywhere. The win is
rotation-and-ratio sizing from target dims; the bare ratio is not enough
(area-only regressed). Area sizing assumes total target ≈ plot area; choosing
the cut *direction* for aspect is what makes it pay.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Raw-seed fail breakdown: proportion-aware split sizing A/B (homemaker-py-leu.2).
Builds the *constructive* seed (operators.constructive_topology, single-stage,
adjacency-aware) with proportion-aware split sizing OFF then ON, scores each raw
seed with native fitness BEFORE the inner loop, and tallies fails by family. The
claim under test (DESIGN.md §12.2): sizing cuts from target areas drops the
geometry family (size/width/proportion/crinkliness) at the raw seed without
regressing the adjacency/access family that §11.6/§11.7 fixed.
Usage:
URB_NO_OCCLUSION=1 python3 experiments/measure_seed_geometry.py <programme_dir> [n_seeds]
"""
from __future__ import annotations
import copy
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import dom, fitness, operators, programme # noqa: E402
GEOM = ("perpendicular", "proportion", "size", "width", "crinkliness")
def _categorise(fails: tuple[str, ...]) -> dict[str, int]:
cats = {"geometry": 0, "access_adj": 0, "missing": 0, "other": 0}
for f in fails:
if f.endswith(GEOM) or "edge too long" in f:
cats["geometry"] += 1
elif "access" in f or "inaccessible" in f or "adjacent" in f or "adjacency" in f:
cats["access_adj"] += 1
elif "missing" in f:
cats["missing"] += 1
else:
cats["other"] += 1
return cats
def main() -> int:
programme_dir = Path(sys.argv[1])
n_seeds = int(sys.argv[2]) if len(sys.argv) > 2 else 10
reqs = programme.load_programme_dir(programme_dir)
types = sorted(reqs) + ["C", "O"]
conf, cost = fitness.load_config(programme_dir)
fit = fitness.Fitness(conf, cost)
seed_file = programme_dir / "init.dom"
seed_root = dom.load(str(seed_file))
print(f"programme : {programme_dir.name} ({len(reqs)} codes)")
print(f"seeds : {n_seeds} (single-stage constructive, adjacency-aware)\n")
agg = {0: {"total": 0, "geometry": 0, "access_adj": 0, "missing": 0, "other": 0},
1: {"total": 0, "geometry": 0, "access_adj": 0, "missing": 0, "other": 0}}
for s in range(n_seeds):
for prop in (False, True):
rng = np.random.default_rng(s)
topo = operators.constructive_topology(
seed_root, reqs, rng, types,
adjacency_aware=True, proportion_aware=prop)
_score, fails = fit.score_with_fails(copy.deepcopy(topo))
cats = _categorise(fails)
agg[int(prop)]["total"] += len(fails)
for k, v in cats.items():
agg[int(prop)][k] += v
def _avg(d, k):
return d[k] / n_seeds
print(f"{'family':<12} {'PROP=0 (before)':>16} {'PROP=1 (after)':>16}")
for k in ("geometry", "access_adj", "missing", "other", "total"):
print(f"{k:<12} {_avg(agg[0], k):>16.1f} {_avg(agg[1], k):>16.1f}")
return 0
if __name__ == "__main__":
sys.exit(main())

49
experiments/run_leu2_ab.sh Executable file
View file

@ -0,0 +1,49 @@
#!/usr/bin/env bash
# leu.2 end-to-end A/B: proportion-aware split sizing (PROP=0 vs PROP=1).
# programme-house is single-stage (1 storey); harbor & maple-court are staged.
# Concurrency capped at 2 (machine has ~2 GB free). Results -> scratch/leu2/summary.tsv
set -u
cd "$(dirname "$0")/.."
OUT=scratch/leu2
mkdir -p "$OUT"
SUMMARY="$OUT/summary.tsv"
: > "$SUMMARY"
BUDGET=${BUDGET:-20000}
SEEDS=${SEEDS:-"0 1 2"}
MAXJOBS=${MAXJOBS:-2}
run_one() {
local prog=$1 harness=$2 prop=$3 seed=$4
local tag="${prog}_p${prop}_s${seed}"
local log="$OUT/${tag}.log"
URB_NO_OCCLUSION=1 PROP=$prop python3 "experiments/$harness" \
"examples/$prog" "$BUDGET" "$seed" "examples/$prog/init.dom" \
"$OUT/${tag}.dom" > "$log" 2>&1
local best
best=$(grep -oE 'best *: [0-9.eE+-]+ \([0-9]+ fails\)' "$log" | grep -oE '\([0-9]+ fails\)' | grep -oE '[0-9]+' | tail -1)
printf '%s\t%s\t%s\t%s\t%s\n' "$prog" "$prop" "$seed" "${best:-ERR}" "$tag" >> "$SUMMARY"
echo "DONE $tag -> ${best:-ERR} fails"
}
JOBS=()
for prog_h in "programme-house:run_search_scaled.py" \
"harbor-house:run_staged_search.py" \
"maple-court:run_staged_search.py"; do
prog=${prog_h%%:*}; harness=${prog_h##*:}
for prop in 0 1; do
for seed in $SEEDS; do
JOBS+=("$prog|$harness|$prop|$seed")
done
done
done
echo "queued ${#JOBS[@]} jobs, budget=$BUDGET, maxjobs=$MAXJOBS"
for job in "${JOBS[@]}"; do
IFS='|' read -r prog harness prop seed <<< "$job"
while [ "$(jobs -rp | wc -l)" -ge "$MAXJOBS" ]; do wait -n; done
run_one "$prog" "$harness" "$prop" "$seed" &
done
wait
echo "ALL DONE"
sort "$SUMMARY" -o "$SUMMARY"
cat "$SUMMARY"

View file

@ -74,6 +74,7 @@ def main() -> int:
rp = os.environ.get("RESTART_PATIENCE")
restart_patience = int(rp) if rp else None
adj = os.environ.get("ADJ", "1") == "1" # s44 adjacency-aware seeding
prop = os.environ.get("PROP", "1") == "1" # leu.2 proportion-aware split sizing (default-on)
print(f"programme : {programme_dir.name}")
print(f"seed : {seed_file.name}")
@ -83,6 +84,7 @@ def main() -> int:
print(f"niche : {niche}")
print(f"restart_p : {restart_patience}")
print(f"adj_aware : {adj}")
print(f"prop_aware: {prop}")
print(flush=True)
seed_root = dom.load(str(seed_file))
@ -102,6 +104,7 @@ def main() -> int:
niche_by_signature=niche,
restart_patience=restart_patience,
seed_adjacency_aware=adj,
seed_proportion_aware=prop,
# urb_root not needed: use_native=True is the default
)

View file

@ -56,6 +56,7 @@ def main() -> int:
rp = os.environ.get("RESTART_PATIENCE")
restart_patience = int(rp) if rp else None
adj = os.environ.get("ADJ", "1") == "1" # s44/ld5 adjacency-aware seeding A/B
prop = os.environ.get("PROP", "1") == "1" # leu.2 proportion-aware split sizing (default-on)
print(f"programme : {programme_dir.name}")
print(f"seed : {seed_file.name}")
@ -65,6 +66,7 @@ def main() -> int:
print(f"niche : {niche}")
print(f"restart_p : {restart_patience}")
print(f"adj_aware : {adj}")
print(f"prop_aware: {prop}")
print(flush=True)
seed_root = dom.load(str(seed_file))
@ -86,6 +88,7 @@ def main() -> int:
niche_by_signature=niche,
restart_patience=restart_patience,
seed_adjacency_aware=adj,
seed_proportion_aware=prop,
)
elapsed = time.perf_counter() - t0

View file

@ -158,6 +158,7 @@ def search(
restart_patience: int | None = None,
restart_elite: int = 1,
seed_adjacency_aware: bool = True,
seed_proportion_aware: bool = True,
) -> SearchResult:
"""Run the memetic loop from ``seed_root`` until ``budget`` oracle
evaluations are consumed. Returns the best individual found; its ``root``
@ -227,6 +228,8 @@ def search(
_key = lambda ind: _rank_fitness(ind)
# Always load reqs so bootstrap_n_leaves can be auto-derived from programme.
reqs = programme.load_programme_dir(programme_dir)
# Constructive seed must honour storey_minimum, not just level: keys (§12.2).
min_storeys = programme.storey_minimum(programme_dir)
if types is None:
# Urb's generic types are canonically UPPERCASE (get_space_types:
# qw/C O S/; the corpus is 100% uppercase). Predicates match
@ -327,7 +330,9 @@ def search(
return (seed_factory(rng), None, child_budget, {}, f"lift/{tag}")
if prog:
topo = operators.constructive_topology(
seed_root, reqs, rng, types, adjacency_aware=seed_adjacency_aware)
seed_root, reqs, rng, types, min_storeys=min_storeys,
adjacency_aware=seed_adjacency_aware,
proportion_aware=seed_proportion_aware)
return (topo, None, child_budget, {}, f"construct/{tag}")
n = int(rng.integers(max(1, n_target - 1), n_target + 2))
return (random_topology(seed_root, n, rng, types), None, child_budget,
@ -447,6 +452,7 @@ def search_staged(
restart_patience: int | None = None,
restart_elite: int = 1,
seed_adjacency_aware: bool = True,
seed_proportion_aware: bool = True,
) -> SearchResult:
"""Staged per-floor topology search (DESIGN.md §11.3, ``homemaker-py-c4c.3``).
@ -471,7 +477,11 @@ def search_staged(
from . import graph
reqs = programme.load_programme_dir(programme_dir)
n_storeys = programme.n_storeys_required(reqs)
# Honour storey_minimum even when no room is pinned to an upper level (§12.2):
# e.g. programme-house is storey_minimum:2 with all rooms level:0, so its
# valid solutions are multi-storey and it must stage, not fall through.
n_storeys = max(programme.n_storeys_required(reqs),
programme.storey_minimum(programme_dir))
def _log(msg: str) -> None:
if log:
@ -485,7 +495,8 @@ def search_staged(
inner_kw=inner_kw, log=log, n_workers=n_workers,
use_grade=use_grade, niche_by_signature=niche_by_signature,
restart_patience=restart_patience, restart_elite=restart_elite,
seed_adjacency_aware=seed_adjacency_aware)
seed_adjacency_aware=seed_adjacency_aware,
seed_proportion_aware=seed_proportion_aware)
if types is None:
types = sorted(reqs) + ["C", "O"]
@ -510,6 +521,7 @@ def search_staged(
niche_by_signature=niche_by_signature,
restart_patience=restart_patience, restart_elite=restart_elite,
seed_adjacency_aware=seed_adjacency_aware,
seed_proportion_aware=seed_proportion_aware,
)
best_base = r1.best.root
_log(f"[staged] stage 1 done: base {r1.best.fitness:.6g} "
@ -525,7 +537,8 @@ def search_staged(
def _seed_factory(rng2):
return operators.lift_base_to_storeys(
best_base, upper, rng2, types, reqs=reqs,
adjacency_aware=seed_adjacency_aware)
adjacency_aware=seed_adjacency_aware,
proportion_aware=seed_proportion_aware)
_log(f"[staged] stage 2: upper floors as deltas, budget {b2}, base_p {base_p}")
r2 = search(

View file

@ -384,6 +384,79 @@ def _grow_leaves(lvl: dom.Node, n_leaves: int, rng: np.random.Generator) -> None
leaf.type = None
def _size_divisions_from_targets(lvl: dom.Node, reqs, fmin: float = 0.04,
fmax: float = 0.96) -> None:
"""Resize each divided node's split ratio from its leaves' TARGET areas.
leu.2 (DESIGN.md §12.2, follow-up to §11.6/§11.7): the constructive seeders
grow geometry with uniform ``[0.5, 0.5]`` cuts *before* types are assigned, so
the raw seed is "more, smaller leaves" of equal area rooms with a large
programme target come out too small, small rooms too big, and the inner loop
must recover all of size/width/proportion from scratch. Once types are known,
every leaf carries a target area (a sized room's ``size``; circulation/outside
absorb the slack), and because ``division=[f, f]`` cuts off left area-fraction
``f`` (rotation-independent), bottom-up target sums compose multiplicatively to
give every leaf area its target.
Area alone is not enough: choosing only the cut *fraction* to hit a target
*area* slices thin slivers with terrible aspect (proportion/width/edge-too-long
fails swamp the size gain measured, §12.2). So each cut also picks the
**rotation** (the two distinct cut directions) that makes its two children
squarest. Rotation depends on the realised parent geometry, so the pass runs
*top-down*; both the ratio and the rotation derive from the target dims, and
neither touches topology or type assignment (§11.6/§11.7 placement is intact).
Generic (non-sized) leaves get a nominal target: the per-leaf share of the
plot slack, floored at ``0.4 ×`` mean room target so a circulation leaf never
shrinks to a sub-door-width sliver (which would undo the §11.6 adjacency win).
"""
from . import geometry
reqs = reqs or {}
geometry.clear_cache()
leaves = lvl.leaves()
if len(leaves) < 2:
return
sized = {lf: reqs[lf.type].size for lf in leaves
if lf.type in reqs and reqs[lf.type].size > 0}
mean_sized = (sum(sized.values()) / len(sized)) if sized else 1.0
n_generic = len(leaves) - len(sized)
slack = geometry.area(lvl) - sum(sized.values())
floor = 0.4 * mean_sized # keep circulation/outside above door-width scale
generic_t = max(floor, slack / n_generic) if n_generic else floor
target = {lf: sized.get(lf, generic_t) for lf in leaves}
def _subtree_target(n: dom.Node) -> float:
if not n.divided:
return max(target.get(n, floor), 1e-6)
return _subtree_target(n.left) + _subtree_target(n.right)
def _rec(n: dom.Node) -> None:
if not n.divided:
return
left = _subtree_target(n.left)
f = min(max(left / (left + _subtree_target(n.right)), fmin), fmax)
# Pick the cut direction (rotation 0 vs 1; 2/3 mirror these for aspect)
# that makes the worse child squarest, given this node's settled geometry.
best_rot, best_aspect = n.rotation, None
for rot in (0, 1):
n.rotation = rot
n.division = [f, f]
geometry.clear_cache()
worst = max(geometry.aspect(n.left), geometry.aspect(n.right))
if best_aspect is None or worst < best_aspect:
best_aspect, best_rot = worst, rot
n.rotation = best_rot
n.division = [f, f]
geometry.clear_cache()
_rec(n.left)
_rec(n.right)
_rec(lvl)
geometry.clear_cache()
def _assign_adjacency_aware(lvl: dom.Node, room_codes: list[str], reqs,
rng: np.random.Generator, door_width: float = 1.2,
fixed_circ: "list[dom.Node] | None" = None) -> None:
@ -493,7 +566,8 @@ def _assign_adjacency_aware(lvl: dom.Node, room_codes: list[str], reqs,
def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
types: list[str], min_storeys: int = 1,
adjacency_aware: bool = True) -> dom.Node:
adjacency_aware: bool = True,
proportion_aware: bool = True) -> dom.Node:
"""Build a seed that instantiates every required space by construction.
The §11.0 diagnosis: random divide+retype chains leave required programme
@ -556,12 +630,22 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
for slot, leaf_idx in enumerate(order):
leaves[int(leaf_idx)].type = assign[slot] if slot < len(assign) else "O"
if proportion_aware:
# leu.2: now that leaves are typed, replace the uniform 0.5 cuts with
# target-proportional ratios so the raw seed sits near feasible size/
# width/proportion. Topology and type assignment are unchanged. Link
# first so upper-storey roots resolve geometry (the else branch above
# does not link, unlike the adjacency-aware branch).
dom._link(child)
_size_divisions_from_targets(lvl, reqs)
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],
reqs=None, adjacency_aware: bool = True) -> dom.Node:
reqs=None, adjacency_aware: bool = True,
proportion_aware: bool = True) -> 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
@ -635,6 +719,15 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
core_node.type = "C" # keep the inherited core as circulation
prev.above = dup
if proportion_aware:
# leu.2: size the upper-floor cuts from target areas too. The base is
# the evolved Stage-1 ground floor and is left untouched; only the
# constructed upper storey's ratios are rewritten. (Cuts inherited from
# the base via below-links are no-ops here — their geometry is fixed
# below — so this best-effort sizes the floor's own new divisions.)
dom._link(child)
_size_divisions_from_targets(dup, reqs)
prev = dup
return _finalise(child)

View file

@ -169,12 +169,8 @@ def write_stage1_programme(
return out_dir
def load_programme_dir(directory: str | Path) -> dict[str, SpaceReq]:
"""Load programme from a directory, merging parent patterns.config as base.
Mirrors urb-evolve.pl: ../patterns.config loaded first, then the local
file's top-level keys override it (same shallow-merge as fitness.load_config).
"""
def _load_merged_conf(directory: "str | Path") -> dict:
"""Merge ``../patterns.config`` then the local one (mirrors load_config)."""
from pathlib import Path as _Path
directory = _Path(directory)
conf: dict = {}
@ -182,4 +178,32 @@ def load_programme_dir(directory: str | Path) -> dict[str, SpaceReq]:
if p.is_file():
with open(p) as fh:
conf.update(yaml.safe_load(fh) or {})
return _parse_spaces(conf)
return conf
def load_programme_dir(directory: str | Path) -> dict[str, SpaceReq]:
"""Load programme from a directory, merging parent patterns.config as base.
Mirrors urb-evolve.pl: ../patterns.config loaded first, then the local
file's top-level keys override it (same shallow-merge as fitness.load_config).
"""
return _parse_spaces(_load_merged_conf(directory))
def storey_minimum(directory: str | Path) -> int:
"""Minimum storey count the programme requires (``storey_minimum`` key).
Independent of ``level:`` keys: a programme can demand N storeys via
``storey_minimum`` without pinning any room to an upper floor (e.g.
programme-house: ``storey_minimum: 2`` but all rooms ``level: 0``). The
constructive seeder and the staged/plain dispatch must honour it, else the
seed is built one storey short and fitness fires a ``storey minimum`` fail the
search has to repair structurally (DESIGN.md §12.2).
"""
return int(_load_merged_conf(directory).get("storey_minimum") or 1)
def n_storeys_for(directory: str | Path) -> int:
"""Storeys the programme implies: the max of level-derived and storey_minimum."""
reqs = load_programme_dir(directory)
return max(n_storeys_required(reqs), storey_minimum(directory))