Memetic search driver: steady-state GA over topology, warm-started inner loop
driver.py (homemaker-py-b39): tournament selection, operators.mutate (storey ops down-weighted) + area-matched crossover, every child's geometry delegated to the warm-started inner loop (Lamarckian write-back; children use a single local CMA phase - the exploratory ladder phase exists for cold projections children never face). Budget stated and accounted in oracle evaluations; near-duplicate fitness guard against population collapse (neutral mutations are common, per 8cs). free_with_keys/ratio_map/warm_x0 promoted from the 8cs experiment into innerloop.py as the Lamarckian inheritance API; alignment with solver.free_branches asserted across the corpus. tests/test_driver.py fakes the inner loop: budget accounting, monotone improvement history, warm-start + sigma plumbing, valid .dom output. 31 tests pass. experiments/run_search.py is the end-to-end acceptance run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4ed809a47c
commit
0beb005a23
6 changed files with 317 additions and 11 deletions
|
|
@ -2,13 +2,13 @@
|
|||
{"id":"homemaker-py-uxz","title":"Native fitness validation: 35-file corpus parity vs oracle; retire oracle (Phase 3 gate)","description":"DESIGN.md §7 Phase 3 gate. Validate the assembled native fitness against urb-fitness.pl across all 35 programme-house .dom files: scores within float tolerance AND identical failure sets. Swap behind the same interface as oracle.score so inner loop and search driver are unchanged; keep the oracle available as validation reference but stop using it in search. Then re-run topology search at scale (separate issue).","acceptance_criteria":"35/35 files: score parity within tolerance, failure sets identical; search runs end-to-end on native fitness with measured speedup vs oracle","status":"open","priority":1,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:27Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:38:27Z","dependencies":[{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-3y7","type":"blocks","created_at":"2026-06-12T00:39:40Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-gnw","type":"blocks","created_at":"2026-06-12T00:39:41Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-gp2","type":"blocks","created_at":"2026-06-12T08:27:44Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-hgg","type":"blocks","created_at":"2026-06-12T00:39:43Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":4,"dependent_count":3,"comment_count":0}
|
||||
{"id":"homemaker-py-hgg","title":"Native fitness: storey/building checks + missing-space failure stacking","description":"DESIGN.md §6. Port ProgrammeDriven/Storey/Building checks: space-count matching with MISSING-SPACE FAILURE STACKING (2 base failures + 1 per size/width/proportion/adjacency/level requirement, up to ~7 — ProgrammeDriven.pm:192-212; reshaping must preserve this hierarchy), adjacency/level/requires_below checks, staircase fit/volume/min-max, public access, circulation \u0026 outside ratios, min internal area (1.2x programme sum), storey limit/minimum, structural failures (edge too long \u003e8 m both variants, unsupported covered outside, covered outside above ground, level not connected, inaccessible usable space), preprocess_building s-\u003eO conversion, and the 0.5^n penalty over value/cost.","acceptance_criteria":"Failure sets and final scores match the oracle on sample files; failure-stacking counts identical","status":"open","priority":1,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:26Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:38:26Z","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-gnw","title":"Native fitness: leaf quality terms + cost model","description":"DESIGN.md §6. Port Leaf.pm quality terms (size, width, proportion, perpendicular, access) with programme-driven parameter lookup (get_space_params fallback chain, generic c/o/s handling, width_inside [4.0,1.0] default), gaussian scoring, FAIL_THRESHOLD=0.1. Also the COST DENOMINATOR — fitness is value/cost: per-leaf area costs, interior/exterior wall edge costs, boundary costs, value rates (Leaf.pm:194-251, Storey.pm:122-147). Cost couples to geometry too.","acceptance_criteria":"Per-leaf quality factors and per-storey cost/value match Perl (float tolerance) on sample corpus files with DEBUG output diffed","notes":"Crinkliness scope (2026-06-12): port SIMPLE crinkliness only — external wall area / floor area with the CIEsky illumination factor pinned to 1 (boundary-overlap geometry from Dom-\u003eWalls stays in scope; the sky model does not). Must match the URB_NO_OCCLUSION-flagged oracle (homemaker-py-gp2), not stock Urb. quality_daylight = 1 for all spaces.","status":"open","priority":1,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:24Z","created_by":"Bruno Postle","updated_at":"2026-06-12T07:28:07Z","dependencies":[{"issue_id":"homemaker-py-gnw","depends_on_id":"homemaker-py-gp2","type":"blocks","created_at":"2026-06-12T08:27:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-3y7","title":"Native fitness: adjacency/connectivity graph build + Merge_Divided semantics","description":"DESIGN.md §6 port scope, §7 Phase 3 (native fitness gates topology search at scale — §4.6). Port the door_width (1.2 m) adjacency graph (Urb Dom Graph), Merge_Divided, and the TWO-PHASE build: adjacency/level/vertical checks run on the UNMERGED tree, graphs rebuilt after Merge_Divided for storey processing (ProgrammeDriven.pm:83-103). Port faithfully — including has_vertical_connection's no-spatial-overlap stub (ProgrammeDriven.pm:399-423) unless the fidelity decision (§8.1) says otherwise; record the decision.","acceptance_criteria":"Graph edges/widths and merged structure match Perl on the 35-file corpus; vertical-connectivity fidelity decision recorded","status":"open","priority":1,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:23Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:38:23Z","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-3y7","title":"Native fitness: adjacency/connectivity graph build + Merge_Divided semantics","description":"DESIGN.md §6 port scope, §7 Phase 3 (native fitness gates topology search at scale — §4.6). Port the door_width (1.2 m) adjacency graph (Urb Dom Graph), Merge_Divided, and the TWO-PHASE build: adjacency/level/vertical checks run on the UNMERGED tree, graphs rebuilt after Merge_Divided for storey processing (ProgrammeDriven.pm:83-103). Port faithfully — including has_vertical_connection's no-spatial-overlap stub (ProgrammeDriven.pm:399-423) unless the fidelity decision (§8.1) says otherwise; record the decision.","acceptance_criteria":"Graph edges/widths and merged structure match Perl on the 35-file corpus; vertical-connectivity fidelity decision recorded","status":"in_progress","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:23Z","created_by":"Bruno Postle","updated_at":"2026-06-12T13:13:09Z","started_at":"2026-06-12T13:13:09Z","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"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-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":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:59Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:38:59Z","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":"open","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-11T23:37:28Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:37:28Z","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":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:37:27Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:37:27Z","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}
|
||||
{"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":"in_progress","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-12T13:13:53Z","started_at":"2026-06-12T13:13:53Z","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}
|
||||
{"id":"homemaker-py-nyb","title":"High-locality topology operators (mutation + subtree crossover)","description":"DESIGN.md §5, §7 Phase 2, §8.4. Mutation moves: divide/undivide leaf, swap children, rotate cut, retype leaf, per-floor delta edits, storey add/delete (cf. Urb Mutate.pm — but geometry sliding belongs to the inner loop, not the operator set). Crossover: area-matched subtree exchange (a subtree = a contiguous region, so crossover is meaningful — Crossover.pm). Operators must be high-locality: small genome change =\u003e small phenotype change, so warm-started inner loops stay cheap.","acceptance_criteria":"Each operator produces valid genomes (oracle scores them without error); locality measured (mean fitness/geometry perturbation per operator)","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-12T13:07:37Z","started_at":"2026-06-12T12:54:23Z","closed_at":"2026-06-12T13:07:37Z","close_reason":"operators.py lands: 7 mutations + area-matched crossover, valid-by-construction via genome.encode repair. 115/115 oracle-valid children; locality measured: geom-pert 0.07-0.33 per op, fitness-pert 0.68-0.99 (0.5^n cliff flags raw moves — warm restart + penalty reshaping confirmed load-bearing). Also fixed dom._link stale below-links on structural mutation.","dependencies":[{"issue_id":"homemaker-py-nyb","depends_on_id":"homemaker-py-k2g","type":"blocks","created_at":"2026-06-12T00:39:36Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-k2g","title":"Topology genome: base-floor tree + per-floor deltas + type assignment","description":"DESIGN.md §5.2, §7 Phase 2. Genome = base-floor slicing topology (primary) + per-leaf type assignment + per-floor divide/undivide deltas (Below-inheritance as regulariser; cut owned by lowest storey where its path is divided — §10). Must round-trip to/from dom.py Node trees so the oracle and inner loop consume it directly. Includes storey count and per-floor type overrides.","acceptance_criteria":"Genome \u003c-\u003e .dom round-trip on all 35 corpus files preserves fitness; multi-storey wall stacking preserved","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:37:26Z","created_by":"Bruno Postle","updated_at":"2026-06-12T12:52:34Z","started_at":"2026-06-12T10:55:21Z","closed_at":"2026-06-12T12:52:34Z","close_reason":"genome.py encode/decode lands. 35/35 oracle fitness parity after round-trip (flag-on); genome fixed-point + owned-projection tests. Dead-field discovery: corpus upper storeys carry drifted dead divisions (97) and rotations (187) — canonicalised by decode, validated fitness-neutral.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-d0s","title":"Experiment: inner-loop optimiser bake-off at equal oracle budgets","description":"DESIGN.md §7 Phase 1, §8.3. DOF is only ~rooms-1 (6–7 on corpus). Compare Nelder-Mead vs CMA-ES vs batched multi-start pattern search at equal oracle-call budgets, measuring fitness gained per oracle call and wall-clock (batch-friendliness matters — §4.6). Measure, don't commit blind.","acceptance_criteria":"Table of fitness-per-budget across \u003e=3 candidates; one optimiser chosen and recorded in DESIGN.md","status":"open","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:59Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:36:59Z","dependencies":[{"issue_id":"homemaker-py-d0s","depends_on_id":"homemaker-py-1p0","type":"blocks","created_at":"2026-06-12T00:39:35Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
|
|
|
|||
55
experiments/run_search.py
Normal file
55
experiments/run_search.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
#!/usr/bin/env python3
|
||||
"""End-to-end memetic search on programme-house (homemaker-py-b39 acceptance).
|
||||
|
||||
Runs driver.search from a corpus seed within a stated oracle-evaluation
|
||||
budget, logs every improvement, writes the best design to
|
||||
scratch/search_best.dom and independently re-scores it through the oracle to
|
||||
prove the output is a valid .dom whose recorded fitness is reproducible.
|
||||
|
||||
The interesting bar: the seed's geometry-only optimum (inner loop alone,
|
||||
DESIGN §4.7 reference: c964435 -> 0.00674). Anything above that is value
|
||||
added by *topology* search.
|
||||
|
||||
Run under the go-forward fitness:
|
||||
URB_NO_OCCLUSION=1 python3 experiments/run_search.py [budget] [rng-seed]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
from homemaker import dom, driver, oracle # noqa: E402
|
||||
|
||||
URB = Path("/home/bruno/src/urb")
|
||||
EX = URB / "examples" / "programme-house"
|
||||
SEED_FILE = EX / "c964435454c459f86c3ed9a5a7621132.dom"
|
||||
OUT = Path(__file__).resolve().parents[1] / "scratch" / "search_best.dom"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
budget = int(sys.argv[1]) if len(sys.argv) > 1 else 2000
|
||||
rng_seed = int(sys.argv[2]) if len(sys.argv) > 2 else 0
|
||||
print(f"seed={SEED_FILE.name} budget={budget} oracle evals, rng seed {rng_seed}",
|
||||
flush=True)
|
||||
|
||||
r = driver.search(dom.load(str(SEED_FILE)), EX, budget=budget,
|
||||
urb_root=URB, seed=rng_seed, log=lambda m: print(m, flush=True))
|
||||
|
||||
print(f"\ndone: {r.n_evals} oracle evals across {r.n_topologies} topologies")
|
||||
print(f"best: {r.best.fitness:.6g} (fails {r.best.n_fails}) via {r.best.lineage}")
|
||||
print("population: " + ", ".join(f"{p.fitness:.4g}/{p.n_fails}f" for p in r.population))
|
||||
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
dom.dump(r.best.root, str(OUT))
|
||||
s = oracle.score(OUT, URB)
|
||||
ok = math.isclose(s.fitness, r.best.fitness, rel_tol=1e-9)
|
||||
print(f"\n{OUT} re-scored standalone: {s.fitness:.6g} ({s.n_fails} fails) "
|
||||
f"-> {'MATCHES search record' if ok else 'MISMATCH'}")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -39,15 +39,7 @@ N_UNDIVIDE = 2
|
|||
TARGET_FRACTION = 0.95
|
||||
|
||||
|
||||
def free_with_keys(root: dom.Node) -> list[tuple[tuple[int, str], dom.Node]]:
|
||||
"""solver.free_branches order, with (level_index, id-path) keys that
|
||||
survive deepcopy and structural mutation."""
|
||||
out = []
|
||||
for li, lvl in enumerate(dom.levels(root)):
|
||||
for b in solver._branches(lvl):
|
||||
if b.below is None or not b.below.divided:
|
||||
out.append(((li, b.id), b))
|
||||
return out
|
||||
free_with_keys = innerloop.free_with_keys # promoted into the library
|
||||
|
||||
|
||||
def divide_leaf(leaf: dom.Node) -> None:
|
||||
|
|
|
|||
149
src/homemaker/driver.py
Normal file
149
src/homemaker/driver.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""Memetic search driver, small-scale (DESIGN.md §5, §7 Phase 2).
|
||||
|
||||
Steady-state memetic GA over topology: the outer loop owns *topology only*
|
||||
(operators.py moves on decoded Node trees); every child's geometry is
|
||||
delegated to the warm-started inner loop (innerloop.optimise), and the
|
||||
optimised ratios are written back into the individual (Lamarckian — measured
|
||||
mandatory, homemaker-py-8cs: cold starts never catch up at equal budget).
|
||||
|
||||
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).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import dom, innerloop, operators, programme
|
||||
|
||||
# children refine a near-optimal inherited geometry: one local CMA phase
|
||||
# (the exploratory ladder phase exists for brutal cold projections, which
|
||||
# warm-started children never face)
|
||||
_CHILD_INNER_KW = {"sigmas": (0.05,)}
|
||||
|
||||
# storey add/delete are drastic (geometry perturbation 0.25-0.33 and a
|
||||
# deleted storey stacks missing-space failures) — sample them rarely
|
||||
_MUTATION_WEIGHTS = {"level_add": 0.2, "level_delete": 0.2}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Individual:
|
||||
root: dom.Node
|
||||
fitness: float
|
||||
n_fails: int
|
||||
ratios: dict[tuple[int, str], float]
|
||||
lineage: str = "seed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
best: Individual
|
||||
population: list[Individual]
|
||||
n_evals: int
|
||||
n_topologies: int
|
||||
history: list[tuple[int, float, str]] = field(default_factory=list)
|
||||
# (oracle evals consumed, new best fitness, lineage) per improvement
|
||||
|
||||
|
||||
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,
|
||||
urb_root=urb_root, **inner_kw)
|
||||
ind = Individual(root=root, fitness=r.fitness, n_fails=r.n_fails,
|
||||
ratios=innerloop.ratio_map(root), lineage=lineage)
|
||||
return ind, r.n_evals
|
||||
|
||||
|
||||
def _tournament(pop: list[Individual], rng: np.random.Generator, k: int = 2) -> Individual:
|
||||
picks = rng.integers(len(pop), size=k)
|
||||
return max((pop[int(i)] for i in picks), key=lambda ind: ind.fitness)
|
||||
|
||||
|
||||
def search(
|
||||
seed_root: dom.Node,
|
||||
programme_dir: str | Path,
|
||||
budget: int = 2000,
|
||||
pop_size: int = 8,
|
||||
child_budget: int = 80,
|
||||
seed_budget: int = 200,
|
||||
p_crossover: float = 0.2,
|
||||
seed: int = 0,
|
||||
types: list[str] | None = None,
|
||||
inner_kw: dict | None = None,
|
||||
urb_root=None,
|
||||
log=None,
|
||||
) -> 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``."""
|
||||
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 {}))
|
||||
if types is None:
|
||||
reqs = programme.load_programme(str(Path(programme_dir) / "patterns.config"))
|
||||
types = sorted(reqs) + ["c", "o"]
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
if log:
|
||||
log(msg)
|
||||
|
||||
n_evals = 0
|
||||
n_topologies = 0
|
||||
result = SearchResult(best=None, population=[], n_evals=0, n_topologies=0)
|
||||
|
||||
def admit(ind: Individual, pop: list[Individual]) -> None:
|
||||
nonlocal n_topologies
|
||||
n_topologies += 1
|
||||
if result.best is None or ind.fitness > result.best.fitness:
|
||||
result.best = ind
|
||||
result.history.append((n_evals, ind.fitness, ind.lineage))
|
||||
_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 any(abs(ind.fitness - p.fitness) <= 1e-9 * max(abs(p.fitness), 1e-300)
|
||||
for p in pop):
|
||||
return
|
||||
if len(pop) < pop_size:
|
||||
pop.append(ind)
|
||||
return
|
||||
worst = min(range(len(pop)), key=lambda i: pop[i].fitness)
|
||||
if ind.fitness > pop[worst].fitness:
|
||||
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)
|
||||
|
||||
while n_evals < budget:
|
||||
if len(pop) >= 2 and rng.random() < p_crossover:
|
||||
a, b = _tournament(pop, rng), _tournament(pop, rng)
|
||||
child_root, _, desc = operators.crossover(a.root, b.root, rng)
|
||||
ratios = {**b.ratios, **a.ratios} # primary parent wins
|
||||
else:
|
||||
parent = _tournament(pop, rng)
|
||||
child_root, desc = operators.mutate(parent.root, rng, types,
|
||||
weights=_MUTATION_WEIGHTS)
|
||||
ratios = parent.ratios
|
||||
x0 = innerloop.warm_x0(child_root, ratios)
|
||||
child, used = _evaluate(child_root, programme_dir, urb_root,
|
||||
x0=x0, budget=child_budget,
|
||||
inner_kw=inner_kw, lineage=desc)
|
||||
n_evals += used
|
||||
admit(child, pop)
|
||||
|
||||
result.population = sorted(pop, key=lambda i: -i.fitness)
|
||||
result.n_evals = n_evals
|
||||
result.n_topologies = n_topologies
|
||||
return result
|
||||
|
|
@ -31,6 +31,27 @@ from . import dom, oracle, solver
|
|||
_EPS = 0.02 # keep cuts off the edges; matches solver/_experiments convention
|
||||
|
||||
|
||||
def free_with_keys(root: dom.Node) -> list[tuple[tuple[int, str], dom.Node]]:
|
||||
"""``solver.free_branches`` order, with (level_index, id-path) keys that
|
||||
survive deepcopy and structural mutation — the currency of Lamarckian
|
||||
ratio inheritance (cuts that survive a topology move keep their values)."""
|
||||
out = []
|
||||
for li, lvl in enumerate(dom.levels(root)):
|
||||
for b in solver._branches(lvl):
|
||||
if b.below is None or not b.below.divided:
|
||||
out.append(((li, b.id), b))
|
||||
return out
|
||||
|
||||
|
||||
def ratio_map(root: dom.Node) -> dict[tuple[int, str], float]:
|
||||
return {k: b.division[0] for k, b in free_with_keys(root)}
|
||||
|
||||
|
||||
def warm_x0(root: dom.Node, ratios: dict[tuple[int, str], float]) -> np.ndarray:
|
||||
"""Warm-start vector for ``root``: surviving cuts inherit, new cuts 0.5."""
|
||||
return np.array([ratios.get(k, 0.5) for k, _ in free_with_keys(root)])
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
x: np.ndarray # best equal-offset ratios, aligned with solver.free_branches(root)
|
||||
|
|
|
|||
89
tests/test_driver.py
Normal file
89
tests/test_driver.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Driver tests with a faked inner loop (no oracle, no perl)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from homemaker import dom, driver, innerloop, solver
|
||||
|
||||
CORPUS = Path("/home/bruno/src/urb/examples/programme-house")
|
||||
SEED_FILE = CORPUS / "c964435454c459f86c3ed9a5a7621132.dom"
|
||||
|
||||
pytestmark = pytest.mark.skipif(not CORPUS.is_dir(), reason="Urb corpus not available")
|
||||
|
||||
|
||||
def test_free_with_keys_aligns_with_free_branches():
|
||||
for f in sorted(CORPUS.glob("*.dom")):
|
||||
root = dom.load(str(f))
|
||||
assert [b for _, b in innerloop.free_with_keys(root)] == solver.free_branches(root), f.name
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_inner(monkeypatch):
|
||||
"""Deterministic fake fitness: rewards leaf count up to 12; consumes the
|
||||
full budget; applies a recognisable ratio so Lamarckian write-back is
|
||||
observable."""
|
||||
calls = []
|
||||
|
||||
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)) + 1e-6 * len(calls)
|
||||
calls.append({"budget": budget, "x0": x0, "kw": kw})
|
||||
for _, b in innerloop.free_with_keys(root):
|
||||
b.division = [0.25, 0.25]
|
||||
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)
|
||||
return calls
|
||||
|
||||
|
||||
def test_search_respects_budget_and_logs(fake_inner):
|
||||
seed_root = dom.load(str(SEED_FILE))
|
||||
lines = []
|
||||
r = driver.search(seed_root, CORPUS, budget=1000, pop_size=4,
|
||||
child_budget=80, seed_budget=120, seed=1, log=lines.append)
|
||||
# budget accounting: seed (120) + children (80 each), stop at >= 1000
|
||||
assert r.n_evals >= 1000
|
||||
assert r.n_evals == 120 + 80 * ((r.n_evals - 120) // 80)
|
||||
assert r.n_evals - 1000 < 80
|
||||
assert r.n_topologies == 1 + (r.n_evals - 120) // 80
|
||||
assert lines, "improvements must be logged"
|
||||
# history monotone in evals and fitness
|
||||
evs = [h[0] for h in r.history]
|
||||
fits = [h[1] for h in r.history]
|
||||
assert evs == sorted(evs)
|
||||
assert fits == sorted(fits)
|
||||
assert r.best.fitness == max(fits)
|
||||
assert len(r.population) <= 4
|
||||
# Lamarckian write-back observable in the best individual
|
||||
assert all(b.division == [0.25, 0.25] for _, b in innerloop.free_with_keys(r.best.root))
|
||||
|
||||
|
||||
def test_search_children_warm_start_and_local_sigma(fake_inner):
|
||||
seed_root = dom.load(str(SEED_FILE))
|
||||
driver.search(seed_root, CORPUS, budget=500, pop_size=4,
|
||||
child_budget=60, seed_budget=100, seed=0)
|
||||
seed_call, child_calls = fake_inner[0], fake_inner[1:]
|
||||
assert seed_call["x0"] is None and seed_call["budget"] == 100
|
||||
assert child_calls
|
||||
for c in child_calls:
|
||||
assert c["budget"] == 60
|
||||
assert c["x0"] is not None # warm-started
|
||||
# inherited cuts carry the parent's written-back ratios
|
||||
assert np.isin(c["x0"], [0.25, 0.5]).all()
|
||||
assert c["kw"].get("sigmas") == (0.05,)
|
||||
|
||||
|
||||
def test_best_root_dumps_valid_dom(fake_inner, tmp_path):
|
||||
seed_root = dom.load(str(SEED_FILE))
|
||||
r = driver.search(seed_root, CORPUS, budget=400, pop_size=3,
|
||||
child_budget=60, seed_budget=100, seed=2)
|
||||
out = tmp_path / "best.dom"
|
||||
dom.dump(r.best.root, str(out))
|
||||
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))
|
||||
Loading…
Add table
Reference in a new issue