Phase 6 §11.5: structural niching + restarts — negative result (c4c.5)

genome.signature: ratio-invariant structural topology hash (per-storey tree
shape + cut orientation + leaf types), the cheap stand-in for the 9gp canonical
encoding. driver gains niche_by_signature (one individual per topology, replaces
the fitness-scalar dedup) and restart_patience (soft restart: keep elites,
refill with fresh seeds); SearchResult gains n_distinct_signatures /
diversity_history / n_restarts.

Diversity criterion MET (final-pop distinct ~5/16 -> 16/16). Gate NOT met:
blank-slate programme-house mean fails 12.3(legacy)/12.7(niche)/13.0(restart)
over 3 seeds at 20000 evals; harbor staged 95/94/108. Niching is a tie within
seed noise, restarts strictly worse — falsifies the premise that the
fitness-scalar dedup causes premature convergence. Both flags default-off,
kept for reuse. Epic c4c complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-06-18 23:42:39 +01:00
parent 6a5f9c4a8a
commit 059964ee05
8 changed files with 331 additions and 50 deletions

View file

@ -15,7 +15,7 @@
{"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-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).","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-17T21:50:01Z","created_by":"Bruno Postle","updated_at":"2026-06-17T21:50:01Z","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":"Note from c4c.4 (§11.4): a per-design graded proximity scalar (fitness.score_with_grade / _leaf_grade, sum of f/FAIL_THRESHOLD over failing per-leaf quality factors) already exists, default-off. Rejected as a SELECTION key (displaces within-tier fitness, which spans ~6 orders of magnitude). It may instead be useful here as a DIVERSITY / niching signal. Confirmed root finding: the harbor high-fail plateau is a topology-basin problem, not a comparator-resolution one -\u003e structural niching/restarts is the right lever.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-17T19:12:54Z","created_by":"Bruno Postle","updated_at":"2026-06-18T21:20:48Z","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.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":"in_progress","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:43Z","started_at":"2026-06-18T21:52:37Z","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}
{"id":"homemaker-py-g0b","title":"homemaker-fitness: native Python CLI replacement for urb-fitness.pl","description":"We need a Python CLI tool that replicates the behaviour of urb-fitness.pl so we can score .dom files without shelling out to Perl. The tool should: accept .dom file paths as arguments (or glob *.dom in cwd if none given), load patterns.config and costs.config from cwd and parent dir (local overrides project-level), skip scoring if .score and .fails files are already newer than the .dom (unless FORCE_UPDATE env var is set), score each .dom using fitness.Fitness.score_with_fails(), write the score to \u003cdom\u003e.score (40-digit float format), write the failures to \u003cdom\u003e.fails, print the score to stderr. Expose as homemaker-fitness entry point in pyproject.toml and as python -m homemaker_layout.fitness_cmd module. This replaces the oracle.py shelling-out path for Phase 3 native fitness.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-14T12:32:29Z","created_by":"Bruno Postle","updated_at":"2026-06-14T16:17:21Z","started_at":"2026-06-14T12:32:52Z","closed_at":"2026-06-14T16:17:21Z","close_reason":"Implemented as homemaker_layout/fitness_cmd.py with homemaker-fitness entry point; exact score parity verified against urb-fitness.pl on corpus","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"homemaker-py-gpx","title":"Native fitness parity gap on multi-storey designs (~3.7%)","description":"During programme-house cold-start runs with the fixed level_add operator, the generated 2-storey design showed native=1.2388e-04 vs oracle=1.1944e-04 (3.7% gap), exceeding the 0.01% rel_tol in test_native_fitness_score_parity. All existing single-storey corpus files pass parity fine (73/73). Hypothesis: a subtle discrepancy in value or cost computation for multi-level trees — candidates are staircase quality, circulation connectivity, or per-storey cost accumulation. To investigate: score a sweep of known multi-storey corpus files natively vs oracle and identify which term diverges.","status":"closed","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-06-14T09:35:34Z","created_by":"Bruno Postle","updated_at":"2026-06-17T17:39:25Z","closed_at":"2026-06-17T17:39:25Z","close_reason":"Closed","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-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":"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":"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":"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-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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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."}

View file

@ -936,14 +936,73 @@ missing-space hierarchy (§6) is preserved: grade can never reward dropping a ro
`score_with_grade` are kept default-off for reproducibility and possible reuse
(e.g. as a *diversity* signal under §11.5 rather than a selection key).
### 11.5 Topology diversity: structural niching + restarts (`homemaker-py-c4c.5`)
### 11.5 Topology diversity: structural niching + restarts (`homemaker-py-c4c.5`) — DONE (negative)
*Stub.* Replace the fitness-scalar dedup (driver.py:174) with a topology
signature so niching is by *structure*, not score; add crowding/restarts/islands
to match urb-evolve's upfront diversity on blank slate.
Premise (epic diagnosis): the population dedups on the **fitness scalar**
(`driver.admit`, `abs(fitness)` within `1e-9`) and so has no structural diversity
preservation — proposed as the root cause of the blank-slate gap (§7 Phase 2:
memetic 18 fails vs urb-evolve 6), a single mutation chain losing to urb-evolve's
upfront random-population diversity.
- *Gate:* blank-slate programme-house reaches ≤ 6 fails at equal budget; distinct
topology-signature count over time quantified before/after.
- *Result:* TODO. (Capstone `homemaker-py-9gp` canonical Polish encoding is the
principled long-term signature — `(a|b)|c == a|(b|c)` collapse — and lands
after §11.2.)
**Implementation (kept, default-off).** A cheap structural topology signature
(`genome.signature`) string-encodes each storey's tree shape + cut orientations
+ leaf types, routed through `encode` so dead inherited fields canonicalise; it
is **ratio-invariant** (same topology, different geometry → same signature). Two
diversity mechanisms, both behind flags on `search`/`search_staged`:
`niche_by_signature` holds at most one individual per signature in the population
(structural niching, keeping the better of a collision) in place of the
fitness-scalar guard; `restart_patience=<evals>` does a soft restart on
stagnation (keep `restart_elite` incumbents, refill with fresh
constructive/random seeds — urb-evolve's upfront diversity as a soft restart).
`SearchResult` gained `n_distinct_signatures` / `diversity_history` /
`n_restarts` to quantify diversity over time.
- *Commands (reproduce, `URB_NO_OCCLUSION=1`, 20000 evals):*
```bash
NICHE=0 python3 experiments/run_search_scaled.py examples/programme-house 20000 <seed> \
examples/programme-house/init.dom scratch/ph_before.dom # legacy dedup (before)
NICHE=1 python3 experiments/run_search_scaled.py examples/programme-house 20000 <seed> \
examples/programme-house/init.dom scratch/ph_niche.dom # structural niching
NICHE=1 RESTART_PATIENCE=2000 python3 experiments/run_search_scaled.py \
examples/programme-house 20000 <seed> examples/programme-house/init.dom scratch/ph_restart.dom
# harbor (staged): swap run_staged_search.py, seed examples/harbor-house/init.dom
```
- *Diversity (the secondary criterion) — MET.* Niching takes the final
population from ~**46 / 16** distinct topologies (legacy dedup) to **16 / 16**;
restarts raise distinct topologies *seen* by ~30 % (≈105138 → ≈164186 on
programme-house). The signature machinery works exactly as designed.
- *Fail count (the gate) — NOT MET.* Blank-slate programme-house, total fails at
budget (lower is better):
| seed | before (legacy) | niche | niche + restart |
|-----:|----------------:|------:|----------------:|
| 0 | **11** | 14 | 12 |
| 1 | **11** | 11 | 14 |
| 2 | 15 | **13**| 13 |
| mean | **12.3** | 12.7 | 13.0 |
Harbor-house (staged, seed 0): legacy **95** (reproduces §11.3 exactly), niche
**94**, niche+restart **108**. Across both programmes niching is a **tie within
seed noise** and restarts are **strictly worse**; nothing approaches the ≤ 6
gate.
- *Why it fails — the premise is falsified by measurement.* More *structural*
population diversity does not buy lower fails: the legacy dedup already holds
14/16 distinct topologies on harbor (Stage-2 starts from lifted bootstraps), so
it was never the diversity bottleneck the epic assumed. Maximal diversity
(16/16) with the fixed tournament pressure just **diffuses** effort — the
fitness-scalar dedup's smaller effective population exploits a basin slightly
harder. Restarts throw away converging Stage-2 work and regress hardest. The
high-fail plateau is a **reachability** problem (operators + encoding cannot
reach the low-fail basins), not a population-management one — the same
conclusion §11.4 reached from the comparator side.
- *Verdict: reject niching/restarts as defaults; the legacy fitness-scalar dedup
stands.* `niche_by_signature` / `restart_patience` are kept default-off for
reproducibility and reuse, and `genome.signature` is the cheap stand-in that the
canonical Polish encoding (**`homemaker-py-9gp`**) supersedes. With §11.3§11.5
all landed, the residual load is genuinely structural: the principled lever is
the canonical encoding (associativity collapse `(a|b)|c == a|(b|c)`) plus richer
topology operators, not outer-loop selection/population reshaping.

View file

@ -67,12 +67,20 @@ def main() -> int:
return 1
use_grade = os.environ.get("USE_GRADE") == "1" # §11.4 graded objective A/B
# §11.5 structural niching + restarts A/B. NICHE=0 falls back to the legacy
# fitness-scalar dedup (the "before"); RESTART_PATIENCE=<evals> enables soft
# restarts (default off).
niche = os.environ.get("NICHE", "0") == "1"
rp = os.environ.get("RESTART_PATIENCE")
restart_patience = int(rp) if rp else None
print(f"programme : {programme_dir.name}")
print(f"seed : {seed_file.name}")
print(f"budget : {budget} native evals")
print(f"rng seed : {rng_seed}")
print(f"use_grade : {use_grade}")
print(f"niche : {niche}")
print(f"restart_p : {restart_patience}")
print(flush=True)
seed_root = dom.load(str(seed_file))
@ -89,6 +97,8 @@ def main() -> int:
seed=rng_seed,
log=lambda m: print(m, flush=True),
use_grade=use_grade,
niche_by_signature=niche,
restart_patience=restart_patience,
# urb_root not needed: use_native=True is the default
)
@ -102,6 +112,15 @@ def main() -> int:
print("population: " + ", ".join(
f"{p.fitness:.4g}/{p.n_fails}f" for p in r.population
))
# §11.5 diversity: distinct topology signatures seen, current population
# structural spread, and restart count.
pop_distinct = len({p.sig for p in r.population})
print(f"diversity : {r.n_distinct_signatures} distinct topologies seen, "
f"{pop_distinct}/{len(r.population)} distinct in final population, "
f"{r.n_restarts} restarts")
if r.diversity_history:
print("pop-distinct over time (evals, pop_distinct, cumulative): "
+ ", ".join(f"({e},{d},{c})" for e, d, c in r.diversity_history))
if r.history:
print("\nimprovement history:")

View file

@ -52,12 +52,17 @@ def main() -> int:
return 1
use_grade = os.environ.get("USE_GRADE") == "1" # §11.4 graded objective A/B
niche = os.environ.get("NICHE", "0") == "1" # §11.5 structural niching A/B
rp = os.environ.get("RESTART_PATIENCE")
restart_patience = int(rp) if rp else None
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(f"use_grade : {use_grade}")
print(f"niche : {niche}")
print(f"restart_p : {restart_patience}")
print(flush=True)
seed_root = dom.load(str(seed_file))
@ -76,6 +81,8 @@ def main() -> int:
seed=rng_seed,
log=lambda m: print(m, flush=True),
use_grade=use_grade,
niche_by_signature=niche,
restart_patience=restart_patience,
)
elapsed = time.perf_counter() - t0
@ -86,6 +93,10 @@ def main() -> int:
print("population: " + ", ".join(
f"{p.fitness:.4g}/{p.n_fails}f" for p in r.population
))
pop_distinct = len({p.sig for p in r.population})
print(f"diversity : {r.n_distinct_signatures} distinct topologies seen, "
f"{pop_distinct}/{len(r.population)} distinct in final population, "
f"{r.n_restarts} restarts")
if r.history:
print("\nimprovement history:")

View file

@ -34,7 +34,7 @@ from pathlib import Path
import numpy as np
from . import dom, fitness, innerloop, operators, programme
from . import dom, fitness, genome, innerloop, operators, programme
_CHILD_INNER_KW: dict = {}
@ -77,6 +77,7 @@ class Individual:
ratios: dict[tuple[int, str], float]
lineage: str = "seed"
grade: float = 0.0 # §11.4 graded proximity; secondary comparator key only
sig: str = "" # §11.5 structural topology signature; niching key
@dataclass
@ -88,6 +89,10 @@ class SearchResult:
history: list[tuple[int, float, str]] = field(default_factory=list)
# (oracle evals consumed, new best fitness, lineage) per improvement
interrupted: bool = False
n_distinct_signatures: int = 0 # §11.5 total distinct topologies ever admitted
diversity_history: list[tuple[int, int, int]] = field(default_factory=list)
# (evals, distinct sigs in population, cumulative distinct sigs seen)
n_restarts: int = 0 # §11.5 diversity restarts triggered
def random_topology(seed_root: dom.Node, n_leaves: int,
@ -118,7 +123,7 @@ def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw,
copy.deepcopy(root))
ind = Individual(root=root, fitness=r.fitness, n_fails=r.n_fails,
ratios=innerloop.ratio_map(root), lineage=lineage,
grade=grade)
grade=grade, sig=genome.signature(root))
return ind, r.n_evals
@ -149,6 +154,9 @@ def search(
seed_factory=None,
base_p: float = 1.0,
use_grade: bool = False,
niche_by_signature: bool = False,
restart_patience: int | None = None,
restart_elite: int = 1,
) -> SearchResult:
"""Run the memetic loop from ``seed_root`` until ``budget`` oracle
evaluations are consumed. Returns the best individual found; its ``root``
@ -166,6 +174,24 @@ def search(
iteration from the current population snapshot and evaluates them in
parallel. Results are admitted in completion order (fastest first), so
later children in each batch see an already-updated population.
``niche_by_signature`` (DESIGN.md §11.5, default ``False`` REJECTED, kept
for reuse) replaces the legacy fitness-scalar duplicate guard with structural
niching: the population holds at most one individual per
:func:`genome.signature` (topology), keeping the better of any collision, so
distinct topologies whose fitness scalars coincide (common in the high-fail
``0.5^n`` regime) are no longer discarded. ``restart_patience`` (default
``None`` = off) triggers a soft restart when the best has not improved for
that many evals: the top ``restart_elite`` incumbents are kept and the rest of
the population is refilled with fresh constructive/random seeds, the
soft-restart analog of urb-evolve's upfront random-population diversity.
Both default off: §11.5 measured that they raise structural diversity as
designed (final-population distinct topologies ~5/16 16/16) but do **not**
lower the fail count a tie within seed noise on blank-slate programme-house
(mean 12.3 12.7) and harbor (95 94), with restarts strictly worse. The
high-fail plateau is therefore not a population-diversity deficit; the lever
is the canonical encoding (``homemaker-py-9gp``) and richer operators.
"""
from .oracle import DEFAULT_URB_ROOT
@ -215,18 +241,37 @@ def search(
n_evals = 0
n_topologies = 0
last_improve = 0 # n_evals at the last best-fitness improvement (restart clock)
seen_sigs: set[str] = set() # §11.5 cumulative distinct topologies ever admitted
result = SearchResult(best=None, population=[], n_evals=0, n_topologies=0)
def admit(ind: Individual, pop: list[Individual]) -> None:
nonlocal n_topologies
nonlocal n_topologies, last_improve
n_topologies += 1
seen_sigs.add(ind.sig)
if result.best is None or _key(ind) > _key(result.best):
result.best = ind
last_improve = n_evals
result.history.append((n_evals, ind.fitness, ind.lineage))
result.diversity_history.append(
(n_evals, len({p.sig for p in pop} | {ind.sig}), len(seen_sigs)))
_log(f"[{n_evals:6d} evals] best {ind.fitness:.6g} "
f"(fails {ind.n_fails}) via {ind.lineage}")
# reject near-duplicates of existing members (population collapse
# guard — neutral mutations are common, homemaker-py-8cs)
if niche_by_signature:
# §11.5 structural niching: at most one individual per topology
# signature, keeping the better of any collision. This preserves
# STRUCTURAL diversity directly — distinct topologies whose fitness
# scalars happen to coincide (common in the high-fail 0.5^n regime)
# are no longer wrongly discarded, and neutral geometry variants of an
# incumbent topology can never crowd out a rival topology.
for i, p in enumerate(pop):
if p.sig == ind.sig:
if _key(ind) > _key(p):
pop[i] = ind
return
else:
# legacy fitness-scalar dedup (population collapse guard —
# neutral mutations are common, homemaker-py-8cs)
if any(abs(ind.fitness - p.fitness) <= 1e-9 * max(abs(p.fitness), 1e-300)
for p in pop):
return
@ -267,6 +312,25 @@ def search(
n_evals += used
admit(ind, pop)
# A fresh seed individual (used for the initial bootstrap and for §11.5
# restart injections). Mirrors the construction order: custom seed_factory >
# programme-aware construction > random divide-grown topology.
prog = {c: r for c, r in reqs.items() if c[0].lower() not in "cos"}
n_target = bootstrap_n_leaves or max(len(reqs), 3)
def _make_seed_task(tag: str) -> tuple:
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).
return (seed_factory(rng), None, child_budget, {}, f"lift/{tag}")
if prog:
topo = operators.constructive_topology(seed_root, reqs, rng, types)
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,
{}, f"bootstrap/{tag}")
interrupted = False
try:
if do_bootstrap:
@ -278,25 +342,7 @@ def search(
# has required spaces, instantiate each by construction so the seed
# population starts with ~zero missing-space failures instead of a
# random divide+retype walk that leaves required rooms absent.
prog = {c: r for c, r in reqs.items() if c[0].lower() not in "cos"}
n_target = bootstrap_n_leaves or max(len(reqs), 3)
tasks = []
for i in range(pop_size):
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)
lineage = f"construct/{i}"
else:
n = int(rng.integers(max(1, n_target - 1), n_target + 2))
topo = random_topology(seed_root, n, rng, types)
lineage = f"bootstrap/{i}"
tasks.append((topo, None, child_budget, {}, lineage))
_run_batch(tasks)
_run_batch([_make_seed_task(str(i)) for i in range(pop_size)])
else:
seed_ind, used = _evaluate(copy.deepcopy(seed_root), programme_dir, urb_root,
x0=None, budget=seed_budget,
@ -306,6 +352,28 @@ def search(
admit(seed_ind, pop)
while n_evals < budget:
# §11.5 diversity restart: if the best has not improved for
# restart_patience evals, keep the top restart_elite incumbents and
# refill the population with fresh constructive/random seeds. This
# re-injects the upfront structural diversity a single mutation chain
# loses (the blank-slate gap, §7 Phase 2) — the soft-restart analog of
# urb-evolve's random initial population. Off by default
# (restart_patience=None) so existing experiments are unaffected.
if (restart_patience is not None and pop
and n_evals - last_improve >= restart_patience
and n_evals + child_budget <= budget):
keep = sorted(pop, key=_key, reverse=True)[:max(1, restart_elite)]
pop[:] = keep
result.n_restarts += 1
last_improve = n_evals # reset clock; avoid immediate re-trigger
n_fresh = min(pop_size - len(pop),
max(0, (budget - n_evals) // child_budget))
_log(f"[{n_evals:6d} evals] restart #{result.n_restarts}: "
f"keep {len(keep)}, inject {n_fresh} fresh seeds")
if n_fresh:
_run_batch([_make_seed_task(f"r{result.n_restarts}.{i}")
for i in range(n_fresh)])
continue
# How many children to generate this iteration: n_workers in parallel,
# but cap at what the remaining budget can afford (ceiling division).
batch_n = (
@ -351,6 +419,7 @@ def search(
result.population = sorted(pop, key=_key, reverse=True)
result.n_evals = n_evals
result.n_topologies = n_topologies
result.n_distinct_signatures = len(seen_sigs)
result.interrupted = interrupted
return result
@ -372,6 +441,9 @@ def search_staged(
log=None,
n_workers: int = 1,
use_grade: bool = False,
niche_by_signature: bool = False,
restart_patience: int | None = None,
restart_elite: int = 1,
) -> SearchResult:
"""Staged per-floor topology search (DESIGN.md §11.3, ``homemaker-py-c4c.3``).
@ -408,7 +480,8 @@ def search_staged(
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,
use_grade=use_grade)
use_grade=use_grade, niche_by_signature=niche_by_signature,
restart_patience=restart_patience, restart_elite=restart_elite)
if types is None:
types = sorted(reqs) + ["C", "O"]
@ -430,6 +503,8 @@ def search_staged(
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,
niche_by_signature=niche_by_signature,
restart_patience=restart_patience, restart_elite=restart_elite,
)
best_base = r1.best.root
_log(f"[staged] stage 1 done: base {r1.best.fitness:.6g} "
@ -455,14 +530,21 @@ def search_staged(
# §11.4: the graded objective targets the dense two-floor quality-fail
# regime, which is Stage 2. Stage 1 keeps its readiness-biased key so the
# substrate-selection semantics (§11.3) are unchanged.
use_grade=use_grade,
use_grade=use_grade, niche_by_signature=niche_by_signature,
restart_patience=restart_patience, restart_elite=restart_elite,
)
# Stitch the two stages into one accounting (total evals, tagged history).
r2.n_evals += r1.n_evals
r2.n_topologies += r1.n_topologies
r2.n_distinct_signatures += r1.n_distinct_signatures
r2.n_restarts += r1.n_restarts
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]
)
r2.diversity_history = (
[(e, d, c) for e, d, c in r1.diversity_history]
+ [(e + r1.n_evals, d, c) for e, d, c in r2.diversity_history]
)
return r2

View file

@ -102,6 +102,47 @@ def encode(root: dom.Node) -> Genome:
return g
# --------------------------------------------------------------------------- #
# signature: Genome -> structural topology hash (homemaker-py-c4c.5, §11.5)
# --------------------------------------------------------------------------- #
# A cheap structural signature so the search can niche by *topology* rather than
# by the fitness scalar. It captures tree shape, cut orientation (rotation) and
# leaf-type assignment per storey, but deliberately ignores division *ratios*
# (geometry): two designs with the same topology and different geometry collapse
# to one signature, which is exactly the "same topology, different geometry"
# equivalence the niching wants. Routing through ``encode`` first canonicalises
# the dead inherited fields (rotation/division on below-linked nodes, see module
# docstring), so genome-equal trees always share a signature. This is the cheap
# stand-in for the canonical Polish encoding (homemaker-py-9gp), which would
# additionally collapse associativity ``(a|b)|c == a|(b|c)``.
def _gnode_sig(g: GNode) -> str:
if g.divided:
return f"({g.rotation}{_gnode_sig(g.left)}{_gnode_sig(g.right)})"
return g.type or "."
def _delta_sig(d: StoreyDelta) -> str:
parts: list[str] = []
for path in sorted(d.undivides):
parts.append(f"u{path}={d.retypes.get(path) or '.'}")
for path in sorted(d.divides):
parts.append(f"d{path}={_gnode_sig(d.divides[path])}")
for path in sorted(p for p in d.retypes if p not in d.undivides):
parts.append(f"t{path}={d.retypes[path] or '.'}")
return ";".join(parts)
def signature(root: dom.Node) -> str:
"""Structural topology signature of a (possibly multi-storey) Node tree.
Equal iff two trees have the same per-storey tree shape, cut orientations and
leaf types independent of division ratios, heights and other geometry. Used
as the niching key in :func:`homemaker_layout.driver.search`.
"""
g = encode(root)
return _gnode_sig(g.base) + "||" + "||".join(_delta_sig(d) for d in g.deltas)
# --------------------------------------------------------------------------- #
# decode: Genome -> Node tree
# --------------------------------------------------------------------------- #

View file

@ -148,6 +148,40 @@ def test_random_topology_leaf_count():
assert n_leaves <= n + 1 # mutate_divide adds exactly one leaf per call
def test_niche_by_signature_keeps_distinct_topologies(fake_inner):
"""§11.5: niching admits at most one individual per topology signature, so
the population is structurally distinct and diversity is reported."""
from homemaker_layout import genome
init_root = dom.load(str(INIT_FILE))
r = driver.search(init_root, CORPUS, budget=2000, pop_size=6,
child_budget=60, seed=3, niche_by_signature=True)
sigs = [genome.signature(p.root) for p in r.population]
assert len(sigs) == len(set(sigs)), "population must be one-per-topology"
assert r.n_distinct_signatures >= len(r.population)
assert r.diversity_history # recorded on each improvement
def test_restart_keeps_elite_and_counts(monkeypatch):
"""§11.5: a stagnation restart fires, is counted, and preserves the best."""
# Saturating fake (no monotone tiebreaker, unlike `fake_inner`): fitness
# peaks at 12 leaves and plateaus, so the best stalls and restarts trigger.
def fake_optimise(root, programme_dir, x0=None, budget=200, urb_root=None, **kw):
n_leaves = sum(len(lvl.leaves()) for lvl in dom.levels(root))
fitness = 1.0 / (1.0 + abs(12 - n_leaves))
return innerloop.Result(
x=np.array([0.25]), fitness=fitness, n_fails=0, fail_lines=(),
x0_fitness=fitness / 2, x0_n_fails=1, n_evals=budget, n_oracle_calls=1,
)
monkeypatch.setattr(innerloop, "optimise", fake_optimise)
init_root = dom.load(str(INIT_FILE))
r = driver.search(init_root, CORPUS, budget=4000, pop_size=4,
child_budget=60, seed=5, niche_by_signature=True,
restart_patience=300, restart_elite=1)
assert r.n_restarts >= 1
assert r.best is not None and r.best.fitness > 0
def test_search_parallel_smoke():
"""n_workers>1 runs without error and produces valid results."""
init_root = dom.load(str(INIT_FILE))

View file

@ -66,3 +66,38 @@ def test_storey_counts():
for f in corpus():
root = dom.load(str(f))
assert genome.encode(root).n_storeys == len(dom.levels(root)), f.name
# --- signature (§11.5 structural niching) --------------------------------- #
def test_signature_invariant_under_roundtrip():
# genome-equal trees (decode canonicalises dead fields) share a signature
for f in corpus():
root = dom.load(str(f))
root2 = genome.decode(genome.encode(root))
assert genome.signature(root) == genome.signature(root2), f.name
def test_signature_ignores_division_ratios():
# same topology, different geometry -> same signature (the niching premise)
import copy
for f in corpus():
root = dom.load(str(f))
sig0 = genome.signature(root)
perturbed = copy.deepcopy(root)
for lvl in dom.levels(perturbed):
for b in solver._branches(lvl):
b.division = [0.37, 0.63] # arbitrary ratio change, same shape
assert genome.signature(perturbed) == sig0, f.name
def test_signature_changes_with_topology():
# a divide mutation changes the structure -> changes the signature
import numpy as np
from homemaker_layout import operators
for f in corpus():
root = genome.decode(genome.encode(dom.load(str(f))))
child, _ = operators.mutate_divide(root, np.random.default_rng(0),
["k1", "l1", "C", "O"])
assert genome.signature(child) != genome.signature(root), f.name