From a0f56b7cdf106c4a2f7cc2e765f29c8f12bc545e Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sun, 14 Jun 2026 07:48:13 +0100 Subject: [PATCH] Add homemaker-evolve CLI tool (homemaker-py-2wc) python -m homemaker.evolve seed.dom [--budget N] [--pop N] [--workers N] ... Installed as homemaker-evolve entry point via pyproject.toml. Takes a single .dom file; infers programme dir from its parent. All parameters available as --flags or HOMEMAKER_* env vars. Output defaults to _evolved.dom; use --output - for stdout. SIGINT/SIGTERM returns best-so-far via new driver.SearchResult.interrupted flag. Also adds dom.dumps() for string serialisation and refactors dom.dump() to use it. Co-Authored-By: Claude Sonnet 4.6 --- .beads/issues.jsonl | 14 ++-- pyproject.toml | 3 + src/homemaker/dom.py | 10 ++- src/homemaker/driver.py | 6 ++ src/homemaker/evolve.py | 154 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 src/homemaker/evolve.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index de92c71..a381269 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -17,19 +17,21 @@ {"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":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:59Z","created_by":"Bruno Postle","updated_at":"2026-06-13T08:48:13Z","started_at":"2026-06-12T21:22:15Z","closed_at":"2026-06-13T08:48:13Z","close_reason":"Bake-off complete: CMA-ES confirmed as Phase 1/2 optimiser. NM wins quality per eval but sequential architecture incompatible with batching (§4.6). Compass stalls on narrow valleys. Results in DESIGN.md §8.3 and experiments/bakeoff_innerloop.*","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} +{"id":"homemaker-py-n5k","title":"Config inheritance: load parent patterns.config/costs.config as base layer","description":"urb-evolve.pl walks up one directory level and loads ../patterns.config and ../costs.config as a base configuration before merging the programme directory's own files on top (local keys win). homemaker-evolve and fitness.load_config should replicate this: when loading a programme directory, first check the parent for each config file and load it, then deep-merge the local file over the top. This lets shared defaults live in a project root while individual programmes only override what differs.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-14T06:22:38Z","created_by":"Bruno Postle","updated_at":"2026-06-14T06:22:38Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-9t6","title":"Package install: pyproject.toml with entry points","description":"The project currently requires PYTHONPATH=/home/bruno/src/homemaker-py/src and is run via 'python3 experiments/...'. There is no installable package. Add a pyproject.toml with: package discovery for src/homemaker/, a [project.scripts] entry point for homemaker-evolve (homemaker-py-2wc), and minimal metadata. After 'pip install -e .' the tool should be on PATH and importable without PYTHONPATH. Keep the existing pyproject.toml if one exists and extend it.","acceptance_criteria":"'pip install -e .' succeeds; 'homemaker-evolve --help' works from any directory; 'import homemaker' works without PYTHONPATH","status":"open","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-13T21:52:35Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:52:35Z","dependencies":[{"issue_id":"homemaker-py-9t6","depends_on_id":"homemaker-py-2wc","type":"blocks","created_at":"2026-06-13T22:52:41Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-gug","title":"Test suite","description":"There are no automated tests. Validation has been done entirely through experiment scripts and the 35-file corpus parity check (homemaker-py-uxz). This is acceptable during exploration but fragile as the codebase grows. Need pytest-based unit tests covering: geometry port correctness (vs known values, not just vs oracle), fitness term correctness (size/width/proportion/adjacency/access/crinkliness/stair terms individually), genome operators (mutations preserve tree invariants), inner loop (convergence on known landscape), and a fast corpus smoke test (subset of the 35 files, score within tolerance). The corpus parity experiment can be the integration test baseline.","acceptance_criteria":"pytest runs clean; geometry, fitness terms, operators, and inner loop each have unit tests; corpus smoke test covers at least 5 files","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T21:52:31Z","created_by":"Bruno Postle","updated_at":"2026-06-13T22:51:04Z","started_at":"2026-06-13T22:40:56Z","closed_at":"2026-06-13T22:51:04Z","close_reason":"Added test_geometry.py (26 tests) and test_fitness.py (35 tests); full suite now 175 tests, all passing","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-5l6","title":"Parallelise outer search population evaluation","description":"The outer memetic search evaluates topologies sequentially. Each eval runs the inner loop (CMA-ES) to convergence — independent across population members. Native fitness is pure Python with no shared mutable state, so population evaluation is embarrassingly parallel. multiprocessing.Pool or concurrent.futures.ProcessPoolExecutor over the child generation batch would give near-linear speedup with population size. At 71.8 evals/s single-threaded on a seeded programme-house run, parallelisation across available cores would proportionally increase the effective budget within the same wall-clock time.","acceptance_criteria":"Population generation parallelised; throughput scales with core count; verified correct (same result distribution as serial)","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T21:52:29Z","created_by":"Bruno Postle","updated_at":"2026-06-14T05:55:16Z","started_at":"2026-06-14T05:37:13Z","closed_at":"2026-06-14T05:55:16Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-d6d","title":"Revisit Nelder-Mead for inner loop (post-oracle)","description":"The Phase 1 bakeoff (homemaker-py-d0s) chose CMA-ES over Nelder-Mead because CMA batches oracle calls (18 vs 200 per topology) — critical when oracle cost is 1 s/dom. That constraint is gone: native fitness evaluates at 71.8 evals/s with no batching penalty. The bakeoff showed NM wins quality per eval by +15% at budget 200 (x1.56 vs x1.41 gain). NM is also simpler, has no hyperparameters, and is inherently sequential which matches the inner loop's single-topology use. Re-run the bakeoff with native fitness; if NM still wins, swap it in. Also evaluate gradient-based optimisation (autograd through the native fitness functions) as a potential further improvement.","acceptance_criteria":"Bakeoff re-run with native fitness; inner loop updated if NM or gradient method outperforms CMA-ES; gain improvement documented","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-13T21:52:27Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:52:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"homemaker-py-2wc","title":"CLI tool: homemaker-evolve (equivalent to urb-evolve.pl)","description":"Wrap the existing memetic search driver as a proper command-line tool, analogous to urb-evolve.pl. The tool should: accept a programme directory and optional seed .dom file as positional args; honour env vars for budget/population (MAX_ITERATIONS, MAX_POP or equivalents); write the best .dom found to the programme directory (or stdout); print progress to stderr; handle SIGINT/SIGTERM gracefully (write best-so-far and exit cleanly). The bulk of the logic already exists in driver.py and experiments/run_search_scaled.py — this is a thin wrapper that makes the search usable from the shell and composable with other tools. Install as bin/homemaker-evolve or src/homemaker/bin/homemaker-evolve.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-13T21:47:55Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:47:55Z","dependency_count":0,"dependent_count":1,"comment_count":0} +{"id":"homemaker-py-2wc","title":"CLI tool: homemaker-evolve (equivalent to urb-evolve.pl)","description":"Wrap the existing memetic search driver as a proper command-line tool, analogous to urb-evolve.pl. The tool should: accept a programme directory and optional seed .dom file as positional args; honour env vars for budget/population (MAX_ITERATIONS, MAX_POP or equivalents); write the best .dom found to the programme directory (or stdout); print progress to stderr; handle SIGINT/SIGTERM gracefully (write best-so-far and exit cleanly). The bulk of the logic already exists in driver.py and experiments/run_search_scaled.py — this is a thin wrapper that makes the search usable from the shell and composable with other tools. Install as bin/homemaker-evolve or src/homemaker/bin/homemaker-evolve.","status":"in_progress","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T21:47:55Z","created_by":"Bruno Postle","updated_at":"2026-06-14T06:01:30Z","started_at":"2026-06-14T06:01:30Z","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-8fe","title":"Fix Urb programme width default (upstream of homemaker-py-can fix)","description":"The native fitness fix in homemaker-py-can derives a sane width from sqrt(size/proportion) when a programme space has no explicit width. The same bug exists upstream in Perl Urb: Fitness/Base.pm and ProgrammeDriven.pm fall back to width_inside [4.0, 1.0] for any programme space without an explicit width key. Fix the Perl oracle to match the native behaviour (same sqrt(size/proportion) formula).","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T21:18:19Z","created_by":"Bruno Postle","updated_at":"2026-06-13T22:14:17Z","started_at":"2026-06-13T21:43:33Z","closed_at":"2026-06-13T22:14:17Z","close_reason":"Fixed: get_space_params now derives width from sqrt(size/proportion) when no explicit width key is present. 34/36 corpus files score higher with the fix; all 111 tests pass after rescoring with URB_NO_OCCLUSION=1.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-can","title":"Programme width defaults: t3 contradiction (impossible width_inside default)","description":"DESIGN.md §8.2, confirmed in source. t3 (3 m2 WC) has no width spec so inherits width_inside [4.0, 1.0] (Fitness/Base.pm:60) — geometrically impossible; designs 'pass' only by failing size instead. Fix AFTER faithful-port validation (port-faithfully-first policy, §8.1): a sane width default scaled to area (e.g. sqrt(area/proportion)) or per-room widths in patterns.config. Applies to native fitness; optionally upstream to Urb.","acceptance_criteria":"No programme space has a default width incompatible with its target area; corpus re-scored and effect documented","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:01Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:21:37Z","started_at":"2026-06-13T21:16:11Z","closed_at":"2026-06-13T21:21:37Z","close_reason":"Fixed in get_space_params: when a programme space has no explicit 'width', derive target from sqrt(size/proportion) instead of falling back to width_inside [4.0, 1.0]. Re-scored 35-file corpus: 32 files improved (+1-121%), 5 files lost spurious width fails. All 109 tests pass. Upstream Perl fix tracked as homemaker-py-8fe.","dependencies":[{"issue_id":"homemaker-py-can","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:47Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-yg5","title":"Penalty reshaping: replace 0.5^n while preserving inner-loop protection","description":"DESIGN.md §4.7, §5.4, §7 Phase 4, §8.5. The 0.5^n cliff gives the outer search no gradient and rewards flag-count over geometry, but it also PROTECTS the inner loop from trading into new failures (§4.5). One fitness shape cannot naively be both soft outside and cliff-protected inside. Candidates: cliff-inside-inner-loop only, lexicographic (failure count first, score second), additive/soft, multi-objective Pareto. Must preserve the missing-space failure hierarchy (worse to drop a room than to have a poor one). Measure landscape + search outcomes; this helps Urb today too.","acceptance_criteria":"Chosen scheme documented with measurements: search improves while inner loop still never trades into new failures","status":"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":"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":"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-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":"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":"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":"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":"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":"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."} +{"_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."} diff --git a/pyproject.toml b/pyproject.toml index 8699135..773de2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,9 @@ dependencies = [ "cma>=3.0", ] +[project.scripts] +homemaker-evolve = "homemaker.evolve:main" + [project.optional-dependencies] dev = ["pytest>=8.0", "ruff>=0.5"] diff --git a/src/homemaker/dom.py b/src/homemaker/dom.py index 516fd98..a1dd526 100644 --- a/src/homemaker/dom.py +++ b/src/homemaker/dom.py @@ -195,11 +195,15 @@ def _emit(n: Node, is_level_root: bool) -> dict: return d +def dumps(root: Node) -> str: + return yaml.safe_dump( + _emit(root, True), default_flow_style=False, sort_keys=False, allow_unicode=True + ) + + def dump(root: Node, path: str) -> None: with open(path, "w") as fh: - yaml.safe_dump( - _emit(root, True), fh, default_flow_style=False, sort_keys=False, allow_unicode=True - ) + fh.write(dumps(root)) # --------------------------------------------------------------------------- # diff --git a/src/homemaker/driver.py b/src/homemaker/driver.py index aac93b5..a923fd5 100644 --- a/src/homemaker/driver.py +++ b/src/homemaker/driver.py @@ -73,6 +73,7 @@ class SearchResult: n_topologies: int history: list[tuple[int, float, str]] = field(default_factory=list) # (oracle evals consumed, new best fitness, lineage) per improvement + interrupted: bool = False def random_topology(seed_root: dom.Node, n_leaves: int, @@ -211,6 +212,7 @@ def search( n_evals += used admit(ind, pop) + interrupted = False try: if do_bootstrap: # Bootstrap: diverse initial population from random topologies. @@ -253,6 +255,9 @@ def search( x0 = innerloop.warm_x0(child_root, ratios) tasks.append((child_root, x0, child_budget, inner_kw, desc)) _run_batch(tasks) + except KeyboardInterrupt: + interrupted = True + _log(f"[{n_evals:6d} evals] interrupted — returning best-so-far") finally: if _pool is not None: _pool.shutdown(wait=True) @@ -260,4 +265,5 @@ def search( result.population = sorted(pop, key=lambda i: -i.fitness) result.n_evals = n_evals result.n_topologies = n_topologies + result.interrupted = interrupted return result diff --git a/src/homemaker/evolve.py b/src/homemaker/evolve.py new file mode 100644 index 0000000..2eb7119 --- /dev/null +++ b/src/homemaker/evolve.py @@ -0,0 +1,154 @@ +"""homemaker-evolve — memetic layout search starting from a .dom file. + +Usage (module): + python -m homemaker.evolve seed.dom [options] + +When installed via pip install -e .: + homemaker-evolve seed.dom [options] + +Positional arguments: + seed.dom starting design file + +Options: + --programme-dir DIR programme directory with patterns.config / costs.config + (default: parent directory of seed.dom) + --budget N evaluation budget (default: $HOMEMAKER_BUDGET or 20000) + --pop N population size (default: $HOMEMAKER_POP or 16) + --child-budget N per-child budget (default: $HOMEMAKER_CHILD_BUDGET or 80) + --workers N parallel workers (default: $HOMEMAKER_WORKERS or 1) + --seed N RNG seed (default: $HOMEMAKER_SEED or 0) + --output PATH output .dom path (default: _evolved.dom + next to seed; use - for stdout) + +Progress is printed to stderr; the .dom is written on completion or interrupt. +""" + +from __future__ import annotations + +import argparse +import os +import signal +import sys +import time +from pathlib import Path + +from . import dom, driver + + +def _env_int(name: str, default: int) -> int: + v = os.environ.get(name) + return int(v) if v is not None else default + + +def _parse_args(argv=None) -> argparse.Namespace: + p = argparse.ArgumentParser( + prog="homemaker-evolve", + description="Memetic building-layout search over slicing trees.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p.add_argument("seed_dom", type=Path, + help="seed .dom file to start search from") + p.add_argument("--programme-dir", type=Path, default=None, metavar="DIR", + help="programme directory (default: parent of seed.dom)") + p.add_argument("--budget", type=int, + default=_env_int("HOMEMAKER_BUDGET", 20000), + metavar="N", help="evaluation budget") + p.add_argument("--pop", type=int, + default=_env_int("HOMEMAKER_POP", 16), + metavar="N", help="population size") + p.add_argument("--child-budget", type=int, + default=_env_int("HOMEMAKER_CHILD_BUDGET", 80), + metavar="N", help="per-child evaluation budget") + p.add_argument("--workers", type=int, + default=_env_int("HOMEMAKER_WORKERS", 1), + metavar="N", help="parallel worker processes") + p.add_argument("--seed", type=int, + default=_env_int("HOMEMAKER_SEED", 0), + metavar="N", help="RNG seed") + p.add_argument("--output", type=Path, default=None, metavar="PATH", + help="output .dom path (- for stdout)") + return p.parse_args(argv) + + +def main(argv=None) -> int: + args = _parse_args(argv) + + seed_file = args.seed_dom.resolve() + if not seed_file.exists(): + print(f"ERROR: seed not found: {seed_file}", file=sys.stderr) + return 1 + + programme_dir = (args.programme_dir or seed_file.parent).resolve() + if not programme_dir.is_dir(): + print(f"ERROR: not a directory: {programme_dir}", file=sys.stderr) + return 1 + if not (programme_dir / "patterns.config").exists(): + print(f"ERROR: no patterns.config in {programme_dir}", file=sys.stderr) + return 1 + + if args.output is None: + out: Path | None = seed_file.parent / (seed_file.stem + "_evolved.dom") + elif str(args.output) == "-": + out = None + else: + out = args.output.resolve() + + print(f"seed : {seed_file}", file=sys.stderr) + print(f"programme : {programme_dir.name}", file=sys.stderr) + print(f"budget : {args.budget}", file=sys.stderr) + print(f"pop : {args.pop}", file=sys.stderr) + print(f"child_budget : {args.child_budget}", file=sys.stderr) + print(f"workers : {args.workers}", file=sys.stderr) + print(f"rng seed : {args.seed}", file=sys.stderr) + print(f"output : {out or 'stdout'}", file=sys.stderr, flush=True) + + seed_root = dom.load(str(seed_file)) + t0 = time.perf_counter() + + # SIGTERM → KeyboardInterrupt so the driver's interrupt handler fires. + signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt())) + + r = driver.search( + seed_root, + programme_dir, + budget=args.budget, + pop_size=args.pop, + child_budget=args.child_budget, + p_crossover=0.2, + seed=args.seed, + n_workers=args.workers, + log=lambda m: print(m, file=sys.stderr, flush=True), + ) + + elapsed = time.perf_counter() - t0 + + print(file=sys.stderr) + status = "interrupted" if r.interrupted else "done" + print(f"--- {status} ---", file=sys.stderr) + print(f"elapsed : {elapsed:.1f}s", file=sys.stderr) + print(f"evals : {r.n_evals} across {r.n_topologies} topologies", + file=sys.stderr) + + if r.best is None: + print("ERROR: no result produced", file=sys.stderr) + return 1 + + print(f"best : {r.best.fitness:.6g} ({r.best.n_fails} fails) " + f"via {r.best.lineage}", file=sys.stderr) + + if r.history: + print("\nimprovement history:", file=sys.stderr) + for ev, fit_val, lin in r.history: + print(f" [{ev:6d}] {fit_val:.6g} ({lin})", file=sys.stderr) + + if out is None: + sys.stdout.write(dom.dumps(r.best.root)) + else: + dom.dump(r.best.root, str(out)) + print(f"written : {out}", file=sys.stderr) + + return 0 + + +if __name__ == "__main__": + sys.exit(main())