homemaker-py-1s3: multi-use leaves as permanent design goal (§26 path b)
Builds path (b) from §26 -- a leaf permanently serving two DIFFERENT compatible programme codes at once, extending leaf-sharing's same-code multiplicity mechanism to different-but-compatible codes. Architect-declared `co_locate` pairs (validated against interchangeable()'s S1-S4 bounds, no transitive closure so the b3v chain problem can't recur), threaded through graph.py's checks via a new leaf_codes() resolver and fitness.py's quality terms (additive size, stricter-of-both width/proportion). Construction-time only, gated behind `multi_use` (default OFF, bit-identical when off). End-to-end A/B (20k evals x 3 seeds x 2 programmes) came back net negative: harbor-house -4.0% but health-centre +24.5% worse (3/3 seeds), because fusing different codes' shape targets via stricter-of-both can impose a tighter joint constraint than either code needed alone, which the tightly- packed health-centre programme can't absorb. Written up as DESIGN.md §33; multi_use stays default OFF, no default-flip recommended. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8agJBT2ZpmF3ErW7wi2wY
This commit is contained in:
parent
eec075c4a7
commit
27719975b0
14 changed files with 941 additions and 80 deletions
|
|
@ -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-1s3","title":"Multi-use leaves as permanent design goal (§26 path b, never attempted)","description":"§26 (homemaker-py-9o5/xi7/b3v) scoped two readings of multi-use leaves (a leaf legitimately serving several DIFFERENT compatible programme codes at once, e.g. study+guest-bedroom, kitchen+dining — Stewart Brand's 'loose-fit' rooms): (a) superposition as a SEARCH RELAXATION — carry uncommitted candidate types per leaf, collapse to one usage only at scoring time; (b) multi-use as the PERMANENT DESIGN GOAL, surviving into the output with no collapse. Only (a) was built and measured, and it was NULL/NEGATIVE — diagnosed as underperforming not from a relaxation gap (measured small, gap_ratio 1.01-1.23) but because the geometry floor dominates: type labels are not the binding constraint on these programmes, so easing them buys nothing while re-typing adds feasibility noise (fitness.py per-eval collapse perturbs counts/adjacency).\n\nPath (b) was never built. It's structurally different from (a): rather than a per-eval relabelling relaxation on top of the existing leaf count, it would permanently REDUCE leaf count by having one leaf serve two rooms' worth of programme requirement simultaneously — the same structural mechanism as leaf-sharing (§13.3, homemaker-py-x3b), which is the single biggest positive lever in the whole DESIGN.md log (harbor-house −21% to −32% at various stages, because it cuts leaf count in a way the search cannot mutate back — §13.4/13.5's key finding that levers the search 'cannot erode' compound, unlike shape-only levers that wash out over a 20k-eval budget).\n\nMultiple independent diagnostics (§12.3 calibration, §12.4's conclusion, §13.1's per-leaf saturation analysis) converge on: the residual fail floor at harbor/maple scale is driven by having as many leaves as there are distinct rooms (52 rooms -\u003e 73 leaves at 44% utilisation gives every leaf a high perimeter/area ratio). Leaf-sharing already exploits this for SAME-code multiplicities; path (b) would extend the same leverage to DIFFERENT-but-compatible codes, which is a materially larger addressable set on programmes with many small single-instance rooms (offices, WCs, meeting rooms — see health-centre, examples/health-centre, 19 distinct codes).\n\nTask: design + build path (b) — likely: SpaceReq gains a compatibility/co-location relation (reuse or extend programme.derive_interchange_classes' S1-S4 guards, or a new explicit 'co_locate' declaration since 'interchangeable' semantics don't fit two DIFFERENT codes coexisting), a leaf can be permanently typed as serving code-pair (or code-set) X, and check_space_counts/quality functions treat the areas as shared per the k-instances-per-leaf model §13.3 already uses for same-code sharing. Gate behind a new conf flag (default OFF, bit-identical when off, per this project's established pattern for every §13.x/§20+ lever). A/B against the current default stack on harbor-house and health-centre (the diverse-room-type programme built for xyu/9yx, §31/§32) before considering a default flip — same discipline as every other lever in this log.","status":"open","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-07-30T07:55:56Z","created_by":"Bruno Postle","updated_at":"2026-07-30T07:55:56Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-1s3","title":"Multi-use leaves as permanent design goal (§26 path b, never attempted)","description":"§26 (homemaker-py-9o5/xi7/b3v) scoped two readings of multi-use leaves (a leaf legitimately serving several DIFFERENT compatible programme codes at once, e.g. study+guest-bedroom, kitchen+dining — Stewart Brand's 'loose-fit' rooms): (a) superposition as a SEARCH RELAXATION — carry uncommitted candidate types per leaf, collapse to one usage only at scoring time; (b) multi-use as the PERMANENT DESIGN GOAL, surviving into the output with no collapse. Only (a) was built and measured, and it was NULL/NEGATIVE — diagnosed as underperforming not from a relaxation gap (measured small, gap_ratio 1.01-1.23) but because the geometry floor dominates: type labels are not the binding constraint on these programmes, so easing them buys nothing while re-typing adds feasibility noise (fitness.py per-eval collapse perturbs counts/adjacency).\n\nPath (b) was never built. It's structurally different from (a): rather than a per-eval relabelling relaxation on top of the existing leaf count, it would permanently REDUCE leaf count by having one leaf serve two rooms' worth of programme requirement simultaneously — the same structural mechanism as leaf-sharing (§13.3, homemaker-py-x3b), which is the single biggest positive lever in the whole DESIGN.md log (harbor-house −21% to −32% at various stages, because it cuts leaf count in a way the search cannot mutate back — §13.4/13.5's key finding that levers the search 'cannot erode' compound, unlike shape-only levers that wash out over a 20k-eval budget).\n\nMultiple independent diagnostics (§12.3 calibration, §12.4's conclusion, §13.1's per-leaf saturation analysis) converge on: the residual fail floor at harbor/maple scale is driven by having as many leaves as there are distinct rooms (52 rooms -\u003e 73 leaves at 44% utilisation gives every leaf a high perimeter/area ratio). Leaf-sharing already exploits this for SAME-code multiplicities; path (b) would extend the same leverage to DIFFERENT-but-compatible codes, which is a materially larger addressable set on programmes with many small single-instance rooms (offices, WCs, meeting rooms — see health-centre, examples/health-centre, 19 distinct codes).\n\nTask: design + build path (b) — likely: SpaceReq gains a compatibility/co-location relation (reuse or extend programme.derive_interchange_classes' S1-S4 guards, or a new explicit 'co_locate' declaration since 'interchangeable' semantics don't fit two DIFFERENT codes coexisting), a leaf can be permanently typed as serving code-pair (or code-set) X, and check_space_counts/quality functions treat the areas as shared per the k-instances-per-leaf model §13.3 already uses for same-code sharing. Gate behind a new conf flag (default OFF, bit-identical when off, per this project's established pattern for every §13.x/§20+ lever). A/B against the current default stack on harbor-house and health-centre (the diverse-room-type programme built for xyu/9yx, §31/§32) before considering a default flip — same discipline as every other lever in this log.","notes":"FINAL RESULT (clean run, task bmiysi3tv): net negative, not recommended for default flip.\n\nharbor-house: baseline 95/101/103 (mean 99.7) vs multi_use 92/101/94 (mean 95.7) = -4.0% (2W/1T/0L)\nhealth-centre: baseline 63/82/71 (mean 72.0) vs multi_use 81/111/77 (mean 89.7) = +24.5% WORSE (0W/0T/3L)\n\nDiagnosis: unlike leaf-sharing (same-code k*target, never changes the shape constraint), multi-use fusion\ncombines width/proportion via stricter-of-both, which can impose a TIGHTER joint shape constraint than\neither code alone required, on top of a larger combined area target. harbor-house (fewer/larger rooms,\nmore slack) absorbs this; health-centre (19 codes packed tight, the exact stress case it was built for)\ndoes not. Net negative when weighted across both programmes.\n\nMechanism is fully built, tested (333/333 passing, 31 new tests), gated OFF by default, bit-identical when\noff. Written up as DESIGN.md §33. multi_use stays default OFF -- not a recommended default flip. No\nfollow-up bead filed (project's established 0-for-several pattern on search-machinery/fitness-shaping\nlevers, per §33's own closing discussion).","status":"in_progress","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-30T07:55:56Z","created_by":"Bruno Postle","updated_at":"2026-07-30T23:14:35Z","started_at":"2026-07-30T09:20:03Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-h10","title":"Re-run §12.3 reassociate/shape-feasibility A/B at fixed worker count","description":"§12.3 (homemaker-py-9gp) measured mutate_reassociate (M3 Wong-Liu move) and the shape-feasibility pre-filter as negative: +3.3/+4.0 fails on maple/harbor. But that A/B ran BEFORE §12.4 (homemaker-py-c3g) found and fixed a real nondeterminism bug — driver._run_batch admitted parallel futures in completion order rather than submission order, producing ±3-6 fail noise between otherwise-identical runs. §12.4's own writeup flags this explicitly: 'sub-±3 effects (the §12.3 +3-4 negatives, the §12.4 ±1.7) should be re-run at a single fixed worker count before being trusted as magnitudes.' That re-run was never done for §12.3.\n\nReassociate is the only search-machinery move in the whole DESIGN.md log verified to reach genuinely new tree topologies (confirmed on synthetic cases in §12.3's own tests) — every other outer-search-machinery lever tried (niching, graded objective, island model, grain annealing, graded connectivity, circulation repair, beam search, ruin-recreate, bubble-diagram signal) is independently null-to-negative for other reasons, so this is the one candidate whose 'negative' verdict might be pure measurement artifact rather than a real finding.\n\nTask: re-run experiments/run_9gp_ab.sh (maple-court + harbor, seeds 0/1/2, 20000 evals, staged) with the post-c3g determinism fix in place, at a single fixed worker count (matching whatever the original run used — check the script/log for workers=N). If the negative holds at fixed worker count, close as confirmed-null (upgrade §12.3's confidence). If it flips positive or neutral, this reopens the reachability question closed in §12.3/§12.4's 'residual is geometry floor, not search-reachability' conclusion — would need a larger-N confirmation before any default flip, per this project's own evidentiary bar (cf. f1d/1ph/e01 pattern).","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-30T07:55:15Z","created_by":"Bruno Postle","updated_at":"2026-07-30T08:14:43Z","started_at":"2026-07-30T08:06:25Z","closed_at":"2026-07-30T08:14:43Z","close_reason":"Confirmed without a full re-run: run_9gp_ab.sh/run_staged_search.py never threaded a worker count, so every §12.3 arm already ran at n_workers=1 (serial) — the mode §12.4 already proved byte-for-byte reproducible even before the completion-order fix (that bug is ProcessPoolExecutor-as_completed-only). Spot-checked empirically too: same config run twice (harbor-house s0, budget 300) gave identical fail counts at every checkpoint. §12.3's negative verdict is CONFIRMED at fixed worker count; DESIGN.md §12.4 updated with the finding.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-9wi","title":"Adjacency-aware discrete assignment for finish-time collapse (QAP/CP-SAT)","description":"fitness.collapse_superposition (homemaker-py-9o5/94g family) already does exact optimal relabelling of superposed leaves via brute-force permutation (CLASS_CAP\u003c=4) or Hungarian (linear_sum_assignment) beyond that -- but _best_assignment's docstring is explicit that the objective is deliberately SEPARABLE per leaf (quality_size * quality_width * quality_proportion only); perpendicular/crinkliness/access/adjacency are assumed usage-invariant within a class and left out, because adjacency quality depends on PAIRS of leaf-label assignments, not one leaf at a time, which breaks the exact separable solve.\n\nProposal: extend the collapse step to account for adjacency between candidate labels -- either a quadratic-assignment-style local search (2-opt swaps over the current Hungarian solution, accepting swaps that improve total adjacency satisfaction) or a CP-SAT (OR-Tools) encoding of the labelling problem with pairwise adjacency terms. This directly extends the one search-adjacent technique (exact/near-exact discrete assignment) that has actually paid off in this project, into territory the current separable solve cannot reach.\n\nMeasure against the current Hungarian-only collapse on harbor-house (heavy interchange-class usage: neighborhoods, meeting rooms, individual rooms) where adjacency-blind relabelling is most likely to leave adjacency fails on the table.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-25T20:18:24Z","created_by":"Bruno Postle","updated_at":"2026-07-26T19:57:55Z","started_at":"2026-07-26T14:50:36Z","closed_at":"2026-07-26T19:57:55Z","close_reason":"2-opt local search DELIVERED beyond collapse_global's Jacobi adjacency relaxation:\nFitness._collapse_value (refactored per-leaf-per-code value, shared by the\nassignment matrix and the polish) + Fitness._two_opt_adjacency_polish (same-level\npairwise label-swap search, kept only on strict improvement -- monotone by\nconstruction) + collapse_global(local_search=..., local_search_passes=...) +\nhomemaker-collapse --local-search CLI flag + regression test\n(test_two_opt_polish_escapes_jacobi_plateau) that proves the Jacobi loop can\n2-cycle between two labellings satisfying ZERO of a satisfiable adjacency set,\nand that 2-opt escapes it.\n\nWent with 2-opt over CP-SAT/OR-Tools: no new dependency (project has no\nortools), directly extends the existing Jacobi machinery, and the issue listed\nit as the first alternative. QAP is NP-hard in general so this is a local\nsearch, not an exact solve, but it strictly dominates the Jacobi-only result\n(never worse, by construction).\n\nMeasured against harbor-house per the issue's own instruction: swept all 11\nevolved-*/3m/materialised .dom files, comparing collapse_global(local_search=False)\nvs (local_search=True). 10/11 matched exactly (Jacobi was already at the local\n2-opt optimum), 0 regressed, 1 improved (evolved-anneal-3M.dom 21-\u003e19 fails --\nresolved a genuine mutual da1\u003c-\u003ek1 adjacency miss). Runtime \u003c1s even on the\nlargest file (90-fail evolved-3M.dom). Findings + the plateau proof saved via\nbd remember. Default left OFF (opt-in via local_search=True /\nhomemaker-collapse --local-search) pending a broader sweep and evolve.py CLI\nwiring -- spun off as homemaker-py-cdl.\n\n298/298 tests pass (7 in test_collapse_global.py, up from 6).","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-f1d","title":"Ruin-and-recreate LNS: rebuild wings with the adjacency-aware constructor mid-search","description":"DESIGN.md's own experiment log shows every 'search machinery' change (niching+restarts 11.5, graded objective 11.4, Wong-Liu reassociation+shape-feasibility 12.3, granularity 12.4, island model 14, grain annealing 16, circulation-repair ops 21/22) has come back null-to-negative, while construction/seeding quality (adjacency-aware seeding 11.6/11.7, proportion-aware seeding 12.2) is the only lever that has ever moved the fail count. operators._assign_adjacency_aware currently only runs once, at seeding.\n\nProposal: a large-neighbourhood-search move that periodically un-divides a whole wing/subtree of the CURRENT BEST individual and reconstructs just that region using the same proven adjacency-aware constructive heuristic (seeded from the surviving circulation spine as fixed_circ, same mechanism lift_base_to_storeys already uses for upper floors), instead of relying only on small local mutation operators to find improvements. Reuses the one technique with a real track record, applied repeatedly during search rather than once at initialisation.\n\nA/B against the current baseline on programme-house and harbor-house at a fixed worker count (see 12.4's determinism-fix note about serial vs parallel admission order before trusting sub-±3 effects).","notes":"Implementation landed (uncommitted, pending A/B): operators.mutate_ruin_recreate — picks a divided, live-cut wing of one storey (2..half its leaves), un-divides it, regrows+retypes it via a scope-generalised operators._assign_adjacency_aware (new 'scope' param restricts retyping to a leaf subset while fixed_circ seeds can be border leaves outside that subset), seeded from already-typed circulation leaves bordering the wing. Room-code budget inside the wing is preserved exactly; circ/outside counts rebuilt at circ_divisor=3/outside_divisor=3. Gated like reassociate/bridge_circulation: mutation_weights['ruin_recreate']=0.0 unless driver.search(enable_ruin_recreate=True); CLI flag --ruin-recreate/--no-ruin-recreate added to evolve.py (default off). 297 existing tests pass unchanged; added smoke coverage (200 applications on harbor-house constructive seeds: zero missing-space regressions, all canonical). A/B now running in background: experiments/run_f1d_ab.sh, qpk protocol (harbor-house budget=2500 seeds 1-3, programme-house budget=3000 seeds 1-5, workers=4, both arms finished with standard --collapse), results -\u003e scratch/f1d_ab_results.tsv.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-25T20:18:02Z","created_by":"Bruno Postle","updated_at":"2026-07-26T08:30:13Z","started_at":"2026-07-25T20:20:56Z","closed_at":"2026-07-26T08:30:13Z","close_reason":"DONE (positive, size-dependent). operators.mutate_ruin_recreate landed: un-divides one wing of a storey (2..half its leaves), rebuilds it via a scope-generalised _assign_adjacency_aware seeded from bordering circulation, mirroring lift_base_to_storeys' core-inheritance mechanism. Gated like reassociate/bridge_circulation (enable_ruin_recreate, default off; --ruin-recreate CLI flag). Initial uniform-weight A/B was null but underpowered (op fired ~1/32 children); a weight=3.0 follow-up (_MUTATION_WEIGHTS['ruin_recreate']=3.0, kept in source) showed a statistically significant win on programme-house across 15 seeds (8W/1L/6T, mean fails 7.07-\u003e6.00, Wilcoxon p=0.041, sign-test p=0.020) but no consistent effect on harbor-house across 8 seeds (3W/2L/3T, mean fails 73.0-\u003e74.5, slight negative lean). Kept default OFF pending a size-threshold follow-up (not filed) -- same conservative bar qpk/1ph applied before its own larger-N confirmation. Full writeup: DESIGN.md §23.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
|
@ -98,27 +98,27 @@
|
|||
{"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":"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":"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":"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":"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":"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":"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-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":"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":"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":"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":"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":"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":"collapse-global-s-jacobi-adjacency-relaxation-homemaker-py","value":"collapse_global's Jacobi adjacency relaxation (homemaker-py-94g) is a synchronous per-round linear-assignment re-solve, which can 2-cycle indefinitely between two labellings that each satisfy ZERO adjacency requirements even though a permutation satisfying ALL of them exists -- proven on a minimal 4-cell chain (p1-q1-p2-q2, two disjoint adjacency pairs p1\u003c-\u003ep2/q1\u003c-\u003eq2) in test_two_opt_polish_escapes_jacobi_plateau. homemaker-py-9wi added Fitness._two_opt_adjacency_polish: a same-level pairwise-swap local search run after the Jacobi fixpoint, gated behind collapse_global(local_search=True) (default off, exposed as homemaker-collapse --local-search). Monotone by construction (a swap is kept only if it strictly increases total reward). Empirically on the 11 harbor-house evolved-*.dom/3m.dom/materialised-3M.dom layouts: 10 matched Jacobi-only exactly, 0 regressed, and evolved-anneal-3M.dom improved 21-\u003e19 fails (fixed a genuine mutual da1\u003c-\u003ek1 adjacency miss the Jacobi loop couldn't reach)."}
|
||||
{"_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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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-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":"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":"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":"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":"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":"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":"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":"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":"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":"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."}
|
||||
|
|
|
|||
84
DESIGN.md
84
DESIGN.md
|
|
@ -3458,3 +3458,87 @@ on every real (non-duplicated-code) programme tested at any scale from 6 rooms (
|
|||
(`harbor-house`), and only ever weakly positive on the synthetic duplicated-code sweep that this result
|
||||
suggests was measuring the wrong thing. No further follow-up is filed — the room-count hypothesis from
|
||||
`f1d` (§23) is now addressed on the diversity axis `xyu` (§31) could not reach.
|
||||
|
||||
## 33. Multi-use leaves as a permanent design goal (`homemaker-py-1s3`, §26 path b) — DONE (mixed, net negative)
|
||||
|
||||
**Motivation.** §26 scoped two readings of "multi-use leaves" — a leaf legitimately serving several
|
||||
DIFFERENT compatible programme codes at once (study+guest bedroom, kitchen+dining, Stewart Brand's
|
||||
"loose-fit" long-life rooms). Path (a), superposition as a per-eval search relaxation, was built and
|
||||
measured NULL/NEGATIVE (§26): the geometry floor dominates, not the type-labelling relaxation gap. Path
|
||||
(b) — multi-use as the *permanent design goal*, surviving into the output with no collapse — was never
|
||||
attempted. The framing going in: path (b) is structurally the same lever as leaf-sharing (§13.3,
|
||||
`homemaker-py-x3b`) — the single biggest positive lever in the project (−32…−39% on the achievable fail
|
||||
floor) — extended from *same*-code multiplicity to *different*-but-compatible codes, with a materially
|
||||
larger addressable set on programmes with many small single-instance rooms (`health-centre`'s 19 distinct
|
||||
codes, §32).
|
||||
|
||||
**Mechanism.** Explicit, architect-declared `co_locate: [code, ...]` per `SpaceReq` (unlike `interchange`
|
||||
classes, never auto-derived — fusing two codes onto one leaf is a much stronger commitment than a soft
|
||||
substitution class). `programme.derive_colocate_pairs` keeps a declared pair only if it *also* passes the
|
||||
existing `interchangeable()` S1-S4 relation (§26/`9o5`) — reusing the already-validated bounds instead of
|
||||
inventing a second relation — and returns pairs only, never folding them into connected components, so the
|
||||
`b3v` transitive-chain failure mode (§26) cannot arise by construction. `Node.co_type` (new field, sibling
|
||||
to `share`/`share_type`) records the second code a leaf serves; `graph.leaf_codes()` is the resolver every
|
||||
programme-check function (`check_space_counts`, `check_adjacency`, `check_level_constraints`,
|
||||
`check_vertical_connectivity`, `has_adjacency`, `has_vertical_connection`) now routes through instead of
|
||||
comparing `leaf.type` directly — returning `[type, co_type]` only while `multi_use` is on AND the pair is
|
||||
still a currently-valid declared co-location (a retype silently drops a stale `co_type`, the same
|
||||
self-healing type-guard `leaf_share` uses). `fitness.quality_size` combines a fused leaf's two codes
|
||||
**additively** (target and sigma both sum — the same operation as leaf-sharing's k×target, generalised
|
||||
from k identical terms to 2 different ones); `quality_width`/`quality_proportion` take the **stricter** of
|
||||
the two codes' targets (max target, min sigma) since shape, unlike floor area, does not add across two
|
||||
uses. Construction-time only (no mutation operator): `operators._colocate_rooms` greedily fuses available
|
||||
same-storey instances of a declared pair (before `_share_rooms`, so same-code sharing still groups
|
||||
whichever code is kept primary), `_leaf_colocate_from_plan` stamps the winning leaves, and
|
||||
`_size_divisions_from_targets` grows the fused leaf to the combined target. Gated behind `multi_use`
|
||||
(default OFF, bit-identical when off — 333/333 tests pass including 31 new ones in `tests/test_multi_use.py`).
|
||||
Threaded end-to-end through `driver.py`/`evolve.py --multi-use`, mirroring `superpose`'s existing wiring.
|
||||
|
||||
**Declared pairs.** Architect-authored in each programme's `patterns.config`, hand-picked from the pool of
|
||||
`interchangeable()`-eligible candidates on semantic grounds (not every eligible pair is a sensible fusion —
|
||||
e.g. `health-centre`'s public/staff WCs and sterilisation room pass the S1-S4 bounds but were deliberately
|
||||
left undeclared): `harbor-house` — foyer/meeting-room (`ef1`/`m`), laundry/plant-room (`la1`/`me1`);
|
||||
`health-centre` — admin/manager's office (`ao1`/`mo1`), admin/staff-room (`ao1`/`br1`), dental/minor-surgery
|
||||
(`de1`/`ms1`), storage/records (`dp1`/`re1`).
|
||||
|
||||
**End-to-end A/B** (`experiments/run_multiuse_ab.sh`, staged search, 20 000 native evals, seeds 0/1/2,
|
||||
4 workers, final native re-score, mirrors §13.3's harness):
|
||||
|
||||
| programme | baseline (s0/1/2) | mean | multi_use (s0/1/2) | mean | Δ |
|
||||
|---------------|--------------------|------:|---------------------|------:|----------:|
|
||||
| harbor-house | 95 / 101 / 103 | 99.7 | 92 / 101 / 94 | 95.7 | **−4.0%** |
|
||||
| health-centre | 63 / 82 / 71 | 72.0 | 81 / 111 / 77 | 89.7 | **+24.5%**|
|
||||
|
||||
harbor-house: multi_use wins 2/3 seeds, ties 1, loses 0. health-centre: multi_use **loses 3/3 seeds**, every
|
||||
seed strictly worse. Net across both programmes: 2 wins / 1 tie / 3 losses, and the health-centre loss
|
||||
(+24.5%) is nearly 6x the magnitude of harbor-house's gain (−4.0%) — a net-negative result, not a wash.
|
||||
|
||||
**Diagnosis — why this lever behaves differently from leaf-sharing despite the structural similarity.**
|
||||
Leaf-sharing's k×target scaling never changes the SHAPE constraint: k identical rooms share one identical
|
||||
width/proportion target, so a shared leaf is exactly as easy or hard to satisfy geometrically as any single
|
||||
instance of that code, just bigger — the only thing that changes is the count check and the size Gaussian's
|
||||
centre. Multi-use fusion is different: combining two potentially-DIFFERENT codes' width/proportion targets
|
||||
via the stricter-of-both rule (necessary — a fused room must serve both uses) can impose a **tighter joint
|
||||
shape constraint than either original code required alone**, on top of a **larger combined area target**
|
||||
that competes for the same limited plot area as every other room. On `harbor-house` (fewer, larger rooms,
|
||||
more slack per leaf) this cost is absorbed and the leaf-count saving wins narrowly. On `health-centre` (19
|
||||
distinct codes packed into a small footprint, the exact stress case §32 was built to probe) the tighter
|
||||
combined constraint and the larger area demand appear to cost more than the leaf-count reduction saves —
|
||||
consistent with §13.1/§13.2's finding that the geometry floor, not room count in isolation, is what
|
||||
dominates on tightly-packed diverse programmes. This was not instrumented further (the effect is large and
|
||||
directionally consistent across all 3 health-centre seeds, not a borderline case needing a relaxation-gap
|
||||
probe the way §26's `xi7` needed one).
|
||||
|
||||
**Status.** `multi_use` stays default **OFF**. Not recommended for a default flip: the result is mixed at
|
||||
best and net negative when weighted across both example programmes, the opposite of leaf-sharing's total
|
||||
separation (§13.3: "*every* share run beats *every* baseline run"). This is the qualitative distinction the
|
||||
project's own vocabulary already has a name for: leaf-sharing is a pure count-relaxation (§13.3's "floor
|
||||
mover" that "the search cannot erode", §13.4/13.5), while different-code fusion is *also* a shape-tightening
|
||||
constraint that construction cannot always pay for — closer in spirit to the search-machinery/fitness-shaping
|
||||
levers that have gone null-to-negative throughout this log (§11.4/11.5, §14, §16, §21, §22, §26, §27, §30,
|
||||
§31) than to the small set of construction/seed-quality wins that actually moved the floor (§13.3, §17/§25).
|
||||
If revisited: a per-pair opt-in (declare `co_locate` only where the architect has verified the combined
|
||||
target is a good match, rather than accepting anything `interchangeable()`-eligible) or a softer shape
|
||||
combination (e.g. averaging width/proportion targets instead of stricter-of-both) might recover the
|
||||
harbor-house-style win without health-centre's penalty — but this was not tested and is not filed as a
|
||||
follow-up bead given the project's 0-for-several record on this class of lever.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ spaces:
|
|||
level: 0
|
||||
adjacency:
|
||||
- c
|
||||
# homemaker-py-1s3 (§26 path b): a foyer can double as an informal meeting
|
||||
# nook (Stewart Brand "loose-fit") — declared here, validated against
|
||||
# interchangeable()'s S1-S4 bounds before it's ever honoured.
|
||||
co_locate:
|
||||
- m
|
||||
|
||||
da1:
|
||||
name: Dining Area
|
||||
|
|
@ -110,6 +115,8 @@ spaces:
|
|||
adjacency:
|
||||
- c
|
||||
count: 3
|
||||
co_locate:
|
||||
- ef1
|
||||
|
||||
# GROUND FLOOR - Service Areas
|
||||
# BATHROOMS - Ground floor (3 total)
|
||||
|
|
@ -136,6 +143,10 @@ spaces:
|
|||
level: 0
|
||||
adjacency:
|
||||
- c
|
||||
# homemaker-py-1s3: laundry and plant/M&E rooms are both "back of house"
|
||||
# utility space — a common real loose-fit pairing.
|
||||
co_locate:
|
||||
- me1
|
||||
|
||||
st1:
|
||||
name: Ground Floor Storage
|
||||
|
|
@ -160,6 +171,8 @@ spaces:
|
|||
level: 0
|
||||
adjacency:
|
||||
- c
|
||||
co_locate:
|
||||
- la1
|
||||
|
||||
# STAFF OFFICES - Anonymous (2 total)
|
||||
of:
|
||||
|
|
|
|||
|
|
@ -101,6 +101,10 @@ spaces:
|
|||
- 0.4
|
||||
adjacency:
|
||||
- c
|
||||
# homemaker-py-1s3 (§26 path b): two similar-scale clinical procedure
|
||||
# rooms, flexible enough to double up in a small practice.
|
||||
co_locate:
|
||||
- de1
|
||||
|
||||
de1:
|
||||
name: Dental Surgery
|
||||
|
|
@ -116,6 +120,8 @@ spaces:
|
|||
adjacency:
|
||||
- c
|
||||
- zt1
|
||||
co_locate:
|
||||
- ms1
|
||||
|
||||
zt1:
|
||||
name: Sterilisation Room
|
||||
|
|
@ -188,6 +194,10 @@ spaces:
|
|||
- 0.4
|
||||
adjacency:
|
||||
- c
|
||||
# homemaker-py-1s3: a small practice's manager's office and admin office
|
||||
# are a natural loose-fit pairing.
|
||||
co_locate:
|
||||
- ao1
|
||||
|
||||
ao1:
|
||||
name: Admin Office
|
||||
|
|
@ -202,6 +212,9 @@ spaces:
|
|||
- 0.5
|
||||
adjacency:
|
||||
- c
|
||||
co_locate:
|
||||
- mo1
|
||||
- br1
|
||||
|
||||
re1:
|
||||
name: Records Room
|
||||
|
|
@ -217,6 +230,8 @@ spaces:
|
|||
adjacency:
|
||||
- c
|
||||
- ao1
|
||||
co_locate:
|
||||
- dp1
|
||||
|
||||
br1:
|
||||
name: Staff Room
|
||||
|
|
@ -231,6 +246,8 @@ spaces:
|
|||
- 0.4
|
||||
adjacency:
|
||||
- c
|
||||
co_locate:
|
||||
- ao1
|
||||
|
||||
kt1:
|
||||
name: Staff Kitchenette
|
||||
|
|
@ -275,6 +292,8 @@ spaces:
|
|||
- 0.4
|
||||
adjacency:
|
||||
- c
|
||||
co_locate:
|
||||
- re1
|
||||
|
||||
t9:
|
||||
name: Public WC
|
||||
|
|
|
|||
44
experiments/run_multiuse_ab.sh
Executable file
44
experiments/run_multiuse_ab.sh
Executable file
|
|
@ -0,0 +1,44 @@
|
|||
#!/usr/bin/env bash
|
||||
# Multi-use leaves A/B (homemaker-py-1s3, DESIGN.md §26 path b): does fusing
|
||||
# permanent DIFFERENT-code pairs onto one leaf (co_locate, validated against
|
||||
# interchangeable()'s S1-S4 bounds) lower the end-to-end fail count the same
|
||||
# way leaf-sharing's SAME-code multiplicity did (§13.3, -32...-39% on the
|
||||
# floor)? Baseline arm (MULTIUSE=0) must reproduce the current default stack;
|
||||
# the experiment arm (MULTIUSE=1) is the lever. Both arms score against
|
||||
# whichever example programme's patterns.config declares co_locate pairs
|
||||
# (harbor-house: ef1/m, la1/me1; health-centre: ao1/mo1, ao1/br1, de1/ms1,
|
||||
# dp1/re1 — see §29/§26-path-b write-up).
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
BUDGET="${1:-20000}"
|
||||
OUT=scratch/multiuse_ab; mkdir -p "$OUT"
|
||||
TSV=scratch/multiuse_results.tsv
|
||||
[ -f "$TSV" ] || printf 'programme\tseed\tmulti_use\tfails\ttopologies\telapsed_s\n' > "$TSV"
|
||||
|
||||
run() { # programme seed multi_use(0|1)
|
||||
local prog="$1" seed="$2" mu="$3"
|
||||
local tag="mu${mu}"
|
||||
local log="$OUT/${prog}_${tag}_s${seed}.log"
|
||||
echo ">>> $prog seed=$seed multi_use=$mu"
|
||||
local t0; t0=$(date +%s)
|
||||
env URB_NO_OCCLUSION=1 MULTIUSE="$mu" WORKERS="${WORKERS:-4}" \
|
||||
python3 experiments/run_staged_search.py "examples/$prog" "$BUDGET" "$seed" \
|
||||
"examples/$prog/init.dom" "$OUT/${prog}_${tag}_s${seed}.dom" > "$log" 2>&1
|
||||
local t1; t1=$(date +%s)
|
||||
local fails topos
|
||||
fails=$(grep 're-scored (native)' "$log" | tail -1 | sed -n 's/.*(\([0-9]*\) fails).*/\1/p')
|
||||
topos=$(grep -m1 '^evals' "$log" | sed -n 's/.*across \([0-9]*\) topologies.*/\1/p')
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$prog" "$seed" "$mu" "${fails:-ERR}" "${topos:-?}" "$((t1-t0))" >> "$TSV"
|
||||
echo " -> ${fails:-ERR} fails, ${topos:-?} topologies, $((t1-t0))s"
|
||||
}
|
||||
|
||||
# baseline controls (must reproduce the current default stack) then the
|
||||
# multi_use arm, both seeds 0/1/2, on both example program with declared
|
||||
# co_locate pairs.
|
||||
for prog in harbor-house health-centre; do
|
||||
for seed in 0 1 2; do run "$prog" "$seed" 0; done
|
||||
for seed in 0 1 2; do run "$prog" "$seed" 1; done
|
||||
done
|
||||
|
||||
echo "=== multi-use A/B complete ==="
|
||||
column -t -s $'\t' "$TSV"
|
||||
|
|
@ -69,27 +69,32 @@ def main() -> int:
|
|||
depth_bal = os.environ.get("DEPTHBAL", "0") == "1" # erc.4 depth-balanced grow A/B
|
||||
interior_o = os.environ.get("INTERIORO", "0") == "1" # ld2 interior light-well A/B
|
||||
out_div = int(os.environ.get("ODIV", "6")) # ld2 outside-leaf-per-room divisor
|
||||
multi_use = os.environ.get("MULTIUSE", "0") == "1" # 1s3 §26 path b multi-use A/B
|
||||
workers = int(os.environ.get("WORKERS", "1")) # parallel child evaluation
|
||||
|
||||
if leaf_share:
|
||||
# erc.3 §13.3: the inner-loop and final-score fitness are built from the
|
||||
# dir's patterns.config (which has no leaf_sharing key); inject it here so
|
||||
# the WHOLE pipeline scores under the same relaxed objective the
|
||||
# constructed shared leaves target. Keeps both A/B arms on one dir.
|
||||
if leaf_share or multi_use:
|
||||
# erc.3 §13.3 / 1s3 §26: the FINAL re-score below (`_native_score`) loads
|
||||
# patterns.config fresh with no overrides, so it must see the same flags
|
||||
# the driver optimised against or the sanity MISMATCH check (and the
|
||||
# reported "re-scored (native)" fail count this script's A/B harness
|
||||
# greps) would be wrong. driver.search_staged already threads its own
|
||||
# overrides into the inner loop; this patches the dir-level default too.
|
||||
_orig_load = fitness.load_config
|
||||
|
||||
def _load_with_sharing(directory, overrides=None):
|
||||
# x3b: driver/innerloop now pass overrides={"leaf_sharing": True}; honour
|
||||
# the kwarg, then pin the experiment's extra A/B knob on top.
|
||||
def _load_with_flags(directory, overrides=None):
|
||||
conf, cost = _orig_load(directory, overrides=overrides)
|
||||
conf = dict(conf)
|
||||
conf["leaf_sharing"] = True
|
||||
# hph §13.8: share-aware edge-too-long cap now defaults ON under
|
||||
# leaf_sharing, so pin both A/B arms explicitly (SHAREEDGE=0 keeps
|
||||
# the pre-flip control reproducible).
|
||||
conf["share_edge_cap"] = share_edge
|
||||
if leaf_share:
|
||||
conf["leaf_sharing"] = True
|
||||
# hph §13.8: share-aware edge-too-long cap now defaults ON under
|
||||
# leaf_sharing, so pin both A/B arms explicitly (SHAREEDGE=0 keeps
|
||||
# the pre-flip control reproducible).
|
||||
conf["share_edge_cap"] = share_edge
|
||||
if multi_use:
|
||||
conf["multi_use"] = True
|
||||
return conf, cost
|
||||
|
||||
fitness.load_config = _load_with_sharing
|
||||
fitness.load_config = _load_with_flags
|
||||
|
||||
print(f"programme : {programme_dir.name}")
|
||||
print(f"seed : {seed_file.name}")
|
||||
|
|
@ -107,6 +112,8 @@ def main() -> int:
|
|||
print(f"leaf_share: {leaf_share} (factor={leaf_share_fac})")
|
||||
print(f"share_edge: {share_edge}")
|
||||
print(f"interior_o: {interior_o} (odiv={out_div})")
|
||||
print(f"multi_use : {multi_use}")
|
||||
print(f"workers : {workers}")
|
||||
print(flush=True)
|
||||
|
||||
seed_root = dom.load(str(seed_file))
|
||||
|
|
@ -123,6 +130,7 @@ def main() -> int:
|
|||
base_p=0.15,
|
||||
p_crossover=0.2,
|
||||
seed=rng_seed,
|
||||
n_workers=workers,
|
||||
log=lambda m: print(m, flush=True),
|
||||
use_grade=use_grade,
|
||||
tournament_k=tournament_k,
|
||||
|
|
@ -139,6 +147,7 @@ def main() -> int:
|
|||
depth_balanced=depth_bal,
|
||||
interior_outside=interior_o,
|
||||
outside_divisor=out_div,
|
||||
multi_use=multi_use,
|
||||
)
|
||||
|
||||
elapsed = time.perf_counter() - t0
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@ class Node:
|
|||
share: int = 1
|
||||
share_type: "str | None" = None
|
||||
|
||||
# 1s3 §26 path b (multi-use leaves): a second code this leaf permanently
|
||||
# serves alongside ``type``. Construction-time only (no mutation operator
|
||||
# sets it); the fitness honours it only while the pair is still a valid
|
||||
# declared co-location (``graph.leaf_codes``), so a retyped leaf silently
|
||||
# drops a stale ``co_type`` the same way a stale ``share`` is dropped.
|
||||
co_type: "str | None" = None
|
||||
|
||||
# level-root only
|
||||
node: list[list[float]] | None = None # working corners (wall-inset)
|
||||
node_file: list[list[float]] | None = None # raw outer corners as read from disk
|
||||
|
|
@ -95,6 +102,8 @@ def _parse(d: dict) -> Node:
|
|||
# type still matches), so on read the assigned-for type is this leaf's.
|
||||
n.share = int(d["share"])
|
||||
n.share_type = n.type
|
||||
if d.get("co_type") is not None:
|
||||
n.co_type = str(d["co_type"])
|
||||
if d.get("node") is not None:
|
||||
n.node = [[float(p[0]), float(p[1])] for p in d["node"]]
|
||||
if d.get("perimeter") is not None:
|
||||
|
|
@ -192,6 +201,8 @@ def _emit(n: Node, is_level_root: bool) -> dict:
|
|||
d["type"] = n.type
|
||||
if n.share > 1 and n.share_type == n.type: # erc.3: live leaf-share only
|
||||
d["share"] = n.share
|
||||
if n.co_type: # 1s3: permanent second-code declaration
|
||||
d["co_type"] = n.co_type
|
||||
d["rotation"] = n.rotation
|
||||
if n.divided:
|
||||
d["division"] = list(n.division)
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ _CHILD_INNER_KW: dict = {}
|
|||
def _overrides_for(leaf_sharing: bool, superpose: bool,
|
||||
max_share: int | None = None,
|
||||
conn_grade: bool = False,
|
||||
collapse_insearch: bool = True) -> dict | None:
|
||||
collapse_insearch: bool = True,
|
||||
multi_use: bool = False) -> dict | None:
|
||||
"""Run-level conf overrides for the native evaluator (None when all off).
|
||||
|
||||
``max_share`` (homemaker-py-kpu) overrides the evaluator's ``leaf_share_max``
|
||||
|
|
@ -63,6 +64,8 @@ def _overrides_for(leaf_sharing: bool, superpose: bool,
|
|||
ov["conn_grade"] = True
|
||||
if collapse_insearch:
|
||||
ov["collapse_insearch"] = True
|
||||
if multi_use:
|
||||
ov["multi_use"] = True
|
||||
return ov or None
|
||||
|
||||
|
||||
|
|
@ -71,7 +74,8 @@ def _fitness_for(programme_dir: str, leaf_sharing: bool = False,
|
|||
superpose: bool = False,
|
||||
max_share: int | None = None,
|
||||
conn_grade: bool = False,
|
||||
collapse_insearch: bool = True) -> "fitness.Fitness":
|
||||
collapse_insearch: bool = True,
|
||||
multi_use: bool = False) -> "fitness.Fitness":
|
||||
"""Cached Fitness evaluator per (programme dir, leaf_sharing) (config load is
|
||||
the cost).
|
||||
|
||||
|
|
@ -83,7 +87,7 @@ def _fitness_for(programme_dir: str, leaf_sharing: bool = False,
|
|||
Cached per process — workers fork their own copy.
|
||||
"""
|
||||
overrides = _overrides_for(leaf_sharing, superpose, max_share, conn_grade,
|
||||
collapse_insearch)
|
||||
collapse_insearch, multi_use)
|
||||
conf, cost = fitness.load_config(programme_dir, overrides=overrides)
|
||||
return fitness.Fitness(conf, cost)
|
||||
|
||||
|
|
@ -166,7 +170,8 @@ def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw,
|
|||
superpose: bool = False,
|
||||
max_share: int | None = None,
|
||||
conn_grade: bool = False,
|
||||
collapse_insearch: bool = True) -> tuple[Individual, int]:
|
||||
collapse_insearch: bool = True,
|
||||
multi_use: bool = False) -> tuple[Individual, int]:
|
||||
# §12.3 shape-feasibility pre-filter (homemaker-py-9gp.1): if even the best
|
||||
# achievable (proportion-aware) geometry of this topology already has at least
|
||||
# as many shape fails as the incumbent's TOTAL fails — and exceeds the tunable
|
||||
|
|
@ -175,12 +180,12 @@ def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw,
|
|||
# makes the proxy safe: a topology whose shape-fail floor is still below the
|
||||
# incumbent is never discarded. Pruned individuals are tagged and never admitted.
|
||||
overrides = _overrides_for(leaf_sharing, superpose, max_share, conn_grade,
|
||||
collapse_insearch)
|
||||
collapse_insearch, multi_use)
|
||||
if (feasibility_max_shape_fails is not None and best_n_fails is not None):
|
||||
pred = operators.predicted_shape_fails(
|
||||
root, _reqs_for(str(programme_dir)),
|
||||
_fitness_for(str(programme_dir), leaf_sharing, superpose, max_share,
|
||||
conn_grade, collapse_insearch))
|
||||
conn_grade, collapse_insearch, multi_use))
|
||||
if pred > feasibility_max_shape_fails and pred >= best_n_fails:
|
||||
ind = Individual(root=root, fitness=0.0, n_fails=pred, ratios={},
|
||||
lineage=f"pruned/{lineage}", grade=0.0,
|
||||
|
|
@ -196,7 +201,7 @@ def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw,
|
|||
if want_grade:
|
||||
_, _, grade = _fitness_for(
|
||||
str(programme_dir), leaf_sharing, superpose, max_share,
|
||||
conn_grade, collapse_insearch).score_with_grade(
|
||||
conn_grade, collapse_insearch, multi_use).score_with_grade(
|
||||
copy.deepcopy(root))
|
||||
ind = Individual(root=root, fitness=r.fitness, n_fails=r.n_fails,
|
||||
ratios=innerloop.ratio_map(root), lineage=lineage,
|
||||
|
|
@ -249,6 +254,7 @@ def search(
|
|||
leaf_sharing: bool = True,
|
||||
leaf_share_factor: int = 3,
|
||||
superpose: bool = False,
|
||||
multi_use: bool = False,
|
||||
depth_balanced: bool = True,
|
||||
interior_outside: bool = True,
|
||||
outside_divisor: int = 3,
|
||||
|
|
@ -364,7 +370,7 @@ def search(
|
|||
# instance, and thus only let them fire, when explicitly enabled.
|
||||
shape_repair_fit = (
|
||||
_fitness_for(str(programme_dir), leaf_sharing, superpose, max_share,
|
||||
conn_grade, collapse_insearch)
|
||||
conn_grade, collapse_insearch, multi_use)
|
||||
if enable_shape_repair else None)
|
||||
# Optional ranking bonus (DESIGN.md §11.3 Stage 1): bias selection toward
|
||||
# individuals with high substrate-readiness via a multiplicative factor
|
||||
|
|
@ -492,7 +498,7 @@ def search(
|
|||
full = [
|
||||
(root, programme_dir, urb_root, x0, budget_, kw_, lin, use_grade,
|
||||
mx, best_nf, leaf_sharing, superpose, max_share, conn_grade,
|
||||
collapse_insearch)
|
||||
collapse_insearch, multi_use)
|
||||
for root, x0, budget_, kw_, lin in tasks
|
||||
]
|
||||
if _pool is not None:
|
||||
|
|
@ -541,7 +547,8 @@ def search(
|
|||
leaf_sharing=leaf_sharing, leaf_share_factor=leaf_share_factor,
|
||||
depth_balanced=depth_balanced,
|
||||
interior_outside=interior_outside, outside_divisor=outside_divisor,
|
||||
construction_beam_width=construction_beam_width)
|
||||
construction_beam_width=construction_beam_width,
|
||||
multi_use=multi_use)
|
||||
return (topo, None, child_budget, {}, f"construct/{tag}")
|
||||
n = int(rng.integers(max(1, n_target - 1), n_target + 2))
|
||||
return (random_topology(seed_root, n, rng, types), None, child_budget,
|
||||
|
|
@ -576,7 +583,8 @@ def search(
|
|||
superpose=superpose,
|
||||
max_share=max_share,
|
||||
conn_grade=conn_grade,
|
||||
collapse_insearch=collapse_insearch)
|
||||
collapse_insearch=collapse_insearch,
|
||||
multi_use=multi_use)
|
||||
n_evals += used
|
||||
admit(seed_ind, pop)
|
||||
|
||||
|
|
@ -668,6 +676,7 @@ def polish_finish(
|
|||
seed: int = 0,
|
||||
n_workers: int = 1,
|
||||
superpose: bool = False,
|
||||
multi_use: bool = False,
|
||||
collapse_insearch: bool = True,
|
||||
rescore_budget: int = 200,
|
||||
log=None,
|
||||
|
|
@ -713,7 +722,8 @@ def polish_finish(
|
|||
unfolded, programme_dir, budget=polish_budget, pop_size=pop_size,
|
||||
child_budget=child_budget, p_crossover=p_crossover, seed=seed,
|
||||
n_workers=n_workers, bootstrap=False, leaf_sharing=False,
|
||||
superpose=superpose, collapse_insearch=collapse_insearch, log=log,
|
||||
superpose=superpose, multi_use=multi_use,
|
||||
collapse_insearch=collapse_insearch, log=log,
|
||||
)
|
||||
else:
|
||||
# No polish: re-optimise the unfolded genome's ratios once and score it
|
||||
|
|
@ -721,7 +731,7 @@ def polish_finish(
|
|||
ind, used = _evaluate(
|
||||
unfolded, programme_dir, None, x0=None, budget=rescore_budget,
|
||||
inner_kw={}, lineage="unfold", leaf_sharing=False, superpose=superpose,
|
||||
collapse_insearch=collapse_insearch)
|
||||
multi_use=multi_use, collapse_insearch=collapse_insearch)
|
||||
r2 = SearchResult(best=ind, population=[ind], n_evals=used, n_topologies=1)
|
||||
r2.n_distinct_signatures = 1
|
||||
r2.history = [(0, ind.fitness, ind.lineage)]
|
||||
|
|
@ -747,6 +757,7 @@ def collapse_best(
|
|||
*,
|
||||
leaf_sharing: bool = False,
|
||||
superpose: bool = False,
|
||||
multi_use: bool = False,
|
||||
log=None,
|
||||
**collapse_kw,
|
||||
) -> SearchResult:
|
||||
|
|
@ -764,7 +775,7 @@ def collapse_best(
|
|||
if result.best is None:
|
||||
return result
|
||||
|
||||
fit = _fitness_for(str(programme_dir), leaf_sharing, superpose)
|
||||
fit = _fitness_for(str(programme_dir), leaf_sharing, superpose, multi_use=multi_use)
|
||||
tree, base_fails, coll_fails, applied = fit.collapse_finish(
|
||||
result.best.root, **collapse_kw
|
||||
)
|
||||
|
|
@ -966,6 +977,7 @@ def search_staged(
|
|||
leaf_sharing: bool = True,
|
||||
leaf_share_factor: int = 3,
|
||||
superpose: bool = False,
|
||||
multi_use: bool = False,
|
||||
depth_balanced: bool = True,
|
||||
interior_outside: bool = True,
|
||||
outside_divisor: int = 3,
|
||||
|
|
@ -1025,6 +1037,7 @@ def search_staged(
|
|||
leaf_sharing=leaf_sharing,
|
||||
leaf_share_factor=leaf_share_factor,
|
||||
superpose=superpose,
|
||||
multi_use=multi_use,
|
||||
depth_balanced=depth_balanced,
|
||||
interior_outside=interior_outside,
|
||||
outside_divisor=outside_divisor,
|
||||
|
|
@ -1065,6 +1078,7 @@ def search_staged(
|
|||
leaf_sharing=leaf_sharing,
|
||||
leaf_share_factor=leaf_share_factor,
|
||||
superpose=superpose,
|
||||
multi_use=multi_use,
|
||||
depth_balanced=depth_balanced,
|
||||
interior_outside=interior_outside,
|
||||
outside_divisor=outside_divisor,
|
||||
|
|
@ -1090,7 +1104,8 @@ def search_staged(
|
|||
leaf_sharing=leaf_sharing, leaf_share_factor=leaf_share_factor,
|
||||
depth_balanced=depth_balanced,
|
||||
interior_outside=interior_outside, outside_divisor=outside_divisor,
|
||||
construction_beam_width=construction_beam_width)
|
||||
construction_beam_width=construction_beam_width,
|
||||
multi_use=multi_use)
|
||||
|
||||
_log(f"[staged] stage 2: upper floors as deltas, budget {b2}, base_p {base_p}")
|
||||
r2 = search(
|
||||
|
|
@ -1115,6 +1130,7 @@ def search_staged(
|
|||
leaf_sharing=leaf_sharing,
|
||||
leaf_share_factor=leaf_share_factor,
|
||||
superpose=superpose,
|
||||
multi_use=multi_use,
|
||||
depth_balanced=depth_balanced,
|
||||
interior_outside=interior_outside,
|
||||
outside_divisor=outside_divisor,
|
||||
|
|
|
|||
|
|
@ -95,6 +95,14 @@ def _parse_args(argv=None) -> argparse.Namespace:
|
|||
"requirements) form equivalence classes and each candidate "
|
||||
"collapses every superposed leaf to its best in-class usage "
|
||||
"before scoring (default: off)")
|
||||
p.add_argument("--multi-use", action=argparse.BooleanOptionalAction,
|
||||
default=_env_bool("HOMEMAKER_MULTI_USE", False),
|
||||
help="homemaker-py-1s3 (§26 path b): multi-use leaves as a "
|
||||
"PERMANENT design goal. Codes declaring a mutual "
|
||||
"'co_locate' (and passing interchangeable()'s S1-S4 "
|
||||
"bounds) may be fused onto one leaf at construction time, "
|
||||
"surviving unchanged into the output (unlike --superpose's "
|
||||
"per-eval collapse to a single usage) (default: off)")
|
||||
p.add_argument("--conn-grade", dest="conn_grade",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=_env_bool("HOMEMAKER_CONN_GRADE", False),
|
||||
|
|
@ -207,6 +215,7 @@ def main(argv=None) -> int:
|
|||
print(f"leaf sharing : {args.leaf_sharing} (factor={args.leaf_share_factor})",
|
||||
file=sys.stderr)
|
||||
print(f"superpose : {args.superpose}", file=sys.stderr)
|
||||
print(f"multi_use : {args.multi_use}", file=sys.stderr)
|
||||
print(f"conn grade : {args.conn_grade}", file=sys.stderr)
|
||||
print(f"bridge circulation : {args.bridge_circulation}", file=sys.stderr)
|
||||
print(f"ruin recreate : {args.ruin_recreate}", file=sys.stderr)
|
||||
|
|
@ -243,6 +252,7 @@ def main(argv=None) -> int:
|
|||
seed=args.seed,
|
||||
n_workers=args.workers,
|
||||
superpose=args.superpose,
|
||||
multi_use=args.multi_use,
|
||||
log=lambda m: print(m, file=sys.stderr, flush=True),
|
||||
)
|
||||
_finish_sharing = False
|
||||
|
|
@ -259,6 +269,7 @@ def main(argv=None) -> int:
|
|||
leaf_sharing=args.leaf_sharing,
|
||||
leaf_share_factor=args.leaf_share_factor,
|
||||
superpose=args.superpose,
|
||||
multi_use=args.multi_use,
|
||||
conn_grade=args.conn_grade,
|
||||
enable_bridge_circulation=args.bridge_circulation,
|
||||
enable_ruin_recreate=args.ruin_recreate,
|
||||
|
|
@ -292,6 +303,7 @@ def main(argv=None) -> int:
|
|||
seed=args.seed,
|
||||
n_workers=args.workers,
|
||||
superpose=args.superpose,
|
||||
multi_use=args.multi_use,
|
||||
collapse_insearch=args.collapse_insearch,
|
||||
log=lambda m: print(m, file=sys.stderr, flush=True),
|
||||
)
|
||||
|
|
@ -307,6 +319,7 @@ def main(argv=None) -> int:
|
|||
r = driver.collapse_best(
|
||||
r, programme_dir,
|
||||
superpose=args.superpose,
|
||||
multi_use=args.multi_use,
|
||||
local_search=args.collapse_local_search,
|
||||
log=lambda m: print(m, file=sys.stderr, flush=True),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -238,6 +238,13 @@ class Fitness:
|
|||
# Fewer Jacobi passes than the finish-time default (6): a per-eval cost,
|
||||
# not a one-shot polish — profile before raising.
|
||||
self._collapse_insearch_iters = int(self.conf("collapse_insearch_iters") or 3)
|
||||
# homemaker-py-1s3 §26 path b: multi-use leaves as a PERMANENT design
|
||||
# goal (no per-eval collapse, unlike superpose above). Default OFF.
|
||||
# When on, a leaf carrying a live, valid co_type counts toward BOTH
|
||||
# codes' requirements simultaneously (graph.leaf_codes), and its size
|
||||
# target combines both codes' area (quality_size).
|
||||
self._multi_use = bool(self.conf("multi_use"))
|
||||
self._colocate_pairs: list | None = None # lazily derived
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Type superposition + collapse (homemaker-py-9o5)
|
||||
|
|
@ -255,6 +262,30 @@ class Fitness:
|
|||
)
|
||||
return self._interchange_classes
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Multi-use leaves (homemaker-py-1s3, §26 path b)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def colocate_pairs(self) -> list:
|
||||
"""Valid co-location pairs, derived once from the programme and
|
||||
cached. Empty list when nothing is declared, in which case
|
||||
``multi_use`` is a no-op and scoring matches baseline."""
|
||||
if self._colocate_pairs is None:
|
||||
from . import programme as _pr
|
||||
reqs = self._programme or {}
|
||||
self._colocate_pairs = (
|
||||
_pr.derive_colocate_pairs(reqs) if reqs else []
|
||||
)
|
||||
return self._colocate_pairs
|
||||
|
||||
def _leaf_co_type(self, leaf: Node) -> "str | None":
|
||||
"""The leaf's live, valid co_type under ``multi_use``, else ``None``."""
|
||||
if not self._multi_use or not leaf.co_type or not leaf.type:
|
||||
return None
|
||||
if frozenset((leaf.type, leaf.co_type)) in self.colocate_pairs():
|
||||
return leaf.co_type
|
||||
return None
|
||||
|
||||
def _usage_quality(self, leaf: Node, usage: str) -> float:
|
||||
"""The usage-DEPENDENT part of a leaf's quality (size x width x
|
||||
proportion) as if it were typed ``usage``. The remaining factors
|
||||
|
|
@ -776,6 +807,13 @@ class Fitness:
|
|||
params = self.conf("proportion_circulation")
|
||||
else:
|
||||
params = self.get_space_params(leaf.type, "proportion")
|
||||
co_type = self._leaf_co_type(leaf)
|
||||
if co_type:
|
||||
# 1s3: a fused leaf must satisfy the STRICTER of its two
|
||||
# codes' proportion targets (max target, min tolerance) —
|
||||
# unlike quality_size, shape doesn't add across two uses.
|
||||
co_params = self.get_space_params(co_type, "proportion")
|
||||
params = [max(params[0], co_params[0]), min(params[1], co_params[1])]
|
||||
aspect = geometry.aspect(leaf)
|
||||
if aspect < params[0]:
|
||||
return 1.0
|
||||
|
|
@ -790,16 +828,28 @@ class Fitness:
|
|||
else:
|
||||
params = self.get_space_params(leaf.type, "size")
|
||||
target, sigma = params[0], params[1]
|
||||
if self._leaf_sharing and t0 != "c" and target > 0:
|
||||
# erc.3: a shared leaf holds k same-code rooms; centre the Gaussian on
|
||||
# k×target (k = leaf's explicit, type-guarded share) and scale sigma by
|
||||
# k so the *fractional* size tolerance is preserved. An undersize
|
||||
# shared leaf now lands a (light) size fail here instead of a (heavy)
|
||||
# missing fail in the count check — the §13.3 leak fix.
|
||||
from . import graph as _graph
|
||||
k = _graph.leaf_share(leaf, self._max_share)
|
||||
if t0 != "c" and target > 0:
|
||||
k = 1
|
||||
if self._leaf_sharing:
|
||||
# erc.3: a shared leaf holds k same-code rooms; centre the
|
||||
# Gaussian on k×target (k = leaf's explicit, type-guarded
|
||||
# share) and scale sigma by k so the *fractional* size
|
||||
# tolerance is preserved. An undersize shared leaf now lands
|
||||
# a (light) size fail here instead of a (heavy) missing fail
|
||||
# in the count check — the §13.3 leak fix.
|
||||
from . import graph as _graph
|
||||
k = _graph.leaf_share(leaf, self._max_share)
|
||||
co_type = None if k > 1 else self._leaf_co_type(leaf)
|
||||
if k > 1:
|
||||
target, sigma = target * k, sigma * k
|
||||
elif co_type:
|
||||
# 1s3 §26 path b: a fused leaf's floor area serves BOTH codes'
|
||||
# requirements at once — additive, the same operation as
|
||||
# leaf-sharing's k×target sum (k identical terms), here with
|
||||
# 2 different terms. A leaf never carries both a live share>1
|
||||
# and a live co_type (construction never stamps both).
|
||||
co_params = self.get_space_params(co_type, "size")
|
||||
target, sigma = target + co_params[0], sigma + co_params[1]
|
||||
return gaussian(geometry.area(leaf), 1.0, target, sigma)
|
||||
|
||||
def quality_width(self, leaf: Node) -> float:
|
||||
|
|
@ -817,6 +867,12 @@ class Fitness:
|
|||
params = self.conf("width_circulation")
|
||||
else:
|
||||
params = self.get_space_params(leaf.type, "width")
|
||||
co_type = self._leaf_co_type(leaf)
|
||||
if co_type:
|
||||
# 1s3: stricter of the two codes' width targets, same reasoning
|
||||
# as quality_proportion above.
|
||||
co_params = self.get_space_params(co_type, "width")
|
||||
params = [max(params[0], co_params[0]), min(params[1], co_params[1])]
|
||||
width = geometry.length_narrowest(leaf)
|
||||
if width > params[0]:
|
||||
return 1.0
|
||||
|
|
@ -1585,7 +1641,8 @@ class Fitness:
|
|||
|
||||
# --- Phase 1: UNMERGED tree checks ---
|
||||
check_fails, missing = graph_mod.check_space_counts(
|
||||
root, programme, self._leaf_sharing, self._max_share)
|
||||
root, programme, self._leaf_sharing, self._max_share,
|
||||
self._multi_use, self.colocate_pairs())
|
||||
failures.extend(check_fails)
|
||||
|
||||
self.preprocess_building(root)
|
||||
|
|
@ -1595,9 +1652,13 @@ class Fitness:
|
|||
|
||||
graph_base_pre = graph_mod.build_graphs(root, self.conf("door_width") or 1.2)
|
||||
|
||||
failures.extend(graph_mod.check_adjacency(root, programme, graph_base_pre, missing))
|
||||
failures.extend(graph_mod.check_level_constraints(root, programme, missing))
|
||||
failures.extend(graph_mod.check_vertical_connectivity(root, programme, missing))
|
||||
failures.extend(graph_mod.check_adjacency(
|
||||
root, programme, graph_base_pre, missing,
|
||||
self._multi_use, self.colocate_pairs()))
|
||||
failures.extend(graph_mod.check_level_constraints(
|
||||
root, programme, missing, self._multi_use, self.colocate_pairs()))
|
||||
failures.extend(graph_mod.check_vertical_connectivity(
|
||||
root, programme, missing, self._multi_use, self.colocate_pairs()))
|
||||
|
||||
# --- Phase 2: MERGED tree ---
|
||||
dom_mod.merge_divided(root)
|
||||
|
|
@ -1676,6 +1737,7 @@ class Fitness:
|
|||
level=c.get("level"),
|
||||
requires_below=c.get("requires_below"),
|
||||
count=int(c.get("count") or 1),
|
||||
co_locate=list(c.get("co_locate") or []),
|
||||
has_size="size" in c,
|
||||
has_width="width" in c,
|
||||
has_proportion="proportion" in c,
|
||||
|
|
|
|||
|
|
@ -409,13 +409,20 @@ def _level_index(n: Node, lvls: list[Node]) -> int:
|
|||
# Adjacency helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def has_adjacency(leaf: Node, target_code: str, G: nx.Graph) -> bool:
|
||||
def _codes_match_prefix(codes: list[str], tc: str) -> bool:
|
||||
return any(c.lower().startswith(tc) for c in codes)
|
||||
|
||||
|
||||
def has_adjacency(leaf: Node, target_code: str, G: nx.Graph,
|
||||
colocate_pairs=(), multi_use: bool = False) -> bool:
|
||||
"""True if ``leaf`` (or its nearest graphed ancestor) has a neighbour whose
|
||||
type matches ``^target_code`` (case-insensitive prefix); mirrors
|
||||
``ProgrammeDriven.pm::has_adjacency``.
|
||||
|
||||
Walking up to the nearest graphed ancestor handles merged nodes that no
|
||||
longer appear as individual vertices in a post-merge graph.
|
||||
longer appear as individual vertices in a post-merge graph. Under
|
||||
``multi_use`` a neighbour's ``leaf_codes()`` (both type and any live
|
||||
co_type) are checked, not just its scalar ``type``.
|
||||
"""
|
||||
node: Node | None = leaf
|
||||
while node is not None and not G.has_node(node):
|
||||
|
|
@ -424,16 +431,17 @@ def has_adjacency(leaf: Node, target_code: str, G: nx.Graph) -> bool:
|
|||
return False
|
||||
tc = target_code.lower()
|
||||
for nb in G.neighbors(node):
|
||||
if nb.type and nb.type.lower().startswith(tc):
|
||||
if _codes_match_prefix(leaf_codes(nb, colocate_pairs, multi_use), tc):
|
||||
return True
|
||||
# neighbour might be a merged branch — check its leaves
|
||||
for nl in (nb.leaves() if nb.divided else []):
|
||||
if nl.type and nl.type.lower().startswith(tc):
|
||||
if _codes_match_prefix(leaf_codes(nl, colocate_pairs, multi_use), tc):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_vertical_connection(leaf: Node, target_code: str, lvls: list[Node]) -> bool:
|
||||
def has_vertical_connection(leaf: Node, target_code: str, lvls: list[Node],
|
||||
colocate_pairs=(), multi_use: bool = False) -> bool:
|
||||
"""True if any leaf on the level directly below has type matching
|
||||
``^target_code`` (case-insensitive); mirrors
|
||||
``ProgrammeDriven.pm::has_vertical_connection``.
|
||||
|
|
@ -446,7 +454,8 @@ def has_vertical_connection(leaf: Node, target_code: str, lvls: list[Node]) -> b
|
|||
return False
|
||||
below_root = lvls[li - 1]
|
||||
tc = target_code.lower()
|
||||
return any(bl.type and bl.type.lower().startswith(tc) for bl in below_root.leaves())
|
||||
return any(_codes_match_prefix(leaf_codes(bl, colocate_pairs, multi_use), tc)
|
||||
for bl in below_root.leaves())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
@ -467,11 +476,30 @@ def leaf_share(leaf: Node, max_share: int) -> int:
|
|||
return 1
|
||||
|
||||
|
||||
def leaf_codes(leaf: Node, colocate_pairs=(), multi_use: bool = False) -> list[str]:
|
||||
"""Codes ``leaf`` counts as (homemaker-py-1s3, §26 path b).
|
||||
|
||||
Normally just ``[leaf.type]``. Under ``multi_use``, a leaf carrying a
|
||||
``co_type`` counts as BOTH codes simultaneously — but only while
|
||||
``{type, co_type}`` is still a valid declared co-location pair
|
||||
(``colocate_pairs``, from ``programme.derive_colocate_pairs``); a generic
|
||||
retype mutation that changes ``leaf.type`` out from under a stale
|
||||
``co_type`` silently drops it, mirroring ``leaf_share``'s type-guard.
|
||||
"""
|
||||
if not leaf.type:
|
||||
return []
|
||||
if multi_use and leaf.co_type and frozenset((leaf.type, leaf.co_type)) in colocate_pairs:
|
||||
return [leaf.type, leaf.co_type]
|
||||
return [leaf.type]
|
||||
|
||||
|
||||
def check_space_counts(
|
||||
root: Node,
|
||||
targets: dict[str, SpaceReq],
|
||||
leaf_sharing: bool = False,
|
||||
max_share: int = 4,
|
||||
multi_use: bool = False,
|
||||
colocate_pairs=(),
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Check design has exactly the required spaces; mirrors
|
||||
``check_space_counts`` in ``ProgrammeDriven.pm:156-215``.
|
||||
|
|
@ -492,8 +520,8 @@ def check_space_counts(
|
|||
count: dict[str, list[Node]] = {}
|
||||
for lvl in levels(root):
|
||||
for leaf in lvl.leaves():
|
||||
if leaf.type:
|
||||
count.setdefault(leaf.type, []).append(leaf)
|
||||
for code in leaf_codes(leaf, colocate_pairs, multi_use):
|
||||
count.setdefault(code, []).append(leaf)
|
||||
|
||||
failures: list[str] = []
|
||||
missing: list[str] = []
|
||||
|
|
@ -543,6 +571,8 @@ def check_adjacency(
|
|||
targets: dict[str, SpaceReq],
|
||||
graph_base: list[nx.Graph],
|
||||
missing: list[str],
|
||||
multi_use: bool = False,
|
||||
colocate_pairs=(),
|
||||
) -> list[str]:
|
||||
"""Adjacency check failures; mirrors
|
||||
``check_adjacency_requirements`` in ``ProgrammeDriven.pm:218-278``.
|
||||
|
|
@ -566,7 +596,7 @@ def check_adjacency(
|
|||
for lvl in lvls:
|
||||
li = lvls.index(lvl)
|
||||
for leaf in lvl.leaves():
|
||||
if leaf.type != code:
|
||||
if code not in leaf_codes(leaf, colocate_pairs, multi_use):
|
||||
continue
|
||||
G = graph_base[li]
|
||||
for adj_code in req.adjacency:
|
||||
|
|
@ -574,7 +604,7 @@ def check_adjacency(
|
|||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if not has_adjacency(leaf, adj_code, G):
|
||||
if not has_adjacency(leaf, adj_code, G, colocate_pairs, multi_use):
|
||||
failures.append(
|
||||
f"{li}/{leaf.id} ({code}) not adjacent to {adj_code}"
|
||||
)
|
||||
|
|
@ -585,6 +615,8 @@ def check_level_constraints(
|
|||
root: Node,
|
||||
targets: dict[str, SpaceReq],
|
||||
missing: list[str],
|
||||
multi_use: bool = False,
|
||||
colocate_pairs=(),
|
||||
) -> list[str]:
|
||||
"""Level constraint failures; mirrors
|
||||
``check_level_constraints`` in ``ProgrammeDriven.pm:319-358``.
|
||||
|
|
@ -604,7 +636,7 @@ def check_level_constraints(
|
|||
for lvl in lvls:
|
||||
li = lvls.index(lvl)
|
||||
for leaf in lvl.leaves():
|
||||
if leaf.type != code:
|
||||
if code not in leaf_codes(leaf, colocate_pairs, multi_use):
|
||||
continue
|
||||
if li != req.level:
|
||||
failures.append(
|
||||
|
|
@ -617,6 +649,8 @@ def check_vertical_connectivity(
|
|||
root: Node,
|
||||
targets: dict[str, SpaceReq],
|
||||
missing: list[str],
|
||||
multi_use: bool = False,
|
||||
colocate_pairs=(),
|
||||
) -> list[str]:
|
||||
"""Vertical connectivity failures; mirrors
|
||||
``check_vertical_connectivity_requirements`` in ``ProgrammeDriven.pm:360-397``.
|
||||
|
|
@ -639,9 +673,10 @@ def check_vertical_connectivity(
|
|||
|
||||
for lvl in lvls:
|
||||
for leaf in lvl.leaves():
|
||||
if leaf.type != code:
|
||||
if code not in leaf_codes(leaf, colocate_pairs, multi_use):
|
||||
continue
|
||||
if not has_vertical_connection(leaf, req.requires_below, lvls):
|
||||
if not has_vertical_connection(leaf, req.requires_below, lvls,
|
||||
colocate_pairs, multi_use):
|
||||
failures.append(
|
||||
f"{code} not connected to {req.requires_below} below"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -666,8 +666,70 @@ def _leaf_mult_from_plan(lvl: dom.Node, plan: dict[str, list[int]]) -> dict:
|
|||
return leaf_mult
|
||||
|
||||
|
||||
def _colocate_rooms(rooms: list[str], colocate_pairs,
|
||||
rng: np.random.Generator) -> tuple[list[str], dict[str, list[str]]]:
|
||||
"""Fuse single instances of DIFFERENT compatible codes onto one leaf
|
||||
(homemaker-py-1s3, §26 path b) — the same structural lever as
|
||||
``_share_rooms`` (fewer, larger leaves), extended from *same*-code
|
||||
multiplicity to *different*-but-compatible codes.
|
||||
|
||||
For each valid declared pair (``programme.derive_colocate_pairs``, order
|
||||
shuffled via ``rng``), pair up ``n = min(available a, available b)``
|
||||
instances: one code is chosen (via ``rng``, once per pair — NOT
|
||||
re-rolled per instance, which would let a code flip between primary and
|
||||
secondary roles across iterations and over-pair beyond what's actually
|
||||
available) as the "secondary" and fully removed (``n`` instances), the
|
||||
other (the "primary") keeps its slot in ``rooms`` unchanged and gets ``n``
|
||||
entries in ``plan``. A primary code may accumulate several secondaries
|
||||
from different declared pairs; ``_leaf_colocate_from_plan`` matches each
|
||||
to a distinct physical leaf. Codes with no available partner are
|
||||
untouched.
|
||||
"""
|
||||
from collections import Counter
|
||||
|
||||
counts = Counter(rooms)
|
||||
plan: dict[str, list[str]] = {}
|
||||
pairs = list(colocate_pairs)
|
||||
order = [pairs[i] for i in rng.permutation(len(pairs))] if pairs else []
|
||||
for pair in order:
|
||||
a, b = sorted(pair)
|
||||
n = min(counts[a], counts[b])
|
||||
if n <= 0:
|
||||
continue
|
||||
primary, secondary = (a, b) if rng.integers(2) == 0 else (b, a)
|
||||
counts[secondary] -= n
|
||||
plan.setdefault(primary, []).extend([secondary] * n)
|
||||
|
||||
reduced: list[str] = []
|
||||
for code, c in counts.items():
|
||||
reduced.extend([code] * c)
|
||||
return reduced, plan
|
||||
|
||||
|
||||
def _leaf_colocate_from_plan(lvl: dom.Node, plan: dict[str, list[str]], reqs) -> dict:
|
||||
"""Stamp each plan's secondary codes onto that primary code's typed
|
||||
leaves (mirrors ``_leaf_mult_from_plan``) and return a leaf→co_type map
|
||||
for sizing. Secondaries are matched biggest-target-first to biggest-area
|
||||
leaves first, same descending-to-descending heuristic as leaf-sharing."""
|
||||
from . import geometry
|
||||
by_code: dict[str, list[dom.Node]] = {}
|
||||
for lf in lvl.leaves():
|
||||
if lf.type:
|
||||
by_code.setdefault(lf.type, []).append(lf)
|
||||
leaf_co: dict = {}
|
||||
for code, secondaries in plan.items():
|
||||
leaves = sorted(by_code.get(code, []), key=geometry.area, reverse=True)
|
||||
secs = sorted(secondaries, key=lambda c: reqs[c].size if c in reqs else 0.0,
|
||||
reverse=True)
|
||||
for lf, co in zip(leaves, secs):
|
||||
lf.co_type = co
|
||||
leaf_co[lf] = co
|
||||
return leaf_co
|
||||
|
||||
|
||||
def _size_divisions_from_targets(lvl: dom.Node, reqs, fmin: float = 0.04,
|
||||
fmax: float = 0.96, leaf_mult: dict | None = None) -> None:
|
||||
fmax: float = 0.96, leaf_mult: dict | None = None,
|
||||
leaf_extra: dict | None = None) -> None:
|
||||
"""Resize each divided node's split ratio from its leaves' TARGET areas.
|
||||
|
||||
leu.2 (DESIGN.md §12.2, follow-up to §11.6/§11.7): the constructive seeders
|
||||
|
|
@ -701,8 +763,9 @@ def _size_divisions_from_targets(lvl: dom.Node, reqs, fmin: float = 0.04,
|
|||
return
|
||||
|
||||
leaf_mult = leaf_mult or {}
|
||||
sized = {lf: reqs[lf.type].size * leaf_mult.get(lf, 1) for lf in leaves
|
||||
if lf.type in reqs and reqs[lf.type].size > 0}
|
||||
leaf_extra = leaf_extra or {}
|
||||
sized = {lf: reqs[lf.type].size * leaf_mult.get(lf, 1) + leaf_extra.get(lf, 0.0)
|
||||
for lf in leaves if lf.type in reqs and reqs[lf.type].size > 0}
|
||||
mean_sized = (sum(sized.values()) / len(sized)) if sized else 1.0
|
||||
n_generic = len(leaves) - len(sized)
|
||||
slack = geometry.area(lvl) - sum(sized.values())
|
||||
|
|
@ -1079,7 +1142,8 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
|
|||
depth_balanced: bool = False,
|
||||
interior_outside: bool = True,
|
||||
outside_divisor: int = 3,
|
||||
construction_beam_width: int = 1) -> dom.Node:
|
||||
construction_beam_width: int = 1,
|
||||
multi_use: bool = False) -> dom.Node:
|
||||
"""Build a seed that instantiates every required space by construction.
|
||||
|
||||
The §11.0 diagnosis: random divide+retype chains leave required programme
|
||||
|
|
@ -1098,11 +1162,13 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
|
|||
Returns a finalised deep copy; ``seed_root`` is unchanged.
|
||||
"""
|
||||
from . import genome as _g
|
||||
from . import programme as _prog
|
||||
|
||||
child = copy.deepcopy(seed_root)
|
||||
prog = _programme_codes(reqs)
|
||||
levels_needed = [r.level for r in prog.values() if r.level is not None]
|
||||
n_storeys = max((max(levels_needed) + 1) if levels_needed else 1, min_storeys)
|
||||
colocate_pairs = _prog.derive_colocate_pairs(reqs) if multi_use else []
|
||||
|
||||
# grow storeys from the bare base by duplicating the top storey (cf.
|
||||
# mutate_level_add / genome._copy_storey), inheriting floor height.
|
||||
|
|
@ -1134,6 +1200,12 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
|
|||
# leaves (each paying the ~1.8 shape-fail tax once, §13.1). The fitness
|
||||
# recovers each leaf's multiplicity from area; here we only reduce the
|
||||
# code list and remember the plan to size shared leaves to k×target.
|
||||
# 1s3 §26 path b: fuse different-but-compatible codes onto one leaf
|
||||
# BEFORE leaf-sharing, so leaf-sharing still groups whichever code
|
||||
# was kept as primary with its remaining same-code siblings.
|
||||
colocate_plan: dict[str, list[str]] = {}
|
||||
if multi_use:
|
||||
rooms, colocate_plan = _colocate_rooms(rooms, colocate_pairs, rng)
|
||||
share_plan: dict[str, list[int]] = {}
|
||||
if leaf_sharing:
|
||||
rooms, share_plan = _share_rooms(rooms, reqs, leaf_share_factor)
|
||||
|
|
@ -1167,8 +1239,12 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
|
|||
# first so upper-storey roots resolve geometry (the else branch above
|
||||
# does not link, unlike the adjacency-aware branch).
|
||||
dom._link(child)
|
||||
leaf_co = _leaf_colocate_from_plan(lvl, colocate_plan, reqs) if multi_use else {}
|
||||
leaf_extra = {lf: reqs[co].size for lf, co in leaf_co.items()
|
||||
if co in reqs and reqs[co].size > 0}
|
||||
_size_divisions_from_targets(
|
||||
lvl, reqs, leaf_mult=_leaf_mult_from_plan(lvl, share_plan))
|
||||
lvl, reqs, leaf_mult=_leaf_mult_from_plan(lvl, share_plan),
|
||||
leaf_extra=leaf_extra)
|
||||
|
||||
return _finalise(child)
|
||||
|
||||
|
|
@ -1183,7 +1259,8 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
|
|||
depth_balanced: bool = False,
|
||||
interior_outside: bool = True,
|
||||
outside_divisor: int = 3,
|
||||
construction_beam_width: int = 1) -> dom.Node:
|
||||
construction_beam_width: int = 1,
|
||||
multi_use: bool = False) -> dom.Node:
|
||||
"""Stack upper storeys onto an evolved single-storey base (DESIGN.md §11.3).
|
||||
|
||||
Stage 2 seeder: the Stage-1 base is the credible ground floor and is left
|
||||
|
|
@ -1198,6 +1275,7 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
|
|||
Returns a finalised deep copy; ``base_root`` is unchanged.
|
||||
"""
|
||||
from . import genome as _g, geometry as _geo
|
||||
from . import programme as _prog
|
||||
|
||||
child = copy.deepcopy(base_root)
|
||||
base = dom.levels(child)[0]
|
||||
|
|
@ -1206,6 +1284,7 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
|
|||
base_cs = [lf for lf in base.leaves()
|
||||
if lf.type and lf.type[0].lower() == "c"]
|
||||
core_path = max(base_cs, key=_geo.area).id if base_cs else None
|
||||
colocate_pairs = _prog.derive_colocate_pairs(reqs) if multi_use and reqs else []
|
||||
|
||||
prev = base
|
||||
for bucket in upper_buckets:
|
||||
|
|
@ -1214,6 +1293,11 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
|
|||
core_node = dup.by_id(core_path) if core_path is not None else None
|
||||
|
||||
rooms = [code for code, cnt in bucket.items() for _ in range(cnt)]
|
||||
# 1s3: fuse different-but-compatible codes onto one leaf on this storey
|
||||
# too, before leaf-sharing (same ordering as constructive_topology).
|
||||
colocate_plan: dict[str, list[str]] = {}
|
||||
if multi_use:
|
||||
rooms, colocate_plan = _colocate_rooms(rooms, colocate_pairs, rng)
|
||||
# erc.3: collapse same-code rooms into fewer shared leaves on this storey
|
||||
# too (§13.3), so upper floors get the same per-leaf-tax saving.
|
||||
share_plan: dict[str, list[int]] = {}
|
||||
|
|
@ -1285,8 +1369,12 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
|
|||
# the base via below-links are no-ops here — their geometry is fixed
|
||||
# below — so this best-effort sizes the floor's own new divisions.)
|
||||
dom._link(child)
|
||||
leaf_co = _leaf_colocate_from_plan(dup, colocate_plan, reqs) if multi_use else {}
|
||||
leaf_extra = {lf: reqs[co].size for lf, co in leaf_co.items()
|
||||
if co in reqs and reqs[co].size > 0}
|
||||
_size_divisions_from_targets(
|
||||
dup, reqs, leaf_mult=_leaf_mult_from_plan(dup, share_plan))
|
||||
dup, reqs, leaf_mult=_leaf_mult_from_plan(dup, share_plan),
|
||||
leaf_extra=leaf_extra)
|
||||
|
||||
prev = dup
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ class SpaceReq:
|
|||
# grouping (e.g. harbor-house's transitive 8-code chain) without disabling
|
||||
# superposition globally.
|
||||
interchange: bool = True
|
||||
# 1s3 §26 path b: codes this one may permanently co-locate on (fuse onto)
|
||||
# one leaf with. Explicit, architect-declared (unlike the auto-derived
|
||||
# interchange classes above) — a pair is only ever honoured if it *also*
|
||||
# passes interchangeable()'s S1-S4 bounds (see derive_colocate_pairs).
|
||||
co_locate: list[str] = field(default_factory=list)
|
||||
# Whether each quality param was explicitly in the config (not a default)
|
||||
has_size: bool = False
|
||||
has_width: bool = False
|
||||
|
|
@ -76,6 +81,7 @@ def _parse_spaces(conf: dict) -> dict[str, SpaceReq]:
|
|||
count=int(c.get("count") or 1),
|
||||
share=int(c.get("share") or 1),
|
||||
interchange=bool(c.get("interchange", True)),
|
||||
co_locate=list(c.get("co_locate") or []),
|
||||
has_size="size" in c,
|
||||
has_width="width" in c,
|
||||
has_proportion="proportion" in c,
|
||||
|
|
@ -179,6 +185,43 @@ def derive_interchange_classes(reqs: dict[str, SpaceReq]) -> list[frozenset[str]
|
|||
return classes
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Co-location pairs (homemaker-py-1s3, §26 path b: permanent multi-use leaves)
|
||||
# --------------------------------------------------------------------------- #
|
||||
#
|
||||
# Unlike interchange classes (auto-derived, soft substitution), fusing two
|
||||
# codes onto one permanent leaf is architect-declared per-code (``co_locate``)
|
||||
# — but a declared pair is only ever honoured if it ALSO passes the existing
|
||||
# interchangeable() S1-S4 relation. This reuses the already-validated bounds
|
||||
# (size/width/proportion similarity, compatible level/service stack, no direct
|
||||
# adjacency edge) instead of inventing a second relation, and — because pairs
|
||||
# are kept individually rather than folded into connected components — sidesteps
|
||||
# the b3v transitive-chain failure mode (a nonsensical A-C fusion can never be
|
||||
# smuggled in via a declared A-B, B-C chain the way interchange classes could).
|
||||
|
||||
def derive_colocate_pairs(reqs: dict[str, SpaceReq]) -> list[frozenset[str]]:
|
||||
"""Valid co-location pairs: architect-declared AND interchangeable().
|
||||
|
||||
A code may declare ``co_locate: [other_code, ...]``; declaration is
|
||||
symmetric (either side declaring is sufficient). Returns one frozenset per
|
||||
valid pair (size always 2); an entry with no interchangeable() partner is
|
||||
silently dropped, never merged into a larger group.
|
||||
"""
|
||||
pairs: list[frozenset[str]] = []
|
||||
seen: set[frozenset[str]] = set()
|
||||
for code, req in reqs.items():
|
||||
for other in req.co_locate:
|
||||
if other not in reqs or other == code:
|
||||
continue
|
||||
key = frozenset((code, other))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if interchangeable(reqs[code], reqs[other]):
|
||||
pairs.append(key)
|
||||
return pairs
|
||||
|
||||
|
||||
def n_storeys_required(reqs: dict[str, SpaceReq]) -> int:
|
||||
"""Number of storeys the programme implies, from the highest ``level:`` key.
|
||||
|
||||
|
|
|
|||
424
tests/test_multi_use.py
Normal file
424
tests/test_multi_use.py
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
"""Tests for multi-use leaves as a permanent design goal (homemaker-py-1s3,
|
||||
DESIGN.md §26 path b).
|
||||
|
||||
Covers the four layers of the feature:
|
||||
1. co-location pair derivation (programme.derive_colocate_pairs)
|
||||
2. the graph resolver + checks (graph.leaf_codes and friends)
|
||||
3. quality-term combination (fitness.quality_size/width/proportion)
|
||||
4. construction-time fusion (operators._colocate_rooms/_leaf_colocate_from_plan)
|
||||
|
||||
plus the default-OFF guarantee at each layer.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from homemaker_layout import dom, geometry, graph, operators, programme
|
||||
from homemaker_layout.dom import Node, _link_subtree
|
||||
from homemaker_layout.fitness import Fitness, gaussian
|
||||
from homemaker_layout.programme import SpaceReq, derive_colocate_pairs
|
||||
|
||||
HARBOR = Path(__file__).parent.parent / "examples" / "harbor-house"
|
||||
|
||||
|
||||
def _req(code, size, width=4.0, proportion=1.5, level=None,
|
||||
requires_below=None, adjacency=None, count=1, co_locate=None):
|
||||
return SpaceReq(
|
||||
code=code, size=size, width=width, proportion=proportion,
|
||||
level=level, requires_below=requires_below,
|
||||
adjacency=list(adjacency or []), count=count,
|
||||
co_locate=list(co_locate or []), has_size=True,
|
||||
has_width=True, has_proportion=True,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Node round-trip (dom.py)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_co_type_round_trips_through_dom_dump_load():
|
||||
root = Node(type="x", co_type="y", rotation=0)
|
||||
_link_subtree(root, None, "")
|
||||
d = dom._emit(root, True)
|
||||
assert d["co_type"] == "y"
|
||||
|
||||
reparsed = dom._parse(d)
|
||||
assert reparsed.type == "x"
|
||||
assert reparsed.co_type == "y"
|
||||
|
||||
|
||||
def test_co_type_absent_when_unset():
|
||||
root = Node(type="x", rotation=0)
|
||||
d = dom._emit(root, True)
|
||||
assert "co_type" not in d
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Derivation (programme.py)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_declared_pair_passing_interchangeable_is_valid():
|
||||
reqs = {"den": _req("den", 9.0, co_locate=["guest"]),
|
||||
"guest": _req("guest", 12.0)}
|
||||
assert derive_colocate_pairs(reqs) == [frozenset({"den", "guest"})]
|
||||
|
||||
|
||||
def test_declaration_is_symmetric():
|
||||
# only the "guest" side declares — still valid, either direction suffices
|
||||
reqs = {"den": _req("den", 9.0),
|
||||
"guest": _req("guest", 12.0, co_locate=["den"])}
|
||||
assert derive_colocate_pairs(reqs) == [frozenset({"den", "guest"})]
|
||||
|
||||
|
||||
def test_declared_pair_failing_size_ratio_is_dropped():
|
||||
# 60/10 = 6x, far outside interchangeable()'s R_SIZE — declaring it doesn't help
|
||||
reqs = {"hall": _req("hall", 60.0, co_locate=["wc"]), "wc": _req("wc", 10.0)}
|
||||
assert derive_colocate_pairs(reqs) == []
|
||||
|
||||
|
||||
def test_declared_pair_with_adjacency_edge_is_dropped():
|
||||
# S4: a required-adjacency pair are coexisting rooms, not a fusable pair
|
||||
reqs = {
|
||||
"x": _req("x", 10.0, adjacency=["y"], co_locate=["y"]),
|
||||
"y": _req("y", 11.0),
|
||||
}
|
||||
assert derive_colocate_pairs(reqs) == []
|
||||
|
||||
|
||||
def test_declared_pair_with_incompatible_level_is_dropped():
|
||||
reqs = {
|
||||
"x": _req("x", 10.0, level=0, co_locate=["y"]),
|
||||
"y": _req("y", 11.0, level=1),
|
||||
}
|
||||
assert derive_colocate_pairs(reqs) == []
|
||||
|
||||
|
||||
def test_no_transitive_closure_unlike_interchange_classes():
|
||||
# p1 declares p2, p2 declares p1+p3; p1-p3 is NEVER inferred (pairs only,
|
||||
# no b3v transitive-chain failure mode) — codes avoid a c/o/s initial
|
||||
# letter, which interchangeable() treats as generic and excludes (S1)
|
||||
reqs = {
|
||||
"p1": _req("p1", 10.0, co_locate=["p2"]),
|
||||
"p2": _req("p2", 12.0, co_locate=["p1", "p3"]),
|
||||
"p3": _req("p3", 15.0),
|
||||
}
|
||||
pairs = derive_colocate_pairs(reqs)
|
||||
assert frozenset({"p1", "p2"}) in pairs
|
||||
assert frozenset({"p2", "p3"}) in pairs
|
||||
assert frozenset({"p1", "p3"}) not in pairs
|
||||
|
||||
|
||||
def test_undeclared_pair_is_never_valid_even_if_interchangeable():
|
||||
# study/guest would pass interchangeable() but neither declares co_locate
|
||||
reqs = {"den": _req("den", 9.0), "guest": _req("guest", 12.0)}
|
||||
assert derive_colocate_pairs(reqs) == []
|
||||
|
||||
|
||||
def test_co_locate_parsed_from_config():
|
||||
conf = {"spaces": {
|
||||
"den": {"size": [9.0, 1.0], "co_locate": ["guest"]},
|
||||
"guest": {"size": [12.0, 1.0]},
|
||||
}}
|
||||
reqs = programme._parse_spaces(conf)
|
||||
assert reqs["den"].co_locate == ["guest"]
|
||||
assert reqs["guest"].co_locate == []
|
||||
assert derive_colocate_pairs(reqs) == [frozenset({"den", "guest"})]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Resolver + checks (graph.py)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _leaf_tree(t: str, co: str | None = None) -> Node:
|
||||
geometry.clear_cache()
|
||||
return Node(node=[[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]],
|
||||
type=t, co_type=co)
|
||||
|
||||
|
||||
def test_leaf_codes_default_off_returns_scalar_type():
|
||||
leaf = Node(type="x", co_type="y")
|
||||
assert graph.leaf_codes(leaf) == ["x"]
|
||||
assert graph.leaf_codes(leaf, [frozenset({"x", "y"})], multi_use=False) == ["x"]
|
||||
|
||||
|
||||
def test_leaf_codes_multi_use_returns_both_when_pair_valid():
|
||||
leaf = Node(type="x", co_type="y")
|
||||
pairs = [frozenset({"x", "y"})]
|
||||
assert sorted(graph.leaf_codes(leaf, pairs, multi_use=True)) == ["x", "y"]
|
||||
|
||||
|
||||
def test_leaf_codes_drops_stale_co_type_after_retype():
|
||||
# mirrors leaf_share's type-guard: a retype invalidates a co_type whose pair
|
||||
# no longer includes the leaf's current type
|
||||
leaf = Node(type="x", co_type="y")
|
||||
pairs = [frozenset({"x", "y"})]
|
||||
leaf.type = "z" # generic retype mutation, co_type never reset
|
||||
assert graph.leaf_codes(leaf, pairs, multi_use=True) == ["z"]
|
||||
|
||||
|
||||
def test_check_space_counts_colocated_leaf_covers_both_codes():
|
||||
reqs = {"x": _req("x", 10.0), "y": _req("y", 9.0)}
|
||||
pairs = [frozenset({"x", "y"})]
|
||||
root = _leaf_tree("x", co="y")
|
||||
|
||||
fails, missing = graph.check_space_counts(
|
||||
root, reqs, multi_use=True, colocate_pairs=pairs)
|
||||
assert fails == [] and missing == []
|
||||
|
||||
|
||||
def test_check_space_counts_default_off_leaves_second_code_missing():
|
||||
reqs = {"x": _req("x", 10.0), "y": _req("y", 9.0)}
|
||||
pairs = [frozenset({"x", "y"})]
|
||||
root = _leaf_tree("x", co="y")
|
||||
|
||||
_fails, missing = graph.check_space_counts(root, reqs) # multi_use default False
|
||||
assert missing == ["y"]
|
||||
_fails, missing = graph.check_space_counts(
|
||||
root, reqs, multi_use=True, colocate_pairs=[]) # pair not declared valid
|
||||
assert missing == ["y"]
|
||||
|
||||
|
||||
def test_check_level_constraints_honours_co_type_leaf():
|
||||
reqs = {"x": _req("x", 10.0, level=0), "y": _req("y", 9.0, level=0)}
|
||||
pairs = [frozenset({"x", "y"})]
|
||||
root = _leaf_tree("x", co="y")
|
||||
_link_subtree(root, None, "")
|
||||
|
||||
assert graph.check_level_constraints(
|
||||
root, reqs, missing=[], multi_use=True, colocate_pairs=pairs) == []
|
||||
|
||||
|
||||
def test_check_vertical_connectivity_honours_co_type_leaf():
|
||||
lower = _leaf_tree("below")
|
||||
upper = _leaf_tree("x", co="y")
|
||||
lower.above = upper
|
||||
dom._link(lower)
|
||||
|
||||
reqs = {"x": _req("x", 10.0, requires_below="below"),
|
||||
"y": _req("y", 9.0, requires_below="below")}
|
||||
pairs = [frozenset({"x", "y"})]
|
||||
|
||||
fails = graph.check_vertical_connectivity(
|
||||
lower, reqs, missing=[], multi_use=True, colocate_pairs=pairs)
|
||||
assert fails == []
|
||||
|
||||
|
||||
def test_has_adjacency_sees_co_type_leaf_only_under_multi_use():
|
||||
geometry.clear_cache()
|
||||
left = Node(type="x", co_type="y")
|
||||
root = Node(
|
||||
node=[[0.0, 0.0], [6.0, 0.0], [6.0, 6.0], [0.0, 6.0]],
|
||||
rotation=0, division=[0.4, 0.4],
|
||||
left=left, right=Node(type="z"),
|
||||
)
|
||||
_link_subtree(root, None, "")
|
||||
G = graph.build_graphs(root, 1.2)[0]
|
||||
right = root.right
|
||||
pairs = [frozenset({"x", "y"})]
|
||||
|
||||
assert graph.has_adjacency(right, "y", G, pairs, multi_use=True) is True
|
||||
assert graph.has_adjacency(right, "y", G, pairs, multi_use=False) is False
|
||||
assert graph.has_adjacency(right, "y", G, [], multi_use=True) is False # undeclared
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Quality-term combination (fitness.py)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _leaf(type_: str, size: float = 4.0, co_type: str | None = None) -> Node:
|
||||
geometry.clear_cache()
|
||||
return Node(
|
||||
node=[[0.0, 0.0], [size, 0.0], [size, size], [0.0, size]],
|
||||
type=type_, co_type=co_type,
|
||||
)
|
||||
|
||||
|
||||
def _rect_leaf(type_: str, width: float, length: float,
|
||||
co_type: str | None = None) -> Node:
|
||||
geometry.clear_cache()
|
||||
return Node(
|
||||
node=[[0.0, 0.0], [length, 0.0], [length, width], [0.0, width]],
|
||||
type=type_, co_type=co_type,
|
||||
)
|
||||
|
||||
|
||||
def _multi_use_conf(pair=True):
|
||||
# x/y stay within interchangeable()'s S2 bounds (R_SIZE=1.5, R_WIDTH=1.3,
|
||||
# R_PROP=1.5) so a declared co_locate is actually valid.
|
||||
spaces = {
|
||||
"x": {"size": [10.0, 2.0], "width": [3.0, 0.5], "proportion": [1.2, 0.5],
|
||||
"count": 1},
|
||||
"y": {"size": [7.0, 1.0], "width": [3.8, 0.2], "proportion": [1.5, 0.1],
|
||||
"count": 1},
|
||||
}
|
||||
if pair:
|
||||
spaces["x"]["co_locate"] = ["y"]
|
||||
return {"multi_use": True, "spaces": spaces}
|
||||
|
||||
|
||||
def test_quality_size_combines_both_codes_area_additively():
|
||||
fit = Fitness(conf=_multi_use_conf())
|
||||
leaf = _leaf("x", size=4.0, co_type="y") # area 16
|
||||
# target 10+7=17, sigma 2+1=3
|
||||
assert fit.quality_size(leaf) == pytest.approx(gaussian(16.0, 1.0, 17.0, 3.0))
|
||||
|
||||
|
||||
def test_quality_width_and_proportion_take_stricter_of_both_targets():
|
||||
fit = Fitness(conf=_multi_use_conf())
|
||||
# elongated rectangle so neither the width nor proportion "already fine"
|
||||
# early-return short-circuits before the gaussian combination runs
|
||||
leaf = _rect_leaf("x", width=2.0, length=10.0, co_type="y")
|
||||
# width target max(3.0,3.8)=3.8, sigma min(0.5,0.2)=0.2
|
||||
assert fit.quality_width(leaf) == pytest.approx(
|
||||
gaussian(geometry.length_narrowest(leaf), 1.0, 3.8, 0.2))
|
||||
# proportion target max(1.2,1.5)=1.5, sigma min(0.5,0.1)=0.1
|
||||
assert fit.quality_proportion(leaf) == pytest.approx(
|
||||
gaussian(geometry.aspect(leaf), 1.0, 1.5, 0.1))
|
||||
|
||||
|
||||
def test_quality_size_ignores_co_type_when_pair_not_declared():
|
||||
fit = Fitness(conf=_multi_use_conf(pair=False))
|
||||
leaf = _leaf("x", size=4.0, co_type="y") # area 16, but pair never declared
|
||||
assert fit.quality_size(leaf) == pytest.approx(gaussian(16.0, 1.0, 10.0, 2.0))
|
||||
|
||||
|
||||
def test_multi_use_default_off_ignores_co_type():
|
||||
conf = _multi_use_conf()
|
||||
conf["multi_use"] = False
|
||||
fit = Fitness(conf=conf)
|
||||
assert fit._multi_use is False
|
||||
leaf = _leaf("x", size=4.0, co_type="y")
|
||||
assert fit.quality_size(leaf) == pytest.approx(gaussian(16.0, 1.0, 10.0, 2.0))
|
||||
|
||||
|
||||
def test_leaf_never_combines_share_and_co_type():
|
||||
# construction never stamps both; if it somehow happened, share wins (k>1
|
||||
# takes precedence over co_type in quality_size)
|
||||
conf = _multi_use_conf()
|
||||
conf["leaf_sharing"] = True
|
||||
fit = Fitness(conf=conf)
|
||||
leaf = _leaf("x", size=4.0, co_type="y")
|
||||
leaf.share, leaf.share_type = 2, "x"
|
||||
# k=2 -> target 20, sigma 4; co_type ignored
|
||||
assert fit.quality_size(leaf) == pytest.approx(gaussian(16.0, 1.0, 20.0, 4.0))
|
||||
|
||||
|
||||
def test_load_config_multi_use_override_merges_last(tmp_path):
|
||||
import yaml
|
||||
|
||||
from homemaker_layout.fitness import load_config
|
||||
|
||||
(tmp_path / "patterns.config").write_text(
|
||||
yaml.safe_dump({"spaces": {"x": {"size": [10.0, 1.0]}}}))
|
||||
|
||||
conf, _ = load_config(tmp_path)
|
||||
assert "multi_use" not in conf
|
||||
|
||||
conf2, _ = load_config(tmp_path, overrides={"multi_use": True})
|
||||
assert conf2["multi_use"] is True
|
||||
assert conf2["spaces"]["x"] == {"size": [10.0, 1.0]}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Construction-time fusion (operators.py)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_colocate_rooms_fuses_available_pair():
|
||||
rooms = ["x", "y", "z"]
|
||||
pairs = [frozenset({"x", "y"})]
|
||||
reduced, plan = operators._colocate_rooms(rooms, pairs, np.random.default_rng(0))
|
||||
|
||||
assert len(plan) == 1
|
||||
(primary, secondaries), = plan.items()
|
||||
assert secondaries == [({"x", "y"} - {primary}).pop()]
|
||||
assert sorted(reduced) == sorted(["z", primary])
|
||||
|
||||
|
||||
def test_colocate_rooms_leaves_unpaired_code_untouched():
|
||||
rooms = ["x", "z"] # no 'y' available to pair with
|
||||
pairs = [frozenset({"x", "y"})]
|
||||
reduced, plan = operators._colocate_rooms(rooms, pairs, np.random.default_rng(0))
|
||||
assert sorted(reduced) == sorted(rooms)
|
||||
assert plan == {}
|
||||
|
||||
|
||||
def test_colocate_rooms_fuses_every_available_instance():
|
||||
rooms = ["x", "x", "y", "y"]
|
||||
pairs = [frozenset({"x", "y"})]
|
||||
reduced, plan = operators._colocate_rooms(rooms, pairs, np.random.default_rng(1))
|
||||
assert len(reduced) == 2
|
||||
assert sum(len(v) for v in plan.values()) == 2
|
||||
|
||||
|
||||
def test_colocate_rooms_no_pairs_is_identity():
|
||||
rooms = ["x", "y", "z"]
|
||||
reduced, plan = operators._colocate_rooms(rooms, [], np.random.default_rng(0))
|
||||
assert sorted(reduced) == sorted(rooms)
|
||||
assert plan == {}
|
||||
|
||||
|
||||
def test_leaf_colocate_from_plan_matches_biggest_target_to_biggest_leaf():
|
||||
geometry.clear_cache()
|
||||
root = Node(
|
||||
node=[[0.0, 0.0], [6.0, 0.0], [6.0, 6.0], [0.0, 6.0]],
|
||||
rotation=0, division=[0.7, 0.7],
|
||||
left=Node(type="x"), right=Node(type="x"),
|
||||
)
|
||||
_link_subtree(root, None, "")
|
||||
reqs = {"big": _req("big", 8.0), "small": _req("small", 2.0)}
|
||||
plan = {"x": ["small", "big"]} # deliberately unsorted
|
||||
|
||||
leaf_co = operators._leaf_colocate_from_plan(root, plan, reqs)
|
||||
|
||||
left, right = root.left, root.right
|
||||
assert geometry.area(left) > geometry.area(right)
|
||||
assert left.co_type == "big"
|
||||
assert right.co_type == "small"
|
||||
assert leaf_co[left] == "big" and leaf_co[right] == "small"
|
||||
|
||||
|
||||
def test_constructive_topology_multi_use_fuses_leaves_and_covers_both_codes():
|
||||
reqs = {
|
||||
"x": _req("x", 10.0, co_locate=["y"]),
|
||||
"y": _req("y", 8.0),
|
||||
"z": _req("z", 6.0),
|
||||
"C": _req("C", 0.0),
|
||||
"O": _req("O", 0.0),
|
||||
}
|
||||
for code in ("C", "O"):
|
||||
reqs[code].has_size = False
|
||||
types = sorted(reqs)
|
||||
seed = Node(node=[[0.0, 0.0], [20.0, 0.0], [20.0, 20.0], [0.0, 20.0]],
|
||||
rotation=0, wall_outer=0.25, wall_inner=0.08)
|
||||
|
||||
plain = operators.constructive_topology(
|
||||
seed, reqs, np.random.default_rng(0), types)
|
||||
fused = operators.constructive_topology(
|
||||
seed, reqs, np.random.default_rng(0), types, multi_use=True)
|
||||
|
||||
n_plain = sum(len(lvl.leaves()) for lvl in dom.levels(plain))
|
||||
n_fused = sum(len(lvl.leaves()) for lvl in dom.levels(fused))
|
||||
assert n_fused < n_plain
|
||||
|
||||
pairs = derive_colocate_pairs(reqs)
|
||||
_fails, missing = graph.check_space_counts(
|
||||
fused, reqs, multi_use=True, colocate_pairs=pairs)
|
||||
assert missing == []
|
||||
|
||||
# default-OFF parity: the plain seed never stamps a co_type
|
||||
assert all(lf.co_type is None for lvl in dom.levels(plain) for lf in lvl.leaves())
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
|
||||
def test_multi_use_default_off_reproduces_plain_construction():
|
||||
# multi_use defaults False and no programme declares co_locate, so
|
||||
# constructive_topology(multi_use left at default) must be untouched.
|
||||
reqs = programme.load_programme_dir(str(HARBOR))
|
||||
types = sorted(reqs) + ["C", "O"]
|
||||
seed = dom.load(str(HARBOR / "init.dom"))
|
||||
a = operators.constructive_topology(seed, reqs, np.random.default_rng(0), types)
|
||||
b = operators.constructive_topology(seed, reqs, np.random.default_rng(0), types,
|
||||
multi_use=False)
|
||||
assert dom.dumps(a) == dom.dumps(b)
|
||||
Loading…
Add table
Reference in a new issue