Compare commits

...

2 commits

Author SHA1 Message Date
eb1d2fc7f0 bd: sync issues.jsonl export after 2g7.4 correction comment
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSwQwpEaHFBkeVSDDWd75S
2026-08-03 07:21:27 +01:00
d148f219c8 homemaker-py-2g7.4: fix shape-curve DP to be rotation-invariant
User review caught a real gap: the DP approximated each quad's (w,h)
via its axis-aligned bounding box in global x/y, correct only because
harbor-house-l0's plot happens to be near-parallel to its own axes
(~7.5% area error). A real building's orthogonal walls need not align
to the survey/CRS axes at all -- confirmed by rotating the plot 45deg,
where the old bbox error jumped to 102% (up to 2x for a rotated square).

Fixed in two steps: (1) measure (w,h) from edge lengths
((edge0+edge2)/2, (edge1+edge3)/2, the geometry.aspect() pairing)
instead of global bbox -- rotation-invariant by construction. (2) this
alone regressed accuracy (99.0% -> 95.5%) because a child's own
rotation parity determines whether its local edge0/edge2 pair aligns
with its parent's edge0/edge2 or edge1/edge3 -- not a matter of degree
to measure empirically (as attempted first) but an exact algebraic
identity (verified float-exact: left.w + right.h == parent.w whenever
left.rotation is even and right.rotation is odd). _child_contrib now
applies this directly, replacing the empirical _orientation/
annotate_orientations machinery entirely -- simpler and correct.

Re-validated: 99.0% agreement on harbor-house-l0 unrotated (back to
matching the original result, same 2 residual mismatches, 0 false
negatives), 100% agreement at 97x speedup on the same plot rotated
45deg (new, via validate_shapecurve.py's rotated_plot_dir helper).
DESIGN.md §37.2 updated with the full correction history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSwQwpEaHFBkeVSDDWd75S
2026-08-03 07:06:48 +01:00
4 changed files with 291 additions and 179 deletions

View file

@ -1,4 +1,4 @@
{"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":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"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.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}
@ -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.5","title":"Experiment: compactness-aware cuts (minimize leaf perimeter/area)","description":"Attacks the #1 factor, crinkliness (346) — a per-leaf perimeter/area property DISTINCT from proportion (aspect ratio). Proportion-aware seeding (leu.2) sizes splits but does not bias toward balanced, square-ish subdivision. Add a KD-tree-style 'keep both children compact' cut rule (prefer the cut orientation/position that minimises summed child perimeter/area) in construction.\n\nCONDITIONAL on Diagnostic A: if A shows per-leaf shape-fail is FLAT across densities (floor intrinsic to slicing density), better cuts at the same leaf count will not pay → this should be closed wont-fix in favour of leaf-sharing. Only build if A shows shape-fail RISES with density. A/B vs §12.2 baseline, seeds 0/1/2, 20000 evals, staged, default-OFF. Record DESIGN.md §13.5.","notes":"DEPRIORITISED by erc.1 verdict (§13.1): per-leaf shape-fail flat vs slicing density and cuts already squarest (_size_divisions_from_targets picks squarest rotation) yet still ~1.8 fails/leaf =\u003e little compactness headroom at fixed leaf count. Floor is intrinsic to leaf COUNT, not cut quality. Revisit only if leaf-sharing (erc.3) underdelivers.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-22T23:16:21Z","created_by":"Bruno Postle","updated_at":"2026-06-28T13:22:17Z","closed_at":"2026-06-28T13:22:17Z","close_reason":"wont-fix (DESIGN §13.7): Diag A (§13.1) showed the floor is intrinsic to leaf COUNT not cut quality; revisit condition was 'only if leaf-sharing underdelivers' but leaf-sharing OVER-delivered (32…39%, §13.3). Condition unmet.","dependencies":[{"issue_id":"homemaker-py-erc.5","depends_on_id":"homemaker-py-erc","type":"parent-child","created_at":"2026-06-23T00:16:21Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-erc.5","depends_on_id":"homemaker-py-erc.1","type":"blocks","created_at":"2026-06-23T00:16:43Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"homemaker-py-2g5","title":"Rebuild occlusion/daylight/sun subsystem in Python (post-Phase-5, after optimisation fully native)","description":"DESIGN.md §6 port scope — a whole subsystem, not a term. quality_daylight (Leaf.pm:281-296) needs Urb::Misc::Sun + Urb::Field::Occlusion (+CIESky); quality_uncrinkliness also takes the occlusion object. Indoor spaces return 1 for daylight; cost is outdoor spaces + crinkliness. Port Sun_horizontal (262980-minute normalisation) and the occlusion wall set from Dom-\u003eWalls.","acceptance_criteria":"Daylight and crinkliness factors match Perl (float tolerance) across the corpus, including multi-storey cases","notes":"Re-scoped 2026-06-12: occlusion disabled in the Urb oracle instead of ported (see homemaker-py-gp2). Native fitness ships with simple crinkliness (illumination factor = 1, in homemaker-py-gnw). This issue is now the eventual Python occlusion rebuild, only after optimisation works entirely in Python. Restores outdoor-daylight and shaded-wall selection pressure.\nReframed 2026-06-17: orthogonal to epic homemaker-py-c4c. This is fitness FIDELITY (restoring daylight + shaded-wall selection pressure to match Perl), not search CAPABILITY — it changes what 'good' means, not the search's ability to find good. It will NOT improve final designs in the sense currently sought. Stays P4, deferred until the topology-search-quality epic lands and optimisation is fully native.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:25Z","created_by":"Bruno Postle","updated_at":"2026-06-17T19:14:48Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"memory","key":"ld2-13-6-interior-o-seed-diagnostic-all","value":"ld2/§13.6 interior-O seed diagnostic: ALL crinkliness fails in the constructed bal+share seed are UNDER-exposed (crink\u003c0.62, landlocked rooms with no facade + no uncovered-O neighbour) — zero over-exposed sliver fails. So the erc crinkliness residual is genuine under-daylighting, validating the interior light-well premise. Default outside_divisor=6 was too sparse (null: harbor 147-\u003e142, crinkliness even rose). odiv=3 is the seed-optimal joint setting: harbor seed fails 147-\u003e129 (-18), maple 219-\u003e206 (-14), landlocked fails drop, at cost of more leaves (harbor +4, maple +8). Because it ADDS leaves it carries the §13.4 wash-out risk; A/B to convergence pending."}
{"_type":"memory","key":"strategy-decision-2026-06-12-bruno-occlusion-daylight","value":"Strategy decision 2026-06-12 (Bruno): occlusion/daylight is ORTHOGONAL to building a scalable optimiser. Disable it in Urb (env flag, homemaker-py-gp2) rather than port it; native fitness uses simple crinkliness (illumination factor = 1); rebuild occlusion in Python only after optimisation is fully native (homemaker-py-2g5, now P4). Consequence: all scores change when the flag flips — re-baseline corpus/.score, DESIGN \\$4.5 gains, gate bars at one clean boundary AFTER homemaker-py-1p0 closes; Phase-2 urb-evolve benchmark must run with the same flag."}
{"_type":"memory","key":"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":"deceptive-valleys-in-topology-search-when-every-single","value":"Deceptive valleys in topology search: when every single-step mutation from a target state passes through a high-fail intermediary (e.g. level_fix displaces a room into 5+ new fails), a compound operator that atomically applies two coordinated changes can escape. Design compound operators to land on the low-fail state directly, bypassing the deceptive gradient. Programme-house example: level_compound_fix atomically moves the level-constrained room AND re-inserts the displaced room adjacent to C in one step (operators.py, 2026-06-14)."}
{"_type":"memory","key":"adjacency-in-binary-slicing-tree-is-structural-not","value":"Adjacency in binary slicing tree is structural, not geometric: the inner-loop NM cannot fix topological adjacency failures. Two paths exist: (1) tree-sibling adjacency — a node is adjacent to its sibling in the tree; (2) cross-zone geometric adjacency — leaves from different subtrees that happen to share a boundary. Staircase/adjacency fails require a topology mutation that changes which nodes are siblings or which zones touch. This was proved empirically on programme-house: staircase fail from rot=0 layout could not be fixed by NM but was fixed by level_retype creating a two-C topology (2026-06-14/15)."}
{"_type":"memory","key":"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":"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":"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":"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":"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":"homemaker-py-3l6-fix-leaf-sharing-evolve-runs","value":"homemaker-py-3l6 fix: leaf-sharing evolve runs now auto-finish before write via driver.polish_finish — unfold_shared_leaves() then a warm-started leaf_sharing=False polish search (--polish-budget, default budget//2). Makes the written .dom honest under canonical homemaker-fitness (internal==canonical when leaf_sharing off). Interrupt path forces polish_budget=0 (unfold+rescore only). This is yaa's unfold-then-polish, made automatic; Schedule B annealing is still kpu."}
{"_type":"memory","key":"island-model-psk-14-is-a-null-priming","value":"Island model (psk, §14) is a NULL: priming a population from N converged independent elites + crossover-heavy migration does not beat best-of-N at equal total budget (maple island 124 vs control 116). The child_probe instrument shows WHY: area-matched crossover across independently-converged elites almost never synthesizes (1-3 of ~64 children beat the better parent, max drop 2-5) because the slicing encoding is non-canonical (9gp), so splices are disruptive not combinatorial. Search-machinery null #3 after graded-objective and niching/restarts; residual stays geometry/shape-bound."}
{"_type":"memory","key":"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":"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":"programme-house-optimisation-result-2026-06-14-15","value":"Programme-house optimisation result (2026-06-14/15): best achievable is 1 fail (l1 wrong level, score ~0.005). 0 fails is geometrically impossible: l1 (min 27m²) must occupy ll (~23m²) at level 0, which eliminates the t3-adj-C provider; dividing ll into lll(l1)+llr(C) gives llr proportion ~6:1 (fails). Python memetic optimizer achieves 1 fail in 50k evals vs Perl optimiser's 2-3 fails. Winning topology: TWO C nodes at level 0 — ll(C) for t3-adj-C via geometric contact, rl(C) for staircase via tree-sibling adjacency to rrr(O). Best .dom: scratch/from-warmstart-fixed.dom and scratch/from-compound3-fixed.dom."}
{"_type":"memory","key":"proportion-aware-constructive-seeding-leu-2-12-2","value":"Proportion-aware constructive seeding (leu.2/§12.2): sizing seed cuts from target AREAS only regresses (thin slivers wreck aspect); you must ALSO pick each cut's rotation for child squareness. It is a convergence ACCELERATOR via a deeper local optimum around the constructed topology: wins where that topology is roughly right and budget is scarce (harbor -13%, maple -10% at 20k evals) but DELAYS small programmes where the seed must be restructured by undivide (programme-house regresses at fixed budget, yet reaches the floor given budget - speed, not asymptote). Default-on. Also: n_storeys must honour storey_minimum, not just level: keys (programme-house storey_minimum:2, all rooms level:0 - was seeded 1 storey short; cq1)."}
{"_type":"memory","key":"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":"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":"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":"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":"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":"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":"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":"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)."}

157
DESIGN.md
View file

@ -4021,28 +4021,76 @@ formulas by construction, not reimplemented magic numbers: same `conf`/
starting with 'c' or 's'/'o' hits the circulation/outside branch, not its own
programme params" quirk — confirmed this is existing product behaviour, not a
bug, by reading `get_space_params`/`quality_size` together). Regions compose
bottom-up through the slicing tree: a node's cut is either a "width-split"
(children share height, widths sum) or "height-split" (heights sum), measured
once from the actual baseline geometry (`_orientation`) rather than derived
symbolically from `rotation` — robust to any rotation convention. Composition
is done on a shared log-spaced grid (interval-sum + a numpy-vectorised
inversion, `_invert`); leaf curves themselves are exact closed forms, so all
discretisation error is confined to internal-node composition. A top-down
`realise()` back-substitution converts a feasible root point into actual
`division` ratios, so the DP's output is a real, scoreable `.dom` tree, not
just a yes/no.
bottom-up through the slicing tree: a node's cut ALWAYS sums its two
children's contributions into the node's own "w" (`edge0+edge2`) dimension,
with "h" (`edge1+edge3`) the shared/cross dimension — a fixed convention of
`geometry.py`'s division formula (`coord_a`/`coord_b` always interpolate
between edge(0,1) and edge(3,2)), not a per-node choice. The only variable is
which of a CHILD's own (w, h) plays which role relative to its parent, an
EXACT function of that child's `rotation` parity (`_child_contrib` — see the
correction below). Composition runs on a shared log-spaced grid (interval-sum
+ a numpy-vectorised inversion, `_invert`); leaf curves themselves are exact
closed forms, so all discretisation error is confined to internal-node
composition. A top-down `realise()` back-substitution converts a feasible
root point into actual `division` ratios, so the DP's output is a real,
scoreable `.dom` tree, not just a yes/no.
**Explicit scope (per the plan's own caveats).** Only size/width/proportion
is modelled — crinkliness/adjacency/access/level connectivity are graph
terms, out of scope by design. Every quad is approximated by its axis-aligned
bounding box (exact only for a true rectangle). `leaf_sharing`/`co_type`
target-adjustment is not modelled (harbor-house-l0's programme doesn't
exercise either).
terms, out of scope by design. Every quad is approximated by a rectangle with
edge-length-derived (w, h) — exact only for a true rectangle/parallelogram
(see the rotation-invariance correction below for why this is edge lengths,
not a bounding box). `leaf_sharing`/`co_type` target-adjustment is not
modelled (harbor-house-l0's programme doesn't exercise either).
**Correction 1 (caught in review): bounding-box (w, h) is not rotation-invariant.**
The first version measured each quad's (w, h) from its axis-aligned bounding
box in global x/y — silently correct only because harbor-house-l0's plot
happens to be near-parallel to its own x/y axes (~7.5% bbox-area error, see
below). Flagged in review: Urb's Perl ancestor (`Urb::Quad::Straighten`/
`Straighten_Root`) explicitly keeps internal walls mutually orthogonal but
NEVER assumes them axis-aligned — `Straighten()` aligns a division parallel/
perpendicular to its PARENT's own division line, not to global x/y, so a
real building's walls can legitimately run at any angle (45° tried explicitly
below) to the survey/CRS axes the plot's `node:` corners are recorded in.
Confirmed by rotating harbor-house-l0's plot 45° about its centroid: bbox
area error jumped from 7.5% to **102%** (a rotated square's bbox is up to 2x
its true area). Fix: `_dims` measures (w, h) from `(edge0+edge2)/2` and
`(edge1+edge3)/2` — the same pairing `geometry.aspect()` already uses —
which depends only on the quad's own edge lengths, never on global
coordinates. This port's equal-offset division convention already gives the
local-orthogonality property Urb's `Straighten()` provides explicitly (no
such pass exists or is needed in `operators.py`), so this is a safe
substitution, not a new modelling assumption.
**Correction 2 (caught in review, and this one REGRESSED accuracy before
being fixed properly): which dimension sums is not a matter of degree.**
Switching to edge-length (w, h) alone was not sufficient — a first attempt
kept the "measure orientation empirically, per node" structure from the bbox
version (comparing children's summed dims against the parent's under two
hypotheses, picking whichever fit better) and this DROPPED agreement on the
untouched harbor-house-l0 benchmark from 99.0% to **95.5%**, with a false
negative appearing for the first time (previously zero). Root cause:
`geometry.coordinate()` applies a node's OWN `rotation` field even when
reading corners it inherited from its parent — a node with odd rotation has
its local edge0/edge2 pair correspond to its PARENT's edge1/edge3 pair
instead (rotation parity selects between a quad's two possible opposite-edge
pairings; `operators.mutate_divide` randomises this on every newly-divided
node, so it's common, not an edge case). This is not something to measure and
approximate — it's an exact algebraic identity: verified numerically
(float-exact, `29.533730484465025 == 29.533730484465025`) that
`left.w + right.h == parent.w` whenever `left.rotation` is even and
`right.rotation` is odd, independent of skew or global orientation.
`_child_contrib(curve, rotation)` applies this directly (`curve.w_of_h` for
even rotation, `curve.h_of_w` for odd) — no geometry measurement, no
baseline-ratio pass, no heuristic threshold, and the empirical `_orientation`/
`annotate_orientations` machinery from both prior versions was deleted
entirely (simpler code, not just more correct).
**Validation** (`experiments/validate_shapecurve.py`, harbor-house-l0, 200
`driver.random_topology` topologies, 2-14 leaves, seed 12345): compared
against NM search **minimising shape-fail count directly** (`ShapeFailEvaluator`,
budget 100), not `innerloop.optimise`'s full aggregate objective — the first
budget 100), not `innerloop.optimise`'s full aggregate objective — an earlier
version of this harness used the full objective and found spurious
"disagreements" where the DP's own realised point independently verified at
**zero** shape fails but NM's full-objective search had wandered away from it,
@ -4051,43 +4099,52 @@ penalty swamps the objective and NM has no pressure to preserve
shape-feasibility specifically. Minimising shape-fail count alone is the
correct apples-to-apples comparison against what the DP claims to solve.
| metric | result | target |
| metric | harbor-house-l0 (unrotated) | harbor-house-l0 rotated 45° |
|---|---|---|
| agreement | 198/200 = **99.0%** | >= 95% |
| false positives (DP feasible, NM can't reach 0) | 2 | — |
| false negatives (DP infeasible, NM reaches 0 anyway) | **0** | — |
| speedup (grid_n=150, vs 100-eval NM) | **93.6x** | >= 50x |
| speedup (grid_n=300) | 42.7x | — |
| plot-level bbox area error | measured **+7.5%** overestimate | quantified |
| agreement | 198/200 = **99.0%** (target >= 95%) | 100/100 = **100.0%** |
| false positives (DP feasible, NM can't reach 0) | 2 | 0 |
| false negatives (DP infeasible, NM reaches 0 anyway) | **0** | 0 |
| speedup (grid_n=150, vs 100-eval NM) | **97.2x** (target >= 50x) | 97.1x |
| plot-level (w,h)-approximation area error | **+7.5%** (bbox, pre-fix) / ~0.1% (edge-length, post-fix) | 102% (bbox, pre-fix) / ~0.1% (edge-length, post-fix) |
Zero false negatives across 200 topologies: the DP never wrongly rejects a
topology NM finds feasible — the safe direction for a pre-filter (worst case
it fails to prune, never wrongly prunes a viable topology). grid_n=150 vs 300
gave **identical** agreement (99.0%, the same 2 mismatches) at 2.2x the
speedup — internal-node grid resolution has headroom below 300 with no
measured accuracy cost on this benchmark; `_invert`'s pure-Python O(N²)
double loop was ~70% of DP wall-clock before vectorising with numpy
(profiled: 170ms → 40ms/topology at grid_n=300 from that change alone).
The unrotated-plot numbers are BACK to matching the original (pre-Correction-2)
99.0%/0-false-negative result exactly — same 2 mismatches, same seeds
(`623465425`/`1523713848`) — confirming Correction 2 fixed the regression it
introduced without disturbing the genuine, separately-diagnosed residual
error below. The 45°-rotated run (`python experiments/validate_shapecurve.py
100 100 150 45` -- same protocol, `n=100` for wall-clock, the plot's `node:`
corners rotated 45° about their centroid into a scratch copy via
`rotated_plot_dir`) is the direct, reproducible test of the concern that
motivated Correction 1: 100% agreement, confirming the fix generalises and
isn't overfit to harbor-house-l0's near-axis-aligned plot. Zero false
negatives in both: the DP never wrongly rejects a topology NM finds feasible
— the safe direction for a pre-filter (worst case it fails to prune, never
wrongly prunes a viable topology). `_invert`'s pure-Python O(N²) double loop
was ~70% of DP wall-clock before vectorising with numpy (profiled: 170ms →
40ms/topology at grid_n=300 from that change alone; grid_n=150 is the
shipped default, no measured accuracy cost vs. 300 on this benchmark).
**Approximation error, root-caused.** Both false positives were traced to the
bounding-box approximation, not a DP logic bug: the DP's own realised point
for both cases had one leaf whose bbox-approximated area (e.g. 29.54 m²,
comfortably inside `[27.12, 52.88]`) was a **real skewed quad** whose true
`geometry.area` (26.90 m²) fell just *below* the true lower bound — a bbox
overestimate of the same ~8-12% magnitude as the plot-level +7.5% figure
above (harbor-house-l0's plot is a near-rectangular trapezoid, not a true
rectangle). Every mismatch occurred within one bbox-error-width of a boundary
— exactly the failure mode the plan's caveat predicted ("equal-offset
skew-quad geometry means DP areas are approximate — measure the approximation
error on real plots first").
**Remaining approximation error, root-caused (unchanged by Corrections 1/2 —
a different, smaller error source).** Both unrotated false positives trace to
the rectangle-vs-true-skewed-quad approximation itself (§37.2's plan-flagged
"equal-offset skew-quad geometry" caveat), not to global rotation or to
composition: the DP's own realised point for both cases had one leaf whose
edge-length-approximated area was comfortably inside its feasible bound, but
whose true `geometry.area` (a real, slightly non-parallelogram quad) fell
just below the true lower bound — an ~8-12% approximation gap, the same
magnitude as harbor-house-l0's own plot-level residual skew. This is a
strictly smaller, already-anticipated error source, distinct from the two
corrections above (which were about measuring w/h and composing them
correctly, not about the rectangle-vs-skew-quad approximation itself).
**ACCEPTANCE: PASS** — all three criteria cleared (agreement, speedup,
quantified approximation error). **Not done in this session** (follow-on,
new bead needed before this can replace `operators.predicted_shape_fails` in
`driver.py`'s real pre-filter path): wiring the DP into `driver._evaluate`/
`innerloop.optimise` as an actual pre-filter + NM warm-start, multi-storey
(`below`-link) support, `leaf_sharing`/`co_type` modelling, and a true
skew-quad (non-bbox) leaf region to remove the measured approximation-error
source rather than just quantify it. `experiments/shapecurve_spike.py` is
kept as a reference/prototype (the §34 `autodiff_spike.py` precedent), not
wired into `innerloop.py`.
quantified approximation error), on both the original and the rotated plot.
**Not done in this session** (follow-on, new bead needed before this can
replace `operators.predicted_shape_fails` in `driver.py`'s real pre-filter
path): wiring the DP into `driver._evaluate`/`innerloop.optimise` as an
actual pre-filter + NM warm-start, multi-storey (`below`-link) support,
`leaf_sharing`/`co_type` modelling, and a true skew-quad (non-rectangle) leaf
region to remove the remaining ~8-12% approximation-error source rather than
just quantify it. `experiments/shapecurve_spike.py` is kept as a reference/
prototype (the §34 `autodiff_spike.py` precedent), not wired into
`innerloop.py`.

View file

@ -9,22 +9,33 @@ height) region is bounded by an area hyperbola (``quality_size``), a min-width
line (``quality_width``), and an aspect-ratio wedge (``quality_proportion``) --
all three are FAIL_THRESHOLD-inversions of the Gaussian/clipped-Gaussian
factors in ``fitness.py`` (see ``leaf_constraints`` below). These per-leaf
regions compose bottom-up through the slicing tree: a "width-split" node
(children share height, widths sum) or "height-split" node (children share
width, heights sum) -- see ``_orientation``.
regions compose bottom-up through the slicing tree: a node's cut ALWAYS sums
its two children's contributions into the node's own "w" (edge0+edge2)
dimension, with "h" (edge1+edge3) the shared/cross dimension -- a fixed
convention of ``geometry.py``'s division formula, no per-node ambiguity.
The only variable is which of a CHILD's own (w, h) plays which role, an
EXACT function of that child's ``rotation`` parity -- see ``_child_contrib``.
Approximations made explicit (the plan's caveats, DESIGN.md §37 point 2):
* Every quad (leaf or internal) is approximated by its axis-aligned
bounding-box (w, h) -- exact only for a true rectangle; harbor-house-l0's
plot is a near-rectangular trapezoid (DESIGN.md says "harbor plot is a
near-rect quad"), so this is the intended first target, not a general
solution for skew quads.
* A node's cut orientation (does it split width or height?) is measured
once from the ACTUAL geometry at ratio=0.5 baseline, not derived from
``rotation`` symbolically -- robust to any rotation convention, but a
property of the *frozen topology*, computed once, not re-derived by the
DP itself.
* Every quad (leaf or internal) is approximated by a rectangle with the
same edge-length-derived (w, h) as ``geometry.aspect`` uses --
``(edge0+edge2)/2`` and ``(edge1+edge3)/2`` -- exact only for a true
rectangle/parallelogram; harbor-house-l0's plot is a near-rectangular
trapezoid (DESIGN.md says "harbor plot is a near-rect quad"), so this is
the intended first target, not a general solution for skew quads. This
is deliberately NOT the quad's axis-aligned bounding box in global x/y --
an earlier version used that and was wrong for any quad whose (locally
orthogonal, per Urb's Straighten lineage -- see ``_dims``) walls aren't
near-parallel to the plot's global x/y axes; edge lengths are
rotation-invariant by construction.
* Composition itself (which of a child's local w/h sums into its parent's
w) is NOT approximated or measured -- an earlier version measured it
empirically per node (comparing children's summed dims against the
parent's) and that REGRESSED accuracy (99.0% -> 95.5% on the 200-
topology validation, with a spurious false negative). It's an exact
algebraic identity determined purely by ``child.rotation % 2`` -- see
``_child_contrib`` and the Stage 2 note above ``_dims``.
* Leaf curves are EXACT closed forms (hyperbola/line/wedge intersection --
no discretisation error). Internal-node composition is done on a shared
discretised grid (log-spaced) with linear interpolation -- this is where
@ -163,55 +174,51 @@ def leaf_constraints(fit, leaf: dom_mod.Node) -> LeafBounds:
# --------------------------------------------------------------------------- #
# Bounding-box geometry + cut-orientation detection (rectangular approximation)
# Local-edge-length dimensions + EXACT rotation-parity composition.
#
# NB (fixed after initial review, in two stages):
#
# Stage 1: the first version measured (w, h) from each quad's axis-aligned
# bounding box in GLOBAL x/y -- correct only when the plot/walls happen to be
# near-parallel to the global axes (true for harbor-house-l0's near-
# rectangular trapezoid, ~7.5% bbox-area error there, but WRONG in general: a
# perfectly rectangular room whose walls run at 45 deg to the survey/CRS axes
# gets a bbox up to 2x its true area -- confirmed by rotating harbor-house-l0's
# plot 45 deg: bbox area error jumped from 7.5% to 102%). Urb's Perl ancestor
# (Urb::Quad::Straighten/Straighten_Root) keeps internal walls mutually
# orthogonal but NEVER assumes them axis-aligned; this port's equal-offset
# division convention gives that same local straightness for free, so each
# node's own 4 corners already form a near-rectangle in ITS OWN frame
# regardless of the plot's global orientation -- (edge0+edge2)/2 and
# (edge1+edge3)/2 (the pairing geometry.aspect() uses) measure that local
# rectangle's two dimensions with no global-axis dependency (``_dims``).
#
# Stage 2: switching to local edge lengths alone was NOT sufficient and
# initially REGRESSED accuracy (99.0% -> 95.5% on the harbor-house-l0 200-
# topology validation, with a false negative appearing for the first time).
# Root cause: geometry.coordinate() applies a node's OWN ``rotation`` field
# even when reading ITS OWN corners as inherited from its parent -- a node
# with odd rotation has its local edge0/edge2 pair correspond to its PARENT's
# edge1/edge3 pair instead of edge0/edge2 (rotation parity selects between a
# quad's two possible opposite-edge pairings). A prior version tried to
# detect this empirically (comparing children's summed dims against the
# parent's, picking whichever of two hypotheses fit better) -- but the
# relationship is not a matter of degree to be measured, it's an EXACT
# algebraic identity determined purely by ``child.rotation % 2``: verified
# numerically (float-exact) that ``left.w + right.h == parent.w`` whenever
# left.rotation is even and right.rotation is odd (and the symmetric case
# generally), for ANY topology, independent of skew or global orientation.
# ``_child_contrib`` below applies this directly -- no geometry measurement,
# no baseline-ratio pass, no heuristic threshold.
# --------------------------------------------------------------------------- #
def _bbox(n: dom_mod.Node) -> tuple[float, float]:
"""Axis-aligned bounding-box (w, h) of a quad's 4 corners."""
xs = [geometry.coordinate(n, i)[0] for i in range(4)]
ys = [geometry.coordinate(n, i)[1] for i in range(4)]
return (max(xs) - min(xs), max(ys) - min(ys))
def _orientation(node: dom_mod.Node) -> str:
"""'w' (width-split, children share height) or 'h' (height-split),
measured from the actual baseline geometry -- see module docstring."""
bw, bh = _bbox(node)
lw, lh = _bbox(node.left)
rw, rh = _bbox(node.right)
err_w = abs((lw + rw) - bw)
err_h = abs((lh + rh) - bh)
return "w" if err_w <= err_h else "h"
def annotate_orientations(level_root: dom_mod.Node) -> dict[int, str]:
"""Baseline-geometry orientation per internal node, keyed by id(node).
Sets every free branch's division to [0.5, 0.5] on the LIVE tree (matching
the inner loop's cold-start convention), clears the geometry cache, then
measures. Caller must re-clear the cache afterwards if it goes on to use
different ratios (the DP itself never reads real coordinates again after
this call -- only the plot bbox, computed separately).
"""
from homemaker_layout import solver
for b in solver._branches(level_root):
if b.below is None or not b.below.divided:
b.division = [0.5, 0.5]
geometry.clear_cache()
orientations: dict[int, str] = {}
def _walk(n: dom_mod.Node) -> None:
if not n.divided:
return
orientations[id(n)] = _orientation(n)
_walk(n.left)
_walk(n.right)
_walk(level_root)
return orientations
def _dims(n: dom_mod.Node) -> tuple[float, float]:
"""Rotation-invariant (w, h) of a quad from its own edge lengths (mirrors
the (edge0+edge2) vs (edge1+edge3) pairing ``geometry.aspect`` uses)."""
w = (geometry.edge_length(n, 0) + geometry.edge_length(n, 2)) / 2
h = (geometry.edge_length(n, 1) + geometry.edge_length(n, 3)) / 2
return (w, h)
# --------------------------------------------------------------------------- #
@ -266,6 +273,15 @@ def make_grid(wmax: float, n: int = 400, wmin: float = 0.1) -> np.ndarray:
return np.geomspace(wmin, wmax, n)
def _child_contrib(curve: "Curve", rotation: int) -> list[Interval]:
"""The child's curve, reinterpreted in the PARENT's frame: parent.w is
ALWAYS the sum of its two children's ``_child_contrib`` (see module
docstring) -- even rotation contributes the child's own w_of_h directly;
odd rotation swaps w<->h (child.h sums; child.w is the one that
approximates the parent's shared/cross dimension)."""
return curve.w_of_h if rotation % 2 == 0 else curve.h_of_w
@dataclass
class Feasibility:
feasible: bool
@ -289,85 +305,82 @@ def check_feasible(root_curve: Curve, grid: np.ndarray, w_plot: float, h_plot: f
def realise(
node: dom_mod.Node,
curves: dict[int, tuple[Curve, Curve]],
orientations: dict[int, str],
grid: np.ndarray,
w: float,
h: float,
) -> None:
"""Write ``division`` on every free branch under ``node`` so its subtree
realises the (w, h) target, given each descendant's precomputed curves.
``curves[id(n)] = (left_curve, right_curve)`` for internal nodes."""
``curves[id(n)] = (left_curve, right_curve)`` for internal nodes.
``node.w`` (the summed dimension) is ALWAYS ``w`` -- the parent-child cut
convention is fixed (see module docstring), not orientation-dependent.
Only each CHILD's rotation parity determines which of ITS OWN (w, h) the
allocated share becomes: even rotation -> child's own w; odd rotation ->
child's own h (the two are swapped for that recursive call).
"""
if not node.divided:
return
cl, cr = curves[id(node)]
orient = orientations[id(node)]
if orient == "w":
rl = _interp_range(grid, cl.w_of_h, h)
rr = _interp_range(grid, cr.w_of_h, h)
lo = max(rl[0], w - rr[1])
hi = min(rl[1], w - rr[0])
wl = min(max((lo + hi) / 2.0, rl[0]), rl[1])
wl = min(max(wl, w - rr[1]), w - rr[0])
wr = w - wl
t = wl / w if w > 0 else 0.5
node.division = [t, t]
realise(node.left, curves, orientations, grid, wl, h)
realise(node.right, curves, orientations, grid, wr, h)
contrib_l = _child_contrib(cl, node.left.rotation)
contrib_r = _child_contrib(cr, node.right.rotation)
rl = _interp_range(grid, contrib_l, h)
rr = _interp_range(grid, contrib_r, h)
lo = max(rl[0], w - rr[1])
hi = min(rl[1], w - rr[0])
wl = min(max((lo + hi) / 2.0, rl[0]), rl[1])
wl = min(max(wl, w - rr[1]), w - rr[0])
wr = w - wl
t = wl / w if w > 0 else 0.5
node.division = [t, t]
if node.left.rotation % 2 == 0:
realise(node.left, curves, grid, wl, h)
else:
rl = _interp_range(grid, cl.h_of_w, w)
rr = _interp_range(grid, cr.h_of_w, w)
lo = max(rl[0], h - rr[1])
hi = min(rl[1], h - rr[0])
hl = min(max((lo + hi) / 2.0, rl[0]), rl[1])
hl = min(max(hl, h - rr[1]), h - rr[0])
hr = h - hl
t = hl / h if h > 0 else 0.5
node.division = [t, t]
realise(node.left, curves, orientations, grid, w, hl)
realise(node.right, curves, orientations, grid, w, hr)
realise(node.left, curves, grid, h, wl)
if node.right.rotation % 2 == 0:
realise(node.right, curves, grid, wr, h)
else:
realise(node.right, curves, grid, h, wr)
def build_curves_with_children(
node: dom_mod.Node, fit, orientations: dict[int, str], grid: np.ndarray,
node: dom_mod.Node, fit, grid: np.ndarray,
out: dict[int, tuple[Curve, Curve]],
) -> Curve:
"""Like ``build_curves`` but also records each internal node's (left,
right) curves in ``out`` for ``realise`` to consume."""
"""Bottom-up: leaf curves are exact closed forms; internal nodes compose
on ``grid`` via the EXACT rotation-parity rule (``_child_contrib``), also
recording each internal node's (left, right) curves in ``out`` for
``realise`` to consume."""
if not node.divided:
b = leaf_constraints(fit, node)
w_of_h = h_of_w = b.range_grid(grid)
return Curve(w_of_h=w_of_h, h_of_w=h_of_w)
cl = build_curves_with_children(node.left, fit, orientations, grid, out)
cr = build_curves_with_children(node.right, fit, orientations, grid, out)
cl = build_curves_with_children(node.left, fit, grid, out)
cr = build_curves_with_children(node.right, fit, grid, out)
out[id(node)] = (cl, cr)
orient = orientations[id(node)]
if orient == "w":
w_of_h = [_interval_add(cl.w_of_h[i], cr.w_of_h[i]) for i in range(len(grid))]
h_of_w = _invert(grid, w_of_h)
else:
h_of_w = [_interval_add(cl.h_of_w[j], cr.h_of_w[j]) for j in range(len(grid))]
w_of_h = _invert(grid, h_of_w)
contrib_l = _child_contrib(cl, node.left.rotation)
contrib_r = _child_contrib(cr, node.right.rotation)
w_of_h = [_interval_add(contrib_l[i], contrib_r[i]) for i in range(len(grid))]
h_of_w = _invert(grid, w_of_h)
return Curve(w_of_h=w_of_h, h_of_w=h_of_w)
def solve(level_root: dom_mod.Node, fit, grid_n: int = 150) -> tuple[bool, dict]:
"""End-to-end: orientation-annotate, compute plot bbox, build curves,
check root feasibility, and (if feasible) write realising ratios in
place. Returns (feasible, info) where info carries timing-relevant
intermediates for the caller."""
orientations = annotate_orientations(level_root)
w_plot, h_plot = _bbox(level_root)
"""End-to-end: compute plot dims, build curves, check root feasibility,
and (if feasible) write realising ratios in place. Returns (feasible,
info) where info carries timing-relevant intermediates for the caller."""
w_plot, h_plot = _dims(level_root)
grid = make_grid(max(w_plot, h_plot) * 1.2, n=grid_n)
curves_by_node: dict[int, tuple[Curve, Curve]] = {}
root_curve = build_curves_with_children(level_root, fit, orientations, grid, curves_by_node)
root_curve = build_curves_with_children(level_root, fit, grid, curves_by_node)
feas = check_feasible(root_curve, grid, w_plot, h_plot)
if feas.feasible:
realise(level_root, curves_by_node, orientations, grid, w_plot, h_plot)
realise(level_root, curves_by_node, grid, w_plot, h_plot)
geometry.clear_cache()
return feas.feasible, {
"w_plot": w_plot, "h_plot": h_plot, "orientations": orientations,
"w_plot": w_plot, "h_plot": h_plot,
"grid": grid, "h_range_at_w": feas.h_range_at_w, "w_range_at_h": feas.w_range_at_h,
}

View file

@ -23,10 +23,15 @@ Usage: python experiments/validate_shapecurve.py [n_topologies] [nm_budget]
from __future__ import annotations
import copy
import math
import shutil
import sys
import tempfile
import time
from pathlib import Path
import numpy as np
import yaml
from homemaker_layout import dom, driver, fitness as fit_mod, geometry, innerloop
@ -37,6 +42,34 @@ PROGRAMME_DIR = "examples/harbor-house-l0"
_SHAPE_SUFFIXES = (" size", " width", " proportion")
def rotated_plot_dir(src_dir: str, degrees: float) -> Path:
"""A scratch copy of ``src_dir`` with the plot's ``node:`` corners rotated
``degrees`` about their centroid -- for testing that the DP's feasibility
verdict doesn't depend on the plot's orientation relative to the survey/
CRS x/y axes it happens to be recorded in (see DESIGN.md §37.2,
"Correction 1"). The programme (patterns.config) is untouched -- rotation
changes nothing about which spaces are required or their targets, only
the plot's physical orientation.
"""
src = Path(src_dir)
dst = Path(tempfile.mkdtemp(prefix="shapecurve_rot_"))
d = yaml.safe_load((src / "init.dom").read_text())
pts = d["node"]
cx = sum(p[0] for p in pts) / len(pts)
cy = sum(p[1] for p in pts) / len(pts)
theta = math.radians(degrees)
cos_t, sin_t = math.cos(theta), math.sin(theta)
def _rot(p):
x, y = p[0] - cx, p[1] - cy
return [x * cos_t - y * sin_t + cx, x * sin_t + y * cos_t + cy]
d["node"] = [_rot(p) for p in pts]
(dst / "init.dom").write_text(yaml.safe_dump(d, default_flow_style=False))
shutil.copy(src / "patterns.config", dst / "patterns.config")
return dst
class ShapeFailEvaluator(innerloop.NativeEvaluator):
"""Like NativeEvaluator, but ``evaluate`` scores -n_shape_fails (ties
broken by the real fitness) so nm_search's greedy hill-climb directly
@ -56,9 +89,10 @@ class ShapeFailEvaluator(innerloop.NativeEvaluator):
return results
def main(n_topologies: int = 200, nm_budget: int = 100, grid_n: int = 150) -> None:
seed_root = dom.load(f"{PROGRAMME_DIR}/init.dom")
conf, cost = fit_mod.load_config(PROGRAMME_DIR)
def main(n_topologies: int = 200, nm_budget: int = 100, grid_n: int = 150,
programme_dir: str = PROGRAMME_DIR) -> None:
seed_root = dom.load(f"{programme_dir}/init.dom")
conf, cost = fit_mod.load_config(programme_dir)
fit = fit_mod.Fitness(conf, cost)
types = sorted(fit.spaces.keys())
@ -97,7 +131,7 @@ def main(n_topologies: int = 200, nm_budget: int = 100, grid_n: int = 150) -> No
t0 = time.time()
topo_nm = copy.deepcopy(topo)
geometry.clear_cache()
with ShapeFailEvaluator(topo_nm, PROGRAMME_DIR) as ev:
with ShapeFailEvaluator(topo_nm, programme_dir) as ev:
x0 = ev.x_current
if len(x0) == 0:
nm_shape_fails: list[str] = []
@ -146,7 +180,15 @@ def main(n_topologies: int = 200, nm_budget: int = 100, grid_n: int = 150) -> No
if __name__ == "__main__":
# Usage: validate_shapecurve.py [n_topologies] [nm_budget] [grid_n] [rotate_deg]
# rotate_deg (optional, default 0): test on a scratch copy of the plot
# rotated this many degrees about its centroid -- DESIGN.md §37.2's
# rotation-invariance check (0 => harbor-house-l0 unmodified).
n = int(sys.argv[1]) if len(sys.argv) > 1 else 200
budget = int(sys.argv[2]) if len(sys.argv) > 2 else 100
grid_n = int(sys.argv[3]) if len(sys.argv) > 3 else 150
main(n, budget, grid_n)
rotate_deg = float(sys.argv[4]) if len(sys.argv) > 4 else 0.0
prog_dir = str(rotated_plot_dir(PROGRAMME_DIR, rotate_deg)) if rotate_deg else PROGRAMME_DIR
if rotate_deg:
print(f"(testing on {PROGRAMME_DIR}'s plot rotated {rotate_deg} deg -> {prog_dir})")
main(n, budget, grid_n, prog_dir)