homemaker-py-2g7.1: plan->dom composer (SVG trace -> slicing-tree .dom)

Composer half of the ground-truth bead: examples/harbor-house/drawings/
harbor-house 1.svg turned out to be a Bonsai/Blender render of 3m.dom's own
IFC (32 IfcSpace paths == 3m.dom's upper-storey leaf count), not a human
trace, so no usable reference exists yet -- this builds and tests the
pipeline that will consume one once traced. compose.py parses storey-N
Inkscape layers of cut-lines + labels against a boundary-stub .dom (plot/
height/elevation only, no room shapes to keep aligned across storeys) and
recursively detects guillotine cuts, mirroring geometry.py's own
division-line algebra; non-slicible regions and label mismatches are
reported by location rather than guessed at. homemaker-compose CLI added.
Renamed dom._link to public dom.link since compose.py needs to re-link from
outside dom.py. Full design writeup in DESIGN.md sec 37.3; actual human
tracing of harbor-house/programme-house is tracked as follow-up under
2g7.1, still open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSwQwpEaHFBkeVSDDWd75S
This commit is contained in:
Bruno Postle 2026-08-03 11:03:02 +01:00
parent eb1d2fc7f0
commit 9296f75cdb
13 changed files with 790 additions and 49 deletions

View file

@ -1,7 +1,7 @@
{"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":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-08-02T09:15:04Z","created_by":"Bruno Postle","updated_at":"2026-08-02T22:42:15Z","started_at":"2026-08-02T18:39:05Z","closed_at":"2026-08-02T22:42:15Z","close_reason":"Prototype PASS: 99.0% agreement (\u003e=95%), 93.6x speedup (\u003e=50x), approximation error quantified (7.5% bbox overestimate). See DESIGN.md §37.2. Not wired into product this session -- follow-up homemaker-py-6xh filed.","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":"{}"}],"comments":[{"id":"019fc645-557c-7351-9349-d693e5eefa2b","issue_id":"homemaker-py-2g7.4","author":"Bruno Postle","text":"Post-close correction (user review): the prototype's rectangular\napproximation used an axis-aligned global bbox for each quad's (w,h) --\ncorrect only by coincidence on harbor-house-l0's near-axis-aligned plot\n(~7.5% area error). A real building's orthogonal walls need not align to\nthe plot's survey/CRS axes; confirmed by rotating the plot 45deg, where\nbbox error jumped to 102%.\n\nFixed: (w,h) now measured from edge lengths (edge0+edge2)/2,\n(edge1+edge3)/2 -- rotation-invariant by construction -- and the\nparent/child composition rule (which dimension sums vs. is shared) is now\nderived EXACTLY from child.rotation parity (verified float-exact\nidentity) instead of the empirical geometric heuristic the closed version\nused (which, tried alone without the parity fix, regressed accuracy\n99.0% -\u003e 95.5%).\n\nRe-validated: 99.0% on harbor-house-l0 unrotated (matches original, 0\nfalse negatives), 100% at 97x speedup on the SAME plot rotated 45deg.\nSee DESIGN.md §37.2 (Correction 1 / Correction 2) for full detail.\nhomemaker-py-6xh (production wiring follow-up) still applies.","created_at":"2026-08-03T06:17:40Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1} {"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":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-08-02T09:15:04Z","created_by":"Bruno Postle","updated_at":"2026-08-02T22:42:15Z","started_at":"2026-08-02T18:39:05Z","closed_at":"2026-08-02T22:42:15Z","close_reason":"Prototype PASS: 99.0% agreement (\u003e=95%), 93.6x speedup (\u003e=50x), approximation error quantified (7.5% bbox overestimate). See DESIGN.md §37.2. Not wired into product this session -- follow-up homemaker-py-6xh filed.","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":"{}"}],"comments":[{"id":"019fc645-557c-7351-9349-d693e5eefa2b","issue_id":"homemaker-py-2g7.4","author":"Bruno Postle","text":"Post-close correction (user review): the prototype's rectangular\napproximation used an axis-aligned global bbox for each quad's (w,h) --\ncorrect only by coincidence on harbor-house-l0's near-axis-aligned plot\n(~7.5% area error). A real building's orthogonal walls need not align to\nthe plot's survey/CRS axes; confirmed by rotating the plot 45deg, where\nbbox error jumped to 102%.\n\nFixed: (w,h) now measured from edge lengths (edge0+edge2)/2,\n(edge1+edge3)/2 -- rotation-invariant by construction -- and the\nparent/child composition rule (which dimension sums vs. is shared) is now\nderived EXACTLY from child.rotation parity (verified float-exact\nidentity) instead of the empirical geometric heuristic the closed version\nused (which, tried alone without the parity fix, regressed accuracy\n99.0% -\u003e 95.5%).\n\nRe-validated: 99.0% on harbor-house-l0 unrotated (matches original, 0\nfalse negatives), 100% at 97x speedup on the SAME plot rotated 45deg.\nSee DESIGN.md §37.2 (Correction 1 / Correction 2) for full detail.\nhomemaker-py-6xh (production wiring follow-up) still applies.","created_at":"2026-08-03T06:17:40Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1}
{"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":"ACCEPTANCE A/B COMPLETE — PASS (2026-08-02, experiments/tier_ab_2g7_3.py,\nharbor-house + maple-court, 3 seeds, budget 20000, leaf_sharing=True,\nn_workers=4, wall ~2h53m):\n\n harbor-house hard mean: flat 11.67 -\u003e tiered 5.33 (soft 29.00 -\u003e 42.33)\n maple-court hard mean: flat 19.33 -\u003e tiered 14.00 (soft 71.33 -\u003e 87.67)\n\nHard-fail mean strictly better on BOTH programmes at fixed budget — the\nrequired acceptance bar. Soft/total rise as expected (budget redirected from\npolishing shape fails to structural ones). Full per-seed log at\nscratch/tier_ab_2g7_3/log.txt (not committed — scratch output, regenerate via\nthe script if needed).\n\nGuards: (1) inner-loop 0.5^n cliff untouched by construction (no diff to\ninnerloop.py or the existing 0.5**len(failures) line) — not re-measured\nempirically, doesn't need to be. (2) tiered key is still lexicographic, not a\nblended scalar, so structurally immune to the §4.8 scalar pathology;\nencoded as tests/test_driver.py::test_use_tiers_prefers_fewer_hard_over_fewer_total_fails.\n\nDESIGN.md §37.1 written up with full table and rationale. Feature lands\ndefault-off (--use-tiers / HOMEMAKER_USE_TIERS / driver.search(use_tiers=)),\nso no existing reproduction changes.\n\nFollow-on (not blocking, filed separately): convergence-SPEED comparison\n(evals to 0 hard fails, tiered vs flat, same budget) — this A/B measured\nfail composition at a fixed budget snapshot, not time-to-solved.","status":"closed","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-02T17:51:18Z","started_at":"2026-08-02T09:58:53Z","closed_at":"2026-08-02T17:51:18Z","close_reason":"Acceptance A/B passed on both harbor-house and maple-court (hard-fail mean strictly better under tiering); guards verified; DESIGN.md §37.1 written up.","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":"ACCEPTANCE A/B COMPLETE — PASS (2026-08-02, experiments/tier_ab_2g7_3.py,\nharbor-house + maple-court, 3 seeds, budget 20000, leaf_sharing=True,\nn_workers=4, wall ~2h53m):\n\n harbor-house hard mean: flat 11.67 -\u003e tiered 5.33 (soft 29.00 -\u003e 42.33)\n maple-court hard mean: flat 19.33 -\u003e tiered 14.00 (soft 71.33 -\u003e 87.67)\n\nHard-fail mean strictly better on BOTH programmes at fixed budget — the\nrequired acceptance bar. Soft/total rise as expected (budget redirected from\npolishing shape fails to structural ones). Full per-seed log at\nscratch/tier_ab_2g7_3/log.txt (not committed — scratch output, regenerate via\nthe script if needed).\n\nGuards: (1) inner-loop 0.5^n cliff untouched by construction (no diff to\ninnerloop.py or the existing 0.5**len(failures) line) — not re-measured\nempirically, doesn't need to be. (2) tiered key is still lexicographic, not a\nblended scalar, so structurally immune to the §4.8 scalar pathology;\nencoded as tests/test_driver.py::test_use_tiers_prefers_fewer_hard_over_fewer_total_fails.\n\nDESIGN.md §37.1 written up with full table and rationale. Feature lands\ndefault-off (--use-tiers / HOMEMAKER_USE_TIERS / driver.search(use_tiers=)),\nso no existing reproduction changes.\n\nFollow-on (not blocking, filed separately): convergence-SPEED comparison\n(evals to 0 hard fails, tiered vs flat, same budget) — this A/B measured\nfail composition at a fixed budget snapshot, not time-to-solved.","status":"closed","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-02T17:51:18Z","started_at":"2026-08-02T09:58:53Z","closed_at":"2026-08-02T17:51:18Z","close_reason":"Acceptance A/B passed on both harbor-house and maple-court (hard-fail mean strictly better under tiering); guards verified; DESIGN.md §37.1 written up.","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.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.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","notes":"2026-08-03: Composer implemented and tested (src/homemaker_layout/compose.py,\ncompose_cmd.py -\u003e homemaker-compose CLI; tests/test_compose.py, 6 tests,\nsynthetic fixtures). Round-trips a synthetic slicible partition through\ndom.dumps/dom.load and homemaker-fitness; non-slicible input raises\nNonSlicible naming the offending region; CLI prints a clean diagnostic\ninstead of a traceback. Full design writeup: DESIGN.md sec 37.3.\n\nAlso found examples/harbor-house/drawings/harbor-house 1.svg is NOT a human\ntrace -- it's a Bonsai/Blender SVG export of 3m.dom's own IFC (32 IfcSpace\npaths == 3m.dom's upper-storey leaf count, timestamp 6min after 3m.dom.ifc).\nNo usable human reference exists anywhere in the repo yet.\n\nRemaining acceptance criterion (\"at least one human harbor-house solution\ntraced, composed, and scored\") is NOT done -- needs the user to actually\ntrace a building in Inkscape using the storey-N-layer / cut-lines-only\nformat documented in DESIGN.md sec 37.3. Tracked as follow-up.","status":"in_progress","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-08-02T09:14:10Z","created_by":"Bruno Postle","updated_at":"2026-08-03T09:50:35Z","started_at":"2026-08-03T07:16:32Z","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} {"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}
{"id":"homemaker-py-ld2","title":"Interior-O courtyard seeding option","description":"_assign_adjacency_aware (operators.py:528) currently places the single O leaf on the MOST PERIPHERAL leaf, where adjacent rooms already have facade. For dense floors (harbor-house ~19 rooms/floor) this wastes the daylight source. Add an option to seed O INTERIOR (as a light well) and to scale O-leaf count with room count, so landlocked rooms get an adjacent uncovered-outside neighbour by construction -\u003e fewer crinkliness fails in the seed. A/B against current peripheral placement.","notes":"Implemented: interior_outside flag + outside_divisor (default 3) threaded through operators.constructive_topology / lift_base_to_storeys, _assign_adjacency_aware (interior light-well placement: most-landlocked leaves first, greedy spread), driver.search/search_staged, run_staged_search.py (INTERIORO/ODIV env). Test test_interior_outside_seeds_landlocked_wells_and_scales_count. A/B script experiments/run_interioro_ab.sh. Seed diagnostic confirmed mechanism (all crinkliness fails landlocked under-exposure) and tuned odiv 6-\u003e3. Full 20k A/B (maple+harbor seeds 0/1/2, control=peripheral must reproduce §13.5 maple 82.3/harbor 40.0) running; DESIGN.md §13.6 verdict pending results.","status":"closed","priority":1,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-23T20:40:19Z","created_by":"Bruno Postle","updated_at":"2026-06-28T06:19:38Z","started_at":"2026-06-27T20:37:42Z","closed_at":"2026-06-28T06:19:38Z","close_reason":"interior-O light-well seeding implemented + A/B done (§13.6): positive on dense floor (harbor -16.4%, all seeds), marginal/neutral on maple (-2.8%). Default-ON flip tracked as follow-up.","dependencies":[{"issue_id":"homemaker-py-ld2","depends_on_id":"homemaker-py-erc","type":"parent-child","created_at":"2026-06-23T21:49:30Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-ld2","title":"Interior-O courtyard seeding option","description":"_assign_adjacency_aware (operators.py:528) currently places the single O leaf on the MOST PERIPHERAL leaf, where adjacent rooms already have facade. For dense floors (harbor-house ~19 rooms/floor) this wastes the daylight source. Add an option to seed O INTERIOR (as a light well) and to scale O-leaf count with room count, so landlocked rooms get an adjacent uncovered-outside neighbour by construction -\u003e fewer crinkliness fails in the seed. A/B against current peripheral placement.","notes":"Implemented: interior_outside flag + outside_divisor (default 3) threaded through operators.constructive_topology / lift_base_to_storeys, _assign_adjacency_aware (interior light-well placement: most-landlocked leaves first, greedy spread), driver.search/search_staged, run_staged_search.py (INTERIORO/ODIV env). Test test_interior_outside_seeds_landlocked_wells_and_scales_count. A/B script experiments/run_interioro_ab.sh. Seed diagnostic confirmed mechanism (all crinkliness fails landlocked under-exposure) and tuned odiv 6-\u003e3. Full 20k A/B (maple+harbor seeds 0/1/2, control=peripheral must reproduce §13.5 maple 82.3/harbor 40.0) running; DESIGN.md §13.6 verdict pending results.","status":"closed","priority":1,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-23T20:40:19Z","created_by":"Bruno Postle","updated_at":"2026-06-28T06:19:38Z","started_at":"2026-06-27T20:37:42Z","closed_at":"2026-06-28T06:19:38Z","close_reason":"interior-O light-well seeding implemented + A/B done (§13.6): positive on dense floor (harbor -16.4%, all seeds), marginal/neutral on maple (-2.8%). Default-ON flip tracked as follow-up.","dependencies":[{"issue_id":"homemaker-py-ld2","depends_on_id":"homemaker-py-erc","type":"parent-child","created_at":"2026-06-23T21:49:30Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"homemaker-py-erc.4","title":"Experiment: depth-balanced / giant-splitting construction (re-scoped by Diag B)","description":"Attacks the #2 factor (size/undersize 242) via the §12.3 paradox: rooms are undersize while 56% of the plot is empty. The shape floor is computed at TARGET dims, so construction never spends the slack. Scale leaves up to consume available plot area (proportionally, preserving target aspect) so rooms reach/exceed target — bigger leaves are also easier to keep compact, so this may help crinkliness/width too.\n\nBuilds on leu.2 (proportion-aware splits sized FROM target dims) by adding a fill step that scales the whole layout (or per-region) to the plot envelope instead of leaving slack as empty plot. Implementation in operators construction / _size_divisions_from_targets.\n\nNOTE: exact fix-site (construction vs inner loop) is decided by Diagnostic B — if B shows leaves park at target with unused plot, this construction lever is correct; if B shows the inner loop simply lacks an expansion gradient, prefer the inner-loop slack-expansion sibling instead. A/B vs §12.2 baseline, seeds 0/1/2, 20000 evals, staged, default-OFF. Record DESIGN.md §13.4.","notes":"RE-SCOPED by Diagnostic B (§13.2). Original premise (rooms parked at target, scale leaves up into 56%-empty plot) is FALSIFIED: sized rooms already hold 1.4-1.5x aggregate target area; the empty-looking plot is ~46% circulation, not claimable void. Real defect: MALDISTRIBUTION by slicing position — same type/target leaf lands 0.05x..14.7x by binary-tree depth; inner loop cannot fix (frozen topology). NEW SCOPE: construction that balances tree DEPTH so equal-target rooms land at comparable depth and/or splits/caps giant leaves so area tracks target. NOT a uniform scale-to-envelope (that would just inflate the giants further). A/B vs §12.2 baseline, seeds 0/1/2, 20000 evals, staged, default-OFF. Record DESIGN.md §13.4. Synergy with erc.3 (leaf-sharing for the starved tail).","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-22T23:16:19Z","created_by":"Bruno Postle","updated_at":"2026-06-26T06:06:51Z","started_at":"2026-06-24T21:18:57Z","closed_at":"2026-06-26T06:06:51Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-erc.4","depends_on_id":"homemaker-py-erc","type":"parent-child","created_at":"2026-06-23T00:16:19Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-erc.4","depends_on_id":"homemaker-py-erc.2","type":"blocks","created_at":"2026-06-23T00:16:45Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-erc.4","title":"Experiment: depth-balanced / giant-splitting construction (re-scoped by Diag B)","description":"Attacks the #2 factor (size/undersize 242) via the §12.3 paradox: rooms are undersize while 56% of the plot is empty. The shape floor is computed at TARGET dims, so construction never spends the slack. Scale leaves up to consume available plot area (proportionally, preserving target aspect) so rooms reach/exceed target — bigger leaves are also easier to keep compact, so this may help crinkliness/width too.\n\nBuilds on leu.2 (proportion-aware splits sized FROM target dims) by adding a fill step that scales the whole layout (or per-region) to the plot envelope instead of leaving slack as empty plot. Implementation in operators construction / _size_divisions_from_targets.\n\nNOTE: exact fix-site (construction vs inner loop) is decided by Diagnostic B — if B shows leaves park at target with unused plot, this construction lever is correct; if B shows the inner loop simply lacks an expansion gradient, prefer the inner-loop slack-expansion sibling instead. A/B vs §12.2 baseline, seeds 0/1/2, 20000 evals, staged, default-OFF. Record DESIGN.md §13.4.","notes":"RE-SCOPED by Diagnostic B (§13.2). Original premise (rooms parked at target, scale leaves up into 56%-empty plot) is FALSIFIED: sized rooms already hold 1.4-1.5x aggregate target area; the empty-looking plot is ~46% circulation, not claimable void. Real defect: MALDISTRIBUTION by slicing position — same type/target leaf lands 0.05x..14.7x by binary-tree depth; inner loop cannot fix (frozen topology). NEW SCOPE: construction that balances tree DEPTH so equal-target rooms land at comparable depth and/or splits/caps giant leaves so area tracks target. NOT a uniform scale-to-envelope (that would just inflate the giants further). A/B vs §12.2 baseline, seeds 0/1/2, 20000 evals, staged, default-OFF. Record DESIGN.md §13.4. Synergy with erc.3 (leaf-sharing for the starved tail).","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-22T23:16:19Z","created_by":"Bruno Postle","updated_at":"2026-06-26T06:06:51Z","started_at":"2026-06-24T21:18:57Z","closed_at":"2026-06-26T06:06:51Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-erc.4","depends_on_id":"homemaker-py-erc","type":"parent-child","created_at":"2026-06-23T00:16:19Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-erc.4","depends_on_id":"homemaker-py-erc.2","type":"blocks","created_at":"2026-06-23T00:16:45Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
@ -123,27 +123,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.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-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} {"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":"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":"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-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":"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":"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":"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":"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":"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":"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-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":"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":"proportion-aware-constructive-seeding-leu-2-12-2","value":"Proportion-aware constructive seeding (leu.2/§12.2): sizing seed cuts from target AREAS only regresses (thin slivers wreck aspect); you must ALSO pick each cut's rotation for child squareness. It is a convergence ACCELERATOR via a deeper local optimum around the constructed topology: wins where that topology is roughly right and budget is scarce (harbor -13%, maple -10% at 20k evals) but DELAYS small programmes where the seed must be restructured by undivide (programme-house regresses at fixed budget, yet reaches the floor given budget - speed, not asymptote). Default-on. Also: n_storeys must honour storey_minimum, not just level: keys (programme-house storey_minimum:2, all rooms level:0 - was seeded 1 storey short; cq1)."}
{"_type":"memory","key":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."}
{"_type":"memory","key":"homemaker-py-pythonpath-set-pythonpath-home-bruno-src","value":"homemaker-layout PYTHONPATH: package installed as 'homemaker-layout' via pip install -e . so 'import homemaker_layout' works from anywhere without PYTHONPATH. For running tests use 'python -m pytest' from project root /home/bruno/src/homemaker-layout (pyproject.toml adds src/ automatically). Never try pip show homemaker — that's the old homemaker-addon conflict."}
{"_type":"memory","key":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"never-use-corpus-filenames-candidate-001-dom-candidate","value":"Never use corpus filenames (candidate-001.dom, candidate-002.dom, generated.dom, init.dom, etc.) as --output targets when running experiments. These are test fixtures. Always write experimental outputs to scratch/ or a timestamped path. Lesson from 2026-06-14: warm-start runs overwrote candidate-001/002.dom and broke graph tests."}
{"_type":"memory","key":"programme-house-optimisation-result-2026-06-14-15","value":"Programme-house optimisation result (2026-06-14/15): best achievable is 1 fail (l1 wrong level, score ~0.005). 0 fails is geometrically impossible: l1 (min 27m²) must occupy ll (~23m²) at level 0, which eliminates the t3-adj-C provider; dividing ll into lll(l1)+llr(C) gives llr proportion ~6:1 (fails). Python memetic optimizer achieves 1 fail in 50k evals vs Perl optimiser's 2-3 fails. Winning topology: TWO C nodes at level 0 — ll(C) for t3-adj-C via geometric contact, rl(C) for staircase via tree-sibling adjacency to rrr(O). Best .dom: scratch/from-warmstart-fixed.dom and scratch/from-compound3-fixed.dom."}
{"_type":"memory","key":"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":"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":"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":"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":"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":"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":"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-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":"experiment-harness-gotcha-the-leaf-sharing-relaxed-objective","value":"Experiment harness gotcha: the leaf-sharing RELAXED objective (§13.3) is injected ONLY by monkeypatching fitness.load_config in the parent process (run_staged_search.py / probe scripts). This is parent-process-only and does NOT propagate into ProcessPoolExecutor workers (n_workers\u003e1), which re-import fitness fresh and score under the STRICT on-disk patterns.config -\u003e r.n_fails MISMATCH (worker strict vs parent relaxed re-score). ALL §13.x floor runs were therefore SERIAL. Any future PARALLEL leaf-sharing experiment will silently mis-score until leaf_sharing lives on disk/CLI (tracked: homemaker-py-x3b). The parallel driver itself is correct; both paths score via load_config(programme_dir)."}
{"_type":"memory","key":"homemaker-py-pythonpath-set-pythonpath-home-bruno-src","value":"homemaker-layout PYTHONPATH: package installed as 'homemaker-layout' via pip install -e . so 'import homemaker_layout' works from anywhere without PYTHONPATH. For running tests use 'python -m pytest' from project root /home/bruno/src/homemaker-layout (pyproject.toml adds src/ automatically). Never try pip show homemaker — that's the old homemaker-addon conflict."}
{"_type":"memory","key":"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":"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":"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":"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)."}

View file

@ -4148,3 +4148,93 @@ region to remove the remaining ~8-12% approximation-error source rather than
just quantify it. `experiments/shapecurve_spike.py` is kept as a reference/ just quantify it. `experiments/shapecurve_spike.py` is kept as a reference/
prototype (the §34 `autodiff_spike.py` precedent), not wired into prototype (the §34 `autodiff_spike.py` precedent), not wired into
`innerloop.py`. `innerloop.py`.
### 37.3 `homemaker-py-2g7.1` plan→dom composer — implemented 2026-08-03, trace still open
**Finding that reframed this bead.** `examples/harbor-house/drawings/harbor-house 1.svg`
looked like it might already be a usable human trace. It isn't: it has
exactly 32 `IfcSpace` path elements, matching the upper-storey leaf count of
`examples/harbor-house/3m.dom` (23 + 32 leaves across two storeys), and its
file timestamp is 6 minutes after `3m.dom.ifc`. It's a Bonsai/Blender SVG
export of `3m.dom`'s own IFC — a rendering of evolution output, not an
independent reference. There is currently no human-generated plan anywhere
in the repo; producing one needs the user to actually trace a building by
hand, which is out of scope for a single session. This session built and
tested the **composer** — the code half of the bead — and specified the
trace format so a real trace can be authored later without redesigning
anything.
**Trace format — lines + labels, not room shapes.** Urb's data model
requires every storey to share the ground-floor plot exactly: in
`geometry.coordinate()`, a level root with a `below` link always inherits
its 4 corners from the level below, and `dom.link()` always finds that link
for a root (`by_id("")` is trivially the root itself, so the below-chain
never breaks at level-root granularity). So there is only ever *one* site
outline (the ground plot), never one per storey to keep aligned. Per storey,
the trace is only straight open cut lines + text labels — never closed room
polygons, which is what makes a rough hand sketch usable: room outlines are
*derived* by recursively finding a line that spans the current region
edge-to-edge (with a snapping tolerance for overlap/undershoot/misalignment
slop), never drawn and matched.
**Metadata sidecar = a stub `.dom` file, not a new schema** (this was the
user's call, and it's the right one — reuses `dom.load()`/`dom.dumps()`
verbatim). A "boundary" `.dom` carries `node` (the plot, level 0 only),
`perimeter`, `height`/`elevation`/`wall_inner`/`wall_outer` per level (via
`above` chaining) and nothing else — no `division`, no `type`, no `l`/`r`.
The composer fills in `division`/`left`/`right`/`rotation` per level from
the SVG trace and re-links. Composer matches against the boundary's
`node_file` (raw, as-authored corners) rather than the wall-inset `node`
a human traces the visible/surveyed outer wall face, not the wall-thickness
inset the geometry engine derives internally.
**Composer only has to get topology right; `solver.solve_ratios` fixes
geometry.** Traced cut positions from a hand sketch are rough. The composer
converts a detected cut into an initial `division` ratio; `compose.refine()`
then calls `solver.solve_ratios(root, targets, strip=False)` to slide cuts
to the best fit for the programme's target dimensions, exactly like the
existing bottom-up solve path (`strip=False` is required — the default
`True` would discard the traced starting ratios and start from 0.5). This
means sub-metre trace precision doesn't matter; only which side of which
line a room falls on does.
**Implementation.** `compose.py`: `parse_svg()` reads Inkscape layers named
`storey-N` (flat, not nested) via `xml.etree.ElementTree`, flattening each
element's `transform` stack (translate/scale/matrix/rotate composed as 2x3
affines); a `<line>` or straight 2-point `<path d="M.. L..">` is a cut, a
`<text>` (its own `x`/`y` or its first `<tspan>`'s) is a label. The
recursive core (`_build`/`_find_span`) mirrors `geometry.py`'s own
division-line algebra exactly (`coord_a`/`coord_b`'s two edge-pairs, and the
left/right child corner formulas the engine uses to re-derive coordinates
top-down) so a composed node's `rotation`/`division` reproduce the traced
corners bit-for-bit when read back. A region with interior lines but none
spanning it raises `NonSlicible(storey, corners)`; a leaf with != 1 label
raises `LabelError` — both name the offending region rather than guessing.
`dom._link` was renamed to the public `dom.link` (one-line rename at all
call sites in `genome.py`/`operators.py`/tests) since the composer needs to
re-link after mutating a loaded boundary tree from outside `dom.py`.
`compose_cmd.py` → `homemaker-compose plan.svg boundary.dom -o out.dom
[--tol 0.15] [--scale 1.0] [--refine]`, mirroring `fitness_cmd.py`'s CLI
shape; catches `NonSlicible`/`LabelError` and prints the diagnostic to
stderr with exit 1 instead of a traceback.
**Verification.** `tests/test_compose.py` (6 tests, synthetic fixtures
only): a 3-room/2-cut partition (exercising both axes and depth-2
recursion) composes, round-trips through `dom.dumps`/`dom.load`, has
leaf areas summing to the plot area, and scores cleanly through
`fitness.Fitness`; the same partition with endpoints perturbed by less
than the default tolerance still composes (and fails with a tightened
tolerance — the negative control); a dangling interior line that spans
neither edge pair raises `NonSlicible` naming the whole-plot region;
label-count mismatches, missing `storey-N` layers, and a boundary/trace
storey-count mismatch each raise a clear error. Manually verified the CLI
end-to-end against the same fixture, including `homemaker-fitness` scoring
the emitted `.dom` (`0/rl size`, `level 0 no outside space`, etc. — expected
fails with no programme/patterns.config on disk). Full suite: 381 passed.
**ACCEPTANCE: PARTIAL.** Composer half done (round-trips a synthetic
slicible partition; non-slicible input reports the offending region). Still
open: tracing an actual harbor-house or programme-house human plan in
Inkscape and composing/scoring it — needs the user's time, tracked as
follow-up under `2g7.1`. `2g7.2` (objective calibration against the human
reference) stays blocked on that trace landing.

View file

@ -16,6 +16,7 @@ dependencies = [
homemaker-evolve = "homemaker_layout.evolve:main" homemaker-evolve = "homemaker_layout.evolve:main"
homemaker-fitness = "homemaker_layout.fitness_cmd:main" homemaker-fitness = "homemaker_layout.fitness_cmd:main"
homemaker-collapse = "homemaker_layout.collapse_cmd:main" homemaker-collapse = "homemaker_layout.collapse_cmd:main"
homemaker-compose = "homemaker_layout.compose_cmd:main"
[project.optional-dependencies] [project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.5"] dev = ["pytest>=8.0", "ruff>=0.5"]

View file

@ -0,0 +1,408 @@
"""SVG trace + boundary ``.dom`` -> full slicing-tree ``.dom`` (homemaker-py-2g7.1).
Ground-truth problem: every non-empty ``.dom`` in this repo is evolution
output, so nobody has ever measured what a *known-good human design* scores
under ``fitness.py``. This module builds one from a hand trace instead of
requiring a hand-authored YAML tree (impractical) or hand-drawn room shapes
(don't line up between storeys or with each other on a rough sketch).
Trace format (see ``DESIGN.md`` sec 37.x for the full write-up):
* A "boundary" ``.dom`` file supplies everything geometric that ISN'T a
guillotine-cut topology: the plot outline (level 0's ``node``), per-storey
``height``/``elevation``, ``wall_inner``/``wall_outer``, ``perimeter``.
It has no ``division``/``type`` ``dom.load()`` parses it as-is. Urb's own
model requires every storey to share the ground-floor footprint exactly
(``geometry.coordinate`` always derives an upper level root from the level
below), so there is exactly one outline, not one per storey.
* An SVG file supplies the topology: one Inkscape layer per storey, named
``storey-0``, ``storey-1``, ... Each layer holds only straight open cut
lines (``<line>`` or a 2-point ``<path d="M.. L..">``) and text labels
(room type codes). No closed room shapes are ever drawn a room's outline
is *derived*, not traced, which is what makes this robust to a rough
sketch (lines that overlap slightly, undershoot a corner, or don't quite
align between storeys all fall within a tolerance).
Composition works by mirroring ``geometry.py``'s own division-line algebra:
starting from the plot quad, recursively look for a traced line that spans
the current quad edge-to-edge (a "guillotine cut" test with a snapping
tolerance), split into two child quads via the exact corner formulas
``geometry.coordinate``/``coord_a``/``coord_b`` use, and recurse. A region
with interior lines but none spanning it is not representable as a guillotine
partition reported via ``NonSlicible`` naming the region, per the bead's
acceptance criteria, rather than silently guessed at.
Traced cut *positions* only need to be roughly right: ``refine()`` calls
``solver.solve_ratios(..., strip=False)`` to slide them to the best fit for
the programme's target dimensions afterward, keeping the traced topology
fixed.
"""
from __future__ import annotations
import math
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from .dom import Node, levels, link
Point = tuple[float, float]
_SVG_NS = "http://www.w3.org/2000/svg"
_INK_NS = "http://www.inkscape.org/namespaces/inkscape"
_STOREY_RE = re.compile(r"^storey-(\d+)$")
def _qn(ns: str, tag: str) -> str:
return f"{{{ns}}}{tag}"
@dataclass
class StoreyTrace:
lines: list[tuple[Point, Point]] = field(default_factory=list)
labels: list[tuple[Point, str]] = field(default_factory=list)
class NonSlicible(Exception):
"""A traced region has interior cut lines but none spans it edge-to-edge
not representable as a guillotine slicing tree."""
def __init__(self, storey: int, corners: list[Point]):
self.storey = storey
self.corners = corners
cx = sum(p[0] for p in corners) / 4
cy = sum(p[1] for p in corners) / 4
super().__init__(
f"storey {storey}: region around ({cx:.2f}, {cy:.2f}) "
f"(corners {corners}) has cut lines that don't fully divide it "
"into two — not a guillotine partition"
)
class LabelError(Exception):
"""A leaf region has zero or more than one room label."""
def __init__(self, storey: int, corners: list[Point], labels: list[str]):
self.storey = storey
self.corners = corners
self.labels = labels
cx = sum(p[0] for p in corners) / 4
cy = sum(p[1] for p in corners) / 4
super().__init__(
f"storey {storey}: leaf region around ({cx:.2f}, {cy:.2f}) has "
f"{len(labels)} label(s) {labels!r}, expected exactly 1"
)
# --------------------------------------------------------------------------- #
# SVG parsing
# --------------------------------------------------------------------------- #
Matrix = tuple[float, float, float, float, float, float] # a b c d e f
_IDENTITY: Matrix = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
def _mat_mul(m1: Matrix, m2: Matrix) -> Matrix:
a1, b1, c1, d1, e1, f1 = m1
a2, b2, c2, d2, e2, f2 = m2
return (
a1 * a2 + c1 * b2,
b1 * a2 + d1 * b2,
a1 * c2 + c1 * d2,
b1 * c2 + d1 * d2,
a1 * e2 + c1 * f2 + e1,
b1 * e2 + d1 * f2 + f1,
)
def _apply(m: Matrix, p: Point) -> Point:
a, b, c, d, e, f = m
return (a * p[0] + c * p[1] + e, b * p[0] + d * p[1] + f)
_TRANSFORM_RE = re.compile(r"(\w+)\s*\(([^)]*)\)")
def _parse_transform(s: str | None) -> Matrix:
if not s:
return _IDENTITY
m = _IDENTITY
for name, args in _TRANSFORM_RE.findall(s):
nums = [float(x) for x in re.split(r"[,\s]+", args.strip()) if x]
if name == "translate":
part: Matrix = (1, 0, 0, 1, nums[0], nums[1] if len(nums) > 1 else 0.0)
elif name == "scale":
sx = nums[0]
sy = nums[1] if len(nums) > 1 else sx
part = (sx, 0, 0, sy, 0, 0)
elif name == "matrix":
part = (nums[0], nums[1], nums[2], nums[3], nums[4], nums[5])
elif name == "rotate":
theta = math.radians(nums[0])
cos_t, sin_t = math.cos(theta), math.sin(theta)
rot: Matrix = (cos_t, sin_t, -sin_t, cos_t, 0, 0)
if len(nums) == 3:
cx, cy = nums[1], nums[2]
part = _mat_mul(_mat_mul((1, 0, 0, 1, cx, cy), rot), (1, 0, 0, 1, -cx, -cy))
else:
part = rot
else:
continue # skewX/skewY: not needed for straight hand traces
m = _mat_mul(m, part)
return m
_PATH_CMD_RE = re.compile(r"([MLZmlz])\s*([^MLZmlz]*)")
def _parse_path_line(d: str) -> tuple[Point, Point] | None:
"""A straight 2-point path ``M x,y L x,y`` (absolute only); None if this
isn't a simple straight segment (curves, more than 2 points, ...)."""
pts: list[Point] = []
for cmd, args in _PATH_CMD_RE.findall(d):
if cmd.upper() == "Z":
continue
if cmd not in ("M", "L"):
return None
nums = [float(x) for x in re.split(r"[,\s]+", args.strip()) if x]
if len(nums) != 2:
return None
pts.append((nums[0], nums[1]))
if len(pts) != 2:
return None
return pts[0], pts[1]
def _walk(el: ET.Element, xf: Matrix, storey: StoreyTrace) -> None:
xf = _mat_mul(xf, _parse_transform(el.get("transform")))
tag = el.tag.rsplit("}", 1)[-1]
if tag == "line":
p1 = _apply(xf, (float(el.get("x1")), float(el.get("y1"))))
p2 = _apply(xf, (float(el.get("x2")), float(el.get("y2"))))
storey.lines.append((p1, p2))
elif tag == "path":
d = el.get("d") or ""
pts = _parse_path_line(d)
if pts is None:
raise ValueError(f"unsupported non-straight-2-point cut path: {d!r}")
storey.lines.append((_apply(xf, pts[0]), _apply(xf, pts[1])))
elif tag == "text":
x, y = el.get("x"), el.get("y")
tspan = el.find(_qn(_SVG_NS, "tspan"))
if tspan is not None and tspan.get("x") is not None:
x, y = tspan.get("x"), tspan.get("y")
text = "".join(el.itertext()).strip()
if x is not None and y is not None and text:
storey.labels.append((_apply(xf, (float(x), float(y))), text))
for child in el:
_walk(child, xf, storey)
def parse_svg(path: str, scale: float = 1.0) -> list[StoreyTrace]:
"""Parse ``storey-N`` Inkscape layers into per-storey traces, storey 0
first. Layers must be flat (not nested inside one another)."""
root = ET.parse(path).getroot()
root_xf = _parse_transform(root.get("transform"))
storeys: dict[int, StoreyTrace] = {}
for g in root.iter(_qn(_SVG_NS, "g")):
if g.get(_qn(_INK_NS, "groupmode")) != "layer":
continue
m = _STOREY_RE.match((g.get(_qn(_INK_NS, "label")) or "").strip())
if not m:
continue
trace = storeys.setdefault(int(m.group(1)), StoreyTrace())
_walk(g, root_xf, trace)
if not storeys:
raise ValueError(f"{path}: no 'storey-N' Inkscape layers found")
result = [storeys.get(i, StoreyTrace()) for i in range(max(storeys) + 1)]
if scale != 1.0:
result = [
StoreyTrace(
lines=[
((p[0] * scale, p[1] * scale), (q[0] * scale, q[1] * scale))
for p, q in t.lines
],
labels=[((p[0] * scale, p[1] * scale), s) for p, s in t.labels],
)
for t in result
]
return result
# --------------------------------------------------------------------------- #
# Geometry: mirrors geometry.py's division-line algebra (coordinate/coord_a/
# coord_b) so composed rotation+division values reproduce these exact corners
# when the engine re-derives them top-down.
# --------------------------------------------------------------------------- #
def _dist(a: Point, b: Point) -> float:
return math.hypot(a[0] - b[0], a[1] - b[1])
def _interp(a: Point, b: Point, t: float) -> Point:
return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)
def _nearest_on_segment(p: Point, a: Point, b: Point) -> tuple[Point, float]:
dx, dy = b[0] - a[0], b[1] - a[1]
length_sq = dx * dx + dy * dy
if length_sq == 0:
return a, 0.0
t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length_sq
t_clamped = max(0.0, min(1.0, t))
return (a[0] + t_clamped * dx, a[1] + t_clamped * dy), t_clamped
def _near_edge(p: Point, a: Point, b: Point, tol: float) -> float | None:
"""Clamped projection parameter (0=a, 1=b) if p is within tol of segment
a-b, else None."""
nearest, t = _nearest_on_segment(p, a, b)
return t if _dist(p, nearest) <= tol else None
def _side(p: Point, a: Point, b: Point) -> float:
return (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])
def _find_span(
corners: list[Point], lines: list[tuple[Point, Point]], tol: float
) -> tuple[int, tuple[float, float], tuple[Point, Point]] | None:
"""Best line spanning ``corners`` edge-to-edge on either axis: axis 0 =
edges (0,1)&(3,2), axis 1 = edges (1,2)&(0,3) mirrors the two edge
pairs ``geometry.coord_a``/``coord_b`` can address via ``rotation``."""
axes = (
(0, corners[0], corners[1], corners[3], corners[2]),
(1, corners[1], corners[2], corners[0], corners[3]),
)
best = None
best_err = None
for axis, ea0, ea1, eb0, eb1 in axes:
for p, q in lines:
for p1, p2 in ((p, q), (q, p)):
t0 = _near_edge(p1, ea0, ea1, tol)
t1 = _near_edge(p2, eb0, eb1, tol)
if t0 is None or t1 is None:
continue
err = _dist(p1, _interp(ea0, ea1, t0)) + _dist(p2, _interp(eb0, eb1, t1))
if best_err is None or err < best_err:
best_err = err
best = (axis, (t0, t1), (p, q))
return best
def _build(
storey: int,
corners: list[Point],
lines: list[tuple[Point, Point]],
labels: list[tuple[Point, str]],
tol: float,
) -> Node:
span = _find_span(corners, lines, tol)
if span is None:
if not lines:
texts = [text for _, text in labels]
if len(texts) != 1:
raise LabelError(storey, corners, texts)
leaf = Node()
leaf.type = texts[0]
return leaf
raise NonSlicible(storey, corners)
axis, (t0, t1), chosen = span
idx = lines.index(chosen)
other_lines = lines[:idx] + lines[idx + 1 :]
if axis == 0:
coord_a = _interp(corners[0], corners[1], t0)
coord_b = _interp(corners[3], corners[2], t1)
left_corners = [corners[0], coord_a, coord_b, corners[3]]
right_corners = [coord_a, corners[1], corners[2], coord_b]
rotation = 0
else:
coord_a = _interp(corners[1], corners[2], t0)
coord_b = _interp(corners[0], corners[3], t1)
left_corners = [corners[1], coord_a, coord_b, corners[0]]
right_corners = [coord_a, corners[2], corners[3], coord_b]
rotation = 1
ref_side = _side(left_corners[0], coord_a, coord_b)
def is_left(p: Point) -> bool:
return (_side(p, coord_a, coord_b) >= 0) == (ref_side >= 0)
left_lines, right_lines = [], []
for p, q in other_lines:
mid = ((p[0] + q[0]) / 2, (p[1] + q[1]) / 2)
(left_lines if is_left(mid) else right_lines).append((p, q))
left_labels, right_labels = [], []
for pt, text in labels:
(left_labels if is_left(pt) else right_labels).append((pt, text))
node = Node()
node.rotation = rotation
node.division = [t0, t1]
node.left = _build(storey, left_corners, left_lines, left_labels, tol)
node.right = _build(storey, right_corners, right_lines, right_labels, tol)
return node
# --------------------------------------------------------------------------- #
# Compose
# --------------------------------------------------------------------------- #
def compose(boundary_root: Node, storeys: list[StoreyTrace], tol: float = 0.15) -> Node:
"""Fill in ``division``/``left``/``right`` on each level of
``boundary_root`` (as loaded by ``dom.load()``) from the traced storeys,
and return the fully-linked, re-composed root."""
level_roots = levels(boundary_root)
if len(storeys) != len(level_roots):
raise ValueError(
f"boundary dom has {len(level_roots)} storey(s), trace has {len(storeys)}"
)
# A human traces against the plot's visible (outer wall face) boundary,
# not the wall_outer-inset working quad dom.load() computes -- so match
# against node_file (the raw corners as authored), same frame the file's
# author drew the boundary quad in.
plot = level_roots[0].node_file or level_roots[0].node
if plot is None or len(plot) != 4:
raise ValueError("boundary dom's level-0 root must have a 4-corner 'node'")
corners = [(float(p[0]), float(p[1])) for p in plot]
for i, (level_root, trace) in enumerate(zip(level_roots, storeys)):
built = _build(i, corners, trace.lines, trace.labels, tol)
level_root.division = built.division
level_root.left = built.left
level_root.right = built.right
level_root.rotation = built.rotation
level_root.type = built.type
if i == 0:
# Every storey above sees level 0's own *rotation-adjusted*
# corners (geometry.coordinate() always derives an upper root
# from the level below via the below-link, using level 0's
# final rotation) -- not necessarily plot's raw file order.
corners = [
(float(plot[(k + level_root.rotation) % 4][0]),
float(plot[(k + level_root.rotation) % 4][1]))
for k in range(4)
]
link(boundary_root)
from . import geometry
geometry.clear_cache()
return boundary_root
def refine(root: Node, programme_dir: str) -> None:
"""Slide the traced cuts to the best fit for the programme's target
dimensions, keeping the traced topology fixed -- trace precision only
needs to get the structure right, not the exact ratios."""
from . import solver
from .programme import load_programme_dir
solver.solve_ratios(root, load_programme_dir(programme_dir), strip=False)

View file

@ -0,0 +1,50 @@
"""homemaker-compose -- SVG trace + boundary .dom -> full slicing-tree .dom
(homemaker-py-2g7.1). See DESIGN.md sec 37.x for the trace format.
Usage:
homemaker-compose plan.svg boundary.dom -o out.dom [--tol 0.15] [--refine]
"""
from __future__ import annotations
import argparse
import os
import sys
from . import dom as dom_mod
from .compose import LabelError, NonSlicible, compose, parse_svg, refine
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(prog="homemaker-compose")
ap.add_argument("svg")
ap.add_argument("boundary_dom")
ap.add_argument("-o", "--output")
ap.add_argument("--tol", type=float, default=0.15, help="cut/edge snapping tolerance")
ap.add_argument("--scale", type=float, default=1.0, help="SVG user-unit -> plan-unit scale")
ap.add_argument(
"--refine",
action="store_true",
help="solve division ratios against patterns.config next to the boundary dom",
)
args = ap.parse_args(argv)
boundary = dom_mod.load(args.boundary_dom)
storeys = parse_svg(args.svg, scale=args.scale)
try:
root = compose(boundary, storeys, tol=args.tol)
except (NonSlicible, LabelError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if args.refine:
refine(root, os.path.dirname(os.path.abspath(args.boundary_dom)))
out_path = args.output or (args.svg.rsplit(".", 1)[0] + ".dom")
dom_mod.dump(root, out_path)
print(out_path)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -159,7 +159,7 @@ def canonicalize_shares(root: Node) -> None:
leaf.share_type = None leaf.share_type = None
def _link(root: Node) -> None: def link(root: Node) -> None:
lvls = levels(root) lvls = levels(root)
for lvl in lvls: for lvl in lvls:
_link_subtree(lvl, None, "") # each level root is parent-less _link_subtree(lvl, None, "") # each level root is parent-less
@ -191,7 +191,7 @@ def load(path: str) -> Node:
with open(path) as fh: with open(path) as fh:
root = _parse(yaml.safe_load(fh)) root = _parse(yaml.safe_load(fh))
_link(root) link(root)
if root.wall_outer is None: if root.wall_outer is None:
root.wall_outer = 0.25 # Urb::Dom::Wall_Outer default root.wall_outer = 0.25 # Urb::Dom::Wall_Outer default
if root.wall_inner is None: if root.wall_inner is None:
@ -401,4 +401,4 @@ def merge_divided(root: Node) -> None:
``below`` / ``parent`` / ``position`` fields stay consistent. ``below`` / ``parent`` / ``position`` fields stay consistent.
""" """
_merge_node(root) _merge_node(root)
_link(root) link(root)

View file

@ -193,6 +193,6 @@ def decode(genome: Genome) -> dom.Node:
lvl.height = delta.height lvl.height = delta.height
prev.above = lvl prev.above = lvl
prev = lvl prev = lvl
dom._link(base) dom.link(base)
geometry.clear_cache() geometry.clear_cache()
return base return base

View file

@ -28,7 +28,7 @@ from . import dom
def _finalise(root: dom.Node) -> dom.Node: def _finalise(root: dom.Node) -> dom.Node:
from . import geometry from . import geometry
dom._link(root) dom.link(root)
geometry.clear_cache() geometry.clear_cache()
return root return root
@ -889,7 +889,7 @@ def unfold_shared_leaves(root: dom.Node, above: int = 1) -> int:
_grow_balanced(leaf, leaf.type, leaf.share) _grow_balanced(leaf, leaf.type, leaf.share)
grown.append(leaf) grown.append(leaf)
if grown: if grown:
dom._link(root) dom.link(root)
geometry.clear_cache() geometry.clear_cache()
for sub in grown: for sub in grown:
_size_subtree_equal(sub) _size_subtree_equal(sub)
@ -1220,7 +1220,7 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
# seeding interior light wells (default 1 peripheral O otherwise). # seeding interior light wells (default 1 peripheral O otherwise).
n_o = max(1, round(len(rooms) / outside_divisor)) if interior_outside else 1 n_o = max(1, round(len(rooms) / outside_divisor)) if interior_outside else 1
_grow_leaves(lvl, len(rooms) + n_o + n_circ, rng, balance=depth_balanced) _grow_leaves(lvl, len(rooms) + n_o + n_circ, rng, balance=depth_balanced)
dom._link(child) dom.link(child)
_assign_adjacency_aware(lvl, rooms, reqs, rng, _assign_adjacency_aware(lvl, rooms, reqs, rng,
interior_outside=interior_outside, n_outside=n_o, interior_outside=interior_outside, n_outside=n_o,
beam_width=construction_beam_width) beam_width=construction_beam_width)
@ -1238,7 +1238,7 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
# width/proportion. Topology and type assignment are unchanged. Link # width/proportion. Topology and type assignment are unchanged. Link
# first so upper-storey roots resolve geometry (the else branch above # first so upper-storey roots resolve geometry (the else branch above
# does not link, unlike the adjacency-aware branch). # does not link, unlike the adjacency-aware branch).
dom._link(child) dom.link(child)
leaf_co = _leaf_colocate_from_plan(lvl, colocate_plan, reqs) if multi_use else {} leaf_co = _leaf_colocate_from_plan(lvl, colocate_plan, reqs) if multi_use else {}
leaf_extra = {lf: reqs[co].size for lf, co in leaf_co.items() leaf_extra = {lf: reqs[co].size for lf, co in leaf_co.items()
if co in reqs and reqs[co].size > 0} if co in reqs and reqs[co].size > 0}
@ -1331,7 +1331,7 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
leaf.right = dom.Node(type=leaf.type) leaf.right = dom.Node(type=leaf.type)
leaf.type = None leaf.type = None
prev.above = dup prev.above = dup
dom._link(child) # link so the upper storey's geometry is computable dom.link(child) # link so the upper storey's geometry is computable
_assign_adjacency_aware( _assign_adjacency_aware(
dup, rooms, reqs, rng, dup, rooms, reqs, rng,
fixed_circ=[core_node] if core_node is not None else None, fixed_circ=[core_node] if core_node is not None else None,
@ -1368,7 +1368,7 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
# constructed upper storey's ratios are rewritten. (Cuts inherited from # constructed upper storey's ratios are rewritten. (Cuts inherited from
# the base via below-links are no-ops here — their geometry is fixed # the base via below-links are no-ops here — their geometry is fixed
# below — so this best-effort sizes the floor's own new divisions.) # below — so this best-effort sizes the floor's own new divisions.)
dom._link(child) dom.link(child)
leaf_co = _leaf_colocate_from_plan(dup, colocate_plan, reqs) if multi_use else {} leaf_co = _leaf_colocate_from_plan(dup, colocate_plan, reqs) if multi_use else {}
leaf_extra = {lf: reqs[co].size for lf, co in leaf_co.items() leaf_extra = {lf: reqs[co].size for lf, co in leaf_co.items()
if co in reqs and reqs[co].size > 0} if co in reqs and reqs[co].size > 0}
@ -1440,12 +1440,12 @@ def mutate_ruin_recreate(root: dom.Node, rng: np.random.Generator,
wing.division = None wing.division = None
wing.type = None wing.type = None
_grow_leaves(wing, max(1, n_new), rng, balance=True) _grow_leaves(wing, max(1, n_new), rng, balance=True)
dom._link(child) dom.link(child)
_assign_adjacency_aware( _assign_adjacency_aware(
lvl, rooms, reqs, rng, fixed_circ=border_circ or None, lvl, rooms, reqs, rng, fixed_circ=border_circ or None,
interior_outside=True, n_outside=n_o, scope=set(wing.leaves())) interior_outside=True, n_outside=n_o, scope=set(wing.leaves()))
dom._link(child) dom.link(child)
_size_divisions_from_targets(wing, reqs) _size_divisions_from_targets(wing, reqs)
return _finalise(child), ( return _finalise(child), (
@ -1518,7 +1518,7 @@ def predicted_shape_fails(root: dom.Node, reqs, fit) -> int:
``root`` is left untouched (a deep copy is laid out and scored). ``root`` is left untouched (a deep copy is laid out and scored).
""" """
child = copy.deepcopy(root) child = copy.deepcopy(root)
dom._link(child) dom.link(child)
for lvl in dom.levels(child): for lvl in dom.levels(child):
_size_divisions_from_targets(lvl, reqs) _size_divisions_from_targets(lvl, reqs)
_, fails = fit.score_with_fails(child) _, fails = fit.score_with_fails(child)

192
tests/test_compose.py Normal file
View file

@ -0,0 +1,192 @@
"""homemaker-py-2g7.1: plan->dom composer tests.
Synthetic fixtures only -- no real human trace exists yet (see DESIGN.md sec
37.x). These exercise the two acceptance-criteria halves: a slicible
partition round-trips to a scoring .dom, and a non-slicible one is reported
with the offending region rather than mis-parsed.
"""
from __future__ import annotations
import textwrap
import pytest
from homemaker_layout import dom, geometry
from homemaker_layout.compose import LabelError, NonSlicible, StoreyTrace, compose, parse_svg
BOUNDARY_YAML = textwrap.dedent(
"""\
node: [[0.0, 0.0], [10.0, 0.0], [10.0, 8.0], [0.0, 8.0]]
perimeter: {a: null, b: null, c: null, d: null}
height: 3.0
elevation: 0.0
wall_inner: 0.08
wall_outer: 0.25
rotation: 0
"""
)
# Plot is 10x8. Cut 1 (axis 0, vertical) at x=4 splits into a left column
# (x:0-4, full height) and a right column (x:4-10). Cut 2 (axis 1,
# horizontal) at y=5 splits the right column into a bottom room (y:0-5) and
# a top room (y:5-8) -- exercises both axes and depth-2 recursion.
GOOD_SVG = textwrap.dedent(
"""\
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">
<g inkscape:groupmode="layer" inkscape:label="storey-0">
<path d="M 4,0 L 4,8"/>
<path d="M 4,5 L 10,5"/>
<text x="2" y="4">cr1</text>
<text x="7" y="2.5">k1</text>
<text x="7" y="6.5">b1</text>
</g>
</svg>
"""
)
# Same partition, endpoints perturbed by < 0.15 (default tol) to exercise
# snapping: a hand-drawn line that overlaps/undershoots slightly.
SLOPPY_SVG = textwrap.dedent(
"""\
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">
<g inkscape:groupmode="layer" inkscape:label="storey-0">
<path d="M 4.06,-0.05 L 3.95,8.07"/>
<path d="M 3.96,5.04 L 10.06,4.93"/>
<text x="2" y="4">cr1</text>
<text x="7" y="2.5">k1</text>
<text x="7" y="6.5">b1</text>
</g>
</svg>
"""
)
# One dangling interior line that touches neither pair of opposite edges --
# not a guillotine cut of the plot, and there is no other line to try.
NON_SLICIBLE_SVG = textwrap.dedent(
"""\
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">
<g inkscape:groupmode="layer" inkscape:label="storey-0">
<path d="M 3,3 L 7,3"/>
</g>
</svg>
"""
)
def _write(tmp_path, name, content):
p = tmp_path / name
p.write_text(content)
return p
def test_composes_synthetic_partition_and_scores(tmp_path):
boundary_path = _write(tmp_path, "boundary.dom", BOUNDARY_YAML)
svg_path = _write(tmp_path, "plan.svg", GOOD_SVG)
boundary = dom.load(str(boundary_path))
storeys = parse_svg(str(svg_path))
root = compose(boundary, storeys)
leaves = root.leaves()
assert sorted(leaf.type for leaf in leaves) == ["b1", "cr1", "k1"]
# round-trips through the .dom text format
out_path = tmp_path / "plan.dom"
dom.dump(root, str(out_path))
reloaded = dom.load(str(out_path))
reloaded_types = sorted(leaf.type for leaf in reloaded.leaves())
assert reloaded_types == ["b1", "cr1", "k1"]
# geometry is sane: leaf areas sum to the (wall-inset) plot area
total = sum(geometry.area(leaf) for leaf in reloaded.leaves())
assert total == pytest.approx(geometry.area(reloaded), rel=1e-9)
# scores cleanly through the native fitness engine (no config on disk:
# unconstrained score, just confirms it runs end-to-end without raising)
from homemaker_layout.fitness import Fitness
fitness = Fitness({}, {})
score, failures = fitness.score_with_fails(reloaded)
assert isinstance(score, float)
assert len(failures) > 0 # unconstrained rooms + no programme: expected fails
def test_snaps_sloppy_hand_traced_lines(tmp_path):
boundary_path = _write(tmp_path, "boundary.dom", BOUNDARY_YAML)
svg_path = _write(tmp_path, "plan.svg", SLOPPY_SVG)
boundary = dom.load(str(boundary_path))
storeys = parse_svg(str(svg_path))
root = compose(boundary, storeys, tol=0.15)
assert sorted(leaf.type for leaf in root.leaves()) == ["b1", "cr1", "k1"]
# a tighter tolerance than the sketch's slop should fail to find the cuts
boundary2 = dom.load(str(boundary_path))
with pytest.raises((NonSlicible, LabelError)):
compose(boundary2, storeys, tol=0.01)
def test_non_slicible_region_is_reported(tmp_path):
boundary_path = _write(tmp_path, "boundary.dom", BOUNDARY_YAML)
svg_path = _write(tmp_path, "plan.svg", NON_SLICIBLE_SVG)
boundary = dom.load(str(boundary_path))
storeys = parse_svg(str(svg_path))
with pytest.raises(NonSlicible) as excinfo:
compose(boundary, storeys)
exc = excinfo.value
assert exc.storey == 0
# names the offending region: should be the whole plot (wall-inset
# corners), since the dangling line doesn't localise to a sub-quad
assert len(exc.corners) == 4
assert "region around" in str(exc)
def test_label_count_mismatch_is_reported(tmp_path):
svg = textwrap.dedent(
"""\
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">
<g inkscape:groupmode="layer" inkscape:label="storey-0">
</g>
</svg>
"""
)
boundary_path = _write(tmp_path, "boundary.dom", BOUNDARY_YAML)
svg_path = _write(tmp_path, "plan.svg", svg)
boundary = dom.load(str(boundary_path))
storeys = parse_svg(str(svg_path))
with pytest.raises(LabelError) as excinfo:
compose(boundary, storeys)
assert excinfo.value.storey == 0
assert excinfo.value.labels == []
def test_parse_svg_rejects_missing_storey_layers(tmp_path):
svg = textwrap.dedent(
"""\
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">
<g inkscape:groupmode="layer" inkscape:label="not-a-storey"/>
</svg>
"""
)
svg_path = _write(tmp_path, "plan.svg", svg)
with pytest.raises(ValueError):
parse_svg(str(svg_path))
def test_compose_rejects_storey_count_mismatch(tmp_path):
boundary_path = _write(tmp_path, "boundary.dom", BOUNDARY_YAML)
boundary = dom.load(str(boundary_path))
with pytest.raises(ValueError):
compose(boundary, [StoreyTrace(), StoreyTrace()])

View file

@ -303,7 +303,7 @@ def _shared_best_result() -> driver.SearchResult:
rotation=0, division=[0.5, 0.5]) rotation=0, division=[0.5, 0.5])
root.left = dom.Node(type="n", share=3, share_type="n") root.left = dom.Node(type="n", share=3, share_type="n")
root.right = dom.Node(type="C") root.right = dom.Node(type="C")
dom._link(root) dom.link(root)
best = driver.Individual(root=root, fitness=1e-5, n_fails=3, ratios={}, best = driver.Individual(root=root, fitness=1e-5, n_fails=3, ratios={},
lineage="construct/0") lineage="construct/0")
r = driver.SearchResult(best=best, population=[best], n_evals=1000, r = driver.SearchResult(best=best, population=[best], n_evals=1000,

View file

@ -194,7 +194,7 @@ def test_check_vertical_connectivity_honours_co_type_leaf():
lower = _leaf_tree("below") lower = _leaf_tree("below")
upper = _leaf_tree("x", co="y") upper = _leaf_tree("x", co="y")
lower.above = upper lower.above = upper
dom._link(lower) dom.link(lower)
reqs = {"x": _req("x", 10.0, requires_below="below"), reqs = {"x": _req("x", 10.0, requires_below="below"),
"y": _req("y", 9.0, requires_below="below")} "y": _req("y", 9.0, requires_below="below")}

View file

@ -57,7 +57,7 @@ def test_level_add_delete():
def test_relink_clears_stale_below_after_base_undivide(): def test_relink_clears_stale_below_after_base_undivide():
# regression: dom._link must clear below-links whose path vanished, or # regression: dom.link must clear below-links whose path vanished, or
# geometry on the mutated tree dereferences orphaned nodes # geometry on the mutated tree dereferences orphaned nodes
from homemaker_layout import geometry from homemaker_layout import geometry
@ -74,7 +74,7 @@ def test_relink_clears_stale_below_after_base_undivide():
target.division = None target.division = None
target.left = target.right = None target.left = target.right = None
target.type = "l1" target.type = "l1"
dom._link(child) dom.link(child)
geometry.clear_cache() geometry.clear_cache()
for lvl in dom.levels(child): for lvl in dom.levels(child):
for leaf in lvl.leaves(): for leaf in lvl.leaves():
@ -88,7 +88,7 @@ def test_all_mutations_survive_undivided_tree():
# an undivided plot (init.dom-style seed) must never crash an operator # an undivided plot (init.dom-style seed) must never crash an operator
bare = dom.Node(type="O", node=[[0, 0], [10, 0], [10, 8], [0, 8]], bare = dom.Node(type="O", node=[[0, 0], [10, 0], [10, 8], [0, 8]],
height=2.7, wall_outer=0.25, wall_inner=0.08) height=2.7, wall_outer=0.25, wall_inner=0.08)
dom._link(bare) dom.link(bare)
for name, op in operators.MUTATIONS.items(): for name, op in operators.MUTATIONS.items():
for seed in range(3): for seed in range(3):
child, desc = op(bare, np.random.default_rng(seed), TYPES) child, desc = op(bare, np.random.default_rng(seed), TYPES)
@ -108,7 +108,7 @@ def test_unfold_shared_leaves_materialises_deficit():
rotation=0, division=[0.5, 0.5]) rotation=0, division=[0.5, 0.5])
root.left = dom.Node(type="n", share=3, share_type="n") # 3-room shared leaf root.left = dom.Node(type="n", share=3, share_type="n") # 3-room shared leaf
root.right = dom.Node(type="C") # untouched root.right = dom.Node(type="C") # untouched
dom._link(root) dom.link(root)
geometry.clear_cache() geometry.clear_cache()
area_before = geometry.area(root) area_before = geometry.area(root)
@ -136,7 +136,7 @@ def test_unfold_shared_leaves_above_grain_cap():
rotation=0, division=[0.5, 0.5]) rotation=0, division=[0.5, 0.5])
root.left = dom.Node(type="n", share=4, share_type="n") # exceeds cap 3 root.left = dom.Node(type="n", share=4, share_type="n") # exceeds cap 3
root.right = dom.Node(type="m", share=3, share_type="m") # at cap 3, kept root.right = dom.Node(type="m", share=3, share_type="m") # at cap 3, kept
dom._link(root) dom.link(root)
geometry.clear_cache() geometry.clear_cache()
created = operators.unfold_shared_leaves(root, above=3) created = operators.unfold_shared_leaves(root, above=3)
@ -303,7 +303,7 @@ def test_interior_outside_seeds_landlocked_wells_and_scales_count():
def _outside_exposure(root): def _outside_exposure(root):
geometry.clear_cache() geometry.clear_cache()
dom._link(root) dom.link(root)
exps, n_o = [], 0 exps, n_o = [], 0
for lvl in dom.levels(root): for lvl in dom.levels(root):
for leaf in lvl.leaves(): for leaf in lvl.leaves():
@ -541,7 +541,7 @@ def _same_axis_chain() -> dom.Node:
root.left.left = dom.Node(type="A") root.left.left = dom.Node(type="A")
root.left.right = dom.Node(type="B") root.left.right = dom.Node(type="B")
root.right = dom.Node(type="C") root.right = dom.Node(type="C")
dom._link(root) dom.link(root)
return root return root
@ -568,7 +568,7 @@ def test_reassociate_noop_on_perpendicular_cuts():
root.left.left = dom.Node(type="A") root.left.left = dom.Node(type="A")
root.left.right = dom.Node(type="B") root.left.right = dom.Node(type="B")
root.right = dom.Node(type="C") root.right = dom.Node(type="C")
dom._link(root) dom.link(root)
_, desc = operators.mutate_reassociate(root, np.random.default_rng(0), TYPES) _, desc = operators.mutate_reassociate(root, np.random.default_rng(0), TYPES)
assert desc == "reassociate noop" assert desc == "reassociate noop"
@ -719,7 +719,7 @@ def _row_of_three(mid_type: str) -> dom.Node:
root.right = dom.Node(rotation=0, division=[0.5, 0.5]) root.right = dom.Node(rotation=0, division=[0.5, 0.5])
root.right.left = dom.Node(type=mid_type) root.right.left = dom.Node(type=mid_type)
root.right.right = dom.Node(type="C") root.right.right = dom.Node(type="C")
dom._link(root) dom.link(root)
return root return root
@ -736,7 +736,7 @@ def _diamond(top_right_type: str) -> dom.Node:
root.right = dom.Node(rotation=1, division=[0.5, 0.5]) root.right = dom.Node(rotation=1, division=[0.5, 0.5])
root.right.left = dom.Node(type=top_right_type) root.right.left = dom.Node(type=top_right_type)
root.right.right = dom.Node(type="C") root.right.right = dom.Node(type="C")
dom._link(root) dom.link(root)
return root return root

View file

@ -79,7 +79,7 @@ def test_substrate_readiness_range_and_core(reqs):
for leaf in dom.levels(base)[0].leaves(): for leaf in dom.levels(base)[0].leaves():
if leaf.type and leaf.type[0].lower() == "c": if leaf.type and leaf.type[0].lower() == "c":
leaf.type = "m" leaf.type = "m"
dom._link(base) dom.link(base)
r_nocore = graph.substrate_readiness(base, reqs, n) r_nocore = graph.substrate_readiness(base, reqs, n)
assert r_nocore < r # losing the reserved core lowers readiness assert r_nocore < r # losing the reserved core lowers readiness