Compare commits

...

10 commits

Author SHA1 Message Date
bb50cb0c49 Added an easier version of programme-house 2026-06-16 07:57:50 +01:00
e623a76bd4 bd dolt push sync 2026-06-15 08:22:30 +01:00
85fc848e4e Document programme-house deceptive valley findings in DESIGN.md §4.10
Records: the level-fix deceptive valley, compound operator design,
warm_x0 initialization bug and fix, two-C topology breakthrough,
0-fails geometric impossibility proof, and final 1-fail results
beating the Perl optimiser.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 08:00:57 +01:00
65085d5c3d Fix warm_x0 to honour operator-specified ratios on new splits
When a compound operator (e.g. level_compound_fix) creates a new
internal node and explicitly sets its division ratio, that ratio was
silently overridden: warm_x0 received parent.ratios which had no entry
for the new node, so Nelder-Mead started at the default 0.5 instead
of the operator's intended 0.25 (for rrl/rrr).  Result: NM evaluated
the compound topology at the wrong geometry and scored 3 fails instead
of 1 — so lex always rejected the compound child, making
level_compound_fix invisible to the outer search.

Fix: for nodes that are genuinely newly divided (not divided in the
parent tree at the same path), inherit the child's operator-set ratio
rather than defaulting to 0.5.  Structural mutations (e.g. swap) can
reveal hidden level-N nodes that retain stale pre-writeback ratios —
those are correctly excluded by checking parent_node.divided.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 07:27:03 +01:00
d330a9171c Add mutate_level_compound_fix: escape deceptive level-fix valley
When level_fix alone displaces a required room (e.g. t3) it triggers 5
oracle fails for the missing room — far worse than the starting 2 fails,
so lex always rejects it.  level_compound_fix atomically: moves the
constrained room to its required floor AND re-inserts the displaced room
by splitting the sibling of the largest C leaf on that floor.  The C
sibling is guaranteed adjacent to C (shared parent split), so the
displaced room keeps its required C-adjacency.

On programme-house this jumps the warmstart from 2 fails (l1 wrong
level + t3 size) to 1 fail (staircase volume), which lex accepts as an
improvement and provides a new base for further mutation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:46:23 +01:00
69eb721c7c Close homemaker-py-g0b 2026-06-14 17:18:23 +01:00
896fc48867 Add homemaker-fitness: native Python CLI to replace urb-fitness.pl
Scores .dom files using fitness.Fitness.score_with_fails(), writes .score
and .fails side-cars in the same format as urb-fitness.pl, and respects the
same skip-if-up-to-date / FORCE_UPDATE caching semantics.

Closes homemaker-py-g0b.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 17:15:33 +01:00
191f603440 Add core_divide, core_undivide, level_fix operators; wire reqs to mutate()
core_divide: divides a C leaf simultaneously on ALL storeys that share that
path, maintaining staircase consistency as an atomic invariant rather than
requiring multi-step recovery.

core_undivide: reverses core_divide consistently across all floors, merging
a C sub-core back into a single C leaf everywhere.

level_fix: atomically moves a level-constrained room to its required floor
by retyping the largest leaf there and vacating the wrong-floor leaf to C.
Requires `reqs` (SpaceReq dict); disabled (zero probability) without it.

mutate() gains `reqs=None` parameter; driver.search() passes its already-
loaded reqs so level_fix fires during the main memetic loop.

Together these let the optimiser escape the deceptive valley around the
2-fail warmstart: level_fix moves l1 to level 0 (reducing fails 2→1),
then core_divide can split the C core to accommodate the displaced t3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 16:10:20 +01:00
507cf82d99 Add mutate_level_retype: swap leaf types between storeys
Cross-storey equivalent of mutate_retype. Directly addresses
level-constraint failures ("l1 on wrong level") by moving a room type
from one floor to another without changing topology or geometry.

Registered in MUTATIONS at default weight (1.0); no drastic geometry
perturbation so it does not need the reduced level_add/delete weight.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 10:52:48 +01:00
517a825505 Fix mutate_level_add: use generic C/O floor instead of room duplicate
Previously level_add copied the top storey exactly, duplicating all
named programme rooms and immediately triggering space-count failures
for every room on the new floor. The lex outer-search comparison
(-n_fails, score) then always rejected the multi-storey child because
its fail count was far higher than the single-storey parent.

Fix: retype all named-room leaves on the new storey to generic C or O
before admitting the child. The outer search then retypes them
incrementally via the normal retype operator. This allows level_add to
produce designs with the same fail count as the parent (storey_minimum
fail removed, no duplication fails added), making the multi-storey
transition visible to the lex selector.

Result on programme-house cold start (init.dom, 100k evals, 4 workers):
  before: 6 fails, single-storey, stuck after 40k evals
  after:  4 fails, two-storey, still improving at 100k

Also adds examples/harbor-house/ from urb/examples for future runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 10:33:05 +01:00
11 changed files with 1058 additions and 9 deletions

View file

@ -9,6 +9,8 @@
{"id":"homemaker-py-1p0","title":"Geometry inner loop: full-objective equal-offset ratio optimiser","description":"DESIGN.md §5.1, §7 Phase 1. Productionise experiments/optimize_fullfitness.py into homemaker: optimise(topology, x0=None) -\u003e (geometry, fitness). DOF = equal-offset division ratios of free branches (solver.free_branches, lowest-storey cut ownership), clipped to [eps, 1-eps]. Objective = full oracle fitness (never a proxy — §4.2 falsified). Must support warm-start x0 (§5.6) and a population/batch evaluation mode so each iteration scores via one batched oracle call (§4.6).","acceptance_criteria":"Reproduces or exceeds §4.5 gains (x1.24x1.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}
@ -28,10 +30,16 @@
{"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 (WongLiu) 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."}

View file

@ -210,6 +210,8 @@ 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):
@ -240,6 +242,91 @@ 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 (23 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 259267.
**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: **23 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

View file

@ -0,0 +1,148 @@
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

View file

@ -0,0 +1,25 @@
---
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

View file

@ -0,0 +1,253 @@
---
# 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

View file

@ -0,0 +1,25 @@
---
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

View file

@ -0,0 +1,99 @@
---
# 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

View file

@ -14,6 +14,7 @@ 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"]

View file

@ -249,8 +249,22 @@ def search(
else:
parent = _tournament(pop, rng, _key)
child_root, desc = operators.mutate(parent.root, rng, types,
weights=_MUTATION_WEIGHTS)
ratios = parent.ratios
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}
x0 = innerloop.warm_x0(child_root, ratios)
tasks.append((child_root, x0, child_budget, inner_kw, desc))
_run_batch(tasks)

View file

@ -0,0 +1,114 @@
"""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())

View file

@ -122,6 +122,260 @@ 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
@ -130,6 +384,14 @@ 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)"
@ -150,17 +412,30 @@ 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) -> tuple[dom.Node, str]:
weights: dict[str, float] | None = None,
reqs=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)
name = rng.choice(names, p=p / p.sum())
# 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)
return MUTATIONS[name](root, rng, types)