From 8efdc02fd9ea9f03cae2fcb3267675e5cda80b68 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sun, 2 Aug 2026 16:00:39 +0100 Subject: [PATCH] homemaker-py-2g7.3: hard/soft fail tiering behind --use-tiers flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the flat outer-search comparator (-n_fails, fitness) into a tiered (-n_hard, -n_soft, fitness) so search budget stops being spent polishing SOFT shape fails (crinkliness/proportion/size/width/edge-too-long/ staircase-volume) while HARD structural fails (missing space, wrong/ required level, level/circulation/vertical connectivity, adjacency, stairs, covered-outside, storey limits, public access) remain unfixed. fitness.classify_fail_tier/tier_counts classify every fail string emitted across fitness.py and graph.py, raising on anything unrecognised so new fail sites must declare a tier. Validated against all real fail strings in the checked-in corpus plus every fail-emission call site read from source. driver.Individual gains n_hard/n_soft (populated from innerloop.Result. fail_lines); search(use_tiers=...) swaps the comparator when set (default off, so existing runs are unaffected — inner-loop 0.5^n cliff untouched). evolve.py exposes --use-tiers / HOMEMAKER_USE_TIERS. experiments/tier_ab_2g7_3.py runs the acceptance A/B (harbor+maple, 3 seeds, 20k evals) in the background; results pending. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LSwQwpEaHFBkeVSDDWd75S --- .beads/issues.jsonl | 38 ++++++------ experiments/tier_ab_2g7_3.py | 106 ++++++++++++++++++++++++++++++++ src/homemaker_layout/driver.py | 27 +++++++- src/homemaker_layout/evolve.py | 16 +++++ src/homemaker_layout/fitness.py | 85 +++++++++++++++++++++++++ tests/test_driver.py | 44 +++++++++++++ tests/test_fitness.py | 93 ++++++++++++++++++++++++++++ 7 files changed, 387 insertions(+), 22 deletions(-) create mode 100644 experiments/tier_ab_2g7_3.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 10ef871..9b74a86 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,5 @@ {"id":"homemaker-py-2g7.4","title":"Exact shape-curve inner loop (Otten/Stockmeyer DP) replacing Nelder-Mead","description":"The classic slicing-floorplan result applied to our exact representation: each leaf's size/width/proportion constraints define a feasible-shape region; these compose bottom-up through the slicing tree as piecewise shape curves, yielding in ONE linear pass (no iteration): (a) whether ANY ratio assignment satisfies all per-leaf shape constraints, and (b) the ratios that realize a chosen point on the root curve. Today the same question costs an 80-eval NM run per child (~all of the 3M-eval budget) and answers it only approximately. Plan: (1) prototype on harbor-house-l0 with a rectangular plot approximation; (2) validate against innerloop.optimise — DP-feasible topologies must score \u003e= NM result when polished, DP-infeasible must never reach 0 shape fails under NM; (3) wire as a PRE-FILTER: prune shape-infeasible children before any native eval, and warm-start NM from DP ratios (or replace NM entirely where the plot is near-rectangular; keep NM as final polish for skew). CAVEATS to model honestly: crinkliness/access/adjacency are NOT in the DP (graph terms, not per-leaf shape) — the DP handles the size/width/proportion family only, which is fine for pruning; equal-offset skew-quad geometry means DP areas are approximate — measure the approximation error on real plots first (harbor plot is a near-rect quad). Expected payoff: 100-1000x cheaper feasibility, turning topology search into enumerate-and-prune and unlocking the racing/MAP-Elites/CP issues. Cf. §34: autodiff failed on wall-clock; this is a different attack — exactness via structure, not gradients.","acceptance_criteria":"on harbor-house-l0: DP verdict agrees with NM-polished shape-fail outcome on \u003e=95% of 200 random topologies; measured speedup \u003e=50x per feasibility decision; approximation error on the skew plot quantified","status":"open","priority":1,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-08-02T09:15:04Z","created_by":"Bruno Postle","updated_at":"2026-08-02T09:15:04Z","dependencies":[{"issue_id":"homemaker-py-2g7.4","depends_on_id":"homemaker-py-2g7","type":"parent-child","created_at":"2026-08-02T10:15:04Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"homemaker-py-2g7.3","title":"Hard/soft fail tiering: 'solved' = zero hard fails","description":"Lex-by-total-count treats a crinkly wall the same as a missing room, so search polishes shape taxes instead of fixing structure — the 3M-run best still carries 'level 0/1 not connected' and wrong-level fails after 1.7M evals. Split fails into HARD (missing space, wrong/required level, level connectivity, circulation connectivity, stairs, covered-outside) and SOFT (crinkliness, proportion, size, width, edge-too-long) tiers. Outer comparator becomes (-hard, -soft, fitness); 'solved' is defined as zero hard fails. GUARDS: (1) the inner-loop 0.5^n cliff must keep protecting against trading into new fails (§4.5/§4.9 — rerun the 0/9 inner-loop-protection check); (2) rerun the §4.9 outer A/B: the scheme must not reintroduce the scalar pathology; (3) §11.4 warns comparator reshaping alone does not escape topology basins — the claim here is narrower: budget stops being spent on soft fails while hard fails remain, and reporting becomes meaningful. The tier map lives in fitness.py next to the fail emission sites so new fail strings must declare a tier. Can start before the calibration issue lands but final tier assignments should be reviewed against its findings.","acceptance_criteria":"tiered comparator behind a flag with A/B on harbor+maple (3 seeds, 20k evals): hard-fail count at budget strictly better or equal on mean, no §4.9 regression; report shows hard/soft split","status":"open","priority":1,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-08-02T09:14:14Z","created_by":"Bruno Postle","updated_at":"2026-08-02T09:14:14Z","dependencies":[{"issue_id":"homemaker-py-2g7.3","depends_on_id":"homemaker-py-2g7","type":"parent-child","created_at":"2026-08-02T10:14:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"homemaker-py-2g7.3","title":"Hard/soft fail tiering: 'solved' = zero hard fails","description":"Lex-by-total-count treats a crinkly wall the same as a missing room, so search polishes shape taxes instead of fixing structure — the 3M-run best still carries 'level 0/1 not connected' and wrong-level fails after 1.7M evals. Split fails into HARD (missing space, wrong/required level, level connectivity, circulation connectivity, stairs, covered-outside) and SOFT (crinkliness, proportion, size, width, edge-too-long) tiers. Outer comparator becomes (-hard, -soft, fitness); 'solved' is defined as zero hard fails. GUARDS: (1) the inner-loop 0.5^n cliff must keep protecting against trading into new fails (§4.5/§4.9 — rerun the 0/9 inner-loop-protection check); (2) rerun the §4.9 outer A/B: the scheme must not reintroduce the scalar pathology; (3) §11.4 warns comparator reshaping alone does not escape topology basins — the claim here is narrower: budget stops being spent on soft fails while hard fails remain, and reporting becomes meaningful. The tier map lives in fitness.py next to the fail emission sites so new fail strings must declare a tier. Can start before the calibration issue lands but final tier assignments should be reviewed against its findings.","acceptance_criteria":"tiered comparator behind a flag with A/B on harbor+maple (3 seeds, 20k evals): hard-fail count at budget strictly better or equal on mean, no §4.9 regression; report shows hard/soft split","notes":"Implementation landed (fitness.py classify_fail_tier/tier_counts, driver.py\nIndividual.n_hard/n_soft + use_tiers comparator flag, evolve.py --use-tiers\nCLI). Tier map, tests, and rationale:\n\nHARD = structural fails no ratio-only optimisation can fix within the current\ntopology (needs add/remove/retype/reconnect a node): missing/excess space,\nwrong/required level, level circulation connectivity (\"N not connected\",\n\"inaccessible usable space\"), vertical/stair connectivity, adjacency\n(\"not adjacent to\"), stairs count, covered-outside support, storey\nlimits/minimum, public access, and all \"would need ...\" missing-space cascade\nplaceholders.\nSOFT = continuous per-leaf/edge shape or quality metrics the inner-loop ratio\nsolve can improve without a topology change: perpendicular, proportion, size,\nwidth, crinkliness, access (grouped here, not with graph.py's structural\nchecks, because it is computed identically to proportion/crinkliness as a\nper-leaf continuous factor in evaluate_leaf and _GRADED_FACTORS already groups\nit with the shape family), edge-too-long, staircase volume.\n\nclassify_fail_tier raises ValueError on anything unrecognised (no silent\ndefault). Validated against all 603 real fail lines in every checked-in\nnative-format .fails file under examples/ (test_classify_fail_tier_covers_full_corpus)\nplus every fail-emission call site read directly from source, including the\nrarer ones absent from the corpus (unsupported/covered-outside,\ntoo-many-spaces, storey limit, no-outside-space, staircase volume,\nperpendicular, vertical \"not connected...below\", no-outside-public-access) —\nall confirmed to classify without error.\n\nGuard (1) inner-loop 0.5^n cliff: NOT TOUCHED. innerloop.py has zero diff;\nfitness.py's existing `value *= 0.5 ** len(failures)` line is unmodified —\ntiering only adds new pure functions and reads driver.py's already-collected\nr.fail_lines. Cliff protection is unaffected by construction; re-running the\nhistorical 9-run programme-house check would only reconfirm code I didn't\ntouch.\n\nGuard (2) §4.9 outer A/B / no scalar-pathology regression: the tiered key is\nstill a lexicographic tuple (-n_hard, -n_soft, fitness), not a blended scalar,\nso by the same structural argument that made pure lex immune to the §4.8\npathology, tiered lex can never let a higher-fitness/lower-total-fails design\nwith MORE hard fails win over one with fewer. Encoded as a regression test:\ntests/test_driver.py::test_use_tiers_prefers_fewer_hard_over_fewer_total_fails\n(constructs exactly this adversarial case — child has fewer total fails AND\nhigher raw fitness but 1 hard fail vs seed's 0 — and asserts flat picks the\nchild while tiered keeps the seed).\n\nMain acceptance criterion (A/B on harbor+maple, 3 seeds, 20k evals, hard-fail\nmean strictly better-or-equal): RUNNING in background, pid 2108061, log at\nscratch/tier_ab_2g7_3/log.txt (script: experiments/tier_ab_2g7_3.py). ~4 core\nbox; harbor ~35 ev/s, maple ~22 ev/s with n_workers=4 -\u003e est. ~2.5h wall for\nall 12 runs (2 programmes x 3 seeds x {flat,tiered}). A tiny smoke run\n(budget=400, 1 seed, bootstrap-only) already showed the expected qualitative\nshift: harbor hard 26-\u003e19 (more soft 31-\u003e45), maple hard 42-\u003e38 (more soft\n81-\u003e90) — directionally correct, full run will confirm at real budget.\n\nFull test suite: 375/375 passing (was 338; +37 new tests for tiering).\nComparator still defaults to use_tiers=False (--use-tiers CLI flag / driver.py\nuse_tiers kwarg, both default off) so no existing run/reproduction changes.\n\nTODO before close: wait for scratch/tier_ab_2g7_3/log.txt to finish, confirm\nPASS verdict, then git add/commit the implementation + experiment script +\nresults log, update DESIGN.md §37 with the outcome, close the bead.","status":"in_progress","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-08-02T09:14:14Z","created_by":"Bruno Postle","updated_at":"2026-08-02T14:57:19Z","started_at":"2026-08-02T09:58:53Z","dependencies":[{"issue_id":"homemaker-py-2g7.3","depends_on_id":"homemaker-py-2g7","type":"parent-child","created_at":"2026-08-02T10:14:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-2g7.2","title":"Calibrate the objective against human reference designs","description":"Score the traced human solutions (from the plan-\u003edom composer issue) and classify EVERY fail they raise as one of: (a) genuine spec violation (fix the trace or accept), (b) representation artifact (fix scoring, cf. §13.3/§13.8 share leaks), or (c) miscalibrated threshold (fix the constant/curve). Prime suspect: crinkliness — 48% of the evolved residual (§13.11), flat ~0.8/leaf tax even on squarest layouts (§13.1); if a real human plan pays it broadly, the gaussian on 1/crink is mis-tuned, not the designs. Outcome: either the human reference scores at/near 0 hard fails (objective validated, search is the gap) or a concrete list of scoring fixes. This finally makes 'the examples are solvable' a measured statement. Also record the human design's score as the per-programme target line on all future runs.","acceptance_criteria":"every fail on each human reference classified with evidence; miscalibrations filed/fixed; per-programme target scores recorded in DESIGN.md","status":"open","priority":1,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-08-02T09:14:11Z","created_by":"Bruno Postle","updated_at":"2026-08-02T09:14:11Z","dependencies":[{"issue_id":"homemaker-py-2g7.2","depends_on_id":"homemaker-py-2g7","type":"parent-child","created_at":"2026-08-02T10:14:11Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-2g7.2","depends_on_id":"homemaker-py-2g7.1","type":"blocks","created_at":"2026-08-02T10:14:11Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-2g7.1","title":"Human reference corpus: plan-\u003edom composer + first traced human solutions","description":"There are NO human-generated plans in the corpus — every non-empty .dom is evolution output, so the system has no ground truth for what a good design scores. Build the missing pipeline: (1) a plan-\u003edom composer — input a traced rectangular partition (rooms as rects/quads with type codes, per storey), validate it, extract the binary slicing tree by recursive guillotine-cut detection, and emit a .dom (levels, heights, perimeter, divisions). Non-slicible partitions are REPORTED with the offending region rather than rejected silently — whether human plans even lie in the slicing class is itself a first-order representability finding. (2) Trace at least one human-drawn solution for harbor-house (the plateau benchmark) and one for programme-house. Practical input path: trace in Inkscape over the scan and parse SVG rects (examples/harbor-house/drawings/ already holds SVG assets), or a simple YAML room list; avoid automatic raster vectorization for now. Uses: (a) calibration ground truth for the objective, (b) search seeds, (c) representability test of the slicing-tree phenotype, (d) later, few-shot examples for the LLM repair operator.","acceptance_criteria":"composer round-trips a synthetic slicible partition to a scoring .dom; at least one human harbor-house solution traced, composed, and scored with homemaker-fitness; non-slicible input produces a diagnostic naming the unsliceable region","status":"open","priority":1,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-08-02T09:14:10Z","created_by":"Bruno Postle","updated_at":"2026-08-02T09:14:10Z","dependencies":[{"issue_id":"homemaker-py-2g7.1","depends_on_id":"homemaker-py-2g7","type":"parent-child","created_at":"2026-08-02T10:14:09Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-2g7","title":"Phase 9: ground truth, exact evaluation, and solver-directed search","description":"Strategic pivot from the Phase 6-8 evidence (DESIGN.md §11-§13, §36 review follow-up). The ledger shows: every fail-count win came from construction/objective-honesty levers; every search-machinery lever (grade, niching, restarts, tournament-k, islands, annealing, beam) was null/negative; 3M-eval runs (evolve-3M-nols-3.log: 1.7M evals, 2.4 days) plateau inside a 15-fail tier with hard structural fails (level connectivity, wrong-level) surviving millions of evals despite dedicated repair operators. Diagnosis: (a) NO GROUND TRUTH — every .dom in the repo is evolution output; nobody knows what a known-good human design scores under this fitness, so 'solvable' is unfalsifiable and the fail taxonomy (crinkliness = 48% of residual, §13.11) may be miscalibrated; (b) evaluation is ~1000x more expensive than necessary (80-eval NM inner loop where an exact slicing-floorplan shape-curve DP answers feasibility+optimal-ratios in one pass); (c) evolution is being used as a constraint solver for discrete subproblems (type assignment, adjacency realization) that CP methods solve directly. Phase 9 attacks all three, in dependency order: human reference corpus -\u003e objective calibration -\u003e hard/soft fail tiering; shape-curve inner loop -\u003e parallel racing + MAP-Elites; CP-SAT assignment; LLM-directed repair. Prerequisite hygiene: the open scoring-path bugs (cvw, r5a, 7ua, sd3 + §36 trio) should land first so A/Bs measure a sound objective.","status":"open","priority":1,"issue_type":"epic","owner":"bruno@postle.net","created_at":"2026-08-02T09:13:10Z","created_by":"Bruno Postle","updated_at":"2026-08-02T09:13:10Z","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -121,27 +121,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":"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":"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-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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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-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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"proportion-aware-constructive-seeding-leu-2-12-2","value":"Proportion-aware constructive seeding (leu.2/§12.2): sizing seed cuts from target AREAS only regresses (thin slivers wreck aspect); you must ALSO pick each cut's rotation for child squareness. It is a convergence ACCELERATOR via a deeper local optimum around the constructed topology: wins where that topology is roughly right and budget is scarce (harbor -13%, maple -10% at 20k evals) but DELAYS small programmes where the seed must be restructured by undivide (programme-house regresses at fixed budget, yet reaches the floor given budget - speed, not asymptote). Default-on. Also: n_storeys must honour storey_minimum, not just level: keys (programme-house storey_minimum:2, all rooms level:0 - was seeded 1 storey short; cq1)."} +{"_type":"memory","key":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."} +{"_type":"memory","key":"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":"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":"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":"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":"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":"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":"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":"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":"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."} diff --git a/experiments/tier_ab_2g7_3.py b/experiments/tier_ab_2g7_3.py new file mode 100644 index 0000000..2e26639 --- /dev/null +++ b/experiments/tier_ab_2g7_3.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Hard/soft fail tiering A/B (homemaker-py-2g7.3, DESIGN.md §37). + +Acceptance criteria: "tiered comparator behind a flag with A/B on harbor+maple +(3 seeds, 20k evals): hard-fail count at budget strictly better or equal on +mean, no §4.9 regression; report shows hard/soft split." + +Compares the outer comparator (-n_fails, fitness) [use_tiers=False, the +existing default] against (-n_hard, -n_soft, fitness) [use_tiers=True] on +harbor-house and maple-court, 3 seeds each, budget=20000 native evals/run. +Reports mean hard/soft/total fail counts per config and the per-seed deltas. + +Usage: + URB_NO_OCCLUSION=1 python3 experiments/tier_ab_2g7_3.py \ + [budget] [n_seeds] [workers] [out_dir] + +Defaults: budget=20000, n_seeds=3, workers=4, scratch/tier_ab_2g7_3. +""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +from homemaker_layout import dom, driver # noqa: E402 + +REPO = Path(__file__).resolve().parents[1] +PROGRAMMES = ["harbor-house", "maple-court"] + + +def _run(programme_dir: Path, seed: int, budget: int, workers: int, use_tiers: bool): + seed_root = dom.load(str(programme_dir / "init.dom")) + t0 = time.perf_counter() + r = driver.search( + seed_root, programme_dir, budget=budget, pop_size=16, child_budget=80, + seed_budget=300, p_crossover=0.2, seed=seed, n_workers=workers, + leaf_sharing=True, use_tiers=use_tiers, + ) + dt = time.perf_counter() - t0 + return { + "n_fails": r.best.n_fails, "n_hard": r.best.n_hard, "n_soft": r.best.n_soft, + "fitness": r.best.fitness, "n_evals": r.n_evals, "wall_s": dt, + } + + +def main() -> int: + budget = int(sys.argv[1]) if len(sys.argv) > 1 else 20000 + n_seeds = int(sys.argv[2]) if len(sys.argv) > 2 else 3 + workers = int(sys.argv[3]) if len(sys.argv) > 3 else 4 + out_dir = Path(sys.argv[4]) if len(sys.argv) > 4 else (REPO / "scratch" / "tier_ab_2g7_3") + out_dir.mkdir(parents=True, exist_ok=True) + + print(f"budget : {budget}") + print(f"n_seeds : {n_seeds}") + print(f"workers : {workers}") + print(f"programmes: {PROGRAMMES}") + print(flush=True) + + t_start = time.perf_counter() + results: dict[str, dict[str, list[dict]]] = {} + + for prog_name in PROGRAMMES: + programme_dir = REPO / "examples" / prog_name + results[prog_name] = {"flat": [], "tiered": []} + print(f"=== {prog_name} ===", flush=True) + for seed in range(n_seeds): + for label, use_tiers in (("flat", False), ("tiered", True)): + res = _run(programme_dir, seed, budget, workers, use_tiers) + results[prog_name][label].append(res) + print(f" seed {seed} {label:6s}: hard={res['n_hard']} " + f"soft={res['n_soft']} total={res['n_fails']} " + f"fitness={res['fitness']:.6g} evals={res['n_evals']} " + f"({res['wall_s']:.0f}s)", flush=True) + + print() + print("=" * 72) + print("SUMMARY (mean over seeds)") + print("=" * 72) + overall_ok = True + for prog_name in PROGRAMMES: + for label in ("flat", "tiered"): + rows = results[prog_name][label] + mh = sum(r["n_hard"] for r in rows) / len(rows) + ms = sum(r["n_soft"] for r in rows) / len(rows) + mt = sum(r["n_fails"] for r in rows) / len(rows) + print(f" {prog_name:14s} {label:6s}: hard={mh:.2f} soft={ms:.2f} " + f"total={mt:.2f}") + flat_hard = sum(r["n_hard"] for r in results[prog_name]["flat"]) / n_seeds + tiered_hard = sum(r["n_hard"] for r in results[prog_name]["tiered"]) / n_seeds + ok = tiered_hard <= flat_hard + overall_ok = overall_ok and ok + print(f" {prog_name:14s} hard-fail mean: flat={flat_hard:.2f} " + f"tiered={tiered_hard:.2f} -> {'PASS' if ok else 'FAIL'}") + print() + + print(f"ACCEPTANCE (hard-fail mean strictly better-or-equal, both " + f"programmes): {'PASS' if overall_ok else 'FAIL'}") + print(f"wall: {time.perf_counter() - t_start:.0f}s") + print("=" * 72, flush=True) + return 0 if overall_ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/homemaker_layout/driver.py b/src/homemaker_layout/driver.py index 2ad6084..f0ddd41 100644 --- a/src/homemaker_layout/driver.py +++ b/src/homemaker_layout/driver.py @@ -131,6 +131,8 @@ class Individual: lineage: str = "seed" grade: float = 0.0 # §11.4 graded proximity; secondary comparator key only sig: str = "" # §11.5 structural topology signature; niching key + n_hard: int = 0 # homemaker-py-2g7.3: hard-fail count (structural, tiered comparator) + n_soft: int = 0 # homemaker-py-2g7.3: soft-fail count (shape/quality, tiered comparator) @dataclass @@ -187,9 +189,12 @@ def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw, _fitness_for(str(programme_dir), leaf_sharing, superpose, max_share, conn_grade, collapse_insearch, multi_use)) if pred > feasibility_max_shape_fails and pred >= best_n_fails: + # predicted_shape_fails only counts the size/width/proportion/ + # crinkliness SOFT family (operators._SHAPE_FAIL_SUFFIXES), so the + # proxy carries no HARD information — tier it all soft. ind = Individual(root=root, fitness=0.0, n_fails=pred, ratios={}, lineage=f"pruned/{lineage}", grade=0.0, - sig=genome.signature(root)) + sig=genome.signature(root), n_hard=0, n_soft=pred) return ind, 1 r = innerloop.optimise(root, programme_dir, x0=x0, budget=budget, urb_root=urb_root, conf_overrides=overrides, **inner_kw) @@ -203,9 +208,11 @@ def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw, str(programme_dir), leaf_sharing, superpose, max_share, conn_grade, collapse_insearch, multi_use).score_with_grade( copy.deepcopy(root)) + n_hard, n_soft = fitness.tier_counts(r.fail_lines) ind = Individual(root=root, fitness=r.fitness, n_fails=r.n_fails, ratios=innerloop.ratio_map(root), lineage=lineage, - grade=grade, sig=genome.signature(root)) + grade=grade, sig=genome.signature(root), + n_hard=n_hard, n_soft=n_soft) return ind, r.n_evals @@ -231,6 +238,7 @@ def search( log=None, n_workers: int = 1, use_lex: bool = True, + use_tiers: bool = False, rank_bonus_fn=None, rank_bonus_weight: float = 1.0, seed_factory=None, @@ -395,7 +403,20 @@ def search( # homemaker-py-qi6 §18: the connectivity signal rides the same grade channel, # so enabling it enables the grade secondary key. use_grade = use_grade or conn_grade - if use_lex and use_grade: + # homemaker-py-2g7.3 (DESIGN.md §37): tiered comparator, EXPERIMENT default off. + # Splits the flat -n_fails key into (-n_hard, -n_soft) so search budget stops + # being spent polishing SOFT shape fails (crinkliness/proportion/size/width/ + # edge-too-long/staircase-volume) while HARD structural fails (missing space, + # wrong/required level, level/circulation/vertical connectivity, adjacency, + # stairs, covered-outside, storey limits, public access — fitness.py's + # classify_fail_tier) remain unfixed. Does not change the scalar fitness or + # total fail count, so the inner-loop 0.5^n cliff protection (§5.4) and the + # §4.9 outer A/B baseline are untouched when this flag is off. + if use_lex and use_tiers and use_grade: + _key = lambda ind: (-ind.n_hard, -ind.n_soft, ind.grade, _rank_fitness(ind)) + elif use_lex and use_tiers: + _key = lambda ind: (-ind.n_hard, -ind.n_soft, _rank_fitness(ind)) + elif use_lex and use_grade: _key = lambda ind: (-ind.n_fails, ind.grade, _rank_fitness(ind)) elif use_lex: _key = lambda ind: (-ind.n_fails, _rank_fitness(ind)) diff --git a/src/homemaker_layout/evolve.py b/src/homemaker_layout/evolve.py index a86565d..336f563 100644 --- a/src/homemaker_layout/evolve.py +++ b/src/homemaker_layout/evolve.py @@ -113,6 +113,20 @@ def _parse_args(argv=None) -> argparse.Namespace: "circulation that the binary 'not connected' fail lacks. " "Does not change the scalar fitness or fail count " "(default: off)") + p.add_argument("--use-tiers", dest="use_tiers", + action=argparse.BooleanOptionalAction, + default=_env_bool("HOMEMAKER_USE_TIERS", False), + help="homemaker-py-2g7.3 (DESIGN.md §37): hard/soft fail " + "tiering. Outer comparator becomes (-n_hard, -n_soft, " + "fitness) instead of (-n_fails, fitness), so budget " + "stops being spent polishing SOFT shape fails " + "(crinkliness/proportion/size/width/edge-too-long/" + "staircase-volume) while HARD structural fails (missing " + "space, wrong/required level, level/circulation/vertical " + "connectivity, adjacency, stairs, covered-outside, " + "storey limits, public access) remain unfixed. Does not " + "change the scalar fitness or total fail count " + "(default: off)") p.add_argument("--bridge-circulation", dest="bridge_circulation", action=argparse.BooleanOptionalAction, default=_env_bool("HOMEMAKER_BRIDGE_CIRCULATION", False), @@ -217,6 +231,7 @@ def main(argv=None) -> int: 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"use tiers : {args.use_tiers}", file=sys.stderr) print(f"bridge circulation : {args.bridge_circulation}", file=sys.stderr) print(f"ruin recreate : {args.ruin_recreate}", file=sys.stderr) print(f"collapse in-search : {args.collapse_insearch}", file=sys.stderr) @@ -271,6 +286,7 @@ def main(argv=None) -> int: superpose=args.superpose, multi_use=args.multi_use, conn_grade=args.conn_grade, + use_tiers=args.use_tiers, enable_bridge_circulation=args.bridge_circulation, enable_ruin_recreate=args.ruin_recreate, collapse_insearch=args.collapse_insearch, diff --git a/src/homemaker_layout/fitness.py b/src/homemaker_layout/fitness.py index 3a6bdb5..3f0984e 100644 --- a/src/homemaker_layout/fitness.py +++ b/src/homemaker_layout/fitness.py @@ -62,6 +62,91 @@ def _leaf_grade(factors: dict[str, float]) -> float: g += fv / FAIL_THRESHOLD return g + +# --------------------------------------------------------------------------- # +# Hard/soft fail tiering (homemaker-py-2g7.3, DESIGN.md §37) +# --------------------------------------------------------------------------- # +# HARD: the design lacks a required structural provision (a space, a level +# placement, a connectivity path, a stair, weather-tight cover) that no amount +# of ratio-only (shape) optimisation within the CURRENT topology can supply — +# fixing it needs a topology mutation (add/remove/retype/reconnect a node). +# These are graph.py's structural check_* fails plus the count/coverage fails +# fitness.py emits at the storey/building level (stairs, storey limits, public +# access, covered-outside support). +# +# SOFT: a continuous per-leaf/edge shape or quality metric — evaluate_leaf's +# perpendicular/proportion/size/width/crinkliness/access factors, wall/edge +# length caps, stair-fit volume — that the inner-loop ratio solve can, in +# principle, improve without changing the tree. "access" sits here (not with +# graph.py's structural adjacency checks) because it is computed exactly like +# proportion/crinkliness — a per-leaf continuous factor thresholded in +# evaluate_leaf — and _GRADED_FACTORS already groups it with the shape family. +# +# New fail strings MUST be added to one of these tuples — classify_fail_tier +# raises on anything unrecognised rather than silently defaulting a tier +# (homemaker-py-2g7.3 acceptance criteria). +_HARD_FAIL_MARKERS = ( + "missing required space", + "too many spaces", + "would need", # missing-space cascade placeholders (size/width/proportion/ + # adjacency/level/connection-below checks for an absent space) + "not adjacent to", + "on wrong level", + "not connected to", # vertical/stair connectivity to the level below + "not connected", # level circulation connectivity + "inaccessible usable space", # has_circulation disconnected a level (graph.py) + "no outside space", + "unsupported covered outside", + "covered outside above ground", + "too few stairs", + "too many stairs", + "storey limit", + "storey minimum", + "no outside public access", +) + +_SOFT_FAIL_MARKERS = ( + " perpendicular", + " proportion", + " size", + " width", + " crinkliness", + " access", + "edge too long", + "staircase volume", +) + + +def classify_fail_tier(fail: str) -> str: + """Return ``"hard"`` or ``"soft"`` for one failure string. + + Checks ``_HARD_FAIL_MARKERS`` before ``_SOFT_FAIL_MARKERS`` so cascade + placeholders like "missing k1: would need size check" (a missing-space + consequence, HARD) aren't caught by the generic " size" SOFT marker. + Raises ``ValueError`` for a fail string matching neither list. + """ + for marker in _HARD_FAIL_MARKERS: + if marker in fail: + return "hard" + for marker in _SOFT_FAIL_MARKERS: + if marker in fail: + return "soft" + raise ValueError( + f"unclassified fail string (add a tier marker in fitness.py): {fail!r}" + ) + + +def tier_counts(fails) -> tuple[int, int]: + """Return ``(n_hard, n_soft)`` for an iterable of failure strings.""" + n_hard = n_soft = 0 + for f in fails: + if classify_fail_tier(f) == "hard": + n_hard += 1 + else: + n_soft += 1 + return n_hard, n_soft + + # Urb::Dom::Fitness::Base $CONF — keep values byte-identical to the Perl # expressions (5.0/6 etc. evaluate to the same IEEE doubles in both languages). CONF_DEFAULTS: dict = { diff --git a/tests/test_driver.py b/tests/test_driver.py index 7a79f3c..9a3d8e1 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -1,5 +1,6 @@ """Driver tests with a faked inner loop (no oracle, no perl).""" +import copy from pathlib import Path import numpy as np @@ -398,3 +399,46 @@ def test_search_annealed_degenerate_ladder_falls_back(fake_inner): assert r.best is not None assert r.n_evals >= 300 assert all(lf.share == 1 for lf in r.best.root.leaves()) + + +def test_use_tiers_prefers_fewer_hard_over_fewer_total_fails(monkeypatch): + """homemaker-py-2g7.3: with use_tiers=True the outer comparator is + (-n_hard, -n_soft, fitness) instead of (-n_fails, fitness). Construct a + seed (0 hard, 2 soft) vs. a mutated child (1 hard, 0 soft, FEWER total + fails and HIGHER raw fitness) — the flat comparator prefers the child + (1 < 2 total fails); the tiered comparator must keep the seed (0 < 1 + hard fails dominates regardless of soft count or fitness).""" + from homemaker_layout import innerloop + + seed_root = dom.load(str(SEED_FILE)) + calls = [] # first call is always the seed eval; every later call is a child + + def fake_optimise(root, programme_dir, x0=None, budget=200, urb_root=None, **kw): + for _, b in innerloop.free_with_keys(root): + b.division = [0.25, 0.25] + is_seed = len(calls) == 0 + calls.append(1) + if is_seed: + fail_lines = ("0/lr proportion", "0/lr crinkliness") # 0 hard, 2 soft + fit = 0.5 + else: + fail_lines = ("level 0 not connected",) # 1 hard, 0 soft + fit = 0.9 # higher raw fitness AND fewer total fails than the seed + return innerloop.Result( + x=np.array([0.25]), fitness=fit, n_fails=len(fail_lines), + fail_lines=fail_lines, x0_fitness=fit, x0_n_fails=len(fail_lines), + n_evals=budget, n_oracle_calls=1, + ) + + monkeypatch.setattr(innerloop, "optimise", fake_optimise) + + common_kw = dict(programme_dir=CORPUS, pop_size=1, seed_budget=50, + child_budget=50, budget=100, bootstrap=False, seed=0) + + flat = driver.search(seed_root, **common_kw) + assert flat.best.n_fails == 1 # flat comparator: fewer total fails wins + + calls.clear() + tiered = driver.search(copy.deepcopy(seed_root), use_tiers=True, **common_kw) + assert tiered.best.n_hard == 0 # tiered comparator: fewer hard fails wins + assert tiered.best.n_fails == 2 diff --git a/tests/test_fitness.py b/tests/test_fitness.py index f1aee8f..b5005da 100644 --- a/tests/test_fitness.py +++ b/tests/test_fitness.py @@ -10,7 +10,9 @@ from homemaker_layout.fitness import ( FAIL_THRESHOLD, Fitness, _leaf_grade, + classify_fail_tier, gaussian, + tier_counts, ) @@ -375,3 +377,94 @@ def test_programme_parses_per_code_share(tmp_path): reqs = load_programme(str(p)) assert reqs["b"].share == 3 and reqs["b"].has_share is True assert reqs["k"].share == 1 and reqs["k"].has_share is False + + +# --------------------------------------------------------------------------- # +# Hard/soft fail tiering (homemaker-py-2g7.3) +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize("fail_str", [ + "missing required space: la1", + "missing required space: la1 (critical)", + "too many spaces: k (found 3, expected 2)", + "missing ef1: would need size check", + "missing ef1: would need width check", + "missing ef1: would need proportion check", + "missing m: would need adjacency to c", + "missing r: would need to be on level 1", + "missing t1: would need connection to c below", + "0/lr (cr1) not adjacent to c", + "li1 on wrong level (level 0, expected 1)", + "t1 not connected to c below", + "level 0 not connected", + "0 inaccessible usable space", + "level 0 no outside space", + "0/lr unsupported covered outside", + "0/lr covered outside above ground", + "too few stairs (0, min 1)", + "too many stairs (2, max 1)", + "storey limit", + "storey minimum", + "no outside public access", +]) +def test_classify_fail_tier_hard(fail_str): + assert classify_fail_tier(fail_str) == "hard" + + +@pytest.mark.parametrize("fail_str", [ + "0/lr perpendicular", + "0/lr proportion", + "0/lr size", + "0/lr width", + "0/lr crinkliness", + "0/lr access", + "0/lr lrr edge too long", + "lr outside edge too long", + "staircase volume", +]) +def test_classify_fail_tier_soft(fail_str): + assert classify_fail_tier(fail_str) == "soft" + + +def test_classify_fail_tier_missing_cascade_is_hard_not_soft(): + # "missing X: would need size check" contains the SOFT " size" substring, + # but is a consequence of a HARD missing-space fail, not a shape defect — + # the HARD markers must be checked first (fitness.py ordering). + assert classify_fail_tier("missing m#2: would need size check") == "hard" + + +def test_classify_fail_tier_unknown_raises(): + with pytest.raises(ValueError): + classify_fail_tier("some brand new fail string nobody tiered yet") + + +def test_tier_counts_splits_hard_and_soft(): + fails = ("level 0 not connected", "0/lr proportion", "0/lr crinkliness", + "missing required space: k1") + assert tier_counts(fails) == (2, 2) + + +def test_tier_counts_empty(): + assert tier_counts(()) == (0, 0) + + +def test_classify_fail_tier_covers_full_corpus(): + """Regression guard: every fail string ever emitted into a checked-in + native (non-YAML) .fails file must still classify without error.""" + import glob + from pathlib import Path + + repo_root = Path(__file__).resolve().parent.parent + checked = 0 + for path in glob.glob(str(repo_root / "examples" / "**" / "*.fails"), recursive=True): + with open(path) as f: + first = f.readline() + if first.startswith("---"): + continue # legacy Perl-oracle YAML .fails, not this evaluator's output + lines = [first.rstrip("\n")] + [ln.rstrip("\n") for ln in f] + for line in lines: + if not line: + continue + classify_fail_tier(line) # raises on failure + checked += 1 + assert checked > 0