Compare commits
No commits in common. "bb50cb0c490ca8d0a511ba41734318260ceec8c8" and "3c8f7aba0785eba8d8521fc2f3a956cde901301a" have entirely different histories.
bb50cb0c49
...
3c8f7aba07
11 changed files with 9 additions and 1058 deletions
|
|
@ -9,8 +9,6 @@
|
|||
{"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-g0b","title":"homemaker-fitness: native Python CLI replacement for urb-fitness.pl","description":"We need a Python CLI tool that replicates the behaviour of urb-fitness.pl so we can score .dom files without shelling out to Perl. The tool should: accept .dom file paths as arguments (or glob *.dom in cwd if none given), load patterns.config and costs.config from cwd and parent dir (local overrides project-level), skip scoring if .score and .fails files are already newer than the .dom (unless FORCE_UPDATE env var is set), score each .dom using fitness.Fitness.score_with_fails(), write the score to \u003cdom\u003e.score (40-digit float format), write the failures to \u003cdom\u003e.fails, print the score to stderr. Expose as homemaker-fitness entry point in pyproject.toml and as python -m homemaker_layout.fitness_cmd module. This replaces the oracle.py shelling-out path for Phase 3 native fitness.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-14T12:32:29Z","created_by":"Bruno Postle","updated_at":"2026-06-14T16:17:21Z","started_at":"2026-06-14T12:32:52Z","closed_at":"2026-06-14T16:17:21Z","close_reason":"Implemented as homemaker_layout/fitness_cmd.py with homemaker-fitness entry point; exact score parity verified against urb-fitness.pl on corpus","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-gpx","title":"Native fitness parity gap on multi-storey designs (~3.7%)","description":"During programme-house cold-start runs with the fixed level_add operator, the generated 2-storey design showed native=1.2388e-04 vs oracle=1.1944e-04 (3.7% gap), exceeding the 0.01% rel_tol in test_native_fitness_score_parity. All existing single-storey corpus files pass parity fine (73/73). Hypothesis: a subtle discrepancy in value or cost computation for multi-level trees — candidates are staircase quality, circulation connectivity, or per-storey cost accumulation. To investigate: score a sweep of known multi-storey corpus files natively vs oracle and identify which term diverges.","status":"open","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-06-14T09:35:34Z","created_by":"Bruno Postle","updated_at":"2026-06-14T09:35:34Z","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":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T22:27:54Z","created_by":"Bruno Postle","updated_at":"2026-06-13T22:39:28Z","started_at":"2026-06-13T22:34:20Z","closed_at":"2026-06-13T22:39:28Z","close_reason":"Closed","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}
|
||||
|
|
@ -30,16 +28,10 @@
|
|||
{"id":"homemaker-py-yg5","title":"Penalty reshaping: replace 0.5^n while preserving inner-loop protection","description":"DESIGN.md §4.7, §5.4, §7 Phase 4, §8.5. The 0.5^n cliff gives the outer search no gradient and rewards flag-count over geometry, but it also PROTECTS the inner loop from trading into new failures (§4.5). One fitness shape cannot naively be both soft outside and cliff-protected inside. Candidates: cliff-inside-inner-loop only, lexicographic (failure count first, score second), additive/soft, multi-objective Pareto. Must preserve the missing-space failure hierarchy (worse to drop a room than to have a poor one). Measure landscape + search outcomes; this helps Urb today too.","acceptance_criteria":"Chosen scheme documented with measurements: search improves while inner loop still never trades into new failures","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:00Z","created_by":"Bruno Postle","updated_at":"2026-06-14T08:16:14Z","started_at":"2026-06-14T07:55:32Z","closed_at":"2026-06-14T08:16:14Z","close_reason":"Implemented lexicographic outer-search comparison (-n_fails, fitness). Inner loop unchanged (0.5^n cliff protection preserved). Experiment penalty_reshape.py confirms 0/9 fail regressions in inner loop and shows lex avoids the 3-fail trap that scalar hits 1/3 of the time. Fixed stale _CHILD_INNER_KW sigmas entry.","dependencies":[{"issue_id":"homemaker-py-yg5","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-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":"adjacency-in-binary-slicing-tree-is-structural-not","value":"Adjacency in binary slicing tree is structural, not geometric: the inner-loop NM cannot fix topological adjacency failures. Two paths exist: (1) tree-sibling adjacency — a node is adjacent to its sibling in the tree; (2) cross-zone geometric adjacency — leaves from different subtrees that happen to share a boundary. Staircase/adjacency fails require a topology mutation that changes which nodes are siblings or which zones touch. This was proved empirically on programme-house: staircase fail from rot=0 layout could not be fixed by NM but was fixed by level_retype creating a two-C topology (2026-06-14/15)."}
|
||||
{"_type":"memory","key":"cli-tool-style-prefer-python-m-homemaker-module","value":"CLI tool style: prefer python -m homemaker.module --parameters pattern, installable via pip install -e . with pyproject.toml entry_points. Not standalone bin/ scripts."}
|
||||
{"_type":"memory","key":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."}
|
||||
{"_type":"memory","key":"deceptive-valleys-in-topology-search-when-every-single","value":"Deceptive valleys in topology search: when every single-step mutation from a target state passes through a high-fail intermediary (e.g. level_fix displaces a room into 5+ new fails), a compound operator that atomically applies two coordinated changes can escape. Design compound operators to land on the low-fail state directly, bypassing the deceptive gradient. Programme-house example: level_compound_fix atomically moves the level-constrained room AND re-inserts the displaced room adjacent to C in one step (operators.py, 2026-06-14)."}
|
||||
{"_type":"memory","key":"urb-fitness-bug-found-fixed-2026-06-12","value":"Urb fitness bug found+fixed 2026-06-12 (patch in /home/bruno/src/urb, uncommitted): ProgrammeDriven.pm ratio_o/ratio_type grepped case-insensitively over the ratios hash and took the FIRST key — nondeterministic (x4.5 score swings) for designs with mixed-case type classes (both 'c' circulation and 'C' covered). Fixed to SUM the class (matches Is_Circulation//Is_Outside semantics); 35/35 corpus scores unchanged. CRITICAL for homemaker-py-3y7/gnw: the native port must implement class-SUM ratios. Building.pm has the same unpatched pattern (site-driven path, not used by our oracle). Also: the memetic search reward-hacked this bug before the fix — search results predating it are noise artifacts."}
|
||||
{"_type":"memory","key":"user-preference-bruno-this-is-a-fedora-system","value":"User preference (Bruno): this is a Fedora system — NEVER install Python packages via pip without asking first; always ask whether to install the rpm via dnf (e.g. python3-cma) before considering pip. Applies to any dependency additions."}
|
||||
{"_type":"memory","key":"warm-x0-initialization-bug-pattern-when-a-topology","value":"warm_x0 initialization bug pattern: when a topology operator explicitly sets division ratios on a newly-created node (e.g. compound_fix sets node.division=[0.25,0.25] for t3), parent.ratios has no entry for that node (it was a leaf). warm_x0 defaults it to 0.5, corrupting the inner loop's starting point and making the operator invisible to lex comparison. Fix: only propagate child ratios for nodes where the parent node was NOT already divided; stale hidden nodes revealed by structural mutations (swap flipping b.below) must NOT contribute their pre-writeback values. See driver.py lines 259-267 (fixed 2026-06-14)."}
|
||||
{"_type":"memory","key":"never-use-corpus-filenames-candidate-001-dom-candidate","value":"Never use corpus filenames (candidate-001.dom, candidate-002.dom, generated.dom, init.dom, etc.) as --output targets when running experiments. These are test fixtures. Always write experimental outputs to scratch/ or a timestamped path. Lesson from 2026-06-14: warm-start runs overwrote candidate-001/002.dom and broke graph tests."}
|
||||
{"_type":"memory","key":"programme-house-optimisation-result-2026-06-14-15","value":"Programme-house optimisation result (2026-06-14/15): best achievable is 1 fail (l1 wrong level, score ~0.005). 0 fails is geometrically impossible: l1 (min 27m²) must occupy ll (~23m²) at level 0, which eliminates the t3-adj-C provider; dividing ll into lll(l1)+llr(C) gives llr proportion ~6:1 (fails). Python memetic optimizer achieves 1 fail in 50k evals vs Perl optimiser's 2-3 fails. Winning topology: TWO C nodes at level 0 — ll(C) for t3-adj-C via geometric contact, rl(C) for staircase via tree-sibling adjacency to rrr(O). Best .dom: scratch/from-warmstart-fixed.dom and scratch/from-compound3-fixed.dom."}
|
||||
{"_type":"memory","key":"homemaker-py-pythonpath-set-pythonpath-home-bruno-src","value":"homemaker-layout PYTHONPATH: package installed as 'homemaker-layout' via pip install -e . so 'import homemaker_layout' works from anywhere without PYTHONPATH. For running tests use 'python -m pytest' from project root /home/bruno/src/homemaker-layout (pyproject.toml adds src/ automatically). Never try pip show homemaker — that's the old homemaker-addon conflict."}
|
||||
{"_type":"memory","key":"multi-storey-staircase-consistency-when-dividing-or-retyping","value":"Multi-storey staircase consistency: when dividing or retyping a circulation (C) leaf at one level, the same structural change should be propagated to the matching leaf on ALL other storeys so the stair core path is maintained. The optimizer cannot fix staircase disruptions through trial-and-error geometry alone — it requires a synchronized multi-level operator that applies the same topology change to every storey simultaneously."}
|
||||
{"_type":"memory","key":"strategy-decision-2026-06-12-bruno-occlusion-daylight","value":"Strategy decision 2026-06-12 (Bruno): occlusion/daylight is ORTHOGONAL to building a scalable optimiser. Disable it in Urb (env flag, homemaker-py-gp2) rather than port it; native fitness uses simple crinkliness (illumination factor = 1); rebuild occlusion in Python only after optimisation is fully native (homemaker-py-2g5, now P4). Consequence: all scores change when the flag flips — re-baseline corpus/.score, DESIGN \\$4.5 gains, gate bars at one clean boundary AFTER homemaker-py-1p0 closes; Phase-2 urb-evolve benchmark must run with the same flag."}
|
||||
{"_type":"memory","key":"urb-oracle-nondeterminism-urb-fitness-pl-output-varies","value":"Urb oracle nondeterminism: urb-fitness.pl output varies run-to-run from Perl hash-order randomisation — .fails line ORDER shuffles (compare sorted, use oracle.Score.fail_lines) and the score float can flip by ~1 ULP (compare with math.isclose rel_tol=1e-12, never ==). Not a batching artifact; affects single runs too. Matters for the Phase 3 native-fitness parity gate (homemaker-py-uxz)."}
|
||||
{"_type":"memory","key":"user-preference-bruno-this-is-a-fedora-system","value":"User preference (Bruno): this is a Fedora system — NEVER install Python packages via pip without asking first; always ask whether to install the rpm via dnf (e.g. python3-cma) before considering pip. Applies to any dependency additions."}
|
||||
{"_type":"memory","key":"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":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."}
|
||||
{"_type":"memory","key":"homemaker-py-pythonpath-set-pythonpath-home-bruno-src","value":"homemaker-layout PYTHONPATH: package installed as 'homemaker-layout' via pip install -e . so 'import homemaker_layout' works from anywhere without PYTHONPATH. For running tests use 'python -m pytest' from project root /home/bruno/src/homemaker-layout (pyproject.toml adds src/ automatically). Never try pip show homemaker — that's the old homemaker-addon conflict."}
|
||||
|
|
|
|||
87
DESIGN.md
87
DESIGN.md
|
|
@ -210,8 +210,6 @@ Urb today and homemaker tomorrow.
|
|||
|
||||
### 4.9 Penalty reshaping decision: lexicographic outer search (measured 2026-06-14)
|
||||
|
||||
|
||||
|
||||
`experiments/penalty_reshape.py`, `URB_NO_OCCLUSION=1`, programme-house.
|
||||
|
||||
**Inner-loop protection** (nm_search, budget 80, 3 files × 3 seeds = 9 runs):
|
||||
|
|
@ -242,91 +240,6 @@ serendipitous runs where scalar also stays in the 2-fail tier).
|
|||
**Decision: lexicographic. `0.5^n` stays in the fitness scalar (inner loop
|
||||
unchanged). Outer search uses `(-n_fails, fitness)` as comparison key.**
|
||||
|
||||
### 4.10 Deceptive level-fix valley and compound operators (measured 2026-06-14/15)
|
||||
|
||||
**Context:** programme-house, Phase 3 native fitness + Phase 4 lex search, seed
|
||||
`warmstart-2f4.dom` (best Phase-3 result, 2 fails at score 0.032). Goal: reach
|
||||
≤ 1 fail, beating the Perl optimiser (2–3 fails).
|
||||
|
||||
**The deceptive valley.** The 2-fail state has l1 (living room, min 27 m²,
|
||||
required level 0) on level 1. The obvious repair is `level_fix`: swap l1 with a
|
||||
leaf on level 0. But every single-step `level_fix` move creates 5+ new fails
|
||||
because the displaced room (t3, the WC) is dropped into an arbitrary slot that
|
||||
violates adjacency, size, and access constraints simultaneously. The lex
|
||||
comparator (`-n_fails, fitness`) correctly rejects these — but the result is that
|
||||
the 2-fail state appears completely surrounded by ≥ 5-fail states, and the search
|
||||
stalls. This is a textbook deceptive valley: the fitness gradient points away from
|
||||
the global optimum.
|
||||
|
||||
**Compound operator.** `mutate_level_compound_fix` (added `operators.py`) escapes
|
||||
the valley by doing two things atomically:
|
||||
|
||||
1. Move l1 to level 0 by swapping it with the *largest* leaf there (the
|
||||
circulation C node, because C is generic and can absorb the swap without
|
||||
producing a new structural failure).
|
||||
2. Re-insert the displaced t3 by dividing the sibling of that C node (so t3
|
||||
lands adjacent to C, satisfying the adjacency requirement).
|
||||
|
||||
The new split gets `division=[0.25,0.25]` (giving t3 ≈ 3.4 m², barely in range)
|
||||
and `rotation=0` (t3 on the left, adjacent to the C sibling).
|
||||
|
||||
**The `warm_x0` initialization bug.** The compound operator sets specific ratios
|
||||
on a newly-created split node. But `driver.py` was initialising the NM inner loop
|
||||
from `parent.ratios`, which has no entry for the new node (it was a leaf).
|
||||
`warm_x0` defaulted the new node to 0.5, giving t3 ≈ 6.8 m² — a size fail —
|
||||
so NM started at 3 fails instead of 1. Lex then always rejected the compound
|
||||
child; `level_compound_fix` was completely invisible to the outer search for
|
||||
~12 000 evals (until `warm_x0` was fixed).
|
||||
|
||||
The correct fix distinguishes genuinely-new split nodes from stale hidden nodes
|
||||
that become visible after structural mutations (e.g. `swap` can flip a `b.below`
|
||||
pointer, revealing pre-writeback division values from a different topology). Only
|
||||
use the child's explicit ratio for node `(li, path)` if the matching node in the
|
||||
parent was *not already divided*; everything else falls through to `parent.ratios`
|
||||
or defaults to 0.5. Fix in `driver.py` lines 259–267.
|
||||
|
||||
**Results (50 000 evals each, pop 8, child_budget 80, 4 workers):**
|
||||
|
||||
| seed | event | eval | fails | score |
|
||||
|------|-------|------|-------|-------|
|
||||
| warmstart-2f4 | seed | 200 | 2 | 0.032 |
|
||||
| warmstart-2f4 | `level_compound_fix` fires | 12 280 | 1 | 0.000122 |
|
||||
| warmstart-2f4 | `level_retype 0/ll<->1/l` | 17 880 | 1 | 0.00497 |
|
||||
| warmstart-2f4 | final | 50 040 | **1** | **0.00518** |
|
||||
| compound3-raw | seed (1-fail hand-built) | 200 | 1 | 0.000118 |
|
||||
| compound3-raw | `level_retype 0/ll<->1/l` | 18 360 | 1 | 0.00383 |
|
||||
| compound3-raw | final | 50 040 | **1** | **0.00523** |
|
||||
|
||||
Perl optimiser reference: **2–3 fails**.
|
||||
|
||||
**The two-C topology breakthrough.** After `level_compound_fix` fires, the
|
||||
topology is: level 0 = `ll(l1), lr(t2), rl(C), rrl(t3), rrr(O)` — but now l1
|
||||
is at level 0 (correct) and t3 is adjacent to rl(C) (staircase). However l1
|
||||
is occupying ll, and rl(C) is the staircase core — so t3-adj-C is satisfied
|
||||
via rl, but there is no second C to satisfy staircase independently. Score
|
||||
≈ 0.000157 (1 fail).
|
||||
|
||||
At eval ≈ 18 000, `level_retype 0/ll<->1/l` (swap the type of ll on level 0
|
||||
with l on level 1) creates a TWO-C configuration at level 0:
|
||||
`ll(C), lr(t2), rl(C), rrl(t3), rrr(O)`, with l1 moving to level 1. The score
|
||||
jumps 25× to ≈ 0.005. Why two C nodes work:
|
||||
|
||||
- `ll(C)` (bottom-left, 23 m²) satisfies t3-adj-C via geometric contact at the
|
||||
l/r zone boundary with `rrl(t3)`.
|
||||
- `rl(C)` (top-right, 8.5 m²) satisfies staircase adjacency via tree adjacency
|
||||
to `rrr(O)` (its right sibling when `r.rotation=3`).
|
||||
|
||||
Both constraints are simultaneously met because binary-tree sibling adjacency and
|
||||
cross-zone geometric adjacency provide *independent* paths.
|
||||
|
||||
**Why 0 fails is geometrically impossible on this programme + plot.** l1 needs
|
||||
min 27 m² at level 0. The only space large enough is `ll` (≈ 23 m², the entire
|
||||
left half of level 0). Putting l1 at `ll` removes the t3-adj-C provider.
|
||||
The alternative — dividing `ll` into `lll(l1)+llr(C)` — gives `llr` a proportion
|
||||
of ≈ 6:1 (width ≈ 0.73 m), failing both the proportion and width constraints.
|
||||
0 fails is not achievable on this programme+plot with a binary slicing tree
|
||||
representation; 1 fail is the geometric optimum.
|
||||
|
||||
---
|
||||
|
||||
## 5. Validated architecture
|
||||
|
|
|
|||
|
|
@ -1,148 +0,0 @@
|
|||
node:
|
||||
- - 0.0
|
||||
- 0.0
|
||||
- - 25.0
|
||||
- 2.0
|
||||
- - 23.0
|
||||
- 31.0
|
||||
- - 0.0
|
||||
- 31.0
|
||||
perimeter:
|
||||
a: private
|
||||
b: private
|
||||
c: null
|
||||
d: null
|
||||
rotation: 0
|
||||
division:
|
||||
- 0.5206890368316089
|
||||
- 0.5206890368316089
|
||||
height: 3.0
|
||||
elevation: 0.0
|
||||
wall_inner: 0.08
|
||||
wall_outer: 0.25
|
||||
l:
|
||||
rotation: 1
|
||||
division:
|
||||
- 0.4900271770215713
|
||||
- 0.4900271770215713
|
||||
l:
|
||||
rotation: 2
|
||||
division:
|
||||
- 0.28955830710300473
|
||||
- 0.28955830710300473
|
||||
l:
|
||||
rotation: 1
|
||||
division:
|
||||
- 0.5900577549923657
|
||||
- 0.5900577549923657
|
||||
l:
|
||||
rotation: 0
|
||||
division:
|
||||
- 0.5042565018475718
|
||||
- 0.5042565018475718
|
||||
l:
|
||||
type: st2
|
||||
rotation: 0
|
||||
r:
|
||||
type: st1
|
||||
rotation: 0
|
||||
r:
|
||||
rotation: 1
|
||||
division:
|
||||
- 0.5042565018475718
|
||||
- 0.5042565018475718
|
||||
l:
|
||||
type: O
|
||||
rotation: 0
|
||||
r:
|
||||
type: ut1
|
||||
rotation: 0
|
||||
r:
|
||||
rotation: 2
|
||||
division:
|
||||
- 0.5254855092815014
|
||||
- 0.5254855092815014
|
||||
l:
|
||||
rotation: 1
|
||||
division:
|
||||
- 0.7453088885860744
|
||||
- 0.7453088885860744
|
||||
l:
|
||||
rotation: 2
|
||||
division:
|
||||
- 0.5622884610865619
|
||||
- 0.5622884610865619
|
||||
l:
|
||||
type: k1
|
||||
rotation: 0
|
||||
r:
|
||||
rotation: 3
|
||||
division:
|
||||
- 0.545507016217933
|
||||
- 0.545507016217933
|
||||
l:
|
||||
type: ws1
|
||||
rotation: 0
|
||||
r:
|
||||
type: cr1
|
||||
rotation: 0
|
||||
r:
|
||||
type: O
|
||||
rotation: 0
|
||||
r:
|
||||
type: da1
|
||||
rotation: 0
|
||||
r:
|
||||
rotation: 2
|
||||
division:
|
||||
- 0.5017908930114854
|
||||
- 0.5017908930114854
|
||||
l:
|
||||
type: r
|
||||
rotation: 0
|
||||
r:
|
||||
type: li1
|
||||
rotation: 0
|
||||
r:
|
||||
rotation: 0
|
||||
division:
|
||||
- 0.4006976137653344
|
||||
- 0.4006976137653344
|
||||
l:
|
||||
rotation: 3
|
||||
division:
|
||||
- 0.48703066466032097
|
||||
- 0.48703066466032097
|
||||
l:
|
||||
rotation: 2
|
||||
division:
|
||||
- 0.5562346554544078
|
||||
- 0.5562346554544078
|
||||
l:
|
||||
type: m
|
||||
rotation: 0
|
||||
r:
|
||||
rotation: 2
|
||||
division:
|
||||
- 0.5042565018475718
|
||||
- 0.5042565018475718
|
||||
l:
|
||||
type: me1
|
||||
rotation: 0
|
||||
r:
|
||||
type: C
|
||||
rotation: 0
|
||||
r:
|
||||
rotation: 2
|
||||
division:
|
||||
- 0.6464404401824229
|
||||
- 0.6464404401824229
|
||||
l:
|
||||
type: O
|
||||
rotation: 0
|
||||
r:
|
||||
type: t
|
||||
rotation: 0
|
||||
r:
|
||||
type: r
|
||||
rotation: 3
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
---
|
||||
node:
|
||||
-
|
||||
- 0.0
|
||||
- 0.0
|
||||
-
|
||||
- 25.0
|
||||
- 2.0
|
||||
-
|
||||
- 23.0
|
||||
- 31.0
|
||||
-
|
||||
- 0.0
|
||||
- 31.0
|
||||
perimeter:
|
||||
a: private
|
||||
b: private
|
||||
c: ~
|
||||
d: ~
|
||||
type: O
|
||||
rotation: 0
|
||||
height: 3
|
||||
elevation: 0.0
|
||||
wall_inner: 0.08
|
||||
wall_outer: 0.25
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
---
|
||||
# Harbor House - Programme-driven building evolution (Simplified)
|
||||
# Two-story community living facility for 50+ residents
|
||||
# Using anonymous interchangeable rooms to reduce complexity
|
||||
|
||||
# Building programme: community living spaces
|
||||
spaces:
|
||||
# GROUND FLOOR - Main Community Areas
|
||||
cr1:
|
||||
name: Common Room with Fireplace
|
||||
size:
|
||||
- 80.0
|
||||
- 10.0
|
||||
width:
|
||||
- 6.0
|
||||
- 1.5
|
||||
proportion:
|
||||
- 2.0
|
||||
- 0.5
|
||||
level: 0
|
||||
adjacency:
|
||||
- c
|
||||
- o
|
||||
|
||||
ef1:
|
||||
name: Entrance Foyer
|
||||
size:
|
||||
- 15.0
|
||||
- 3.0
|
||||
width:
|
||||
- 3.0
|
||||
- 0.5
|
||||
level: 0
|
||||
adjacency:
|
||||
- c
|
||||
|
||||
da1:
|
||||
name: Dining Area
|
||||
size:
|
||||
- 60.0
|
||||
- 8.0
|
||||
width:
|
||||
- 5.0
|
||||
- 1.0
|
||||
proportion:
|
||||
- 2.5
|
||||
- 0.8
|
||||
level: 0
|
||||
adjacency:
|
||||
- c
|
||||
- k1
|
||||
- o
|
||||
|
||||
k1:
|
||||
name: Kitchen
|
||||
size:
|
||||
- 30.0
|
||||
- 5.0
|
||||
width:
|
||||
- 4.0
|
||||
- 1.0
|
||||
level: 0
|
||||
adjacency:
|
||||
- da1
|
||||
- c
|
||||
|
||||
# SLEEPING AREAS - Anonymous neighborhoods (5 total, 2 ground + 3 first)
|
||||
n:
|
||||
name: Neighborhood
|
||||
size:
|
||||
- 60.0
|
||||
- 8.0
|
||||
width:
|
||||
- 5.0
|
||||
- 1.0
|
||||
proportion:
|
||||
- 2.0
|
||||
- 0.6
|
||||
adjacency:
|
||||
- c
|
||||
count: 5
|
||||
|
||||
# GROUND FLOOR - Activity Spaces
|
||||
ws1:
|
||||
name: Workshop Space
|
||||
size:
|
||||
- 40.0
|
||||
- 6.0
|
||||
width:
|
||||
- 5.0
|
||||
- 1.0
|
||||
proportion:
|
||||
- 1.8
|
||||
- 0.5
|
||||
level: 0
|
||||
adjacency:
|
||||
- c
|
||||
- o
|
||||
|
||||
# MEETING ROOMS - Anonymous interchangeable (3 total)
|
||||
m:
|
||||
name: Meeting Room
|
||||
size:
|
||||
- 10.0
|
||||
- 2.0
|
||||
width:
|
||||
- 2.5
|
||||
- 0.5
|
||||
level: 0
|
||||
adjacency:
|
||||
- c
|
||||
count: 3
|
||||
|
||||
# GROUND FLOOR - Service Areas
|
||||
# BATHROOMS - Ground floor (3 total)
|
||||
t:
|
||||
name: Bathroom
|
||||
size:
|
||||
- 6.0
|
||||
- 1.5
|
||||
width:
|
||||
- 2.0
|
||||
- 0.5
|
||||
adjacency:
|
||||
- c
|
||||
count: 6 # 3 ground + 3 first floor
|
||||
|
||||
la1:
|
||||
name: Laundry Room
|
||||
size:
|
||||
- 20.0
|
||||
- 4.0
|
||||
width:
|
||||
- 3.0
|
||||
- 0.8
|
||||
level: 0
|
||||
adjacency:
|
||||
- c
|
||||
|
||||
st1:
|
||||
name: Ground Floor Storage
|
||||
size:
|
||||
- 22.0
|
||||
- 4.0
|
||||
width:
|
||||
- 3.0
|
||||
- 0.8
|
||||
level: 0
|
||||
adjacency:
|
||||
- c
|
||||
|
||||
me1:
|
||||
name: Mechanical/Electrical Room
|
||||
size:
|
||||
- 25.0
|
||||
- 4.0
|
||||
width:
|
||||
- 3.5
|
||||
- 0.8
|
||||
level: 0
|
||||
adjacency:
|
||||
- c
|
||||
|
||||
# STAFF OFFICES - Anonymous (2 total)
|
||||
of:
|
||||
name: Staff Office
|
||||
size:
|
||||
- 12.5
|
||||
- 2.5
|
||||
width:
|
||||
- 2.5
|
||||
- 0.5
|
||||
level: 0
|
||||
adjacency:
|
||||
- c
|
||||
count: 2
|
||||
|
||||
# FIRST FLOOR - Community Areas
|
||||
li1:
|
||||
name: Library Corner
|
||||
size:
|
||||
- 20.0
|
||||
- 4.0
|
||||
width:
|
||||
- 3.5
|
||||
- 0.8
|
||||
level: 1
|
||||
adjacency:
|
||||
- c
|
||||
|
||||
# FIRST FLOOR - Individual Rooms (10 anonymous rooms)
|
||||
r:
|
||||
name: Individual Room
|
||||
size:
|
||||
- 10.0
|
||||
- 2.0
|
||||
width:
|
||||
- 2.5
|
||||
- 0.5
|
||||
level: 1
|
||||
adjacency:
|
||||
- c
|
||||
count: 10
|
||||
|
||||
# FIRST FLOOR - Storage
|
||||
st2:
|
||||
name: First Floor Storage
|
||||
size:
|
||||
- 18.0
|
||||
- 3.5
|
||||
width:
|
||||
- 3.0
|
||||
- 0.8
|
||||
level: 1
|
||||
adjacency:
|
||||
- c
|
||||
|
||||
ut1:
|
||||
name: Utilities Closet
|
||||
size:
|
||||
- 14.0
|
||||
- 3.0
|
||||
width:
|
||||
- 2.5
|
||||
- 0.6
|
||||
level: 1
|
||||
adjacency:
|
||||
- c
|
||||
|
||||
# Building constraints
|
||||
storey_minimum: 2
|
||||
storey_limit: 3
|
||||
force_roof_garden: 0
|
||||
|
||||
# Circulation ratio (allow more circulation for larger building)
|
||||
ratio_circulation:
|
||||
- 0.08
|
||||
- 0.15
|
||||
|
||||
# Outside space ratio (courtyard requirement)
|
||||
ratio_outside:
|
||||
- 0.15
|
||||
- 0.10
|
||||
|
||||
# Staircase requirements (main + secondary)
|
||||
staircase_min: 2
|
||||
staircase_max: 2
|
||||
|
||||
# Economic parameters
|
||||
value_inside: 300.0
|
||||
value_circulation: 50.0
|
||||
value_outside: 100.0
|
||||
value_supported: 300.0
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
---
|
||||
node:
|
||||
-
|
||||
- 1.67100188077385
|
||||
- 11.9169190395339
|
||||
-
|
||||
- 8.72124918939699
|
||||
- 13.7119442286569
|
||||
-
|
||||
- 6.79637028658874
|
||||
- 21.3797854212389
|
||||
-
|
||||
- -0.42152047183069
|
||||
- 19.9368564494913
|
||||
perimeter:
|
||||
a: private
|
||||
b: ~
|
||||
c: ~
|
||||
d: ~
|
||||
type: O
|
||||
rotation: 0
|
||||
height: 3
|
||||
elevation: 0.0
|
||||
wall_inner: 0.08
|
||||
wall_outer: 0.25
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
---
|
||||
# Example patterns.config for programme-driven building evolution
|
||||
#
|
||||
# programme-house2: fixes for geometric feasibility on this plot (59 m²/floor)
|
||||
#
|
||||
# Fix 1: l1 level constraint removed — upper-floor LDK is achievable on a
|
||||
# tight urban plot; the level: 0 requirement caused 21/~100 corpus fails.
|
||||
# Fix 2: l1 size reduced from [35.0, 8.0] (min ~27 m²) to [25.0, 6.0]
|
||||
# (min ~19 m²) — 25 m² is realistic for a 2-bed house on this plot
|
||||
# and fits within a half-floor cell without competing with circulation.
|
||||
|
||||
# Building programme: 2-bedroom house with ensuite
|
||||
spaces:
|
||||
b1:
|
||||
name: Master Bedroom
|
||||
size:
|
||||
- 16.0
|
||||
- 4.0
|
||||
width:
|
||||
- 4.0
|
||||
- 1.0
|
||||
proportion:
|
||||
- 1.5
|
||||
- 0.5
|
||||
adjacency:
|
||||
- c
|
||||
t1:
|
||||
name: Ensuite Bathroom
|
||||
size:
|
||||
- 5.0
|
||||
- 1.5
|
||||
width:
|
||||
- 2.5
|
||||
- 0.5
|
||||
adjacency:
|
||||
- b1
|
||||
b2:
|
||||
name: Second Bedroom
|
||||
size:
|
||||
- 12.0
|
||||
- 3.0
|
||||
width:
|
||||
- 3.5
|
||||
- 0.8
|
||||
adjacency:
|
||||
- c
|
||||
t2:
|
||||
name: Guest Bathroom
|
||||
size:
|
||||
- 4.0
|
||||
- 1.5
|
||||
adjacency:
|
||||
- c
|
||||
l1:
|
||||
name: Living/Dining/Kitchen
|
||||
size:
|
||||
- 25.0
|
||||
- 6.0
|
||||
width:
|
||||
- 4.5
|
||||
- 1.0
|
||||
proportion:
|
||||
- 2.0
|
||||
- 0.8
|
||||
adjacency:
|
||||
- c
|
||||
t3:
|
||||
name: Ground Floor WC
|
||||
size:
|
||||
- 3.0
|
||||
- 1.0
|
||||
adjacency:
|
||||
- c
|
||||
level: 0
|
||||
|
||||
# Building constraints
|
||||
storey_minimum: 2
|
||||
storey_limit: 5
|
||||
force_roof_garden: 0
|
||||
|
||||
# Circulation ratio (penalise excessive corridors)
|
||||
ratio_circulation:
|
||||
- 0.00
|
||||
- 0.15
|
||||
|
||||
# Outside space ratio
|
||||
ratio_outside:
|
||||
- 0.30
|
||||
- 0.15
|
||||
|
||||
# Staircase requirements
|
||||
staircase_min: 1
|
||||
staircase_max: 1
|
||||
|
||||
# Other standard parameters can be included
|
||||
value_inside: 300.0
|
||||
value_circulation: 50.0
|
||||
value_outside: 100.0
|
||||
value_supported: 300.0
|
||||
|
|
@ -14,7 +14,6 @@ dependencies = [
|
|||
|
||||
[project.scripts]
|
||||
homemaker-evolve = "homemaker_layout.evolve:main"
|
||||
homemaker-fitness = "homemaker_layout.fitness_cmd:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0", "ruff>=0.5"]
|
||||
|
|
|
|||
|
|
@ -249,22 +249,8 @@ def search(
|
|||
else:
|
||||
parent = _tournament(pop, rng, _key)
|
||||
child_root, desc = operators.mutate(parent.root, rng, types,
|
||||
weights=_MUTATION_WEIGHTS,
|
||||
reqs=reqs)
|
||||
# Carry operator-specified ratios for nodes that are genuinely
|
||||
# newly divided (existed as leaves in the parent, are now
|
||||
# divided in the child). Structural mutations (e.g. swap) can
|
||||
# reveal previously-hidden nodes whose stale pre-writeback
|
||||
# ratios must NOT be propagated — those default to 0.5.
|
||||
parent_lvls = dom.levels(parent.root)
|
||||
new_splits = {
|
||||
(li, path): val
|
||||
for (li, path), val in innerloop.ratio_map(child_root).items()
|
||||
if li >= len(parent_lvls)
|
||||
or not (pn := parent_lvls[li].by_id(path))
|
||||
or not pn.divided
|
||||
}
|
||||
ratios = {**new_splits, **parent.ratios}
|
||||
weights=_MUTATION_WEIGHTS)
|
||||
ratios = parent.ratios
|
||||
x0 = innerloop.warm_x0(child_root, ratios)
|
||||
tasks.append((child_root, x0, child_budget, inner_kw, desc))
|
||||
_run_batch(tasks)
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
"""homemaker-fitness — native Python replacement for urb-fitness.pl.
|
||||
|
||||
Scores .dom files using the native fitness engine (fitness.Fitness) and writes
|
||||
.score / .fails side-car files in the same format as urb-fitness.pl.
|
||||
|
||||
Usage (module):
|
||||
python -m homemaker_layout.fitness_cmd [dom1.dom dom2.dom ...]
|
||||
|
||||
When installed via pip install -e .:
|
||||
homemaker-fitness [dom1.dom dom2.dom ...]
|
||||
|
||||
If no arguments are given, all *.dom files in the current directory are scored.
|
||||
|
||||
Scoring is skipped for a file if its .score and .fails side-cars already exist
|
||||
and are both newer than the .dom file and the config files — set FORCE_UPDATE=1
|
||||
to override (matches urb-fitness.pl behaviour).
|
||||
|
||||
Score is written to <dom>.score as a 40-decimal-place float; failures are
|
||||
written one-per-line to <dom>.fails. Score is also printed to stderr.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from . import dom as dom_mod
|
||||
from . import geometry
|
||||
from .fitness import Fitness, load_config
|
||||
|
||||
|
||||
def _config_mtime(directory: Path) -> float:
|
||||
"""Latest mtime of any patterns/costs config file visible to this directory."""
|
||||
mtime = 0.0
|
||||
for name in ("patterns.config", "costs.config"):
|
||||
for p in (directory.parent / name, directory / name):
|
||||
if p.is_file():
|
||||
t = p.stat().st_mtime
|
||||
if t > mtime:
|
||||
mtime = t
|
||||
return mtime
|
||||
|
||||
|
||||
def _should_score(dom_path: Path, config_mtime: float) -> bool:
|
||||
"""Return True if the dom needs (re-)scoring."""
|
||||
if os.environ.get("FORCE_UPDATE") or os.environ.get("DEBUG"):
|
||||
return True
|
||||
score_p = Path(f"{dom_path}.score")
|
||||
fails_p = Path(f"{dom_path}.fails")
|
||||
if not score_p.exists() or not fails_p.exists():
|
||||
return True
|
||||
dom_mtime = dom_path.stat().st_mtime
|
||||
score_mtime = score_p.stat().st_mtime
|
||||
if score_mtime < dom_mtime or score_mtime < config_mtime:
|
||||
return True
|
||||
if fails_p.stat().st_mtime < dom_mtime:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def score_file(dom_path: Path, fitness: Fitness) -> float:
|
||||
"""Score one .dom file, write side-cars, return score."""
|
||||
root = dom_mod.load(str(dom_path))
|
||||
geometry.clear_cache()
|
||||
score, failures = fitness.score_with_fails(root)
|
||||
|
||||
score_str = f"{score:.40f}"
|
||||
score_p = Path(f"{dom_path}.score")
|
||||
score_p.unlink(missing_ok=True)
|
||||
score_p.write_text(score_str + "\n")
|
||||
|
||||
fails_p = Path(f"{dom_path}.fails")
|
||||
fails_p.unlink(missing_ok=True)
|
||||
fails_p.write_text("\n".join(failures) + ("\n" if failures else ""))
|
||||
|
||||
return score
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
args = (argv or sys.argv)[1:]
|
||||
|
||||
cwd = Path.cwd()
|
||||
conf, cost = load_config(cwd)
|
||||
fitness = Fitness(conf, cost)
|
||||
cfg_mtime = _config_mtime(cwd)
|
||||
|
||||
if args:
|
||||
dom_paths = [Path(a) for a in args if a.endswith(".dom")]
|
||||
else:
|
||||
dom_paths = sorted(cwd.glob("*.dom"))
|
||||
print("Calculating fitness for all files in current folder", file=sys.stderr)
|
||||
|
||||
for dom_path in dom_paths:
|
||||
dom_path = Path(dom_path)
|
||||
if not dom_path.exists():
|
||||
print(f"File not found, skipping: {dom_path}", file=sys.stderr)
|
||||
continue
|
||||
if not _should_score(dom_path, cfg_mtime):
|
||||
continue
|
||||
try:
|
||||
score = score_file(dom_path, fitness)
|
||||
except Exception as exc:
|
||||
print(f"Error scoring {dom_path}: {exc}", file=sys.stderr)
|
||||
continue
|
||||
print(f"{score:.40f}", file=sys.stderr)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -122,260 +122,6 @@ def mutate_rotate(root: dom.Node, rng: np.random.Generator,
|
|||
return _finalise(child), f"rotate {li}/{n.id or 'root'}"
|
||||
|
||||
|
||||
def mutate_level_fix(root: dom.Node, rng: np.random.Generator,
|
||||
types: list[str], reqs=None) -> tuple[dom.Node, str]:
|
||||
"""Atomically move a level-constrained room to its required floor.
|
||||
|
||||
Finds a room type with a ``level: N`` constraint that currently sits on the
|
||||
wrong storey. Retypes the LARGEST leaf on the required floor to that room,
|
||||
and retypes the vacated wrong-floor leaf to a generic (C or O). Does not
|
||||
undivide anything, so the size may still be suboptimal — the inner NM loop
|
||||
fixes geometry, and subsequent core_divide / retype mutations fill in any
|
||||
displaced rooms.
|
||||
|
||||
Requires ``reqs`` (dict[str, SpaceReq] from programme.load_programme_dir).
|
||||
"""
|
||||
if not reqs:
|
||||
return _finalise(copy.deepcopy(root)), "level_fix noop"
|
||||
|
||||
from . import geometry as _geo
|
||||
|
||||
level_types = {code: req.level for code, req in reqs.items()
|
||||
if getattr(req, "level", None) is not None}
|
||||
if not level_types:
|
||||
return _finalise(copy.deepcopy(root)), "level_fix noop"
|
||||
|
||||
child = copy.deepcopy(root)
|
||||
lvls = dom.levels(child)
|
||||
|
||||
violations = [
|
||||
(li, lf, code, req_level)
|
||||
for code, req_level in level_types.items()
|
||||
for li, lvl in enumerate(lvls)
|
||||
for lf in lvl.leaves()
|
||||
if lf.type == code and li != req_level
|
||||
]
|
||||
if not violations:
|
||||
return _finalise(child), "level_fix noop"
|
||||
|
||||
li_wrong, wrong_leaf, code, req_level = _pick(rng, violations)
|
||||
if req_level >= len(lvls):
|
||||
return _finalise(child), "level_fix noop"
|
||||
|
||||
correct_leaves = lvls[req_level].leaves()
|
||||
if not correct_leaves:
|
||||
return _finalise(child), "level_fix noop"
|
||||
|
||||
# Pick the largest leaf on the correct floor as the best landing spot
|
||||
target = max(correct_leaves, key=lambda lf: _geo.area(lf))
|
||||
target.type = code
|
||||
|
||||
generics = [t for t in types if t.upper() in ("C", "O")]
|
||||
wrong_leaf.type = str(rng.choice(generics)) if generics else "C"
|
||||
|
||||
return _finalise(child), (
|
||||
f"level_fix {code}: lvl{li_wrong}/{wrong_leaf.id or 'root'}"
|
||||
f" → lvl{req_level}/{target.id or 'root'}"
|
||||
)
|
||||
|
||||
|
||||
def mutate_level_compound_fix(root: dom.Node, rng: np.random.Generator,
|
||||
types: list[str], reqs=None) -> tuple[dom.Node, str]:
|
||||
"""Compound level fix: move level-constrained room + re-insert displaced room.
|
||||
|
||||
Extends level_fix: after landing the constrained room (e.g. l1) on its
|
||||
required floor, the displaced room (e.g. t3) is re-inserted by splitting
|
||||
the SIBLING of the largest C leaf on that floor. The C sibling is always
|
||||
geometrically adjacent to C (they share the same parent split), so the
|
||||
displaced room inherits that adjacency. Division is applied to the target
|
||||
floor only (not core-divide style), since the displaced room only needs to
|
||||
appear on its required floor.
|
||||
|
||||
This avoids the 5-fail "missing t3" penalty that level_fix alone causes
|
||||
when the landing spot displaces a required room.
|
||||
"""
|
||||
if not reqs:
|
||||
return _finalise(copy.deepcopy(root)), "level_compound_fix noop"
|
||||
|
||||
from . import geometry as _geo
|
||||
|
||||
level_types = {code: req.level for code, req in reqs.items()
|
||||
if getattr(req, "level", None) is not None}
|
||||
if not level_types:
|
||||
return _finalise(copy.deepcopy(root)), "level_compound_fix noop"
|
||||
|
||||
child = copy.deepcopy(root)
|
||||
lvls = dom.levels(child)
|
||||
|
||||
violations = [
|
||||
(li, lf, code, req_level)
|
||||
for code, req_level in level_types.items()
|
||||
for li, lvl in enumerate(lvls)
|
||||
for lf in lvl.leaves()
|
||||
if lf.type == code and li != req_level
|
||||
]
|
||||
if not violations:
|
||||
return _finalise(child), "level_compound_fix noop"
|
||||
|
||||
li_wrong, wrong_leaf, code, req_level = _pick(rng, violations)
|
||||
if req_level >= len(lvls):
|
||||
return _finalise(child), "level_compound_fix noop"
|
||||
|
||||
correct_leaves = lvls[req_level].leaves()
|
||||
if not correct_leaves:
|
||||
return _finalise(child), "level_compound_fix noop"
|
||||
|
||||
target = max(correct_leaves, key=lambda lf: _geo.area(lf))
|
||||
displaced_type = target.type
|
||||
|
||||
# Apply level_fix part
|
||||
target.type = code
|
||||
generics = [t for t in types if t.upper() in ("C", "O")]
|
||||
wrong_leaf.type = str(rng.choice(generics)) if generics else "C"
|
||||
|
||||
desc = (f"level_compound_fix {code}: lvl{li_wrong} → lvl{req_level}/{target.id or 'root'}")
|
||||
|
||||
# Re-insert displaced room if it was a named room
|
||||
if displaced_type and displaced_type.upper() not in ("C", "O", "S"):
|
||||
displaced_req = reqs.get(displaced_type)
|
||||
displaced_level = getattr(displaced_req, "level", None) if displaced_req else None
|
||||
insert_level = displaced_level if displaced_level is not None else req_level
|
||||
|
||||
lvls = dom.levels(child)
|
||||
# Find C-sibling pairs: (parent, sibling_of_C) on insert_level.
|
||||
# The sibling of a C leaf shares a parent split → guaranteed adjacent to C.
|
||||
# Pick the largest such sibling as the host for the displaced room.
|
||||
sibling_cands: list[dom.Node] = []
|
||||
for li2, n in _owned_branches(child):
|
||||
if li2 != insert_level:
|
||||
continue
|
||||
l_is_c = n.left.type and n.left.type.upper() == "C" and not n.left.divided
|
||||
r_is_c = n.right.type and n.right.type.upper() == "C" and not n.right.divided
|
||||
if l_is_c and not n.right.divided and n.right.id:
|
||||
sibling_cands.append(n.right)
|
||||
if r_is_c and not n.left.divided and n.left.id:
|
||||
sibling_cands.append(n.left)
|
||||
|
||||
if sibling_cands:
|
||||
host = max(sibling_cands, key=lambda lf: _geo.area(lf))
|
||||
host_path = host.id
|
||||
|
||||
node = lvls[insert_level].by_id(host_path)
|
||||
if node is not None and not node.divided:
|
||||
host_orig_type = node.type
|
||||
# rotation=0 (vertical left-right split): left child neighbours C;
|
||||
# displaced room goes left (small), host type preserved right (large).
|
||||
# Inner NM tunes the exact split ratio.
|
||||
node.division = [0.25, 0.25]
|
||||
node.rotation = 0
|
||||
node.left = dom.Node(type=displaced_type) # small, adjacent to C
|
||||
node.right = dom.Node(type=host_orig_type) # large, preserves host
|
||||
node.type = None
|
||||
|
||||
desc += f" + insert {displaced_type} into {host_path}/lvl{insert_level}"
|
||||
|
||||
return _finalise(child), desc
|
||||
|
||||
|
||||
def mutate_core_divide(root: dom.Node, rng: np.random.Generator,
|
||||
types: list[str]) -> tuple[dom.Node, str]:
|
||||
"""Divide a circulation leaf at the same path across ALL storeys at once.
|
||||
|
||||
Staircase cores (C leaves at the same path on 2+ consecutive floors) are
|
||||
disrupted if a single-storey divide changes the C path on only one floor.
|
||||
This operator applies the same rotation and division to every floor that
|
||||
has a C leaf at the chosen path, maintaining staircase consistency as an
|
||||
atomic invariant rather than a multi-step recovery task.
|
||||
"""
|
||||
child = copy.deepcopy(root)
|
||||
lvls = dom.levels(child)
|
||||
|
||||
# Collect paths that are C leaves on 2+ floors
|
||||
c_paths: dict[str, list[int]] = {}
|
||||
for li, lvl in enumerate(lvls):
|
||||
for lf in lvl.leaves():
|
||||
if lf.type and lf.type.upper() == "C":
|
||||
c_paths.setdefault(lf.id, []).append(li)
|
||||
core_paths = [(path, lis) for path, lis in c_paths.items() if len(lis) >= 2]
|
||||
if not core_paths:
|
||||
return _finalise(child), "core_divide noop"
|
||||
|
||||
path, level_indices = _pick(rng, core_paths)
|
||||
rotation = int(rng.integers(4))
|
||||
division = [0.5, 0.5]
|
||||
|
||||
for li in level_indices:
|
||||
node = lvls[li].by_id(path)
|
||||
if node is None or node.divided:
|
||||
continue
|
||||
node.division = list(division)
|
||||
node.rotation = rotation
|
||||
node.left = dom.Node(type="C")
|
||||
node.right = dom.Node(type=str(_pick(rng, types)))
|
||||
node.type = None
|
||||
|
||||
return _finalise(child), f"core_divide {path} ({len(level_indices)} floors)"
|
||||
|
||||
|
||||
def mutate_core_undivide(root: dom.Node, rng: np.random.Generator,
|
||||
types: list[str]) -> tuple[dom.Node, str]:
|
||||
"""Reverse of core_divide: merge a C sub-core back into a single C leaf on all floors.
|
||||
|
||||
Picks a C leaf (e.g. 'rll') whose parent is also a C leaf on 2+ floors,
|
||||
then undivides the parent on every floor simultaneously, restoring the
|
||||
larger staircase footprint without a temporary path-mismatch fail.
|
||||
"""
|
||||
child = copy.deepcopy(root)
|
||||
lvls = dom.levels(child)
|
||||
|
||||
# Find divided nodes whose left child is C (candidate for core_undivide):
|
||||
# the parent path must have C.left on 2+ floors.
|
||||
parent_paths: dict[str, list[int]] = {}
|
||||
for li, lvl in enumerate(lvls):
|
||||
for n in [n for li2, n in _owned_branches(child) if li2 == li]:
|
||||
if (n.left.type and n.left.type.upper() == "C"
|
||||
and not n.left.divided and not n.right.divided):
|
||||
parent_paths.setdefault(n.id or "", []).append(li)
|
||||
core_parents = [(p, lis) for p, lis in parent_paths.items() if len(lis) >= 2]
|
||||
if not core_parents:
|
||||
return _finalise(child), "core_undivide noop"
|
||||
|
||||
path, level_indices = _pick(rng, core_parents)
|
||||
for li in level_indices:
|
||||
node = lvls[li].by_id(path)
|
||||
if node is None or not node.divided:
|
||||
continue
|
||||
keep = [t for t in (node.left.type, node.right.type)
|
||||
if t and t[0].lower() not in "cos"]
|
||||
node.type = keep[0] if keep else (node.left.type or str(_pick(rng, types)))
|
||||
node.division = None
|
||||
node.left = node.right = None
|
||||
|
||||
return _finalise(child), f"core_undivide {path} ({len(level_indices)} floors)"
|
||||
|
||||
|
||||
def mutate_level_retype(root: dom.Node, rng: np.random.Generator,
|
||||
types: list[str]) -> tuple[dom.Node, str]:
|
||||
"""Swap the types of two leaves on different storeys.
|
||||
|
||||
The cross-storey equivalent of mutate_retype; directly addresses
|
||||
level-constraint failures (e.g. "l1 on wrong level") by moving a room
|
||||
type from one floor to another without changing topology or geometry.
|
||||
"""
|
||||
child = copy.deepcopy(root)
|
||||
lvls = dom.levels(child)
|
||||
if len(lvls) < 2:
|
||||
return _finalise(child), "level_retype noop"
|
||||
all_lv = _leaves(child)
|
||||
li_a, a = _pick(rng, all_lv)
|
||||
other = [(li, lf) for li, lf in all_lv if li != li_a]
|
||||
if not other:
|
||||
return _finalise(child), "level_retype noop"
|
||||
li_b, b = _pick(rng, other)
|
||||
a.type, b.type = b.type, a.type
|
||||
return _finalise(child), f"level_retype {li_a}/{a.id or 'root'}<->{li_b}/{b.id or 'root'}"
|
||||
|
||||
|
||||
def mutate_level_add(root: dom.Node, rng: np.random.Generator,
|
||||
types: list[str]) -> tuple[dom.Node, str]:
|
||||
from . import genome as _g
|
||||
|
|
@ -384,14 +130,6 @@ def mutate_level_add(root: dom.Node, rng: np.random.Generator,
|
|||
top = dom.levels(child)[-1]
|
||||
dup = _g._copy_storey(top)
|
||||
dup.height = top.height
|
||||
# Retype all named-room leaves to generic C/O so the new storey carries no
|
||||
# duplicated programme rooms. The outer search retypes them incrementally.
|
||||
generic = [t for t in types if t.upper() in ("C", "O")]
|
||||
if not generic:
|
||||
generic = ["C"]
|
||||
for leaf in dup.leaves():
|
||||
if leaf.type not in ("C", "O", None):
|
||||
leaf.type = str(rng.choice(generic))
|
||||
top.above = dup
|
||||
return _finalise(child), f"level_add ({len(dom.levels(child))} storeys)"
|
||||
|
||||
|
|
@ -412,30 +150,17 @@ MUTATIONS = {
|
|||
"retype": mutate_retype,
|
||||
"swap": mutate_swap,
|
||||
"rotate": mutate_rotate,
|
||||
"core_divide": mutate_core_divide,
|
||||
"core_undivide": mutate_core_undivide,
|
||||
"level_fix": mutate_level_fix,
|
||||
"level_compound_fix": mutate_level_compound_fix,
|
||||
"level_retype": mutate_level_retype,
|
||||
"level_add": mutate_level_add,
|
||||
"level_delete": mutate_level_delete,
|
||||
}
|
||||
|
||||
|
||||
def mutate(root: dom.Node, rng: np.random.Generator, types: list[str],
|
||||
weights: dict[str, float] | None = None,
|
||||
reqs=None) -> tuple[dom.Node, str]:
|
||||
weights: dict[str, float] | None = None) -> tuple[dom.Node, str]:
|
||||
"""Apply one random mutation drawn from MUTATIONS."""
|
||||
names = sorted(MUTATIONS)
|
||||
p = np.array([(weights or {}).get(n, 1.0) for n in names], dtype=float)
|
||||
# level_fix needs programme reqs; disable it silently when not available
|
||||
if reqs is None:
|
||||
p[names.index("level_fix")] = 0.0
|
||||
if p.sum() == 0:
|
||||
p[:] = 1.0
|
||||
name = str(rng.choice(names, p=p / p.sum()))
|
||||
if name in ("level_fix", "level_compound_fix"):
|
||||
return MUTATIONS[name](root, rng, types, reqs=reqs)
|
||||
name = rng.choice(names, p=p / p.sum())
|
||||
return MUTATIONS[name](root, rng, types)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue