From 33d79be3fe44c368c69b0064cff54fc4d83b7592 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sat, 13 Jun 2026 23:29:12 +0100 Subject: [PATCH] Cold-start bootstrap: diverse initial population for blank-slate search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the seed is an undivided bare plot (init.dom), auto-generate pop_size random topologies before the memetic loop starts, each evaluated at child_budget. This crosses the zero-feasibility region that single-seed chaining cannot escape — the programme-house cold start was stalling at 18 fails after 2000 evals vs urb-evolve's 6. Auto-detection via seed_root.divided preserves the existing single-seed path for warm starts from existing designs; all previous tests pass unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .beads/issues.jsonl | 7 +++-- src/homemaker/driver.py | 62 ++++++++++++++++++++++++++++++++++++----- tests/test_driver.py | 59 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 22da354..d9c1d9a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -9,7 +9,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.24–x1.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-0px","title":"Blank-slate cold-start initialisation","description":"The outer search stalls when starting from init.dom (Phase 2 gate: 18 fails after 2000 evals vs urb-evolve's 6). The root cause is single-seed topology mutation chaining — building structure one room at a time gives no gradient across the large zero-feasibility region. Fix requires multi-start bootstrap: generate a diverse initial population by random topology sampling, or a greedy room-placement initialiser that satisfies adjacency/level constraints before handing off to the memetic loop. Without this the tool is only useful for refining existing designs, not designing new buildings from scratch.","acceptance_criteria":"Cold-start from init.dom reaches comparable fail count to urb-evolve within equal eval budget; tested on programme-house","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-13T21:52:15Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:52:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"homemaker-py-hqw","title":"Make homemaker-py standalone: remove dependency on Perl Urb package","description":"Currently tests and fitness scoring depend on the Perl Urb package (urb-fitness.pl) and corpus files in /home/bruno/src/urb/examples/. The tool should be fully standalone and not require any external Perl packages or local urb corpus paths. This includes: bundling or reimplementing any needed reference data, making the native Python fitness the default path, and ensuring tests pass without /home/bruno/src/urb present.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-13T22:27:54Z","created_by":"Bruno Postle","updated_at":"2026-06-13T22:27:54Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"homemaker-py-0px","title":"Blank-slate cold-start initialisation","description":"The outer search stalls when starting from init.dom (Phase 2 gate: 18 fails after 2000 evals vs urb-evolve's 6). The root cause is single-seed topology mutation chaining — building structure one room at a time gives no gradient across the large zero-feasibility region. Fix requires multi-start bootstrap: generate a diverse initial population by random topology sampling, or a greedy room-placement initialiser that satisfies adjacency/level constraints before handing off to the memetic loop. Without this the tool is only useful for refining existing designs, not designing new buildings from scratch.","acceptance_criteria":"Cold-start from init.dom reaches comparable fail count to urb-evolve within equal eval budget; tested on programme-house","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T21:52:15Z","created_by":"Bruno Postle","updated_at":"2026-06-13T22:28:58Z","started_at":"2026-06-13T22:24:02Z","closed_at":"2026-06-13T22:28:58Z","close_reason":"Bootstrap implemented: auto-detect bare-plot seed, generate pop_size random topologies, evaluate each at child_budget before memetic loop; 3 new tests all green","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-ccw","title":"Scaled topology search on native fitness","description":"DESIGN.md §7 Phase 3 closing step. Once native fitness passes corpus parity, re-run the Phase-2 memetic search at real scale (population/generations comparable to urb-evolve) on the native objective. This is the first point where the §1 scaling question gets a real answer.","acceptance_criteria":"Full-scale run on programme-house beats both urb-evolve and the small-scale Phase-2 result; larger programme attempted","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:59Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:11:13Z","started_at":"2026-06-13T20:49:27Z","closed_at":"2026-06-13T21:11:13Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-ccw","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:44Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-ccw","depends_on_id":"homemaker-py-way","type":"blocks","created_at":"2026-06-12T00:39:45Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-way","title":"Benchmark: memetic loop vs urb-evolve at equal oracle-call budget (Phase 2 gate)","description":"DESIGN.md §7 Phase 2 gate. Compare against urb-evolve from the same seeds/programmes at equal oracle-evaluation budget — NOT generations (urb-evolve has diversity injection/culling baked in, so generations are not comparable). Go/no-go: memetic loop must beat equal-budget urb-evolve. Scaling up waits for native fitness.","acceptance_criteria":"Best-fitness and failure-count comparison at \u003e=2 budgets, \u003e=3 seeds; go/no-go decision recorded in DESIGN.md","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:37:28Z","created_by":"Bruno Postle","updated_at":"2026-06-13T08:55:03Z","started_at":"2026-06-12T21:13:20Z","closed_at":"2026-06-13T08:55:03Z","close_reason":"Phase-2 gate run (benchmark_vs_urbevolve.py, 2026-06-13, 2000 evals, URB_NO_OCCLUSION=1): 2/3 seeds → REVIEW. Memetic beats urb-evolve by 1.91x/1.63x on seeded designs; blank-slate init.dom stalls at 18 fails vs urb-evolve's 6 (random-pop init advantage). Fix: patterns.config was missing from re-score cwd (run_search.py), giving false near-zero finals in first run. Results recorded in DESIGN.md §7 Phase 2 gate.","dependencies":[{"issue_id":"homemaker-py-way","depends_on_id":"homemaker-py-b39","type":"blocks","created_at":"2026-06-12T00:39:39Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-way","depends_on_id":"homemaker-py-gp2","type":"blocks","created_at":"2026-06-12T08:27:45Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-b39","title":"Memetic search driver, small-scale (budgets in oracle evaluations)","description":"DESIGN.md §5, §7 Phase 2, §4.6 arithmetic. Memetic EA/SA over topology genomes wrapping the geometry inner loop (warm-started per §5.6); score = best full fitness over the inner loop. Explicitly small-scale on the batched oracle: tens of topologies, budget accounted in oracle evaluations, not generations. Population evaluation batched into single oracle calls.","acceptance_criteria":"End-to-end run on programme-house completes within a stated oracle-call budget and logs evaluations; produces valid .dom output","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:37:27Z","created_by":"Bruno Postle","updated_at":"2026-06-12T21:10:05Z","started_at":"2026-06-12T13:13:53Z","closed_at":"2026-06-12T21:10:05Z","close_reason":"driver.search() lands: steady-state memetic GA, tournament selection, operators + crossover, warm-started inner loop (Lamarckian write-back), budgets accounted in oracle evaluations. Acceptance run (URB_NO_OCCLUSION=1, budget 2000, seed c964435): 2010 evals / 23 topologies, best 0.00765/2 fails via crossover = x1.14 over the geometry-only optimum; output .dom re-scores standalone at exactly the recorded fitness. En route: found + fixed Urb ratio_o/ratio_type first-match nondeterminism (class-sum patch, 35/35 corpus parity) after the search reward-hacked it; operators now emit canonical uppercase generics (Bruno's correction: C=circulation, Is_Covered is a predicate).","dependencies":[{"issue_id":"homemaker-py-b39","depends_on_id":"homemaker-py-1p0","type":"blocks","created_at":"2026-06-12T00:39:37Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-b39","depends_on_id":"homemaker-py-nyb","type":"blocks","created_at":"2026-06-12T00:39:38Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} @@ -26,9 +27,9 @@ {"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":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:00Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:39:00Z","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-9gp","title":"Canonical slicing encoding (normalized Polish expression) + shape feasibility","description":"DESIGN.md §5.5, §7 Phase 5. Representation upgrade once core lands: normalized Polish expression / skewed slicing tree (Wong–Liu) for redundancy-free, high-locality topology moves (M1/M2/M3); bottom-up shape-feasibility checks to prune infeasible topologies before the inner loop. Goal: scale to larger programmes. Excluded representations stay excluded (§2): no sequence-pair/B*-tree (non-slicing).","acceptance_criteria":"Encoding round-trips with the genome; M1/M2/M3 moves implemented; measured search improvement on a larger-than-house programme","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:02Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:39:02Z","dependencies":[{"issue_id":"homemaker-py-9gp","depends_on_id":"homemaker-py-ccw","type":"blocks","created_at":"2026-06-12T00:39:48Z","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.","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-12T07:27: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-py PYTHONPATH: set PYTHONPATH=/home/bruno/src/homemaker-py/src or use 'python -m pytest' from the project root (which reads pyproject.toml and adds src/ automatically). Never try 'pip show' or 'pip install' — it's not installed as a package."} {"_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":"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":"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":"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."} diff --git a/src/homemaker/driver.py b/src/homemaker/driver.py index b609f59..4f812bd 100644 --- a/src/homemaker/driver.py +++ b/src/homemaker/driver.py @@ -10,6 +10,13 @@ Budgets are stated and accounted in **oracle evaluations** (scored .dom files), never generations (§4.6 arithmetic). This driver is deliberately small-scale for the Phase-2 proof on the batched Perl oracle; scaling up waits for the native fitness (Phase 3). + +Cold-start bootstrap (homemaker-py-0px): when the seed is an undivided bare +plot, the search auto-generates a diverse initial population by randomly +applying divide mutations until each topology has approximately the programme +room count, then evaluates all pop_size individuals before the memetic loop +begins. This crosses the zero-feasibility region that single-seed chaining +cannot escape. """ from __future__ import annotations @@ -51,6 +58,20 @@ class SearchResult: # (oracle evals consumed, new best fitness, lineage) per improvement +def random_topology(seed_root: dom.Node, n_leaves: int, + rng: np.random.Generator, types: list[str]) -> dom.Node: + """Grow a random topology from ``seed_root`` by repeated divide mutations. + + Applies ``mutate_divide`` until the total leaf count across all storeys + reaches ``n_leaves``. The result is a deep copy; ``seed_root`` is + unchanged. + """ + root = copy.deepcopy(seed_root) + while sum(len(lvl.leaves()) for lvl in dom.levels(root)) < n_leaves: + root, _ = operators.mutate_divide(root, rng, types) + return root + + def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw, lineage: str) -> tuple[Individual, int]: r = innerloop.optimise(root, programme_dir, x0=x0, budget=budget, @@ -72,6 +93,8 @@ def search( pop_size: int = 8, child_budget: int = 80, seed_budget: int = 200, + bootstrap: bool | None = None, + bootstrap_n_leaves: int | None = None, p_crossover: float = 0.2, seed: int = 0, types: list[str] | None = None, @@ -81,20 +104,30 @@ def search( ) -> SearchResult: """Run the memetic loop from ``seed_root`` until ``budget`` oracle evaluations are consumed. Returns the best individual found; its ``root`` - carries the optimised geometry and dumps to a valid ``.dom``.""" + carries the optimised geometry and dumps to a valid ``.dom``. + + ``bootstrap=None`` (default) auto-detects: if ``seed_root`` is an + undivided bare plot, generates a diverse initial population of ``pop_size`` + random topologies (each with approximately ``bootstrap_n_leaves`` leaves) + before the memetic loop starts. Pass ``bootstrap=False`` to force the + legacy single-seed path (appropriate for warm starts from existing designs). + """ from .oracle import DEFAULT_URB_ROOT urb_root = urb_root or DEFAULT_URB_ROOT rng = np.random.default_rng(seed) inner_kw = dict(_CHILD_INNER_KW, **(inner_kw or {})) + # Always load reqs so bootstrap_n_leaves can be auto-derived from programme. + reqs = programme.load_programme(str(Path(programme_dir) / "patterns.config")) if types is None: - reqs = programme.load_programme(str(Path(programme_dir) / "patterns.config")) # Urb's generic types are canonically UPPERCASE (get_space_types: # qw/C O S/; the corpus is 100% uppercase). Predicates match # case-insensitively but Dom->Ratios keys raw strings — mixing cases # fragments the class buckets, so never emit lowercase generics. types = sorted(reqs) + ["C", "O"] + do_bootstrap = (not seed_root.divided) if bootstrap is None else bootstrap + def _log(msg: str) -> None: if log: log(msg) @@ -124,11 +157,26 @@ def search( pop[worst] = ind pop: list[Individual] = [] - seed_ind, used = _evaluate(copy.deepcopy(seed_root), programme_dir, urb_root, - x0=None, budget=seed_budget, - inner_kw={}, lineage="seed") - n_evals += used - admit(seed_ind, pop) + if do_bootstrap: + # Bootstrap: diverse initial population from random topologies. + # Each individual is a cold start, so use the exploratory sigma + # schedule (inner_kw={} → cma_search defaults: sigmas=(0.05, 0.15)). + # Leaf count varied ±1 around the target to increase structural diversity. + n_target = bootstrap_n_leaves or max(len(reqs), 3) + for i in range(pop_size): + n = int(rng.integers(max(1, n_target - 1), n_target + 2)) + topo = random_topology(seed_root, n, rng, types) + ind, used = _evaluate(topo, programme_dir, urb_root, + x0=None, budget=child_budget, + inner_kw={}, lineage=f"bootstrap/{i}") + n_evals += used + admit(ind, pop) + else: + seed_ind, used = _evaluate(copy.deepcopy(seed_root), programme_dir, urb_root, + x0=None, budget=seed_budget, + inner_kw={}, lineage="seed") + n_evals += used + admit(seed_ind, pop) while n_evals < budget: if len(pop) >= 2 and rng.random() < p_crossover: diff --git a/tests/test_driver.py b/tests/test_driver.py index 1f061b9..be1396a 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -9,6 +9,7 @@ from homemaker import dom, driver, innerloop, solver CORPUS = Path("/home/bruno/src/urb/examples/programme-house") SEED_FILE = CORPUS / "c964435454c459f86c3ed9a5a7621132.dom" +INIT_FILE = CORPUS / "init.dom" pytestmark = pytest.mark.skipif(not CORPUS.is_dir(), reason="Urb corpus not available") @@ -87,3 +88,61 @@ def test_best_root_dumps_valid_dom(fake_inner, tmp_path): reloaded = dom.load(str(out)) assert sum(len(lvl.leaves()) for lvl in dom.levels(reloaded)) == \ sum(len(lvl.leaves()) for lvl in dom.levels(r.best.root)) + + +def test_bootstrap_cold_start(fake_inner): + """Bootstrap auto-triggers from a bare undivided plot and fills the + population with pop_size diverse random topologies before the main loop.""" + init_root = dom.load(str(INIT_FILE)) + assert not init_root.divided, "init.dom should be an undivided bare plot" + + pop_size = 4 + child_budget = 60 + budget = 500 + r = driver.search(init_root, CORPUS, budget=budget, pop_size=pop_size, + child_budget=child_budget, seed_budget=100, seed=7) + + # All evaluations use child_budget (no seed_budget call) + assert r.n_evals % child_budget == 0 + assert r.n_evals >= budget + assert r.n_evals - budget < child_budget + # Every topology (bootstrap + main loop) is counted + assert r.n_topologies == r.n_evals // child_budget + # Population is full + assert len(r.population) == pop_size + # Bootstrap individuals all had x0=None (cold starts) + assert all(c["x0"] is None for c in fake_inner[:pop_size]) + # Bootstrap uses exploratory sigma schedule (inner_kw={}, no sigmas override) + assert all("sigmas" not in c["kw"] for c in fake_inner[:pop_size]) + # Main loop children are warm-started + main_calls = fake_inner[pop_size:] + assert main_calls # at least one main-loop child + assert all(c["x0"] is not None for c in main_calls) + + +def test_bootstrap_disabled_for_divided_seed(fake_inner): + """A divided seed (warm start) auto-selects the legacy single-seed path.""" + seed_root = dom.load(str(SEED_FILE)) + assert seed_root.divided + + r = driver.search(seed_root, CORPUS, budget=500, pop_size=4, + child_budget=60, seed_budget=100, seed=0) + + # First call is the seed evaluated at seed_budget + assert fake_inner[0]["budget"] == 100 + assert fake_inner[0]["x0"] is None + # Remaining are warm-started children at child_budget + assert all(c["budget"] == 60 for c in fake_inner[1:]) + + +def test_random_topology_leaf_count(): + """random_topology produces a topology with at least n_leaves leaves.""" + import numpy as np + init_root = dom.load(str(INIT_FILE)) + rng = np.random.default_rng(0) + types = ["b1", "b2", "l1", "t1", "t2", "t3", "C", "O"] + for n in (3, 5, 7, 10): + topo = driver.random_topology(init_root, n, rng, types) + n_leaves = sum(len(lvl.leaves()) for lvl in dom.levels(topo)) + assert n_leaves >= n + assert n_leaves <= n + 1 # mutate_divide adds exactly one leaf per call