diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 427a11a..36a391a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -16,6 +16,8 @@ {"id":"homemaker-py-1p0","title":"Geometry inner loop: full-objective equal-offset ratio optimiser","description":"DESIGN.md §5.1, §7 Phase 1. Productionise experiments/optimize_fullfitness.py into homemaker: optimise(topology, x0=None) -\u003e (geometry, fitness). DOF = equal-offset division ratios of free branches (solver.free_branches, lowest-storey cut ownership), clipped to [eps, 1-eps]. Objective = full oracle fitness (never a proxy — §4.2 falsified). Must support warm-start x0 (§5.6) and a population/batch evaluation mode so each iteration scores via one batched oracle call (§4.6).","acceptance_criteria":"Reproduces or exceeds §4.5 gains (x1.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-9gp.2","title":"M3 Wong-Liu re-association reachability move","description":"9gp.2: add mutate_reassociate, the associativity move (a|b)|c \u003c-\u003e a|(b|c) (same-axis tree rotation on owned/live cuts) missing from the swap(M1)/rotate(M2) set. Targets the §11.4/§11.5 reachability bottleneck. Round-trip/invariant tests. MEASURE value on maple-court vs leu.2 baseline — either result is a valid verdict per the re-scoped bead. DESIGN.md §12.3.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-20T16:54:07Z","created_by":"Bruno Postle","updated_at":"2026-06-20T16:54:07Z","dependencies":[{"issue_id":"homemaker-py-9gp.2","depends_on_id":"homemaker-py-9gp","type":"parent-child","created_at":"2026-06-20T17:54:07Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"homemaker-py-9gp.1","title":"Shape-feasibility pre-filter before inner loop","description":"9gp.1: predict per-leaf shape fails (size/width/proportion/crinkliness) at the proportion-aware target geometry, prune clearly-infeasible topologies before the inner loop so budget flows to feasible ones. Reuse operators._size_divisions_from_targets + fitness quality methods. Default OFF; threshold is a measured parameter. Hook in driver._evaluate. Measure on maple-court + harbor vs leu.2 baseline. DESIGN.md §12.3.","notes":"IMPLEMENTED (impl + tests + smoke), MEASUREMENT PENDING. operators.predicted_shape_fails + driver._evaluate pre-filter hook; search/search_staged params feasibility_filter, feasibility_max_shape_fails (default OFF). Env FEAS=1 MAXSHAPE=\u003cn\u003e. Tests green. Acceptance needs the maple-court/harbor A/B sweep (DESIGN.md §12.3, handed to user).","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-20T16:53:48Z","created_by":"Bruno Postle","updated_at":"2026-06-20T17:53:46Z","started_at":"2026-06-20T16:54:15Z","dependencies":[{"issue_id":"homemaker-py-9gp.1","depends_on_id":"homemaker-py-9gp","type":"parent-child","created_at":"2026-06-20T17:53:48Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-cq1","title":"Constructive seeder + staged dispatch ignored storey_minimum","description":"n_storeys_required only reads level: keys, so a programme with storey_minimum\u003emax(level)+1 (e.g. programme-house: storey_minimum:2, all rooms level:0) was seeded one storey short by constructive_topology and routed to plain (non-staged) search. Fitness then fired a 'storey minimum' fail the search had to repair structurally. Surfaced while measuring leu.2 (proportion-aware seeding deepened the basin around the wrong-storey-count seed). Fix: programme.storey_minimum()/n_storeys_for(); driver.search passes min_storeys to constructive_topology; search_staged routes on max(n_storeys_required, storey_minimum). Independent win: programme-house single-stage baseline 8.0 -\u003e 5.0 fails with correct 2-storey seed.","notes":"Fixed. programme.storey_minimum()/n_storeys_for(); driver.search passes min_storeys to constructive_topology; search_staged routes on max(n_storeys_required, storey_minimum). No-op for harbor/maple; programme-house single-stage baseline 8.0-\u003e5.0 with correct 2-storey seed. 204 tests pass.","status":"closed","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-06-20T08:59:31Z","created_by":"Bruno Postle","updated_at":"2026-06-20T12:32:30Z","closed_at":"2026-06-20T12:32:30Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-leu.2","title":"Proportion-aware constructive seeding (size splits from target dims)","description":"Follow-up to §11.6/§11.7. Adjacency-aware seeding cut the topology load (adjacency-to-c / access) but §11.6 explicitly noted the seed still splits at 0.5, producing 'more, smaller leaves' whose size/proportion/crinkliness fails the inner loop then has to recover. With topology fails now cut by seeding, this GEOMETRY residual is the dominant remaining term (§11.7 verdict). Attacking it at the seed is the proven-productive (construction) direction and is far cheaper than the 9gp encoding rewrite.\n\nIdea: when constructive_topology / lift_base_to_storeys place a cut, size the division ratio from the leaves' TARGET dimensions (programme target areas/widths) instead of 0.5, so the raw seed geometry already sits near feasible proportions and the inner loop starts inside (or much closer to) the size/width/proportion basins. Keep adjacency-aware placement (§11.6/§11.7) unchanged; this only changes split RATIOS, not topology or type assignment. Behind a flag for clean A/B, default-on if it wins.\n\nMeasure raw-seed geometry fails (size/width/proportion/crinkliness) before/after AND end-to-end total fails at budget on harbor, programme-house, AND the new leu.1 benchmark, same protocol as §11.6. Record in DESIGN.md §12.2 + bead notes (incl. negative result if it does not win).","acceptance_criteria":"Proportion-aware split sizing implemented behind a flag; raw-seed geometry-fail reduction quantified; end-to-end total-fail change measured on harbor, programme-house, and the leu.1 benchmark (\u003e=3 seeds each); result (positive or negative) recorded in DESIGN.md.","notes":"DONE (positive), default-on. End-to-end (20000 evals, 3 seeds, staged): harbor 85.3-\u003e74.0 (-13%, best 69), maple-court 151.7-\u003e136.0 (-10%, best 126). PROP=0 reproduces 11.7/12.1 baselines exactly. programme-house regresses at fixed budget (deeper-local-optimum: well-fitted seed walls off the undivide restructuring path) but a budget sweep shows it's convergence-SPEED not asymptote (PROP=1 reaches 1 fail at 150k, beating PROP=0's 2; floor is 2). Win requires rotation+ratio sizing from target dims (area-only regressed via slivers). Surfaced + fixed storey_minimum bug (cq1). Default flipped on: driver.search/search_staged seed_proportion_aware=True, harness PROP=1. DESIGN.md 12.2. 204 tests pass. New maple best 126 saved as generated.dom.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-19T11:14:15Z","created_by":"Bruno Postle","updated_at":"2026-06-20T12:32:28Z","started_at":"2026-06-19T13:03:27Z","closed_at":"2026-06-20T12:32:28Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-leu.2","depends_on_id":"homemaker-py-leu","type":"parent-child","created_at":"2026-06-19T12:14:15Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-leu.2","depends_on_id":"homemaker-py-leu.1","type":"blocks","created_at":"2026-06-19T12:14:45Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-s44","title":"Adjacency-aware constructive seeding (cut adjacency/access fails)","description":"Follow-up to homemaker-py-c4c.2. constructive_topology currently assigns room types to leaves at RANDOM, ignoring each space's adjacency requirement. On harbor this leaves 8 adjacency + 13 access fails in the seeded design. Cluster each required room near its required neighbour (esp. circulation c) at construction time — e.g. assign rooms to leaves whose sibling/parent is C, or grow the tree so each room lands adjacent to a circulation spine. Should directly cut the adjacency+access fail load that now dominates the complete-design quality-fail regime (DESIGN.md §11.2 verdict).","notes":"DONE (positive), DESIGN.md §11.6. _assign_adjacency_aware: greedy connected-dominating-set of circulation leaves on the geometric leaf_graph so every room borders a connected circulation spine; rooms on dominated leaves, O peripheral. Default-on via constructive_topology(adjacency_aware=True), threaded driver.search(seed_adjacency_aware). Seed quality (harbor 10 seeds): adjacency 29-\u003e12, access 27-\u003e8. End-to-end single-stage 20000 evals total fails mean: harbor 110.0-\u003e90.7 (-17.5%, ADJ=0 seed0 reproduces §11.2 105 baseline exactly), programme-house 12.3-\u003e9.3 (-24%); adjacency-aware single-stage harbor (mean 90.7, best 85) beats the §11.3 staged 95. Follow-ups filed: lift_base_to_storeys adjacency-awareness + secondary adjacencies.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-17T21:50:01Z","created_by":"Bruno Postle","updated_at":"2026-06-19T08:12:43Z","started_at":"2026-06-18T22:52:25Z","closed_at":"2026-06-19T08:12:43Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -25,7 +27,7 @@ {"id":"homemaker-py-gpx","title":"Native fitness parity gap on multi-storey designs (~3.7%)","description":"During programme-house cold-start runs with the fixed level_add operator, the generated 2-storey design showed native=1.2388e-04 vs oracle=1.1944e-04 (3.7% gap), exceeding the 0.01% rel_tol in test_native_fitness_score_parity. All existing single-storey corpus files pass parity fine (73/73). Hypothesis: a subtle discrepancy in value or cost computation for multi-level trees — candidates are staircase quality, circulation connectivity, or per-storey cost accumulation. To investigate: score a sweep of known multi-storey corpus files natively vs oracle and identify which term diverges.","status":"closed","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-06-14T09:35:34Z","created_by":"Bruno Postle","updated_at":"2026-06-17T17:39:25Z","closed_at":"2026-06-17T17:39:25Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-hqw","title":"Make homemaker-py standalone: remove dependency on Perl Urb package","description":"Currently tests and fitness scoring depend on the Perl Urb package (urb-fitness.pl) and corpus files in /home/bruno/src/urb/examples/. The tool should be fully standalone and not require any external Perl packages or local urb corpus paths. This includes: bundling or reimplementing any needed reference data, making the native Python fitness the default path, and ensuring tests pass without /home/bruno/src/urb present.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T22:27:54Z","created_by":"Bruno Postle","updated_at":"2026-06-13T22:39:28Z","started_at":"2026-06-13T22:34:20Z","closed_at":"2026-06-13T22:39:28Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-0px","title":"Blank-slate cold-start initialisation","description":"The outer search stalls when starting from init.dom (Phase 2 gate: 18 fails after 2000 evals vs urb-evolve's 6). The root cause is single-seed topology mutation chaining — building structure one room at a time gives no gradient across the large zero-feasibility region. Fix requires multi-start bootstrap: generate a diverse initial population by random topology sampling, or a greedy room-placement initialiser that satisfies adjacency/level constraints before handing off to the memetic loop. Without this the tool is only useful for refining existing designs, not designing new buildings from scratch.","acceptance_criteria":"Cold-start from init.dom reaches comparable fail count to urb-evolve within equal eval budget; tested on programme-house","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T21:52:15Z","created_by":"Bruno Postle","updated_at":"2026-06-13T22:28:58Z","started_at":"2026-06-13T22:24:02Z","closed_at":"2026-06-13T22:28:58Z","close_reason":"Bootstrap implemented: auto-detect bare-plot seed, generate pop_size random topologies, evaluate each at child_budget before memetic loop; 3 new tests all green","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"homemaker-py-9gp","title":"Canonical slicing encoding (normalized Polish expression) + shape feasibility","description":"RE-SCOPED 2026-06-19 under epic homemaker-py-leu. Canonical slicing encoding capstone (DESIGN.md §5.5, §7 Phase 5): normalized Polish expression / skewed slicing tree (Wong-Liu) for redundancy-free, high-locality topology moves; bottom-up shape-feasibility checks to prune infeasible topologies before the inner loop. Goal: scale to larger programmes. Excluded representations stay excluded (§2): no sequence-pair / B*-tree (non-slicing).\n\nSCOPE CHANGE — one of three original justifications is now DEAD. The original bead leaned on 'provides the principled topology SIGNATURE that c4c.5 niching needs ((a|b)|c == a|(b|c) collapse)'. §11.5 (c4c.5) FALSIFIED niching: maximal structural diversity did not lower fails, and genome.signature already exists as the cheap stand-in. So the niching-signature rationale is dropped. The surviving, EVIDENCE-SUPPORTED parts:\n (a) M1/M2/M3 Wong-Liu moves — richer topology operators that attack the REACHABILITY bottleneck §11.4 AND §11.5 both independently fingered (operators+encoding cannot reach low-fail basins). This is the core justification.\n (b) Shape-feasibility pruning before the inner loop — targets the §11.7 geometry/shape residual (size/proportion/crinkliness) AND saves inner-loop budget, which is the part that actually buys SCALING.\nAssociativity collapse for its own sake is unproven at 16 rooms; its value must be MEASURED on the leu.1 \u003e16-room benchmark, not assumed.\n\nSurvey carry-over (still true): current encoding is base-floor slicing tree + per-storey deltas (GNode), not a Polish expression; 11 mutation operators work on decoded Node trees; decode() fixed-point removes intra-encoding redundancy but tree structure is not canonical. Genome: genome.py; operators: operators.py; tests: test_genome.py, test_operators.py.\n\nORDER: lands LAST in the epic — on the strongest seed (after leu.2 proportion-aware seeding) and with the leu.1 benchmark in place to actually measure the scaling claim. Do not build encoding machinery on an unmeasured premise (the §11.4/§11.5 failure mode).","acceptance_criteria":"Encoding round-trips with the genome; M1/M2/M3 moves implemented; shape-feasibility pre-filter prunes infeasible topologies before the inner loop; MEASURED search improvement on the leu.1 larger-than-house benchmark vs its documented baseline; result recorded in DESIGN.md §12.3.","notes":"Survey 2026-06-17: current encoding is base-floor slicing tree + per-storey deltas (GNode), not a Polish expression. 11 mutation operators work on decoded Node trees. Redundancy within the encoding is eliminated by decode() fixed-point, but tree structure itself is not canonical (a|b)|c vs a|(b|c) are distinct genomes for the same partition. M1/M2/M3 Wong-Liu moves not implemented. No pre-inner-loop shape feasibility pruning. Native fitness (homemaker-py-mz5) and parity gap (homemaker-py-gpx) are now both closed, so the explicit DESIGN.md defer condition is met. Work is justified at \u003e16 rooms where redundancy and coarse moves hurt search. Genome: genome.py, operators: operators.py, tests: test_genome.py, test_operators.py.\nReframed 2026-06-17 under epic homemaker-py-c4c (topology-search quality). This is the CAPSTONE, not the entry point: the bottleneck is topology-search QUALITY on full programmes (random room typing -\u003e missing-room stacking; flat high-fail gradient; fitness-scalar dedup), addressed first by construction (c4c.2), staging (c4c.3), graded objective (c4c.4), diversity (c4c.5). Canonical Polish encoding then lands on a search that can already construct, and provides the principled topology SIGNATURE that c4c.5 niching needs ((a|b)|c == a|(b|c) collapse). Sequence after c4c.2.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:02Z","created_by":"Bruno Postle","updated_at":"2026-06-19T11:14:37Z","dependencies":[{"issue_id":"homemaker-py-9gp","depends_on_id":"homemaker-py-c4c.2","type":"blocks","created_at":"2026-06-17T20:14:45Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-9gp","depends_on_id":"homemaker-py-c4c.5","type":"relates-to","created_at":"2026-06-17T20:14:46Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-9gp","depends_on_id":"homemaker-py-ccw","type":"blocks","created_at":"2026-06-12T00:39:48Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-9gp","depends_on_id":"homemaker-py-leu","type":"parent-child","created_at":"2026-06-19T12:14:35Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-9gp","depends_on_id":"homemaker-py-leu.1","type":"blocks","created_at":"2026-06-19T12:14:47Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-9gp","depends_on_id":"homemaker-py-leu.2","type":"blocks","created_at":"2026-06-19T12:14:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} +{"id":"homemaker-py-9gp","title":"Canonical slicing encoding (normalized Polish expression) + shape feasibility","description":"RE-SCOPED 2026-06-19 under epic homemaker-py-leu. Canonical slicing encoding capstone (DESIGN.md §5.5, §7 Phase 5): normalized Polish expression / skewed slicing tree (Wong-Liu) for redundancy-free, high-locality topology moves; bottom-up shape-feasibility checks to prune infeasible topologies before the inner loop. Goal: scale to larger programmes. Excluded representations stay excluded (§2): no sequence-pair / B*-tree (non-slicing).\n\nSCOPE CHANGE — one of three original justifications is now DEAD. The original bead leaned on 'provides the principled topology SIGNATURE that c4c.5 niching needs ((a|b)|c == a|(b|c) collapse)'. §11.5 (c4c.5) FALSIFIED niching: maximal structural diversity did not lower fails, and genome.signature already exists as the cheap stand-in. So the niching-signature rationale is dropped. The surviving, EVIDENCE-SUPPORTED parts:\n (a) M1/M2/M3 Wong-Liu moves — richer topology operators that attack the REACHABILITY bottleneck §11.4 AND §11.5 both independently fingered (operators+encoding cannot reach low-fail basins). This is the core justification.\n (b) Shape-feasibility pruning before the inner loop — targets the §11.7 geometry/shape residual (size/proportion/crinkliness) AND saves inner-loop budget, which is the part that actually buys SCALING.\nAssociativity collapse for its own sake is unproven at 16 rooms; its value must be MEASURED on the leu.1 \u003e16-room benchmark, not assumed.\n\nSurvey carry-over (still true): current encoding is base-floor slicing tree + per-storey deltas (GNode), not a Polish expression; 11 mutation operators work on decoded Node trees; decode() fixed-point removes intra-encoding redundancy but tree structure is not canonical. Genome: genome.py; operators: operators.py; tests: test_genome.py, test_operators.py.\n\nORDER: lands LAST in the epic — on the strongest seed (after leu.2 proportion-aware seeding) and with the leu.1 benchmark in place to actually measure the scaling claim. Do not build encoding machinery on an unmeasured premise (the §11.4/§11.5 failure mode).","acceptance_criteria":"Encoding round-trips with the genome; M1/M2/M3 moves implemented; shape-feasibility pre-filter prunes infeasible topologies before the inner loop; MEASURED search improvement on the leu.1 larger-than-house benchmark vs its documented baseline; result recorded in DESIGN.md §12.3.","notes":"Survey 2026-06-17: current encoding is base-floor slicing tree + per-storey deltas (GNode), not a Polish expression. 11 mutation operators work on decoded Node trees. Redundancy within the encoding is eliminated by decode() fixed-point, but tree structure itself is not canonical (a|b)|c vs a|(b|c) are distinct genomes for the same partition. M1/M2/M3 Wong-Liu moves not implemented. No pre-inner-loop shape feasibility pruning. Native fitness (homemaker-py-mz5) and parity gap (homemaker-py-gpx) are now both closed, so the explicit DESIGN.md defer condition is met. Work is justified at \u003e16 rooms where redundancy and coarse moves hurt search. Genome: genome.py, operators: operators.py, tests: test_genome.py, test_operators.py.\nReframed 2026-06-17 under epic homemaker-py-c4c (topology-search quality). This is the CAPSTONE, not the entry point: the bottleneck is topology-search QUALITY on full programmes (random room typing -\u003e missing-room stacking; flat high-fail gradient; fitness-scalar dedup), addressed first by construction (c4c.2), staging (c4c.3), graded objective (c4c.4), diversity (c4c.5). Canonical Polish encoding then lands on a search that can already construct, and provides the principled topology SIGNATURE that c4c.5 niching needs ((a|b)|c == a|(b|c) collapse). Sequence after c4c.2.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:02Z","created_by":"Bruno Postle","updated_at":"2026-06-20T13:09:46Z","started_at":"2026-06-20T13:09:46Z","dependencies":[{"issue_id":"homemaker-py-9gp","depends_on_id":"homemaker-py-c4c.2","type":"blocks","created_at":"2026-06-17T20:14:45Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-9gp","depends_on_id":"homemaker-py-c4c.5","type":"relates-to","created_at":"2026-06-17T20:14:46Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-9gp","depends_on_id":"homemaker-py-ccw","type":"blocks","created_at":"2026-06-12T00:39:48Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-9gp","depends_on_id":"homemaker-py-leu","type":"parent-child","created_at":"2026-06-19T12:14:35Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-9gp","depends_on_id":"homemaker-py-leu.1","type":"blocks","created_at":"2026-06-19T12:14:47Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-9gp","depends_on_id":"homemaker-py-leu.2","type":"blocks","created_at":"2026-06-19T12:14:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-ccw","title":"Scaled topology search on native fitness","description":"DESIGN.md §7 Phase 3 closing step. Once native fitness passes corpus parity, re-run the Phase-2 memetic search at real scale (population/generations comparable to urb-evolve) on the native objective. This is the first point where the §1 scaling question gets a real answer.","acceptance_criteria":"Full-scale run on programme-house beats both urb-evolve and the small-scale Phase-2 result; larger programme attempted","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:59Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:11:13Z","started_at":"2026-06-13T20:49:27Z","closed_at":"2026-06-13T21:11:13Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-ccw","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:44Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-ccw","depends_on_id":"homemaker-py-way","type":"blocks","created_at":"2026-06-12T00:39:45Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-way","title":"Benchmark: memetic loop vs urb-evolve at equal oracle-call budget (Phase 2 gate)","description":"DESIGN.md §7 Phase 2 gate. Compare against urb-evolve from the same seeds/programmes at equal oracle-evaluation budget — NOT generations (urb-evolve has diversity injection/culling baked in, so generations are not comparable). Go/no-go: memetic loop must beat equal-budget urb-evolve. Scaling up waits for native fitness.","acceptance_criteria":"Best-fitness and failure-count comparison at \u003e=2 budgets, \u003e=3 seeds; go/no-go decision recorded in DESIGN.md","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:37:28Z","created_by":"Bruno Postle","updated_at":"2026-06-13T08:55:03Z","started_at":"2026-06-12T21:13:20Z","closed_at":"2026-06-13T08:55:03Z","close_reason":"Phase-2 gate run (benchmark_vs_urbevolve.py, 2026-06-13, 2000 evals, URB_NO_OCCLUSION=1): 2/3 seeds → REVIEW. Memetic beats urb-evolve by 1.91x/1.63x on seeded designs; blank-slate init.dom stalls at 18 fails vs urb-evolve's 6 (random-pop init advantage). Fix: patterns.config was missing from re-score cwd (run_search.py), giving false near-zero finals in first run. Results recorded in DESIGN.md §7 Phase 2 gate.","dependencies":[{"issue_id":"homemaker-py-way","depends_on_id":"homemaker-py-b39","type":"blocks","created_at":"2026-06-12T00:39:39Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-way","depends_on_id":"homemaker-py-gp2","type":"blocks","created_at":"2026-06-12T08:27:45Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-b39","title":"Memetic search driver, small-scale (budgets in oracle evaluations)","description":"DESIGN.md §5, §7 Phase 2, §4.6 arithmetic. Memetic EA/SA over topology genomes wrapping the geometry inner loop (warm-started per §5.6); score = best full fitness over the inner loop. Explicitly small-scale on the batched oracle: tens of topologies, budget accounted in oracle evaluations, not generations. Population evaluation batched into single oracle calls.","acceptance_criteria":"End-to-end run on programme-house completes within a stated oracle-call budget and logs evaluations; produces valid .dom output","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:37:27Z","created_by":"Bruno Postle","updated_at":"2026-06-12T21:10:05Z","started_at":"2026-06-12T13:13:53Z","closed_at":"2026-06-12T21:10:05Z","close_reason":"driver.search() lands: steady-state memetic GA, tournament selection, operators + crossover, warm-started inner loop (Lamarckian write-back), budgets accounted in oracle evaluations. Acceptance run (URB_NO_OCCLUSION=1, budget 2000, seed c964435): 2010 evals / 23 topologies, best 0.00765/2 fails via crossover = x1.14 over the geometry-only optimum; output .dom re-scores standalone at exactly the recorded fitness. En route: found + fixed Urb ratio_o/ratio_type first-match nondeterminism (class-sum patch, 35/35 corpus parity) after the search reward-hacked it; operators now emit canonical uppercase generics (Bruno's correction: C=circulation, Is_Covered is a predicate).","dependencies":[{"issue_id":"homemaker-py-b39","depends_on_id":"homemaker-py-1p0","type":"blocks","created_at":"2026-06-12T00:39:37Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-b39","depends_on_id":"homemaker-py-nyb","type":"blocks","created_at":"2026-06-12T00:39:38Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} @@ -43,17 +45,17 @@ {"id":"homemaker-py-can","title":"Programme width defaults: t3 contradiction (impossible width_inside default)","description":"DESIGN.md §8.2, confirmed in source. t3 (3 m2 WC) has no width spec so inherits width_inside [4.0, 1.0] (Fitness/Base.pm:60) — geometrically impossible; designs 'pass' only by failing size instead. Fix AFTER faithful-port validation (port-faithfully-first policy, §8.1): a sane width default scaled to area (e.g. sqrt(area/proportion)) or per-room widths in patterns.config. Applies to native fitness; optionally upstream to Urb.","acceptance_criteria":"No programme space has a default width incompatible with its target area; corpus re-scored and effect documented","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:01Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:21:37Z","started_at":"2026-06-13T21:16:11Z","closed_at":"2026-06-13T21:21:37Z","close_reason":"Fixed in get_space_params: when a programme space has no explicit 'width', derive target from sqrt(size/proportion) instead of falling back to width_inside [4.0, 1.0]. Re-scored 35-file corpus: 32 files improved (+1-121%), 5 files lost spurious width fails. All 109 tests pass. Upstream Perl fix tracked as homemaker-py-8fe.","dependencies":[{"issue_id":"homemaker-py-can","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:47Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-yg5","title":"Penalty reshaping: replace 0.5^n while preserving inner-loop protection","description":"DESIGN.md §4.7, §5.4, §7 Phase 4, §8.5. The 0.5^n cliff gives the outer search no gradient and rewards flag-count over geometry, but it also PROTECTS the inner loop from trading into new failures (§4.5). One fitness shape cannot naively be both soft outside and cliff-protected inside. Candidates: cliff-inside-inner-loop only, lexicographic (failure count first, score second), additive/soft, multi-objective Pareto. Must preserve the missing-space failure hierarchy (worse to drop a room than to have a poor one). Measure landscape + search outcomes; this helps Urb today too.","acceptance_criteria":"Chosen scheme documented with measurements: search improves while inner loop still never trades into new failures","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:00Z","created_by":"Bruno Postle","updated_at":"2026-06-14T08:16:14Z","started_at":"2026-06-14T07:55:32Z","closed_at":"2026-06-14T08:16:14Z","close_reason":"Implemented lexicographic outer-search comparison (-n_fails, fitness). Inner loop unchanged (0.5^n cliff protection preserved). Experiment penalty_reshape.py confirms 0/9 fail regressions in inner loop and shows lex avoids the 3-fail trap that scalar hits 1/3 of the time. Fixed stale _CHILD_INNER_KW sigmas entry.","dependencies":[{"issue_id":"homemaker-py-yg5","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-2g5","title":"Rebuild occlusion/daylight/sun subsystem in Python (post-Phase-5, after optimisation fully native)","description":"DESIGN.md §6 port scope — a whole subsystem, not a term. quality_daylight (Leaf.pm:281-296) needs Urb::Misc::Sun + Urb::Field::Occlusion (+CIESky); quality_uncrinkliness also takes the occlusion object. Indoor spaces return 1 for daylight; cost is outdoor spaces + crinkliness. Port Sun_horizontal (262980-minute normalisation) and the occlusion wall set from Dom-\u003eWalls.","acceptance_criteria":"Daylight and crinkliness factors match Perl (float tolerance) across the corpus, including multi-storey cases","notes":"Re-scoped 2026-06-12: occlusion disabled in the Urb oracle instead of ported (see homemaker-py-gp2). Native fitness ships with simple crinkliness (illumination factor = 1, in homemaker-py-gnw). This issue is now the eventual Python occlusion rebuild, only after optimisation works entirely in Python. Restores outdoor-daylight and shaded-wall selection pressure.\nReframed 2026-06-17: orthogonal to epic homemaker-py-c4c. This is fitness FIDELITY (restoring daylight + shaded-wall selection pressure to match Perl), not search CAPABILITY — it changes what 'good' means, not the search's ability to find good. It will NOT improve final designs in the sense currently sought. Stays P4, deferred until the topology-search-quality epic lands and optimisation is fully native.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:25Z","created_by":"Bruno Postle","updated_at":"2026-06-17T19:14:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"memory","key":"deceptive-valleys-in-topology-search-when-every-single","value":"Deceptive valleys in topology search: when every single-step mutation from a target state passes through a high-fail intermediary (e.g. level_fix displaces a room into 5+ new fails), a compound operator that atomically applies two coordinated changes can escape. Design compound operators to land on the low-fail state directly, bypassing the deceptive gradient. Programme-house example: level_compound_fix atomically moves the level-constrained room AND re-inserts the displaced room adjacent to C in one step (operators.py, 2026-06-14)."} -{"_type":"memory","key":"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":"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-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":"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-oracle-nondeterminism-urb-fitness-pl-output-varies","value":"Urb oracle nondeterminism: urb-fitness.pl output varies run-to-run from Perl hash-order randomisation — .fails line ORDER shuffles (compare sorted, use oracle.Score.fail_lines) and the score float can flip by ~1 ULP (compare with math.isclose rel_tol=1e-12, never ==). Not a batching artifact; affects single runs too. Matters for the Phase 3 native-fitness parity gate (homemaker-py-uxz)."} -{"_type":"memory","key":"user-preference-bruno-this-is-a-fedora-system","value":"User preference (Bruno): this is a Fedora system — NEVER install Python packages via pip without asking first; always ask whether to install the rpm via dnf (e.g. python3-cma) before considering pip. Applies to any dependency additions."} -{"_type":"memory","key":"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":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."} -{"_type":"memory","key":"multi-storey-staircase-consistency-when-dividing-or-retyping","value":"Multi-storey staircase consistency: when dividing or retyping a circulation (C) leaf at one level, the same structural change should be propagated to the matching leaf on ALL other storeys so the stair core path is maintained. The optimizer cannot fix staircase disruptions through trial-and-error geometry alone — it requires a synchronized multi-level operator that applies the same topology change to every storey simultaneously."} -{"_type":"memory","key":"programme-house-optimisation-result-2026-06-14-15","value":"Programme-house optimisation result (2026-06-14/15): best achievable is 1 fail (l1 wrong level, score ~0.005). 0 fails is geometrically impossible: l1 (min 27m²) must occupy ll (~23m²) at level 0, which eliminates the t3-adj-C provider; dividing ll into lll(l1)+llr(C) gives llr proportion ~6:1 (fails). Python memetic optimizer achieves 1 fail in 50k evals vs Perl optimiser's 2-3 fails. Winning topology: TWO C nodes at level 0 — ll(C) for t3-adj-C via geometric contact, rl(C) for staircase via tree-sibling adjacency to rrr(O). Best .dom: scratch/from-warmstart-fixed.dom and scratch/from-compound3-fixed.dom."} -{"_type":"memory","key":"strategy-decision-2026-06-12-bruno-occlusion-daylight","value":"Strategy decision 2026-06-12 (Bruno): occlusion/daylight is ORTHOGONAL to building a scalable optimiser. Disable it in Urb (env flag, homemaker-py-gp2) rather than port it; native fitness uses simple crinkliness (illumination factor = 1); rebuild occlusion in Python only after optimisation is fully native (homemaker-py-2g5, now P4). Consequence: all scores change when the flag flips — re-baseline corpus/.score, DESIGN \\$4.5 gains, gate bars at one clean boundary AFTER homemaker-py-1p0 closes; Phase-2 urb-evolve benchmark must run with the same flag."} +{"_type":"memory","key":"urb-oracle-nondeterminism-urb-fitness-pl-output-varies","value":"Urb oracle nondeterminism: urb-fitness.pl output varies run-to-run from Perl hash-order randomisation — .fails line ORDER shuffles (compare sorted, use oracle.Score.fail_lines) and the score float can flip by ~1 ULP (compare with math.isclose rel_tol=1e-12, never ==). Not a batching artifact; affects single runs too. Matters for the Phase 3 native-fitness parity gate (homemaker-py-uxz)."} +{"_type":"memory","key":"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":"urb-fitness-bug-found-fixed-2026-06-12","value":"Urb fitness bug found+fixed 2026-06-12 (patch in /home/bruno/src/urb, uncommitted): ProgrammeDriven.pm ratio_o/ratio_type grepped case-insensitively over the ratios hash and took the FIRST key — nondeterministic (x4.5 score swings) for designs with mixed-case type classes (both 'c' circulation and 'C' covered). Fixed to SUM the class (matches Is_Circulation//Is_Outside semantics); 35/35 corpus scores unchanged. CRITICAL for homemaker-py-3y7/gnw: the native port must implement class-SUM ratios. Building.pm has the same unpatched pattern (site-driven path, not used by our oracle). Also: the memetic search reward-hacked this bug before the fix — search results predating it are noise artifacts."} +{"_type":"memory","key":"user-preference-bruno-this-is-a-fedora-system","value":"User preference (Bruno): this is a Fedora system — NEVER install Python packages via pip without asking first; always ask whether to install the rpm via dnf (e.g. python3-cma) before considering pip. Applies to any dependency additions."} {"_type":"memory","key":"warm-x0-initialization-bug-pattern-when-a-topology","value":"warm_x0 initialization bug pattern: when a topology operator explicitly sets division ratios on a newly-created node (e.g. compound_fix sets node.division=[0.25,0.25] for t3), parent.ratios has no entry for that node (it was a leaf). warm_x0 defaults it to 0.5, corrupting the inner loop's starting point and making the operator invisible to lex comparison. Fix: only propagate child ratios for nodes where the parent node was NOT already divided; stale hidden nodes revealed by structural mutations (swap flipping b.below) must NOT contribute their pre-writeback values. See driver.py lines 259-267 (fixed 2026-06-14)."} +{"_type":"memory","key":"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":"programme-house-optimisation-result-2026-06-14-15","value":"Programme-house optimisation result (2026-06-14/15): best achievable is 1 fail (l1 wrong level, score ~0.005). 0 fails is geometrically impossible: l1 (min 27m²) must occupy ll (~23m²) at level 0, which eliminates the t3-adj-C provider; dividing ll into lll(l1)+llr(C) gives llr proportion ~6:1 (fails). Python memetic optimizer achieves 1 fail in 50k evals vs Perl optimiser's 2-3 fails. Winning topology: TWO C nodes at level 0 — ll(C) for t3-adj-C via geometric contact, rl(C) for staircase via tree-sibling adjacency to rrr(O). Best .dom: scratch/from-warmstart-fixed.dom and scratch/from-compound3-fixed.dom."} +{"_type":"memory","key":"proportion-aware-constructive-seeding-leu-2-12-2","value":"Proportion-aware constructive seeding (leu.2/§12.2): sizing seed cuts from target AREAS only regresses (thin slivers wreck aspect); you must ALSO pick each cut's rotation for child squareness. It is a convergence ACCELERATOR via a deeper local optimum around the constructed topology: wins where that topology is roughly right and budget is scarce (harbor -13%, maple -10% at 20k evals) but DELAYS small programmes where the seed must be restructured by undivide (programme-house regresses at fixed budget, yet reaches the floor given budget - speed, not asymptote). Default-on. Also: n_storeys must honour storey_minimum, not just level: keys (programme-house storey_minimum:2, all rooms level:0 - was seeded 1 storey short; cq1)."} +{"_type":"memory","key":"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":"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":"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":"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."} diff --git a/DESIGN.md b/DESIGN.md index bd138ef..8138413 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1274,3 +1274,72 @@ target dims; neither touches topology or type assignment. Threaded through rotation-and-ratio sizing from target dims; the bare ratio is not enough (area-only regressed). Area sizing assumes total target ≈ plot area; choosing the cut *direction* for aspect is what makes it pay. + +### 12.3 Re-scoped 9gp: shape feasibility + reachability moves (`homemaker-py-9gp`) + +Re-scoped capstone of the epic (2026-06-19): the original canonical-Polish- +expression rewrite was justified partly by a niching *signature*, but §11.5 +falsified niching and `genome.signature` already supplies the cheap stand-in. The +two surviving, evidence-supported parts are landed here as operators on the +existing decoded `Node` tree — **no** Polish-expression rewrite — each measured +independently against the §12.2 leu.2 baseline (maple-court staged 136.0, harbor +74.0). A true canonical encoding is revisited only if the M3 measurement proves +associativity valuable at scale. + +**9gp.1 — shape-feasibility pre-filter (scaling lever).** `operators. +predicted_shape_fails(root, reqs, fit)` lays a topology out at its proportion- +aware target geometry (reusing `_size_divisions_from_targets`, §12.2 — the +squarest layout the inner loop warm-starts from) and counts the +size/width/proportion/crinkliness fails the native fitness reports: a cheap +lower-bound proxy for the best shape the topology can reach. `driver._evaluate` +calls it *before* the inner loop and **prunes** (1 feasibility eval instead of +~80 inner-loop evals) when the predicted shape fails both exceed a tunable +threshold *and* are ≥ the incumbent's total fails — the second guard makes the +proxy safe (a topology whose shape floor is still below the incumbent is never +discarded). Pruned individuals are tagged `pruned/…`, counted as explored +topologies but never bred from or ranked, so budget flows to feasible topologies. +Seed/bootstrap/restart batches are never filtered (construction invariants must +survive). Threaded as `search(…, feasibility_filter, feasibility_max_shape_fails)` +through `search_staged`; **default OFF** so the §12.2 controls reproduce exactly +(`test_feasibility_filter_off_matches_baseline`). Env: `FEAS=1 MAXSHAPE=`. + +**9gp.2 — M3 Wong-Liu re-association move (reachability lever).** `operators. +mutate_reassociate` adds the associativity move `(a|b)|c ↔ a|(b|c)` on two +**same-orientation** live cuts (both directions, for reversibility): a pure- +topology move that preserves the leaf set and types but reaches tree shapes the +existing set cannot. M1 (operand swap) is `mutate_swap` and M2 (single-cut +orientation complement) is `mutate_rotate`; associativity was the missing +canonical-slicing move attacking the reachability bottleneck §11.4/§11.5 both +fingered. Only live cuts (`below is None`, as `mutate_rotate`) are restructured, +so dead inherited fields are untouched and `encode` re-anchors deltas; the two +restructured cuts default to `0.5` and the inner loop recovers their ratios. +Registered in `MUTATIONS`; **default OFF** via `enable_reassociate` (forces its +mutation weight to 0 so the baseline is byte-identical). Env: `REASSOC=1`. + +- *Implementation status (this session):* both land with unit tests + (`tests/test_operators.py`: reassociate preserves the leaf multiset, changes + the signature, noops on perpendicular cuts, stays canonical on the harbor + corpus; `predicted_shape_fails` is non-negative, pure, deterministic. + `tests/test_driver.py`: filter-off reproduces the baseline trajectory; + filter-on prunes at 1 eval/topology and never admits a pruned individual). + Full suite green (211 passed). A short smoke run on maple-court confirms both + paths execute under the real native fitness. + +- *Measurement (A/B sweep — TODO, handed off).* maple-court + harbor, seeds + 0/1/2, 20000 evals, four configs vs the §12.2 baseline: + + ```bash + # baseline control (must reproduce 136.0 / 74.0) + URB_NO_OCCLUSION=1 python3 experiments/run_staged_search.py examples/maple-court 20000 … + # 9gp.2 reassociate only + REASSOC=1 URB_NO_OCCLUSION=1 python3 experiments/run_staged_search.py … + # 9gp.1 shape-feasibility filter only (sweep MAXSHAPE) + FEAS=1 MAXSHAPE= URB_NO_OCCLUSION=1 python3 experiments/run_staged_search.py … + # combined + REASSOC=1 FEAS=1 MAXSHAPE= URB_NO_OCCLUSION=1 python3 experiments/run_staged_search.py … + ``` + + Per the re-scoped bead, **either result is a valid verdict** — the discipline + is to MEASURE associativity's value at >16 rooms, not assume it (the §11.4/§11.5 + failure mode). Record the table + one-line verdict here, then close 9gp.1, + 9gp.2, 9gp. diff --git a/experiments/run_staged_search.py b/experiments/run_staged_search.py index d14614e..ce634f5 100644 --- a/experiments/run_staged_search.py +++ b/experiments/run_staged_search.py @@ -57,6 +57,10 @@ def main() -> int: restart_patience = int(rp) if rp else None adj = os.environ.get("ADJ", "1") == "1" # s44/ld5 adjacency-aware seeding A/B prop = os.environ.get("PROP", "1") == "1" # leu.2 proportion-aware split sizing (default-on) + reassoc = os.environ.get("REASSOC", "0") == "1" # 9gp.2 M3 reassociate move A/B + feas = os.environ.get("FEAS", "0") == "1" # 9gp.1 shape-feasibility pre-filter A/B + _ms = os.environ.get("MAXSHAPE") # 9gp.1 prune threshold (shape-fail count) + max_shape = int(_ms) if _ms else None print(f"programme : {programme_dir.name}") print(f"seed : {seed_file.name}") @@ -67,6 +71,8 @@ def main() -> int: print(f"restart_p : {restart_patience}") print(f"adj_aware : {adj}") print(f"prop_aware: {prop}") + print(f"reassoc : {reassoc}") + print(f"feas_filt : {feas} (max_shape={max_shape})") print(flush=True) seed_root = dom.load(str(seed_file)) @@ -89,6 +95,9 @@ def main() -> int: restart_patience=restart_patience, seed_adjacency_aware=adj, seed_proportion_aware=prop, + enable_reassociate=reassoc, + feasibility_filter=feas, + feasibility_max_shape_fails=max_shape, ) elapsed = time.perf_counter() - t0 diff --git a/src/homemaker_layout/driver.py b/src/homemaker_layout/driver.py index ab4f5ef..c68d974 100644 --- a/src/homemaker_layout/driver.py +++ b/src/homemaker_layout/driver.py @@ -50,6 +50,13 @@ def _fitness_for(programme_dir: str) -> "fitness.Fitness": conf, cost = fitness.load_config(programme_dir) return fitness.Fitness(conf, cost) + +@functools.lru_cache(maxsize=None) +def _reqs_for(programme_dir: str) -> dict: + """Cached programme requirements per dir, for the §12.3 shape-feasibility + pre-filter (homemaker-py-9gp.1). Cached per process — workers fork a copy.""" + return programme.load_programme_dir(programme_dir) + # storey add/delete are drastic (geometry perturbation 0.25-0.33 and a # deleted storey stacks missing-space failures) — sample them rarely. # place_missing is the high-leverage §11.2 repair: it noops cheaply once the @@ -110,7 +117,24 @@ def random_topology(seed_root: dom.Node, n_leaves: int, def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw, - lineage: str, want_grade: bool = False) -> tuple[Individual, int]: + lineage: str, want_grade: bool = False, + feasibility_max_shape_fails: int | None = None, + best_n_fails: int | None = None) -> 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 + # threshold — it cannot beat the incumbent, so prune it for one feasibility + # eval instead of spending the full inner-loop budget. The best_n_fails guard + # 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. + 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))) + 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, + sig=genome.signature(root)) + return ind, 1 r = innerloop.optimise(root, programme_dir, x0=x0, budget=budget, urb_root=urb_root, **inner_kw) # §11.4: read the graded proximity scalar off the optimised tree. The inner @@ -159,6 +183,9 @@ def search( restart_elite: int = 1, seed_adjacency_aware: bool = True, seed_proportion_aware: bool = True, + enable_reassociate: bool = False, + feasibility_filter: bool = False, + feasibility_max_shape_fails: int | None = None, ) -> SearchResult: """Run the memetic loop from ``seed_root`` until ``budget`` oracle evaluations are consumed. Returns the best individual found; its ``root`` @@ -200,6 +227,12 @@ def search( urb_root = urb_root or DEFAULT_URB_ROOT rng = np.random.default_rng(seed) inner_kw = dict(_CHILD_INNER_KW, **(inner_kw or {})) + # §12.3 M3 reassociate (homemaker-py-9gp.2) is default-OFF: force its weight to + # 0 unless enabled, so the leu.2 baseline reproduces byte-for-byte (the operator + # never fires) and the A/B is a clean single-variable toggle. + mutation_weights = dict(_MUTATION_WEIGHTS) + if not enable_reassociate: + mutation_weights["reassociate"] = 0.0 # Optional ranking bonus (DESIGN.md §11.3 Stage 1): bias selection toward # individuals with high substrate-readiness via a multiplicative factor # (1 + W·bonus) on fitness. The reported fitness/history stay the TRUE @@ -253,6 +286,10 @@ def search( nonlocal n_topologies, last_improve n_topologies += 1 seen_sigs.add(ind.sig) + # §12.3 pruned by the shape-feasibility filter: counted as an explored + # topology (so the prune rate is visible) but never bred from or ranked. + if ind.lineage.startswith("pruned/"): + return if result.best is None or _key(ind) > _key(result.best): result.best = ind last_improve = n_evals @@ -296,11 +333,18 @@ def search( def _run_batch( tasks: list[tuple], # (root, x0, budget_, inner_kw_, lineage) + filter_on: bool = False, ) -> None: - """Evaluate a batch of tasks and admit results; parallel when _pool set.""" + """Evaluate a batch of tasks and admit results; parallel when _pool set. + + ``filter_on`` enables the §12.3 shape-feasibility pre-filter for this + batch — used for mutation children only, never for the seed/bootstrap or + restart batches (construction invariants must survive).""" nonlocal n_evals + mx = feasibility_max_shape_fails if (filter_on and feasibility_filter) else None + best_nf = result.best.n_fails if result.best is not None else None full = [ - (root, programme_dir, urb_root, x0, budget_, kw_, lin, use_grade) + (root, programme_dir, urb_root, x0, budget_, kw_, lin, use_grade, mx, best_nf) for root, x0, budget_, kw_, lin in tasks ] if _pool is not None: @@ -397,7 +441,7 @@ def search( else: parent = _tournament(pop, rng, _key) child_root, desc = operators.mutate(parent.root, rng, types, - weights=_MUTATION_WEIGHTS, + weights=mutation_weights, reqs=reqs, base_p=base_p) # Carry operator-specified ratios for nodes that are genuinely # newly divided (existed as leaves in the parent, are now @@ -415,7 +459,7 @@ def search( ratios = {**new_splits, **parent.ratios} x0 = innerloop.warm_x0(child_root, ratios) tasks.append((child_root, x0, child_budget, inner_kw, desc)) - _run_batch(tasks) + _run_batch(tasks, filter_on=True) except KeyboardInterrupt: interrupted = True _log(f"[{n_evals:6d} evals] interrupted — returning best-so-far") @@ -453,6 +497,9 @@ def search_staged( restart_elite: int = 1, seed_adjacency_aware: bool = True, seed_proportion_aware: bool = True, + enable_reassociate: bool = False, + feasibility_filter: bool = False, + feasibility_max_shape_fails: int | None = None, ) -> SearchResult: """Staged per-floor topology search (DESIGN.md §11.3, ``homemaker-py-c4c.3``). @@ -496,7 +543,10 @@ def search_staged( use_grade=use_grade, niche_by_signature=niche_by_signature, restart_patience=restart_patience, restart_elite=restart_elite, seed_adjacency_aware=seed_adjacency_aware, - seed_proportion_aware=seed_proportion_aware) + seed_proportion_aware=seed_proportion_aware, + enable_reassociate=enable_reassociate, + feasibility_filter=feasibility_filter, + feasibility_max_shape_fails=feasibility_max_shape_fails) if types is None: types = sorted(reqs) + ["C", "O"] @@ -522,6 +572,9 @@ def search_staged( restart_patience=restart_patience, restart_elite=restart_elite, seed_adjacency_aware=seed_adjacency_aware, seed_proportion_aware=seed_proportion_aware, + enable_reassociate=enable_reassociate, + feasibility_filter=feasibility_filter, + feasibility_max_shape_fails=feasibility_max_shape_fails, ) best_base = r1.best.root _log(f"[staged] stage 1 done: base {r1.best.fitness:.6g} " @@ -552,6 +605,9 @@ def search_staged( # substrate-selection semantics (§11.3) are unchanged. use_grade=use_grade, niche_by_signature=niche_by_signature, restart_patience=restart_patience, restart_elite=restart_elite, + enable_reassociate=enable_reassociate, + feasibility_filter=feasibility_filter, + feasibility_max_shape_fails=feasibility_max_shape_fails, ) # Stitch the two stages into one accounting (total evals, tagged history). diff --git a/src/homemaker_layout/operators.py b/src/homemaker_layout/operators.py index e416b5b..f713458 100644 --- a/src/homemaker_layout/operators.py +++ b/src/homemaker_layout/operators.py @@ -733,6 +733,81 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int] return _finalise(child) +def mutate_reassociate(root: dom.Node, rng: np.random.Generator, + types: list[str]) -> tuple[dom.Node, str]: + """Wong-Liu M3 associativity move: ``(a|b)|c <-> a|(b|c)`` on parallel cuts. + + A pure-topology reachability move (homemaker-py-9gp.2, DESIGN.md §12.3). M1 + (operand swap) is ``mutate_swap`` and M2 (single-cut orientation complement) + is ``mutate_rotate``; the missing canonical-slicing move is *associativity* — + regrouping three regions split by two **same-orientation** cuts into the + mirror tree shape. It preserves the leaf set and types but reaches tree + structures the divide/undivide/swap/rotate set cannot, attacking the + reachability bottleneck §11.4/§11.5 both fingered. + + Only **live** cuts are restructured (``below is None``, as ``mutate_rotate``), + so dead inherited fields are never touched and ``encode`` re-anchors any + upper-storey deltas (operators edit the phenotype; the genome re-derives). + The two restructured cuts default to ``[0.5, 0.5]`` and the inner loop + recovers their ratios (cold, cf. ``mutate_divide``'s new cut). + """ + child = copy.deepcopy(root) + # Candidate parents P with a same-orientation, live, divided child on a side. + cands: list[tuple[int, dom.Node, str]] = [] + for li, P in _owned_branches(child): + if P.below is not None: + continue + for side in ("l", "r"): + kid = P.left if side == "l" else P.right + if (kid.divided and kid.below is None + and (kid.rotation % 2) == (P.rotation % 2)): + cands.append((li, P, side)) + if not cands: + return _finalise(child), "reassociate noop" + + li, P, side = _pick(rng, cands) + rot = P.rotation + if side == "l": # (a|b)|c -> a|(b|c) + a, b, c = P.left.left, P.left.right, P.right + inner = dom.Node(rotation=rot) + inner.division = [0.5, 0.5] + inner.left, inner.right = b, c + P.left, P.right = a, inner + else: # a|(b|c) -> (a|b)|c + a, b, c = P.left, P.right.left, P.right.right + inner = dom.Node(rotation=rot) + inner.division = [0.5, 0.5] + inner.left, inner.right = a, b + P.left, P.right = inner, c + P.division = [0.5, 0.5] + return _finalise(child), f"reassociate {li}/{P.id or 'root'}" + + +def predicted_shape_fails(root: dom.Node, reqs, fit) -> int: + """Predicted per-leaf shape fails at the proportion-aware target geometry. + + Shape-feasibility proxy (homemaker-py-9gp.1, DESIGN.md §12.3). Lays the + topology out with :func:`_size_divisions_from_targets` — the squarest + target-proportional geometry the inner loop warm-starts from, i.e. the best + shape this topology can plausibly reach — then counts the + size/width/proportion/crinkliness fails the native ``fit`` reports. Used to + prune clearly-infeasible topologies *before* the inner loop, so budget flows + to feasible ones. A heuristic lower-bound proxy, not a true bound; the caller + guards against pruning anything that could still beat the incumbent. + + ``root`` is left untouched (a deep copy is laid out and scored). + """ + child = copy.deepcopy(root) + dom._link(child) + for lvl in dom.levels(child): + _size_divisions_from_targets(lvl, reqs) + _, fails = fit.score_with_fails(child) + return sum(1 for f in fails if f.endswith(_SHAPE_FAIL_SUFFIXES)) + + +_SHAPE_FAIL_SUFFIXES = (" size", " width", " proportion", " crinkliness") + + def mutate_core_divide(root: dom.Node, rng: np.random.Generator, types: list[str]) -> tuple[dom.Node, str]: """Divide a circulation leaf at the same path across ALL storeys at once. @@ -868,6 +943,7 @@ MUTATIONS = { "retype": mutate_retype, "swap": mutate_swap, "rotate": mutate_rotate, + "reassociate": mutate_reassociate, "core_divide": mutate_core_divide, "core_undivide": mutate_core_undivide, "level_fix": mutate_level_fix, diff --git a/tests/test_driver.py b/tests/test_driver.py index 96f77e4..69d8f61 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -182,6 +182,51 @@ def test_restart_keeps_elite_and_counts(monkeypatch): assert r.best is not None and r.best.fitness > 0 +def test_feasibility_filter_off_matches_baseline(fake_inner): + """§12.3: with the filter and reassociate OFF (defaults), the run is + identical to one that omits the params — a clean A/B control.""" + init_root = dom.load(str(INIT_FILE)) + base = driver.search(init_root, CORPUS, budget=600, pop_size=4, + child_budget=60, seed_budget=100, seed=9) + off = driver.search(init_root, CORPUS, budget=600, pop_size=4, + child_budget=60, seed_budget=100, seed=9, + enable_reassociate=False, feasibility_filter=False, + feasibility_max_shape_fails=0) + # Same search trajectory: identical best topology and accounting. (Absolute + # fitness carries the fake_inner monotone tiebreaker, which shares one call + # counter across both runs in this fixture, so compare the signature.) + assert off.best.sig == base.best.sig + assert off.n_topologies == base.n_topologies + assert off.n_evals == base.n_evals + + +def test_feasibility_filter_prunes_cheaply(fake_inner, monkeypatch): + """§12.3 (homemaker-py-9gp.1): a pruned topology costs one feasibility eval + instead of the full child_budget, so the filter explores far more topologies + per budget; pruned individuals never displace the incumbent.""" + from homemaker_layout import operators + + # Force every filtered child to be pruned (shape-fail floor above any + # threshold and ≥ the incumbent's fail count). + monkeypatch.setattr(operators, "predicted_shape_fails", + lambda root, reqs, fit: 999) + + init_root = dom.load(str(INIT_FILE)) + budget, child_budget, pop_size = 1200, 60, 4 + on = driver.search(init_root, CORPUS, budget=budget, pop_size=pop_size, + child_budget=child_budget, seed_budget=100, seed=4, + feasibility_filter=True, feasibility_max_shape_fails=0) + + # Bootstrap (pop_size topologies at child_budget) then 1-eval prunes: the + # remaining budget buys ~one topology per eval, far more than child_budget. + bootstrap_evals = pop_size * child_budget + assert on.n_topologies > pop_size + (budget - bootstrap_evals) // child_budget + assert on.n_evals >= budget + # No pruned (untuned, fitness=0) individual is admitted to the population. + assert all(p.lineage and not p.lineage.startswith("pruned/") for p in on.population) + assert on.best is not None and not on.best.lineage.startswith("pruned/") + + def test_search_parallel_smoke(): """n_workers>1 runs without error and produces valid results.""" init_root = dom.load(str(INIT_FILE)) diff --git a/tests/test_operators.py b/tests/test_operators.py index 5446b57..79e6633 100644 --- a/tests/test_operators.py +++ b/tests/test_operators.py @@ -218,3 +218,86 @@ def test_crossover_yields_canonical_pair(): assert desc.startswith("crossover") canonical(ca) canonical(cb) + + +# --------------------------------------------------------------------------- # +# 9gp.2 — M3 re-association move +# --------------------------------------------------------------------------- # +def _leaf_types(root: dom.Node) -> list[str]: + return sorted(lf.type or "." for lvl in dom.levels(root) for lf in lvl.leaves()) + + +def _same_axis_chain() -> dom.Node: + """A 3-leaf ``(a|b)|c`` tree with two parallel (same-orientation) cuts.""" + root = dom.Node(rotation=0, division=[0.4, 0.4]) + root.left = dom.Node(rotation=0, division=[0.5, 0.5]) + root.left.left = dom.Node(type="A") + root.left.right = dom.Node(type="B") + root.right = dom.Node(type="C") + dom._link(root) + return root + + +def test_reassociate_preserves_leaves_changes_shape(): + root = _same_axis_chain() + before_types = _leaf_types(root) + before_sig = genome.signature(root) + child, desc = operators.mutate_reassociate(root, np.random.default_rng(0), TYPES) + assert "noop" not in desc + # leaf set + types are an invariant; only the tree shape changes + assert _leaf_types(child) == before_types + assert genome.signature(child) != before_sig + canonical(child) + # parent untouched in place + assert genome.signature(root) == before_sig + canonical(root) + + +def test_reassociate_noop_on_perpendicular_cuts(): + # Outer cut rotation 0, inner cut rotation 1 (perpendicular) → not the + # associativity precondition, so there is no candidate and it noops. + root = dom.Node(rotation=0, division=[0.4, 0.4]) + root.left = dom.Node(rotation=1, division=[0.5, 0.5]) + root.left.left = dom.Node(type="A") + root.left.right = dom.Node(type="B") + root.right = dom.Node(type="C") + dom._link(root) + _, desc = operators.mutate_reassociate(root, np.random.default_rng(0), TYPES) + assert desc == "reassociate noop" + + +@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available") +def test_reassociate_on_corpus_is_canonical_and_total(): + from homemaker_layout import programme + + reqs = programme.load_programme_dir(str(HARBOR)) + types = sorted(reqs) + ["C", "O"] + root = dom.load(str(HARBOR / "generated.dom")) + before = _leaf_types(root) + for seed in range(8): + child, desc = operators.mutate_reassociate(root, np.random.default_rng(seed), types) + canonical(child) + if "noop" not in desc: + # leaf multiset preserved even on a real multi-storey tree + assert _leaf_types(child) == before + + +# --------------------------------------------------------------------------- # +# 9gp.1 — shape-feasibility proxy +# --------------------------------------------------------------------------- # +@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available") +def test_predicted_shape_fails_is_nonneg_and_pure(): + from homemaker_layout import fitness, programme + + reqs = programme.load_programme_dir(str(HARBOR)) + conf, cost = fitness.load_config(str(HARBOR)) + fit = fitness.Fitness(conf, cost) + root = dom.load(str(HARBOR / "generated.dom")) + n_leaves = sum(len(lvl.leaves()) for lvl in dom.levels(root)) + + pred = operators.predicted_shape_fails(root, reqs, fit) + assert isinstance(pred, int) and pred >= 0 + # input root is untouched (a deep copy is laid out and scored) + assert sum(len(lvl.leaves()) for lvl in dom.levels(root)) == n_leaves + # deterministic + assert operators.predicted_shape_fails(root, reqs, fit) == pred