From 880a214d960ea5cdfe7e73f2d154347ae8ad1aaf Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sat, 18 Jul 2026 10:29:44 +0100 Subject: [PATCH] 94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public-access term (preserve_public_access, default on): when the building's only street access is an l/k ROOM neighbour of a public outside leaf (no circulation fallback — an existential building-level check the per-leaf objective can't see), that leaf is pinned (kept, its demand slot decremented) so the collapse can't drop "no outside public access". Best layout 15→13 becomes 15→12 with zero new fails; sweep total 172→171, still monotone. collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper, scores on throwaway copies (score_with_fails merges in place), returns the collapse only if fails don't increase. Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical re-score); evolve.py runs it after the sharing polish behind --collapse/ --no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies it to an existing .dom, writing .collapsed.dom. tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s exclusion, no-op safety, keep-better/unmerged. 267 pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm --- .beads/issues.jsonl | 35 ++++---- pyproject.toml | 1 + src/homemaker_layout/collapse_cmd.py | 97 ++++++++++++++++++++++ src/homemaker_layout/driver.py | 44 ++++++++++ src/homemaker_layout/evolve.py | 21 +++++ src/homemaker_layout/fitness.py | 97 ++++++++++++++++++++-- tests/test_collapse_global.py | 120 +++++++++++++++++++++++++++ 7 files changed, 390 insertions(+), 25 deletions(-) create mode 100644 src/homemaker_layout/collapse_cmd.py create mode 100644 tests/test_collapse_global.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index baefc7b..09c0b72 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -22,7 +22,7 @@ {"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-94g","title":"Global cell↔room collapse: generalise 9o5 matching to all spaces (WFC-style)","description":"Generalise the 9o5 superposition/collapse from interchange-classes to a GLOBAL cell↔room assignment: evolution searches unlabelled floorplans (tree shape + circulation/room/outside), a collapse function optimally LABELS each candidate. Mechanically an assignment problem — N cells (computed area/width/proportion/level/graph-pos) ↔ M required rooms (target dims + level + adjacency) — minimising total fail-cost; reuse Fitness._best_assignment (Hungarian/brute) but over the whole leaf set instead of one class. WFC framing = the constructive algorithm: each cell a distribution over types, PROPAGATE hard constraints exactly (level, requires_below service stacks, must-have adjacency) to prune, OBSERVE lowest-entropy cell, collapse to best-fitting room weighted by fit-quality, backtrack on contradiction.\n\nMOTIVATION (harbor-house evolved-3M-nols-3.dom, best layout, 15 fails): ~11 of 15 are LABEL-RELATIVE — 4 size (0/lrrll,0/rllll,1/lrlr,1/lrrll), 3 width (0/rlrrlr,0/rrllrr,0/rrrr), 2 proportion (0/rlrlr,1/rlrlr), 2 wrong-level (me1 L1-\u003e0, r L0-\u003e1). A cell fails size/width/proportion because the room ASSIGNED to it wants dims the cell lacks; relabel to a room it fits and the fail vanishes. Wrong-level is a HARD constraint a level-respecting collapse honours for free. Only 4 are shape-intrinsic and out of scope: crinkliness x2 (0/llll,0/rlllr) + not-connected x2 (level 0/1). Realistic target: 15 -\u003e ~4-6.\n\nWHY IT MAY SUCCEED WHERE 9o5 WAS FALSIFIED (xi7): 9o5 went negative because (1) auto-derived classes were semantically wrong (harbor-house 8-code chain, see homemaker-py-b3v) and (2) collapse perturbed feasibility (ON ADDS fails, 38v33/48v43). A GLOBAL, hard-constraint-respecting collapse sidesteps both: no fragile similarity classes; never violates level/adjacency/stack so it cannot ADD feasibility fails — only improve or match.\n\nRISK: search-landscape flattening. fitness = max-over-labellings makes the objective flatter/noisier (many topologies collapse to similar best scores), removing the gradient evolution climbs — the likely cause of 9o5's negative verdict, AMPLIFIED at full scope. Mitigations to A/B: (a) collapse only at FINISH (search on committed types, one relabel pass at end — cheap, strictly cannot worsen final score); (b) warm-start collapse from evolved labels as a local polish.\n\nRECOMMENDED FIRST STEP (cheapest, ~1 day, strictly cannot worsen final layout): FINISH-TIME global collapse — after a normal run, one optimal cell-\u003eroom assignment over the full leaf set with hard constraints enforced, then re-score. Measures empirically how many of the 11 label-relative fails are real slack vs already-optimal. If it clears a meaningful chunk, justifies the in-search WFC collapse + the landscape-flattening A/B. Does NOT fix crinkliness/connectivity (need geometry + circulation-placement work, file separately).\n\nFiles: fitness.py (_best_assignment, collapse_superposition), programme.py (constraints), driver.py (finish hook), graph.py (adjacency for propagation).","notes":"OPTION SWEEP (collapse_global, 6 harbor-house evolved layouts, fail counts):\n variant TOTAL(base 195) monotone?\n adj_off/quality 192 (-3) no (regresses 3 files; keep-better 187)\n adj_on /quality 185 (-10) no (keep-better 183)\n adj_off/threshold 181 (-14) YES (raw==keep)\n adj_on /threshold 172 (-23) YES (raw==keep) \u003c-- WINNER, now the default\nPer file (adj_on/threshold): nols-3 15-\u003e13, nols 32-\u003e26, 3M 90-\u003e82, warmshare 20-\u003e15,\nunfold 15-\u003e15, anneal-3M 23-\u003e21. Never worse than baseline on any of the 6.\n\nFINDINGS:\n1. THRESHOLD objective is the bigger lever. Maximising the COUNT of passing\n size/width/proportion factors (\u003e= FAIL_THRESHOLD=0.1), with continuous fit only\n as a tiebreak, removes the fail-SHUFFLE the continuous-quality objective caused\n (it would push one leaf just over threshold and another just under). quality-\u003ethreshold\n alone: adj_on 185-\u003e172, adj_off 192-\u003e181.\n2. ADJACENCY helps on TOP of threshold (172 vs 181). Both a satisfied adjacency and a\n passing factor carry the same weight (_COLLAPSE_FAIL_W), so fails are minimised jointly.\n3. adj_on/threshold is MONOTONE across all 6 -\u003e the keep-better scored guard is\n UNNECESSARY for this config (raw total == keep-better total). Guard still worth adding\n as a cheap safety belt before wiring a CLI/finish-hook, since monotonicity isn't proven\n in general.\n\nRESIDUAL (best layout 15-\u003e13): clears 0/lrrll size + both wrong-level; the only appear is\n\"no outside public access\" — a BUILDING-LEVEL constraint (an l/c/k room must neighbour a\npublic street-edge outside leaf) not captured by the per-leaf/adjacency model. Modelling it\n(and the crinkliness/not-connected shape-intrinsic fails, out of scope) is the next lever\nbeyond label slack. The aspirational 15-\u003e4-6 is NOT reachable by relabelling alone; ~2-3\nof this layout's label-relative fails are genuine reclaimable slack, the rest are geometry-\nor building-level bound.\n\nDefault is now objective=\"threshold\", adjacency=True. Substrate committed d52cce6 + this sweep.\nNext: (i) keep-better guard + CLI/finish-hook wiring; (ii) building-level public-access term;\n(iii) in-search WFC A/B (landscape-flattening risk, xi7).","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-17T16:17:12Z","created_by":"Bruno Postle","updated_at":"2026-07-18T07:36:17Z","started_at":"2026-07-17T21:07:05Z","dependencies":[{"issue_id":"homemaker-py-94g","depends_on_id":"homemaker-py-b3v","type":"related","created_at":"2026-07-17T17:23:20Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-94g","depends_on_id":"homemaker-py-xi7","type":"related","created_at":"2026-07-17T17:23:06Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"homemaker-py-94g","title":"Global cell↔room collapse: generalise 9o5 matching to all spaces (WFC-style)","description":"Generalise the 9o5 superposition/collapse from interchange-classes to a GLOBAL cell↔room assignment: evolution searches unlabelled floorplans (tree shape + circulation/room/outside), a collapse function optimally LABELS each candidate. Mechanically an assignment problem — N cells (computed area/width/proportion/level/graph-pos) ↔ M required rooms (target dims + level + adjacency) — minimising total fail-cost; reuse Fitness._best_assignment (Hungarian/brute) but over the whole leaf set instead of one class. WFC framing = the constructive algorithm: each cell a distribution over types, PROPAGATE hard constraints exactly (level, requires_below service stacks, must-have adjacency) to prune, OBSERVE lowest-entropy cell, collapse to best-fitting room weighted by fit-quality, backtrack on contradiction.\n\nMOTIVATION (harbor-house evolved-3M-nols-3.dom, best layout, 15 fails): ~11 of 15 are LABEL-RELATIVE — 4 size (0/lrrll,0/rllll,1/lrlr,1/lrrll), 3 width (0/rlrrlr,0/rrllrr,0/rrrr), 2 proportion (0/rlrlr,1/rlrlr), 2 wrong-level (me1 L1-\u003e0, r L0-\u003e1). A cell fails size/width/proportion because the room ASSIGNED to it wants dims the cell lacks; relabel to a room it fits and the fail vanishes. Wrong-level is a HARD constraint a level-respecting collapse honours for free. Only 4 are shape-intrinsic and out of scope: crinkliness x2 (0/llll,0/rlllr) + not-connected x2 (level 0/1). Realistic target: 15 -\u003e ~4-6.\n\nWHY IT MAY SUCCEED WHERE 9o5 WAS FALSIFIED (xi7): 9o5 went negative because (1) auto-derived classes were semantically wrong (harbor-house 8-code chain, see homemaker-py-b3v) and (2) collapse perturbed feasibility (ON ADDS fails, 38v33/48v43). A GLOBAL, hard-constraint-respecting collapse sidesteps both: no fragile similarity classes; never violates level/adjacency/stack so it cannot ADD feasibility fails — only improve or match.\n\nRISK: search-landscape flattening. fitness = max-over-labellings makes the objective flatter/noisier (many topologies collapse to similar best scores), removing the gradient evolution climbs — the likely cause of 9o5's negative verdict, AMPLIFIED at full scope. Mitigations to A/B: (a) collapse only at FINISH (search on committed types, one relabel pass at end — cheap, strictly cannot worsen final score); (b) warm-start collapse from evolved labels as a local polish.\n\nRECOMMENDED FIRST STEP (cheapest, ~1 day, strictly cannot worsen final layout): FINISH-TIME global collapse — after a normal run, one optimal cell-\u003eroom assignment over the full leaf set with hard constraints enforced, then re-score. Measures empirically how many of the 11 label-relative fails are real slack vs already-optimal. If it clears a meaningful chunk, justifies the in-search WFC collapse + the landscape-flattening A/B. Does NOT fix crinkliness/connectivity (need geometry + circulation-placement work, file separately).\n\nFiles: fitness.py (_best_assignment, collapse_superposition), programme.py (constraints), driver.py (finish hook), graph.py (adjacency for propagation).","notes":"WIRED + PUBLIC-ACCESS TERM DONE.\n1. Public-access pin (preserve_public_access=True, default): when the building's\n ONLY street access is an l/k ROOM neighbour of a public outside leaf (no\n circulation fallback — the existential building check the per-leaf objective\n can't see), that room leaf is PINNED (kept + its demand slot decremented) so\n the collapse can't drop \"no outside public access\". On the best layout this\n turns 15-\u003e13 into 15-\u003e12 (the lone regression removed, zero new fails). Sweep\n total 172-\u003e171, still monotone across all 6.\n2. Keep-better wrapper Fitness.collapse_finish(root, **kw) -\u003e (tree, base, coll,\n applied): scores on throwaway copies (score_with_fails merges in place),\n returns collapsed only if fails don't increase. Safety belt (config already\n monotone here, not proven so in general).\n3. Wiring: driver.collapse_best(result, programme_dir, ...) updates result.best\n (lineage +collapse, canonical re-score). evolve.py runs it after the sharing\n polish behind --collapse/--no-collapse (default ON). Standalone CLI\n homemaker-collapse file.dom (pyproject entry) writes \u003cstem\u003e.collapsed.dom;\n flags --adjacency/--public-access/--objective/--keep-better. Verified the\n written dom independently re-scores 15-\u003e12.\n4. Tests: tests/test_collapse_global.py (6) — demand-set relabel, level hard\n constraint, c/o/s exclusion, no-op safety, keep-better/unmerged. 267 pass.\n\nSTILL OPEN (separate work, not label slick): geometry-intrinsic fails — long-thin\nuseless cells (width/proportion/crinkliness) and not-connected — need geometry/\ntopology + circulation-placement search, NOT the collapse (see bd memory\ncollapse-global-94g...). In-search WFC collapse A/B also still open (xi7 risk).","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-17T16:17:12Z","created_by":"Bruno Postle","updated_at":"2026-07-18T09:28:38Z","started_at":"2026-07-17T21:07:05Z","dependencies":[{"issue_id":"homemaker-py-94g","depends_on_id":"homemaker-py-b3v","type":"related","created_at":"2026-07-17T17:23:20Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-94g","depends_on_id":"homemaker-py-xi7","type":"related","created_at":"2026-07-17T17:23:06Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-3l6","title":"Leaf-sharing default makes internal fitness diverge from canonical homemaker-fitness score","description":"Default is --leaf-sharing (on). Leaf sharing is a fitness-evaluation knob (fitness.py:415 quality_size, plus edge cap and count check): a shared leaf of code X is credited as satisfying k programme entries, with its size Gaussian re-centred on k*target. The evolve internal objective therefore rewards genomes that under-materialise the programme. When the winning .dom is re-scored by the canonical homemaker-fitness (leaf_sharing off), those un-materialised copies become 'missing required space ... (critical)' fails.\n\nObserved on examples/harbor-house (init.dom, budget 3M, workers 2):\n - leaf sharing ON (default): internal best 1.03e-05, but canonical score 6.73e-29 with 90 fails (15 critical missing-room).\n - --no-leaf-sharing (warm-started to full budget): internal and canonical agree at 4.19e-06, 15 fails, 0 critical -- ~9500x better than the prior best 3m.dom (4.41e-10).\n\nSo the default silently optimises an objective the canonical scorer does not credit, and writes a catastrophically worse .dom than its reported internal fitness implies.\n\nOptions to consider:\n 1. Make --no-leaf-sharing the default (strict per-leaf baseline agrees with canonical scorer).\n 2. Before writing output, re-score best-so-far with leaf_sharing off and warn (or refuse) if it regresses vs internal fitness.\n 3. Materialise/unfold shared leaves into k distinct rooms when writing the .dom, so the output satisfies the per-room programme.\n 4. Keep sharing as an early-phase relaxation only and anneal leaf_share_factor down to 0 before finishing (see related annealing investigation).","notes":"FIXED (option 3+2 combined, auto-finish): leaf-sharing runs now unfold+polish+rescore before write so output is honest under the canonical scorer.\n\nImplementation:\n- driver.polish_finish(result, programme_dir, polish_budget, ...): deep-copies best, operators.unfold_shared_leaves() to materialise the count deficit, then warm-starts a leaf_sharing=False search (bootstrap=False) from the unfolded genome. polish_budget\u003c=0 -\u003e single rescore eval only (used on interrupt). Stitches evals/topologies/sigs/restarts/history onto the sharing run; history tagged share:/polish: since the two objectives are not comparable. Returned best.fitness is canonical (leaf_sharing off =\u003e internal==canonical).\n- evolve.py: new --polish-budget flag (env HOMEMAKER_POLISH_BUDGET, default -1=auto=budget//2, 0=unfold+rescore only). main() calls polish_finish when --leaf-sharing on; interrupt forces polish_budget=0 for a fast honest output.\n\nVerified end-to-end (harbor-house, budget 3000 + polish 1500): reported polish fitness 4.79788e-27 EXACTLY matches canonical homemaker-fitness, 0 critical fails (was: internal 1.03e-05 vs canonical 6.73e-29 w/ 15 critical). Tiny budget so absolute quality low but honesty restored. Tests: driver.polish_finish x3 (test_driver.py), full suite 254 pass.\n\nDefault kept --leaf-sharing on per decision (sharing's topology-search speed retained; output made honest by the finish). Schedule B in-run annealing remains kpu.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-05T16:24:34Z","created_by":"Bruno Postle","updated_at":"2026-07-15T08:00:26Z","started_at":"2026-07-15T06:56:29Z","closed_at":"2026-07-15T08:00:26Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-rq2","title":"Flip share_edge_cap default-ON + rebaseline §13.x floor (hph follow-up)","description":"hph/§13.8 A/B confirmed the share-aware edge-too-long cap is positive and monotone-harmless (maple 80.3→74.0, harbor 34.7→31.0, zero regressions across 6 seeds). The fix shipped behind the SHAREEDGE/share_edge_cap knob (default OFF) so controls reproduce. This issue flips the default ON for leaf-sharing runs — it completes the §13.3 leaf-share objective relaxation on the wall measure, mirroring the pll/interior_outside default flips. Rebaselines the §13.x full-stack floor numbers (harbor 34.7→31.0, maple 80.3→74.0 become the new baseline). Couple with INTERIORO/odiv3 if those are also being default-flipped. Verify the test suite + a control re-score still reproduce post-flip.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-28T20:01:11Z","created_by":"Bruno Postle","updated_at":"2026-06-28T20:39:00Z","started_at":"2026-06-28T20:32:40Z","closed_at":"2026-06-28T20:39:00Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-hph","title":"edge-too-long not share-aware: shared leaves (share\u003e1) penalised for aggregate wall length (§13.7 follow-up)","description":"DESIGN §13.7 flagged edge-too-long as harbor's top fail class (6). Dissection (experiments/diag_edge_too_long.py on the 500k probe best) shows the 6 fails are only 2 distinct locations:\n\n(1) DOMINANT ~4/6: leaf 'lllr' on both levels is a share=3 leaf (one quad = 3 rooms, 247 m2, edges 15-17 m, aspect 1.2 NEARLY SQUARE). Its walls exceed the flat 8 m cap purely because it aggregates 3 rooms — a leaf-sharing REPRESENTATION ARTIFACT, not a design flaw. §13.3 relaxed size/missing for shared leaves (quality_size centres on k*target) but edge_cost (fitness.py:474) and outside_edge_cost (fitness.py:490) still use a flat 8.0 m regardless of leaf.share. So a shared leaf is penalised for being big — the same leak §13.3 closed, on a different measure.\n\n(2) ~2/6: leaf 'llll' is a 1.2 m x 16.7 m sliver (aspect 14) at correct area — a REAL narrow-room pathology, already caught by width/proportion. Its edge-too-long is the wall it shares with lllr.\n\nNo corridors involved.\n\nPROPOSED FIX: make edge-too-long share-aware — exempt or scale the 8 m cap by leaf.share (type-guarded, as graph.leaf_share does) in edge_cost/outside_edge_cost, mirroring quality_size's k*target. Clears the ~4 artifact fails without masking the narrow sliver. Optional separate lever: lift/parametrise the flat 8 m cap for non-domestic programmes (harbor-house) — blunter, lower priority. A/B under §13 protocol (controls reproduce harbor 34.0 / maple 80.3); record verdict. Repro: experiments/diag_edge_too_long.py.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-28T13:51:26Z","created_by":"Bruno Postle","updated_at":"2026-06-28T20:03:00Z","started_at":"2026-06-28T14:46:18Z","closed_at":"2026-06-28T20:03:00Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -77,25 +77,26 @@ {"id":"homemaker-py-erc.6","title":"Experiment: inner-loop slack-expansion objective term","description":"Inner-loop counterpart to plot-fill construction. If Diagnostic B shows the inner loop has room to expand leaves into slack but no objective gradient to do so (the scalar rewards hitting target area but not exceeding it where slack exists), add a term/incentive so the ratio optimiser pushes leaf boundaries out to consume neighbouring slack and satisfy size, rather than parking at target.\n\nCONDITIONAL on Diagnostic B: build this only if B localizes the gap to the inner loop (room to expand, no gradient); if B shows construction targets too-small dims, prefer the plot-fill construction sibling. Must preserve the §5.4 inner-loop cliff / §4.9 lexicographic protection — the term sits where it cannot displace the fail-count ordering. A/B vs §12.2 baseline, seeds 0/1/2, 20000 evals, staged, default-OFF. Record DESIGN.md §13.6.","notes":"DEPRIORITISED by Diagnostic B (§13.2). B shows the inner loop CANNOT repair undersize: the slack is depth-driven maldistribution baked into the frozen topology, and the equal-offset ratio DOF cannot shrink a 14x leaf to feed a starved one without trading into shape fails (0.5^n cliff). Wrong DOF and wrong direction — the blocker is slicing POSITION, not a missing expansion reward. Fix belongs upstream in construction/topology (erc.4 re-scoped, erc.3). Keep as a low-priority follow-up only if a depth-balanced construction still leaves a residual size gradient the inner loop could pick up.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-22T23:16:24Z","created_by":"Bruno Postle","updated_at":"2026-06-28T13:22:22Z","closed_at":"2026-06-28T13:22:22Z","close_reason":"wont-fix (DESIGN §13.7): Diag B (§13.2) showed the inner loop cannot repair undersize (wrong DOF — slicing position, frozen-topology ratios). Superseded by depth-balanced construction (erc.4). Condition unmet.","dependencies":[{"issue_id":"homemaker-py-erc.6","depends_on_id":"homemaker-py-erc","type":"parent-child","created_at":"2026-06-23T00:16:23Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-erc.6","depends_on_id":"homemaker-py-erc.2","type":"blocks","created_at":"2026-06-23T00:16:47Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-erc.5","title":"Experiment: compactness-aware cuts (minimize leaf perimeter/area)","description":"Attacks the #1 factor, crinkliness (346) — a per-leaf perimeter/area property DISTINCT from proportion (aspect ratio). Proportion-aware seeding (leu.2) sizes splits but does not bias toward balanced, square-ish subdivision. Add a KD-tree-style 'keep both children compact' cut rule (prefer the cut orientation/position that minimises summed child perimeter/area) in construction.\n\nCONDITIONAL on Diagnostic A: if A shows per-leaf shape-fail is FLAT across densities (floor intrinsic to slicing density), better cuts at the same leaf count will not pay → this should be closed wont-fix in favour of leaf-sharing. Only build if A shows shape-fail RISES with density. A/B vs §12.2 baseline, seeds 0/1/2, 20000 evals, staged, default-OFF. Record DESIGN.md §13.5.","notes":"DEPRIORITISED by erc.1 verdict (§13.1): per-leaf shape-fail flat vs slicing density and cuts already squarest (_size_divisions_from_targets picks squarest rotation) yet still ~1.8 fails/leaf =\u003e little compactness headroom at fixed leaf count. Floor is intrinsic to leaf COUNT, not cut quality. Revisit only if leaf-sharing (erc.3) underdelivers.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-22T23:16:21Z","created_by":"Bruno Postle","updated_at":"2026-06-28T13:22:17Z","closed_at":"2026-06-28T13:22:17Z","close_reason":"wont-fix (DESIGN §13.7): Diag A (§13.1) showed the floor is intrinsic to leaf COUNT not cut quality; revisit condition was 'only if leaf-sharing underdelivers' but leaf-sharing OVER-delivered (−32…−39%, §13.3). Condition unmet.","dependencies":[{"issue_id":"homemaker-py-erc.5","depends_on_id":"homemaker-py-erc","type":"parent-child","created_at":"2026-06-23T00:16:21Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-erc.5","depends_on_id":"homemaker-py-erc.1","type":"blocks","created_at":"2026-06-23T00:16:43Z","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.\nReframed 2026-06-17: orthogonal to epic homemaker-py-c4c. This is fitness FIDELITY (restoring daylight + shaded-wall selection pressure to match Perl), not search CAPABILITY — it changes what 'good' means, not the search's ability to find good. It will NOT improve final designs in the sense currently sought. Stays P4, deferred until the topology-search-quality epic lands and optimisation is fully native.","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-17T19:14:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_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":"ld2-13-6-interior-o-seed-diagnostic-all","value":"ld2/§13.6 interior-O seed diagnostic: ALL crinkliness fails in the constructed bal+share seed are UNDER-exposed (crink\u003c0.62, landlocked rooms with no facade + no uncovered-O neighbour) — zero over-exposed sliver fails. So the erc crinkliness residual is genuine under-daylighting, validating the interior light-well premise. Default outside_divisor=6 was too sparse (null: harbor 147-\u003e142, crinkliness even rose). odiv=3 is the seed-optimal joint setting: harbor seed fails 147-\u003e129 (-18), maple 219-\u003e206 (-14), landlocked fails drop, at cost of more leaves (harbor +4, maple +8). Because it ADDS leaves it carries the §13.4 wash-out risk; A/B to convergence pending."} {"_type":"memory","key":"unfold-strategy-for-shared-leaves-homemaker-py-8iv","value":"Unfold strategy for shared leaves (homemaker-py-8iv, resolved 2026-07-16): use the BALANCED GRID (operators._grow_balanced/_size_subtree_equal), NOT circulation-aware slicing. Slicing a shared leaf perpendicular to its access edge so every child touches the corridor was implemented + A/B-tested and LOST decisively (150k-eval warm-start polish from evolved-3M: slice 41 fails/3.5e-14 vs grid 25 fails/2.4e-09, grid ahead at every milestone). Reason: k rooms all touching one wall are intrinsically thin slices; that geometric debt (proportion/long/width) is unfixable without topology change, while the grid's squarer children let local search re-route access cheaply via level_retype/place_missing/level_fix. Lesson: at the sharing-\u003eno-sharing transition, prioritise squarer children and leave access to local search; do not reintroduce slicing in Schedule B (kpu)."} +{"_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":"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":"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":"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":"proportion-aware-constructive-seeding-leu-2-12-2","value":"Proportion-aware constructive seeding (leu.2/§12.2): sizing seed cuts from target AREAS only regresses (thin slivers wreck aspect); you must ALSO pick each cut's rotation for child squareness. It is a convergence ACCELERATOR via a deeper local optimum around the constructed topology: wins where that topology is roughly right and budget is scarce (harbor -13%, maple -10% at 20k evals) but DELAYS small programmes where the seed must be restructured by undivide (programme-house regresses at fixed budget, yet reaches the floor given budget - speed, not asymptote). Default-on. Also: n_storeys must honour storey_minimum, not just level: keys (programme-house storey_minimum:2, all rooms level:0 - was seeded 1 storey short; cq1)."} +{"_type":"memory","key":"9o5-multi-use-leaves-is-path-a-superposition","value":"9o5 multi-use leaves is path (a) — superposition as SEARCH RELAXATION that COLLAPSES to specific usage at the end, NOT path (b) loose-fit/no-collapse. Bruno's intent: codes with SIMILAR leaf requirements form an interchangeable equivalence class; during evolution the solver doesn't commit which leaf serves which specific usage (smoother landscape, no fighting over exact leaf usage); at the end the layout is CONDENSED to specific usages by brute-forcing the in-class assignment (3 interchangeable usages over 3 leaves = 3! = 6 combinations to check, pick best). 'Derive automatically' compatibility = requirement-similarity grouping. This reverses the issue's stated 'path b preferred' note."} +{"_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":"run-to-run-reproducibility-in-homemaker-layout-serial","value":"Run-to-run reproducibility in homemaker-layout: serial search (workers=1) is byte-for-byte deterministic; parallel (workers\u003e1) is now deterministic too AFTER fixing driver._run_batch to admit futures in submission order (was as_completed/completion order, bug xcy). Reproducibility holds only for a FIXED worker count — serial vs parallel differ because children-per-iteration is 1 vs n_workers (different batch granularity), which is expected, not a bug. The constructive seeder was NEVER nondeterministic: _assign_adjacency_aware has unique idx tiebreaks; comparing topologies with Python builtin hash() of the signature STRING is invalid (PYTHONHASHSEED salts str hashing per process) — use a stable hash (sha1) or genome.signature equality."} +{"_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":"collapse-global-94g-and-any-label-usage-optimisation","value":"collapse_global (94g) and any label/usage optimisation CANNOT fix geometry-intrinsic fails. The harbor-house 15-fail best layout contains long-thin cells that are useless whatever room usage is assigned — their width/proportion/crinkliness fails are shape-bound, not label slack. Two consequences: (1) do not over-claim collapse gains — only ~2-3 of that layout's fails are reclaimable relabel slack, the rest are geometry- or building-level bound; (2) the threshold objective must not be tuned to 'pass' a degenerate cell via a permissive room type — a metric-pass on a physically useless space is gaming, not a fix. Real remedies for these are geometry/topology search (cell shape) and circulation placement, filed separately, not the collapse."} +{"_type":"memory","key":"experiment-seeding-pitfall-run-search-scaled-py-s","value":"Experiment seeding pitfall: run_search_scaled.py's default PH_SEED (c964…dom) is a FINISHED programme-house design — passing it warm-starts and floors at ~3 fails, NOT a blank-slate topology search. For blank-slate runs comparable to §11.5/§11.6 baselines, seed from examples/programme-house/init.dom (a bare undivided plot; driver bootstrap auto-triggers only on bare plots). Bit the 6zy sweep — first pass used c964 and falsely showed 3-fail floor across the whole grid."} {"_type":"memory","key":"experiment-harness-gotcha-the-leaf-sharing-relaxed-objective","value":"Experiment harness gotcha: the leaf-sharing RELAXED objective (§13.3) is injected ONLY by monkeypatching fitness.load_config in the parent process (run_staged_search.py / probe scripts). This is parent-process-only and does NOT propagate into ProcessPoolExecutor workers (n_workers\u003e1), which re-import fitness fresh and score under the STRICT on-disk patterns.config -\u003e r.n_fails MISMATCH (worker strict vs parent relaxed re-score). ALL §13.x floor runs were therefore SERIAL. Any future PARALLEL leaf-sharing experiment will silently mis-score until leaf_sharing lives on disk/CLI (tracked: homemaker-py-x3b). The parallel driver itself is correct; both paths score via load_config(programme_dir)."} {"_type":"memory","key":"homemaker-py-3l6-fix-leaf-sharing-evolve-runs","value":"homemaker-py-3l6 fix: leaf-sharing evolve runs now auto-finish before write via driver.polish_finish — unfold_shared_leaves() then a warm-started leaf_sharing=False polish search (--polish-budget, default budget//2). Makes the written .dom honest under canonical homemaker-fitness (internal==canonical when leaf_sharing off). Interrupt path forces polish_budget=0 (unfold+rescore only). This is yaa's unfold-then-polish, made automatic; Schedule B annealing is still kpu."} -{"_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":"island-model-psk-14-is-a-null-priming","value":"Island model (psk, §14) is a NULL: priming a population from N converged independent elites + crossover-heavy migration does not beat best-of-N at equal total budget (maple island 124 vs control 116). The child_probe instrument shows WHY: area-matched crossover across independently-converged elites almost never synthesizes (1-3 of ~64 children beat the better parent, max drop 2-5) because the slicing encoding is non-canonical (9gp), so splices are disruptive not combinatorial. Search-machinery null #3 after graded-objective and niching/restarts; residual stays geometry/shape-bound."} -{"_type":"memory","key":"run-to-run-reproducibility-in-homemaker-layout-serial","value":"Run-to-run reproducibility in homemaker-layout: serial search (workers=1) is byte-for-byte deterministic; parallel (workers\u003e1) is now deterministic too AFTER fixing driver._run_batch to admit futures in submission order (was as_completed/completion order, bug xcy). Reproducibility holds only for a FIXED worker count — serial vs parallel differ because children-per-iteration is 1 vs n_workers (different batch granularity), which is expected, not a bug. The constructive seeder was NEVER nondeterministic: _assign_adjacency_aware has unique idx tiebreaks; comparing topologies with Python builtin hash() of the signature STRING is invalid (PYTHONHASHSEED salts str hashing per process) — use a stable hash (sha1) or genome.signature equality."} {"_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":"ld2-13-6-interior-o-seed-diagnostic-all","value":"ld2/§13.6 interior-O seed diagnostic: ALL crinkliness fails in the constructed bal+share seed are UNDER-exposed (crink\u003c0.62, landlocked rooms with no facade + no uncovered-O neighbour) — zero over-exposed sliver fails. So the erc crinkliness residual is genuine under-daylighting, validating the interior light-well premise. Default outside_divisor=6 was too sparse (null: harbor 147-\u003e142, crinkliness even rose). odiv=3 is the seed-optimal joint setting: harbor seed fails 147-\u003e129 (-18), maple 219-\u003e206 (-14), landlocked fails drop, at cost of more leaves (harbor +4, maple +8). Because it ADDS leaves it carries the §13.4 wash-out risk; A/B to convergence pending."} -{"_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":"experiment-seeding-pitfall-run-search-scaled-py-s","value":"Experiment seeding pitfall: run_search_scaled.py's default PH_SEED (c964…dom) is a FINISHED programme-house design — passing it warm-starts and floors at ~3 fails, NOT a blank-slate topology search. For blank-slate runs comparable to §11.5/§11.6 baselines, seed from examples/programme-house/init.dom (a bare undivided plot; driver bootstrap auto-triggers only on bare plots). Bit the 6zy sweep — first pass used c964 and falsely showed 3-fail floor across the whole grid."} -{"_type":"memory","key":"proportion-aware-constructive-seeding-leu-2-12-2","value":"Proportion-aware constructive seeding (leu.2/§12.2): sizing seed cuts from target AREAS only regresses (thin slivers wreck aspect); you must ALSO pick each cut's rotation for child squareness. It is a convergence ACCELERATOR via a deeper local optimum around the constructed topology: wins where that topology is roughly right and budget is scarce (harbor -13%, maple -10% at 20k evals) but DELAYS small programmes where the seed must be restructured by undivide (programme-house regresses at fixed budget, yet reaches the floor given budget - speed, not asymptote). Default-on. Also: n_storeys must honour storey_minimum, not just level: keys (programme-house storey_minimum:2, all rooms level:0 - was seeded 1 storey short; cq1)."} -{"_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."} +{"_type":"memory","key":"island-model-psk-14-is-a-null-priming","value":"Island model (psk, §14) is a NULL: priming a population from N converged independent elites + crossover-heavy migration does not beat best-of-N at equal total budget (maple island 124 vs control 116). The child_probe instrument shows WHY: area-matched crossover across independently-converged elites almost never synthesizes (1-3 of ~64 children beat the better parent, max drop 2-5) because the slicing encoding is non-canonical (9gp), so splices are disruptive not combinatorial. Search-machinery null #3 after graded-objective and niching/restarts; residual stays geometry/shape-bound."} {"_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":"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":"9o5-multi-use-leaves-is-path-a-superposition","value":"9o5 multi-use leaves is path (a) — superposition as SEARCH RELAXATION that COLLAPSES to specific usage at the end, NOT path (b) loose-fit/no-collapse. Bruno's intent: codes with SIMILAR leaf requirements form an interchangeable equivalence class; during evolution the solver doesn't commit which leaf serves which specific usage (smoother landscape, no fighting over exact leaf usage); at the end the layout is CONDENSED to specific usages by brute-forcing the in-class assignment (3 interchangeable usages over 3 leaves = 3! = 6 combinations to check, pick best). 'Derive automatically' compatibility = requirement-similarity grouping. This reverses the issue's stated 'path b preferred' note."} +{"_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":"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)."} diff --git a/pyproject.toml b/pyproject.toml index 95e30d5..0f5bb23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ [project.scripts] homemaker-evolve = "homemaker_layout.evolve:main" homemaker-fitness = "homemaker_layout.fitness_cmd:main" +homemaker-collapse = "homemaker_layout.collapse_cmd:main" [project.optional-dependencies] dev = ["pytest>=8.0", "ruff>=0.5"] diff --git a/src/homemaker_layout/collapse_cmd.py b/src/homemaker_layout/collapse_cmd.py new file mode 100644 index 0000000..507d1e0 --- /dev/null +++ b/src/homemaker_layout/collapse_cmd.py @@ -0,0 +1,97 @@ +"""homemaker-collapse — finish-time global cell→room collapse on a .dom file. + +Relabels a layout's room cells to the programme rooms they fit best via one +optimal assignment (hard level constraint, adjacency relaxation, public-access +pinning — see fitness.Fitness.collapse_global), keeping the result only if the +fail count does not increase. Labels only — geometry is never touched, so +shape-intrinsic fails (long-thin cells, crinkliness) are unaffected by design. + +Like homemaker-fitness you MUST cd to the directory holding the .dom so that +patterns.config / costs.config resolve. + +Usage (module): + python -m homemaker_layout.collapse_cmd file.dom [file2.dom ...] [-o OUT.dom] + +When installed via pip install -e .: + homemaker-collapse file.dom [...] + +Writes the collapsed layout to .collapsed.dom (or -o OUT for a single +input; - for stdout) and prints "base → collapsed fails" per file to stderr. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from . import dom as dom_mod +from .fitness import Fitness, load_config + + +def _parse_args(argv): + p = argparse.ArgumentParser(prog="homemaker-collapse", description=__doc__) + p.add_argument("dom", type=Path, nargs="+", help="input .dom file(s)") + p.add_argument("-o", "--output", type=Path, default=None, metavar="PATH", + help="output path (single input only; - for stdout). Default: " + ".collapsed.dom beside each input") + p.add_argument("--adjacency", action=argparse.BooleanOptionalAction, default=True, + help="enforce required room↔room adjacency (default: on)") + p.add_argument("--public-access", dest="public_access", + action=argparse.BooleanOptionalAction, default=True, + help="pin the sole street-access provider (default: on)") + p.add_argument("--objective", choices=("threshold", "quality"), + default="threshold", + help="threshold = minimise fail count; quality = maximise " + "continuous fit (default: threshold)") + p.add_argument("--keep-better", dest="keep_better", + action=argparse.BooleanOptionalAction, default=True, + help="revert if the collapse increases the fail count " + "(default: on)") + return p.parse_args(argv) + + +def main(argv=None) -> int: + args = _parse_args(argv or sys.argv[1:]) + if args.output is not None and len(args.dom) != 1: + print("error: -o/--output requires exactly one input", file=sys.stderr) + return 2 + + conf, cost = load_config(Path.cwd()) + fit = Fitness(conf, cost) + kw = dict( + adjacency=args.adjacency, + objective=args.objective, + preserve_public_access=args.public_access, + ) + + rc = 0 + for dom_path in args.dom: + if not dom_path.exists(): + print(f"not found, skipping: {dom_path}", file=sys.stderr) + rc = 1 + continue + root = dom_mod.load(str(dom_path)) + if args.keep_better: + tree, base_f, coll_f, applied = fit.collapse_finish(root, **kw) + else: + import copy + base_f = len(fit.score_with_fails(copy.deepcopy(root))[1]) + fit.collapse_global(root, **kw) + coll_f = len(fit.score_with_fails(copy.deepcopy(root))[1]) + tree, applied = root, True + verb = "applied" if applied else "reverted" + print(f"{dom_path.name}: {base_f} → {coll_f} fails ({verb})", file=sys.stderr) + + if str(args.output) == "-": + sys.stdout.write(dom_mod.dumps(tree)) + else: + out = args.output or dom_path.with_suffix(".collapsed.dom") + dom_mod.dump(tree, str(out)) + print(f"written: {out}", file=sys.stderr) + + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/homemaker_layout/driver.py b/src/homemaker_layout/driver.py index 6cb4693..2d16137 100644 --- a/src/homemaker_layout/driver.py +++ b/src/homemaker_layout/driver.py @@ -645,6 +645,50 @@ def polish_finish( return r2 +def collapse_best( + result: SearchResult, + programme_dir: str | Path, + *, + leaf_sharing: bool = False, + superpose: bool = False, + log=None, + **collapse_kw, +) -> SearchResult: + """homemaker-py-94g: finish-time global cell→room collapse on the best layout. + + Relabels the best tree's room cells to the programme rooms they fit best via + one optimal assignment (hard level constraint, adjacency relaxation, and + public-access pinning — see :meth:`fitness.Fitness.collapse_global`), keeping + the result only if the fail count does not increase (:meth:`collapse_finish`). + A strictly monotone finish-time polish that searches only labels, not + geometry, so it cannot touch shape-intrinsic fails (long-thin cells, etc.). + + Updates ``result.best`` in place with the canonically re-scored relabelling + when it helps; otherwise leaves the result untouched.""" + if result.best is None: + return result + + fit = _fitness_for(str(programme_dir), leaf_sharing, superpose) + tree, base_fails, coll_fails, applied = fit.collapse_finish( + result.best.root, **collapse_kw + ) + if log: + verb = "applied" if applied else "reverted — no improvement" + log(f"[finish] collapse: {base_fails} → {coll_fails} fails ({verb})") + if applied: + score, fails, grade = fit.score_with_grade(copy.deepcopy(tree)) + result.best = Individual( + root=tree, + fitness=score, + n_fails=len(fails), + ratios=result.best.ratios, + lineage=result.best.lineage + "+collapse", + grade=grade, + sig=result.best.sig, + ) + return result + + def search_annealed( seed_root: dom.Node, programme_dir: str | Path, diff --git a/src/homemaker_layout/evolve.py b/src/homemaker_layout/evolve.py index 8581367..e6111c6 100644 --- a/src/homemaker_layout/evolve.py +++ b/src/homemaker_layout/evolve.py @@ -116,6 +116,13 @@ def _parse_args(argv=None) -> argparse.Namespace: "canonical scorer. -1 = auto (budget//2); 0 = unfold + " "rescore only, no polish. Ignored with --no-leaf-sharing " "(default: -1)") + p.add_argument("--collapse", action=argparse.BooleanOptionalAction, + default=True, + help="homemaker-py-94g: finish-time global cell→room collapse — " + "relabel the best layout's room cells to the programme " + "rooms they fit best (level + adjacency + public-access " + "constrained), kept only if it does not increase the fail " + "count. Labels only, never geometry (default: on)") p.add_argument("--output", type=Path, default=None, metavar="PATH", help="output .dom path (- for stdout)") return p.parse_args(argv) @@ -234,6 +241,20 @@ def main(argv=None) -> int: log=lambda m: print(m, file=sys.stderr, flush=True), ) + # homemaker-py-94g: finish-time global cell→room collapse. Relabels the best + # layout's room cells to the programme rooms they fit best (label search only, + # no geometry change), kept only if it does not increase the fail count. Runs + # after the sharing polish so it acts on the canonical (materialised) best. + if args.collapse and r.best is not None: + print(file=sys.stderr) + print("--- collapse (homemaker-py-94g): finish-time cell→room relabel ---", + file=sys.stderr, flush=True) + r = driver.collapse_best( + r, programme_dir, + superpose=args.superpose, + log=lambda m: print(m, file=sys.stderr, flush=True), + ) + elapsed = time.perf_counter() - t0 print(file=sys.stderr) diff --git a/src/homemaker_layout/fitness.py b/src/homemaker_layout/fitness.py index 24f9cb4..d97788a 100644 --- a/src/homemaker_layout/fitness.py +++ b/src/homemaker_layout/fitness.py @@ -338,6 +338,7 @@ class Fitness: root: Node, adjacency: bool = True, objective: str = "threshold", + preserve_public_access: bool = True, iters: int = 6, ) -> None: """Finish-time GLOBAL cell->room collapse (homemaker-py-94g): relabel @@ -375,7 +376,16 @@ class Fitness: same weight (_COLLAPSE_FAIL_W = one avoided fail), so the collapse minimises (adjacency + size/width/proportion) fails jointly. + PRESERVE_PUBLIC_ACCESS pins the room leaf that solely provides the + building's street access (an l/k neighbour of a public outside leaf, with + no circulation fallback) so the collapse cannot drop the building-level + "no outside public access" check — the one recurring regression the + per-leaf objective cannot see (it is existential and building-scoped). + One-shot finish-time pass on a committed layout, not a per-eval re-type.""" + from collections import Counter + from . import graph as graph_mod + prog = self._programme or {} if not prog: return @@ -383,12 +393,34 @@ class Fitness: if not room_codes: return lvls = dom_mod.levels(root) - supply = [lf for lvl in lvls for lf in lvl.leaves() if lf.type in room_codes] + graphs = ( + graph_mod.build_graphs(root, self.conf("door_width") or 1.2) + if (adjacency or preserve_public_access) + else None + ) + + pinned = ( + self._public_access_pins(root, graphs, lvls, room_codes) + if (preserve_public_access and graphs is not None) + else set() + ) + supply = [ + lf + for lvl in lvls + for lf in lvl.leaves() + if lf.type in room_codes and id(lf) not in pinned + ] if not supply: return - slots: list[str] = [] - for code in sorted(room_codes): - slots.extend([code] * max(0, prog[code].count)) + # Demand = room-code counts, minus one slot per pinned leaf (its instance + # is already met by the pin, so it must not be demanded of another leaf). + slot_counts = Counter({c: max(0, prog[c].count) for c in room_codes}) + if pinned: + for lvl in lvls: + for lf in lvl.leaves(): + if id(lf) in pinned and slot_counts.get(lf.type, 0) > 0: + slot_counts[lf.type] -= 1 + slots = [c for c in sorted(slot_counts) for _ in range(slot_counts[c])] if not slots: return @@ -430,12 +462,9 @@ class Fitness: supply[r].type = slots[c] return - # Adjacency relaxation. Build the pre-merge base graph once (fixed + # Adjacency relaxation on the pre-merge base graph (built above, fixed # geometry). A satisfied adjacency is worth fail_w — one avoided fail, # the same unit as a passing factor — so both are minimised jointly. - from . import graph as graph_mod - - graphs = graph_mod.build_graphs(root, self.conf("door_width") or 1.2) code_adj = {code: prog[code].adjacency for code in set(slots)} prev_labels: list[str | None] = None # type: ignore[assignment] @@ -464,6 +493,58 @@ class Fitness: break prev_labels = new_labels + def _public_access_pins( + self, root: Node, graphs: list, lvls: list, room_codes: set + ) -> set[int]: + """id()s of room leaves to hold fixed so the building keeps street access + across a collapse. If a ground circulation leaf already gives public + access it is invariant (circulation is never relabelled) — return empty. + Otherwise, for each outside leaf that provides public access solely via an + l/k ROOM neighbour (no circulation fallback), pin one such neighbour.""" + for lvl in lvls: + for lf in lvl.leaves(): + if ( + lf.type + and lf.type[0].lower() == "c" + and self._public_access(lf, root) is not None + ): + return set() + pins: set[int] = set() + for li, lvl in enumerate(lvls): + G = graphs[li] + for lf in lvl.leaves(): + if not G.has_node(lf): + continue + if not self._public_access_outside(lf, G, root): + continue + nbs = list(G.neighbors(lf)) + if any(nb.type and nb.type[0].lower() == "c" for nb in nbs): + continue # circulation neighbour keeps access invariant + for nb in nbs: + if nb.type in room_codes and nb.type[0].lower() in ("l", "k"): + pins.add(id(nb)) + break + return pins + + def collapse_finish(self, root: Node, **kw) -> tuple[Node, int, int, bool]: + """Keep-better finish-time collapse: apply :meth:`collapse_global` to a + copy and return it only if it does not INCREASE the fail count, else the + original — a strictly monotone polish (safety belt; collapse_global is + already monotone on the harbor-house set but not proven so in general). + + Returns ``(tree, base_fails, collapsed_fails, applied)``. Both the input + and returned trees are UNMERGED — scoring is done on throwaway deepcopies + because ``score_with_fails`` merges the tree in place.""" + import copy + + base_fails = len(self.score_with_fails(copy.deepcopy(root))[1]) + cand = copy.deepcopy(root) + self.collapse_global(cand, **kw) + cand_fails = len(self.score_with_fails(copy.deepcopy(cand))[1]) + if cand_fails <= base_fails: + return cand, base_fails, cand_fails, True + return copy.deepcopy(root), base_fails, cand_fails, False + def conf(self, key: str): v = self._conf.get(key) if v is not None: diff --git a/tests/test_collapse_global.py b/tests/test_collapse_global.py new file mode 100644 index 0000000..483d875 --- /dev/null +++ b/tests/test_collapse_global.py @@ -0,0 +1,120 @@ +"""Tests for the finish-time global cell→room collapse (homemaker-py-94g). + +Covers the contracts of Fitness.collapse_global / collapse_finish: + - global relabel to the demand set (larger cell → larger target) + - hard level constraint (never introduce a wrong-level fail) + - c/o/s partition exclusion (circulation/structure cells are never relabelled) + - no-op safety (no programme) and the keep-better wrapper +""" + +from homemaker_layout import geometry +from homemaker_layout.dom import Node, _link_subtree +from homemaker_layout.fitness import Fitness + + +def _two_leaf_root(t_left: str, t_right: str, side: float = 6.0, div: float = 0.4): + geometry.clear_cache() + root = Node( + node=[[0, 0], [side, 0], [side, side], [0, side]], + rotation=0, division=[div, div], + left=Node(type=t_left), right=Node(type=t_right), + ) + _link_subtree(root, None, "") + return root + + +def _conf(spaces, **extra): + return {"spaces": spaces, **extra} + + +# --------------------------------------------------------------------------- # +# Global relabel +# --------------------------------------------------------------------------- # + +def test_relabels_to_demand_set(): + # two leaves both typed b1; demand {b1, b2} — collapse spreads them so the + # larger cell takes the larger target (b1=16) and the smaller takes b2=12. + conf = _conf({ + "b1": {"size": [16.0, 4.0], "width": [4.0, 1.0], "proportion": [1.5, 0.5]}, + "b2": {"size": [12.0, 3.0], "width": [3.5, 0.8], "proportion": [1.5, 0.5]}, + }) + fit = Fitness(conf=conf) + root = _two_leaf_root("b1", "b1") + left, right = root.leaves() + assert geometry.area(right) > geometry.area(left) + + fit.collapse_global(root) + + assert sorted(lf.type for lf in root.leaves()) == ["b1", "b2"] + assert right.type == "b1" + assert left.type == "b2" + + +# --------------------------------------------------------------------------- # +# Hard level constraint +# --------------------------------------------------------------------------- # + +def test_level_constraint_never_assigns_wrong_level(): + # b2 requires level 1; a single-storey tree is all level 0, so no leaf may + # take b2 — both stay b1 rather than gaining a wrong-level fail. + conf = _conf({ + "b1": {"size": [16.0, 4.0]}, + "b2": {"size": [12.0, 3.0], "level": 1}, + }) + fit = Fitness(conf=conf) + root = _two_leaf_root("b1", "b1") + fit.collapse_global(root) + assert all(lf.type == "b1" for lf in root.leaves()) + assert "b2" not in {lf.type for lf in root.leaves()} + + +# --------------------------------------------------------------------------- # +# c/o/s partition exclusion +# --------------------------------------------------------------------------- # + +def test_cos_prefixed_cells_are_not_relabelled(): + # cr1 collides with the c* (circulation) convention the scorer counts against, + # so it is skeleton — never relabelled and never a demand slot. + conf = _conf({ + "cr1": {"size": [20.0, 4.0]}, + "b1": {"size": [16.0, 4.0]}, + }) + fit = Fitness(conf=conf) + root = _two_leaf_root("cr1", "b1") + fit.collapse_global(root) + assert sorted(lf.type for lf in root.leaves()) == ["b1", "cr1"] + + +# --------------------------------------------------------------------------- # +# No-op safety + defaults +# --------------------------------------------------------------------------- # + +def test_no_programme_is_noop(): + fit = Fitness(conf={}) + root = _two_leaf_root("b1", "b1") + fit.collapse_global(root) + assert [lf.type for lf in root.leaves()] == ["b1", "b1"] + + +def test_single_code_is_noop(): + # one assignable code, count 2 → demand == supply of the same code → no change + conf = _conf({"b1": {"size": [16.0, 4.0], "count": 2}}) + fit = Fitness(conf=conf) + root = _two_leaf_root("b1", "b1") + fit.collapse_global(root) + assert [lf.type for lf in root.leaves()] == ["b1", "b1"] + + +def test_collapse_finish_is_keep_better_and_unmerged(): + # collapse_finish returns (tree, base, collapsed, applied); the tree it hands + # back is unmerged (leaves still carry their divisions), and collapsed<=base. + conf = _conf({ + "b1": {"size": [16.0, 4.0], "width": [4.0, 1.0], "proportion": [1.5, 0.5]}, + "b2": {"size": [12.0, 3.0], "width": [3.5, 0.8], "proportion": [1.5, 0.5]}, + }) + fit = Fitness(conf=conf) + root = _two_leaf_root("b1", "b1") + tree, base_f, coll_f, applied = fit.collapse_finish(root) + assert coll_f <= base_f + assert applied == (coll_f < base_f) or coll_f == base_f + assert len(tree.leaves()) == 2 # unmerged: both room leaves intact