erc.4: depth-balanced construction mechanism + floor probe (§13.4)

_grow_leaves grew a random caterpillar, so equal-target rooms landed at
wildly different binary-tree depths — the depth-driven size maldistribution
Diagnostic B (§13.2) localized (same code at 0.05x and 14.7x target). The
depth_balanced flag always splits a shallowest leaf instead, growing a
near-complete tree so the proportion-aware sizing pass hits each target with
cut fractions near their proportional value.

Floor probe (diag_depth_balance.py): depth spread collapses 7->1, the giant
ratio falls (maxR 12->8 harbor / 16->6 maple), %undersize 54->25 / 42->22,
and the achievable floor drops -12% harbor / -11% maple at EQUAL leaf count.
Additive with leaf-sharing (bal+sh3 beats §13.3 share3-alone). Default OFF,
214 tests pass; threaded through driver.search/search_staged and exposed via
DEPTHBAL in run_staged_search.py. End-to-end 20k A/B running.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-06-25 22:36:24 +01:00
parent 6b23c459d8
commit 4aaa295dc1
7 changed files with 352 additions and 21 deletions

View file

@ -1,5 +1,5 @@
{"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":"Construction lever (high prior under erc), sibling of erc.3 (leaf-sharing) and erc.4 (plot-filling). Directly attacks the crinkliness residual (erc decomposition: crinkliness 346 on maple): landlocked rooms get an adjacent uncovered-O light well by construction. Follow erc shared protocol (A/B maple+harbor seeds 0/1/2, 20k evals, control reproduces 136.0/74.0, DESIGN.md §13.x).","status":"open","priority":1,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-23T20:40:19Z","created_by":"Bruno Postle","updated_at":"2026-06-23T20:50:24Z","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":"open","priority":1,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-22T23:16:19Z","created_by":"Bruno Postle","updated_at":"2026-06-23T21:47:03Z","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":"in_progress","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-24T21:18:57Z","started_at":"2026-06-24T21:18:57Z","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.3","title":"Experiment: leaf-sharing / multi-room leaves in construction","description":"Strongest untried construction lever. §12.3 named 'merge or share leaves across same-class rooms' but c3g never tested it — c3g only coarsened the circulation spine (circ_divisor), trading shape gains for equal access/adjacency damage (null). Leaf-sharing is DIFFERENT: it reduces leaf count by collapsing same-class rooms (e.g. several O/storage, or same-type repeated rooms) into a shared leaf, attacking crinkliness(346)+size(242) directly WITHOUT coarsening circulation — so it should dodge the access penalty that sank c3g.\n\nImplementation sketch: in operators.constructive_topology (+ lift path), allow rooms of the same class/type (and compatible adjacency) to be instantiated as one larger leaf rather than one-leaf-per-room, lowering leaves-per-room from ~1.4 toward 1.0 or below. Honour storey_minimum and required-room presence (a shared leaf must still satisfy each merged room's presence/area in the fitness check, or the merge must be limited to rooms the fitness treats as fungible).\n\nTests the deepest open question: whether 52 rooms simply cannot be well-shaped as 52 leaves at this density. A/B vs §12.2 baseline (maple 136.0, harbor 74.0), seeds 0/1/2, 20000 evals, staged; default-OFF toggle so controls reproduce. Record DESIGN.md §13.3.","notes":"A/B DONE (§13.3): staged 20k, seeds 0/1/2, factor 3. maple 137.0→86.3 (37%), harbor 74.0→50.3 (32%). Baseline arm reproduces §12.2 exactly (maple 137 vs 136, harbor 74.0 vs 74.0). Total separation: every share run beats every baseline run same-programme. ~35% faster (fewer leaves). First Phase-8 floor-mover; 5th construction/seed win. Closing.","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-22T23:16:15Z","created_by":"Bruno Postle","updated_at":"2026-06-24T20:51:20Z","started_at":"2026-06-23T21:51:08Z","closed_at":"2026-06-24T20:51:20Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-erc.3","depends_on_id":"homemaker-py-erc","type":"parent-child","created_at":"2026-06-23T00:16:15Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-erc.3","depends_on_id":"homemaker-py-erc.1","type":"blocks","created_at":"2026-06-23T00:16:42Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"homemaker-py-erc.2","title":"Diagnostic B: undersize-despite-slack localization (construction-target vs inner-loop-fill)","description":"GATES the plot-fill-construction vs inner-loop-expansion decision. The paradox from §12.3: plot utilisation is 0.44 (56% empty) yet size fails are 242 (rooms UNDERSIZE). Where is the slack stranded, and at which stage should it be spent?\n\nMeasure, on constructive seeds for maple-court + harbor (seeds 0/1/2):\n1. After CONSTRUCTION (before inner loop): per-leaf achieved area vs target area, and total occupied vs plot area. Are leaves parked at target with the slack left as unused plot, or is the slack distributed but mis-shaped?\n2. After the INNER LOOP optimises ratios: did size fails drop — i.e. does the ratio solve already expand leaves into slack, or does it have no gradient/incentive to exceed target? Compare predicted_shape_fails (target geometry) vs achieved size fails (post-optimise).\n\nThe §12.3 calibration (floor at TARGET dims ≈ achieved) already hints the inner loop is NOT filling slack — confirm and quantify, and identify whether the gap is (a) construction targets too-small dims given the plot, or (b) the objective gives no reward for exceeding target area. Output: DESIGN.md §13.2.\n\nDECISION RULE: if rooms are parked at target with unused plot → fix in CONSTRUCTION (plot-fill, erc child). If the inner loop has the room to expand but no objective gradient → fix in the INNER LOOP (slack-expansion term, erc child). Reads only; no behaviour change.","notes":"VERDICT (DESIGN.md §13.2): the '56% empty plot' is a misreading. Sized rooms already occupy ~50-54% of plot and hold 1.4-1.5x their aggregate target area (util\u003etgtFill); ~46% of plot is CIRCULATION, not claimable void (out only 3-4%). Size fails are pure MALDISTRIBUTION set by SLICING POSITION: median room at target (a/t~1.0) but long undersize tail (p25~0.35, min 0.05) starves while a few giants balloon (max 6.8x harbor, 14.7x maple). Same type/target lands at BOTH extremes (harbor r t=10: 68m2 \u0026 2.3m2; maple n t=60: ~target \u0026 2.7m2) =\u003e area dictated by binary-tree depth, not target. Inner loop CANNOT repair it: budget-80 size fails move only -1.6/-3.7, %undersize flat-to-worse; frozen-topology ratio DOF + 0.5^n cliff + symmetric size gaussian. =\u003e FALSIFIES plot-fill-as-claim-void (re-scope erc.4 to depth-balanced/giant-splitting construction), DEPRIORITISE erc.6 (wrong DOF). Reinforces erc.3 leaf-sharing for the starved tail. Script: experiments/diag_slack_localization.py","status":"closed","priority":1,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-22T23:15:42Z","created_by":"Bruno Postle","updated_at":"2026-06-23T21:46:34Z","started_at":"2026-06-23T21:17:07Z","closed_at":"2026-06-23T21:46:34Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-erc.2","depends_on_id":"homemaker-py-erc","type":"parent-child","created_at":"2026-06-23T00:15:42Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0}
{"id":"homemaker-py-erc.1","title":"Diagnostic A: per-leaf shape-fail vs density/granularity profile","description":"GATES the leaf-sharing vs compactness-cuts decision. The open question from §12.3: is the shape floor intrinsic to slicing at this leaf density (→ fewer leaves is the only lever), or fixable by better-shaped cuts at the same leaf count?\n\nMeasure: per-leaf shape-fail rate (crinkliness/size/proportion/width, broken out) as a function of leaves-per-room and plot utilisation, across the existing programmes spanning density — harbor (16 rooms) vs maple-court (52 rooms) — and, if cheap, a synthetic sweep that holds the programme fixed while varying leaf count (e.g. reuse the circ_divisor / construction granularity knob already in place to generate coarser vs finer constructive seeds and score predicted_shape_fails per leaf).\n\nReads, does not change behaviour: use operators.predicted_shape_fails + the per-leaf factor breakdown already in fitness.py (the §12.3 residual table was produced this way). Output: a table of per-leaf shape-fail vs density, written into DESIGN.md §13.1.\n\nDECISION RULE (write it into the verdict): if per-leaf shape-fail is FLAT across densities → floor is intrinsic to slicing density → prioritise leaf-sharing (erc child), deprioritise/close compactness-cuts. If it RISES with density → better cuts can pay → keep compactness-cuts. This is a measurement, not an experiment; no A/B, no baseline reproduction needed.","notes":"VERDICT (DESIGN.md §13.1): per-leaf shape-fail is FLAT vs slicing density in the controlled synthetic sweep (maple-court, room set fixed, circ_divisor 2-\u003e9: leaves 81-\u003e63, per-leaf rate 1.72-1.94 with no trend; TOTAL shape fails track leaf count ~linearly 139-\u003e116). Crinkliness dominates (~0.8/leaf) and is flat. Cuts already squarest (_size_divisions_from_targets) yet still ~1.8 fails/leaf =\u003e little compactness headroom at fixed count. Floor is INTRINSIC to per-leaf slicing. =\u003e prioritise leaf-sharing (erc.3), deprioritise compactness-cuts (erc.5). NOT the c3g null: that removed circulation leaves (access damage cancelled gain); leaf-sharing removes ROOM-leaf count without touching the spine. Script: experiments/diag_leaf_shapefail.py","status":"closed","priority":1,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-22T23:15:40Z","created_by":"Bruno Postle","updated_at":"2026-06-23T21:00:34Z","started_at":"2026-06-23T20:53:52Z","closed_at":"2026-06-23T21:00:34Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-erc.1","depends_on_id":"homemaker-py-erc","type":"parent-child","created_at":"2026-06-23T00:15:39Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0}
@ -65,18 +65,18 @@
{"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":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-22T23:16:24Z","created_by":"Bruno Postle","updated_at":"2026-06-23T21:47:05Z","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":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-22T23:16:21Z","created_by":"Bruno Postle","updated_at":"2026-06-23T21:00:46Z","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":"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":"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":"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":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."}
{"_type":"memory","key":"deceptive-valleys-in-topology-search-when-every-single","value":"Deceptive valleys in topology search: when every single-step mutation from a target state passes through a high-fail intermediary (e.g. level_fix displaces a room into 5+ new fails), a compound operator that atomically applies two coordinated changes can escape. Design compound operators to land on the low-fail state directly, bypassing the deceptive gradient. Programme-house example: level_compound_fix atomically moves the level-constrained room AND re-inserts the displaced room adjacent to C in one step (operators.py, 2026-06-14)."}
{"_type":"memory","key":"run-to-run-reproducibility-in-homemaker-layout-serial","value":"Run-to-run reproducibility in homemaker-layout: serial search (workers=1) is byte-for-byte deterministic; parallel (workers\u003e1) is now deterministic too AFTER fixing driver._run_batch to admit futures in submission order (was as_completed/completion order, bug xcy). Reproducibility holds only for a FIXED worker count — serial vs parallel differ because children-per-iteration is 1 vs n_workers (different batch granularity), which is expected, not a bug. The constructive seeder was NEVER nondeterministic: _assign_adjacency_aware has unique idx tiebreaks; comparing topologies with Python builtin hash() of the signature STRING is invalid (PYTHONHASHSEED salts str hashing per process) — use a stable hash (sha1) or genome.signature equality."}
{"_type":"memory","key":"urb-fitness-bug-found-fixed-2026-06-12","value":"Urb fitness bug found+fixed 2026-06-12 (patch in /home/bruno/src/urb, uncommitted): ProgrammeDriven.pm ratio_o/ratio_type grepped case-insensitively over the ratios hash and took the FIRST key — nondeterministic (x4.5 score swings) for designs with mixed-case type classes (both 'c' circulation and 'C' covered). Fixed to SUM the class (matches Is_Circulation//Is_Outside semantics); 35/35 corpus scores unchanged. CRITICAL for homemaker-py-3y7/gnw: the native port must implement class-SUM ratios. Building.pm has the same unpatched pattern (site-driven path, not used by our oracle). Also: the memetic search reward-hacked this bug before the fix — search results predating it are noise artifacts."}
{"_type":"memory","key":"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":"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":"proportion-aware-constructive-seeding-leu-2-12-2","value":"Proportion-aware constructive seeding (leu.2/§12.2): sizing seed cuts from target AREAS only regresses (thin slivers wreck aspect); you must ALSO pick each cut's rotation for child squareness. It is a convergence ACCELERATOR via a deeper local optimum around the constructed topology: wins where that topology is roughly right and budget is scarce (harbor -13%, maple -10% at 20k evals) but DELAYS small programmes where the seed must be restructured by undivide (programme-house regresses at fixed budget, yet reaches the floor given budget - speed, not asymptote). Default-on. Also: n_storeys must honour storey_minimum, not just level: keys (programme-house storey_minimum:2, all rooms level:0 - was seeded 1 storey short; cq1)."}
{"_type":"memory","key":"strategy-decision-2026-06-12-bruno-occlusion-daylight","value":"Strategy decision 2026-06-12 (Bruno): occlusion/daylight is ORTHOGONAL to building a scalable optimiser. Disable it in Urb (env flag, homemaker-py-gp2) rather than port it; native fitness uses simple crinkliness (illumination factor = 1); rebuild occlusion in Python only after optimisation is fully native (homemaker-py-2g5, now P4). Consequence: all scores change when the flag flips — re-baseline corpus/.score, DESIGN \\$4.5 gains, gate bars at one clean boundary AFTER homemaker-py-1p0 closes; Phase-2 urb-evolve benchmark must run with the same flag."}
{"_type":"memory","key":"user-preference-bruno-this-is-a-fedora-system","value":"User preference (Bruno): this is a Fedora system — NEVER install Python packages via pip without asking first; always ask whether to install the rpm via dnf (e.g. python3-cma) before considering pip. Applies to any dependency additions."}
{"_type":"memory","key":"adjacency-in-binary-slicing-tree-is-structural-not","value":"Adjacency in binary slicing tree is structural, not geometric: the inner-loop NM cannot fix topological adjacency failures. Two paths exist: (1) tree-sibling adjacency — a node is adjacent to its sibling in the tree; (2) cross-zone geometric adjacency — leaves from different subtrees that happen to share a boundary. Staircase/adjacency fails require a topology mutation that changes which nodes are siblings or which zones touch. This was proved empirically on programme-house: staircase fail from rot=0 layout could not be fixed by NM but was fixed by level_retype creating a two-C topology (2026-06-14/15)."}
{"_type":"memory","key":"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":"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":"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":"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":"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":"warm-x0-initialization-bug-pattern-when-a-topology","value":"warm_x0 initialization bug pattern: when a topology operator explicitly sets division ratios on a newly-created node (e.g. compound_fix sets node.division=[0.25,0.25] for t3), parent.ratios has no entry for that node (it was a leaf). warm_x0 defaults it to 0.5, corrupting the inner loop's starting point and making the operator invisible to lex comparison. Fix: only propagate child ratios for nodes where the parent node was NOT already divided; stale hidden nodes revealed by structural mutations (swap flipping b.below) must NOT contribute their pre-writeback values. See driver.py lines 259-267 (fixed 2026-06-14)."}
{"_type":"memory","key":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."}
{"_type":"memory","key":"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."}

View file

@ -1678,3 +1678,55 @@ help. Follow-ups: surface `leaf_sharing` on the `homemaker-evolve` CLI / as a
`patterns.config` key for production use, sweep `leaf_share_factor`/`max_share`,
and test the `erc.4` depth-balancing synergy (shared leaves at correct absolute
area) now that the leak is closed.
### 13.4 Experiment: depth-balanced construction (`homemaker-py-erc.4`) — A/B RUNNING
The lever Diagnostic B (§13.2) called for. B localized the size fails to
depth-driven **maldistribution**: a leaf's area is the product of cut fractions
down its ancestry in the binary slicing tree, so the same-target room lands at
0.05× and 14.7× by *slicing position*, and the inner loop (frozen topology)
provably cannot move it. The fix must live in construction, where leaf area is
decided.
**Mechanism — depth-balanced tree growth.** `_grow_leaves` grew the tree by
splitting a *random* leaf each step → a random caterpillar whose leaves sit at
wildly different depths. The `depth_balanced` flag instead always splits a
*shallowest* current leaf (`operators._leaves_with_depth`), growing a
near-complete binary tree so all leaves land at comparable depth. The
proportion-aware sizing pass (`_size_divisions_from_targets`) then hits each
target with cut fractions near their proportional value instead of compounding
`fmin`/`fmax` clamp error down a deep spine. Type-agnostic and topology-only — it
changes *which* leaf is split, not the type assignment or the proportional sizing
— so it composes with adjacency-aware seeding and leaf-sharing unchanged. Default
OFF (214 tests pass with it off); threaded through `constructive_topology` /
`lift_base_to_storeys``driver.search`/`search_staged`, exposed via `DEPTHBAL`
in `run_staged_search.py`.
**Floor probe** (`experiments/diag_depth_balance.py`, harbor + maple, seeds
0/1/2) — build the §12.2 seed OFF vs balanced (vs balanced+share3 as the `erc.7`
preview), score at the seed geometry and after `innerloop.optimise` (nm, budget
80). `dDep` = leaf-depth spread (maxmin); `maxR`/`minR` = max/min achieved/target
over sized leaves; `%und` = fraction below 0.9×target. Averaged:
| programme | mode | leaves | total | size | crink | %und | maxR | minR | dDep |
|-----------|-------------|-------:|------:|-----:|------:|-----:|-----:|-----:|-----:|
| harbor | OFF +il | 45.0 | 120.3 | 21.7 | 33.7 | 54.2 | 12.0 | 0.1 | 7.0 |
| harbor | bal +il | 45.0 | 106.0 | 21.0 | 31.3 | 25.0 | 8.3 | 0.2 | 1.0 |
| harbor | bal+sh3 +il | 25.7 | 65.3 | 11.7 | 17.3 | 29.0 | 4.1 | 0.3 | 1.0 |
| maple | OFF +il | 73.0 | 194.7 | 37.3 | 58.3 | 42.3 | 16.4 | 0.0 | 6.7 |
| maple | bal +il | 73.0 | 173.0 | 37.3 | 61.7 | 22.4 | 6.2 | 0.2 | 1.0 |
| maple | bal+sh3 +il | 47.0 | 113.7 | 22.3 | 38.7 | 17.7 | 7.9 | 0.4 | 2.0 |
**The depth spread collapses (7→1) and the giant leaf is tamed** — maxR 12.0→8.3
harbor / 16.4→6.2 maple, %undersize 54→25 / 42→22 — at **equal leaf count** (45 /
73, no rooms removed). The achievable floor drops **12 % harbor (120.3→106.0) /
11 % maple (194.7→173.0)** purely from tree *shape*, with zero missing-fail
leak. Most of the total drop is in width/proportion (the giants were the wide,
wrong-aspect leaves), not the soft size Gaussian (size barely moves). Crucially
it is **additive with leaf-sharing**: `bal+sh3` beats §13.3's `share3`-alone floor
(harbor 65.3 vs 73.3, maple 113.7 vs 133.0) — balancing places the *survivors* of
sharing at correct absolute area, exactly the synergy `erc.7` was filed for.
**End-to-end A/B** (`experiments/run_depthbal_ab.sh`, staged search, 20 000 native
evals, seeds 0/1/2, `DEPTHBAL=1` vs default-OFF baseline, leaf-sharing OFF in both
arms) — IN PROGRESS; verdict + scoreboard update to follow.

View file

@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Depth-balanced construction floor probe (homemaker-py-erc.4, DESIGN.md §13.4).
Cheap de-risk BEFORE the full 20k A/B. Diagnostic B (§13.2) localized the size
fails to depth-driven MALDISTRIBUTION: a leaf's area is the product of cut
fractions down its ancestry in the binary slicing tree, so the default random
(`_grow_leaves` picks a random leaf to split) caterpillar lands equal-target
rooms at depths that differ by many levels the same code seen at 0.05x and
14.7x target. The inner loop provably cannot repair it (frozen topology).
erc.4 lever: grow a DEPTH-BALANCED tree (always split a shallowest leaf), so all
leaves sit at comparable depth and the proportion-aware sizing pass hits each
target with cut fractions near their proportional value instead of compounding
fmin/fmax clamp error down a deep spine.
This script builds the §12.2 constructive seed three ways OFF (baseline),
balanced (erc.4), balanced+share3 (the erc.7 synergy preview) and at each
mode reports (a) the area maldistribution (mean achieved/target, % undersize,
max/min ratio, leaf-depth spread) and (b) the fail floor at the seed geometry
and again after innerloop.optimise, under the matching objective.
DECISION RULE: if balancing tightens the a/t spread (max ratio down, %under
down) AND lowers size + total fails vs OFF -> the floor moves -> thread the flag
through the driver for the staged 20k A/B. If the spread / fails do not move ->
depth balance alone cannot pay; consider explicit giant-splitting instead.
Usage:
URB_NO_OCCLUSION=1 python3 experiments/diag_depth_balance.py
"""
from __future__ import annotations
import copy
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import ( # noqa: E402
dom, fitness, geometry, innerloop, operators, programme)
PROGRAMMES = ["harbor-house", "maple-court"]
SEEDS = (0, 1, 2)
BUDGET = 80 # bootstrap child budget, as in Diagnostics A/B and §13.3
ROOT = Path(__file__).resolve().parents[1]
CATS = ("missing", "size", "width", "proportion", "crinkliness",
"adjacency", "access", "other")
def _bucket(fails) -> dict[str, int]:
out = {k: 0 for k in CATS}
for f in fails:
if "missing" in f or "too many" in f:
out["missing"] += 1
elif f.endswith(" size"):
out["size"] += 1
elif f.endswith(" width"):
out["width"] += 1
elif f.endswith(" proportion"):
out["proportion"] += 1
elif f.endswith(" crinkliness"):
out["crinkliness"] += 1
elif "adjacen" in f:
out["adjacency"] += 1
elif "access" in f or "inaccessible" in f:
out["access"] += 1
else:
out["other"] += 1
return out
class _force_sharing:
"""Make innerloop's NativeEvaluator build its fitness with ``leaf_sharing``
on, so the inner loop optimises the SAME relaxed objective the seed was
scored under (the dir's patterns.config has no such key)."""
def __init__(self, on: bool):
self.on = on
def __enter__(self):
self._orig = fitness.load_config
if self.on:
def patched(directory, _orig=self._orig):
conf, cost = _orig(directory)
conf = dict(conf)
conf["leaf_sharing"] = True
return conf, cost
fitness.load_config = patched
return self
def __exit__(self, *exc):
fitness.load_config = self._orig
def _maldist(topo, fit, reqs) -> dict:
"""Achieved/target spread over sized leaves + leaf-depth spread."""
geometry.clear_cache()
ratios = []
for lvl in dom.levels(topo):
for lf in lvl.leaves():
r = reqs.get(lf.type) if lf.type else None
if r is not None and r.has_size and r.size > 0:
tgt = r.size * (lf.share if lf.share_type == lf.type else 1)
ratios.append(geometry.area(lf) / tgt if tgt else float("nan"))
depths = [d for lvl in dom.levels(topo)
for _l, d in operators._leaves_with_depth(lvl)]
ratios = np.array(ratios) if ratios else np.array([float("nan")])
return {
"mean_ratio": float(np.mean(ratios)),
"pct_under": 100.0 * float(np.mean(ratios < 0.9)),
"max_ratio": float(np.max(ratios)),
"min_ratio": float(np.min(ratios)),
"depth_spread": (max(depths) - min(depths)) if depths else 0,
}
def _measure(fit, pdir, seed_root, reqs, types, s, balanced, sharing, factor):
rng = np.random.default_rng(s)
topo = operators.constructive_topology(
seed_root, reqs, rng, types, adjacency_aware=True, proportion_aware=True,
depth_balanced=balanced, leaf_sharing=sharing, leaf_share_factor=factor)
n_leaves = sum(len(lvl.leaves()) for lvl in dom.levels(topo))
md = _maldist(topo, fit, reqs)
_s, fails = fit.score_with_fails(copy.deepcopy(topo))
before = {"n_leaves": n_leaves, "total": len(fails), **_bucket(fails), **md}
after_tree = copy.deepcopy(topo)
with _force_sharing(sharing):
innerloop.optimise(after_tree, str(pdir), x0=None, budget=BUDGET,
method="nm", use_native=True)
_s2, fails2 = fit.score_with_fails(copy.deepcopy(after_tree))
after = {"n_leaves": n_leaves, "total": len(fails2), **_bucket(fails2),
**_maldist(after_tree, fit, reqs)}
return before, after
def _avg(rows, k):
return sum(r[k] for r in rows) / len(rows)
def main() -> int:
print("Depth-balanced construction floor probe (§13.4)\n")
print(f"Seeds: {SEEDS}. OFF = random-grow baseline; bal = depth_balanced; "
"bal+sh3 = balanced + leaf_sharing f3.")
print(f"seed = constructive seed; +il = after innerloop.optimise (nm, "
f"budget={BUDGET}).\n")
cols = ("leaves", "total", "size", "crink", "missing", "a/t", "%und",
"maxR", "minR", "dDep")
hdr = f"{'programme':<14}{'mode':>10}" + "".join(f"{c:>8}" for c in cols)
def _row(name, label, rows):
vals = [_avg(rows, "n_leaves"), _avg(rows, "total"), _avg(rows, "size"),
_avg(rows, "crinkliness"), _avg(rows, "missing"),
_avg(rows, "mean_ratio"), _avg(rows, "pct_under"),
_avg(rows, "max_ratio"), _avg(rows, "min_ratio"),
_avg(rows, "depth_spread")]
print(f"{name:<14}{label:>10}" + "".join(f"{v:>8.1f}" for v in vals))
for name in PROGRAMMES:
pdir = ROOT / "examples" / name
reqs = programme.load_programme_dir(pdir)
types = sorted(reqs) + ["C", "O"]
conf, cost = fitness.load_config(pdir)
seed_root = dom.load(str(pdir / "init.dom"))
fit_off = fitness.Fitness(conf, cost)
conf_on = dict(conf)
conf_on["leaf_sharing"] = True
fit_on = fitness.Fitness(conf_on, cost)
print(hdr)
print("-" * len(hdr))
modes = [("OFF", fit_off, False, False, 1),
("bal", fit_off, True, False, 1),
("bal+sh3", fit_on, True, True, 3)]
for label, fit, balanced, sharing, factor in modes:
pairs = [_measure(fit, pdir, seed_root, reqs, types, s,
balanced, sharing, factor) for s in SEEDS]
_row(name, label, [b for b, _a in pairs])
_row(name, label + "+il", [a for _b, a in pairs])
print()
return 0
if __name__ == "__main__":
sys.exit(main())

45
experiments/run_depthbal_ab.sh Executable file
View file

@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Depth-balanced construction A/B (erc.4, DESIGN.md §13.4): does growing a
# DEPTH-BALANCED slicing tree (always split a shallowest leaf) instead of the
# default random caterpillar lower the end-to-end fail count? Diag B (§13.2)
# localized the size fails to depth-driven maldistribution (same-target rooms at
# 0.05x..14.7x by slicing position); the §13.4 floor probe showed balancing
# collapses the depth spread (7->1) and the giant ratio (maxR 12.7->8.4 harbor,
# 16.7->6.3 maple) and drops the achievable seed floor -12% harbor / -11% maple
# at EQUAL leaf count.
#
# Baseline arm (DEPTHBAL=0) must reproduce the §12.2 figures (maple 136.0,
# harbor 74.0); the balanced arm (DEPTHBAL=1) is the experiment. Leaf-sharing
# stays OFF in both arms here — the share synergy is erc.7.
set -u
cd "$(dirname "$0")/.."
BUDGET="${1:-20000}"
OUT=scratch/depthbal_ab; mkdir -p "$OUT"
TSV=scratch/depthbal_results.tsv
[ -f "$TSV" ] || printf 'programme\tseed\tdepthbal\tfails\ttopologies\telapsed_s\n' > "$TSV"
run() { # programme seed depthbal(0|1)
local prog="$1" seed="$2" bal="$3"
local tag="db${bal}"
local log="$OUT/${prog}_${tag}_s${seed}.log"
echo ">>> $prog seed=$seed depthbal=$bal"
local t0; t0=$(date +%s)
env URB_NO_OCCLUSION=1 DEPTHBAL="$bal" \
python3 experiments/run_staged_search.py "examples/$prog" "$BUDGET" "$seed" \
"examples/$prog/init.dom" "$OUT/${prog}_${tag}_s${seed}.dom" > "$log" 2>&1
local t1; t1=$(date +%s)
local fails topos
fails=$(grep 're-scored (native)' "$log" | tail -1 | sed -n 's/.*(\([0-9]*\) fails).*/\1/p')
topos=$(grep -m1 '^evals' "$log" | sed -n 's/.*across \([0-9]*\) topologies.*/\1/p')
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$prog" "$seed" "$bal" "${fails:-ERR}" "${topos:-?}" "$((t1-t0))" >> "$TSV"
echo " -> ${fails:-ERR} fails, ${topos:-?} topologies, $((t1-t0))s"
}
# baseline controls (reproduce §12.2) then the depth-balanced arm, seeds 0/1/2
for prog in maple-court harbor-house; do
for seed in 0 1 2; do run "$prog" "$seed" 0; done
for seed in 0 1 2; do run "$prog" "$seed" 1; done
done
echo "=== depth-balanced A/B complete ==="
column -t -s $'\t' "$TSV"

View file

@ -64,6 +64,7 @@ def main() -> int:
circ_div = int(os.environ.get("CIRCDIV", "3")) # c3g circ-per-room granularity knob
leaf_share = os.environ.get("LEAFSHARE", "0") == "1" # erc.3 leaf-sharing A/B
leaf_share_fac = int(os.environ.get("LEAFSHAREFAC", "2"))
depth_bal = os.environ.get("DEPTHBAL", "0") == "1" # erc.4 depth-balanced grow A/B
if leaf_share:
# erc.3 §13.3: the inner-loop and final-score fitness are built from the
@ -121,6 +122,7 @@ def main() -> int:
circ_divisor=circ_div,
leaf_sharing=leaf_share,
leaf_share_factor=leaf_share_fac,
depth_balanced=depth_bal,
)
elapsed = time.perf_counter() - t0

View file

@ -189,6 +189,7 @@ def search(
circ_divisor: int = 3,
leaf_sharing: bool = False,
leaf_share_factor: int = 2,
depth_balanced: bool = False,
) -> SearchResult:
"""Run the memetic loop from ``seed_root`` until ``budget`` oracle
evaluations are consumed. Returns the best individual found; its ``root``
@ -389,7 +390,8 @@ def search(
adjacency_aware=seed_adjacency_aware,
proportion_aware=seed_proportion_aware,
circ_divisor=circ_divisor,
leaf_sharing=leaf_sharing, leaf_share_factor=leaf_share_factor)
leaf_sharing=leaf_sharing, leaf_share_factor=leaf_share_factor,
depth_balanced=depth_balanced)
return (topo, None, child_budget, {}, f"construct/{tag}")
n = int(rng.integers(max(1, n_target - 1), n_target + 2))
return (random_topology(seed_root, n, rng, types), None, child_budget,
@ -516,6 +518,7 @@ def search_staged(
circ_divisor: int = 3,
leaf_sharing: bool = False,
leaf_share_factor: int = 2,
depth_balanced: bool = False,
) -> SearchResult:
"""Staged per-floor topology search (DESIGN.md §11.3, ``homemaker-py-c4c.3``).
@ -565,7 +568,8 @@ def search_staged(
feasibility_max_shape_fails=feasibility_max_shape_fails,
circ_divisor=circ_divisor,
leaf_sharing=leaf_sharing,
leaf_share_factor=leaf_share_factor)
leaf_share_factor=leaf_share_factor,
depth_balanced=depth_balanced)
if types is None:
types = sorted(reqs) + ["C", "O"]
@ -597,6 +601,7 @@ def search_staged(
circ_divisor=circ_divisor,
leaf_sharing=leaf_sharing,
leaf_share_factor=leaf_share_factor,
depth_balanced=depth_balanced,
)
best_base = r1.best.root
_log(f"[staged] stage 1 done: base {r1.best.fitness:.6g} "
@ -615,7 +620,8 @@ def search_staged(
adjacency_aware=seed_adjacency_aware,
proportion_aware=seed_proportion_aware,
circ_divisor=circ_divisor,
leaf_sharing=leaf_sharing, leaf_share_factor=leaf_share_factor)
leaf_sharing=leaf_sharing, leaf_share_factor=leaf_share_factor,
depth_balanced=depth_balanced)
_log(f"[staged] stage 2: upper floors as deltas, budget {b2}, base_p {base_p}")
r2 = search(
@ -635,6 +641,7 @@ def search_staged(
circ_divisor=circ_divisor,
leaf_sharing=leaf_sharing,
leaf_share_factor=leaf_share_factor,
depth_balanced=depth_balanced,
)
# Stitch the two stages into one accounting (total evals, tagged history).

View file

@ -373,9 +373,32 @@ def mutate_place_missing(root: dom.Node, rng: np.random.Generator,
return _finalise(child), f"place_missing {code} -> {host_id}"
def _grow_leaves(lvl: dom.Node, n_leaves: int, rng: np.random.Generator) -> None:
"""Subdivide ``lvl``'s subtree in place until it has ``n_leaves`` leaves."""
def _leaves_with_depth(n: dom.Node, d: int = 0) -> list[tuple[dom.Node, int]]:
"""Every leaf under ``n`` paired with its depth below ``n``."""
if not n.divided:
return [(n, d)]
return _leaves_with_depth(n.left, d + 1) + _leaves_with_depth(n.right, d + 1)
def _grow_leaves(lvl: dom.Node, n_leaves: int, rng: np.random.Generator,
balance: bool = False) -> None:
"""Subdivide ``lvl``'s subtree in place until it has ``n_leaves`` leaves.
``balance`` (erc.4, §13.4): always split a *shallowest* current leaf, growing
a near-complete binary tree instead of the default random caterpillar. Diag B
(§13.2) localized the size fails to depth-driven MALDISTRIBUTION leaf area is
set by the product of cut fractions down its ancestry, so a random unbalanced
tree lands equal-target rooms at depths that differ by many levels (same code
seen at 0.05× and 14.7× target). Keeping all leaves at comparable depth lets
the proportion-aware sizing pass hit each target with cut fractions near their
proportional value, instead of compounding fmin/fmax clamp error down a deep
spine."""
while len(lvl.leaves()) < n_leaves:
if balance:
ld = _leaves_with_depth(lvl)
dmin = min(d for _l, d in ld)
leaf = _pick(rng, [l for l, d in ld if d == dmin])
else:
leaf = _pick(rng, lvl.leaves())
leaf.division = [0.5, 0.5]
leaf.rotation = int(rng.integers(4))
@ -633,7 +656,8 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
proportion_aware: bool = True,
circ_divisor: int = 3,
leaf_sharing: bool = False,
leaf_share_factor: int = 2) -> dom.Node:
leaf_share_factor: int = 2,
depth_balanced: bool = False) -> dom.Node:
"""Build a seed that instantiates every required space by construction.
The §11.0 diagnosis: random divide+retype chains leave required programme
@ -694,12 +718,12 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
# tree finalisable and geometry.leaf_graph derives coords on demand.
# c3g granularity knob: ~one circ per `circ_divisor` rooms (default 3).
n_circ = max(1, -(-len(rooms) // circ_divisor))
_grow_leaves(lvl, len(rooms) + 1 + n_circ, rng)
_grow_leaves(lvl, len(rooms) + 1 + n_circ, rng, balance=depth_balanced)
dom._link(child)
_assign_adjacency_aware(lvl, rooms, reqs, rng)
else:
assign = rooms + ["C", "O"] # +core circulation, +outside
_grow_leaves(lvl, len(assign), rng)
_grow_leaves(lvl, len(assign), rng, balance=depth_balanced)
leaves = lvl.leaves()
order = rng.permutation(len(leaves))
for slot, leaf_idx in enumerate(order):
@ -724,7 +748,8 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
proportion_aware: bool = True,
circ_divisor: int = 3,
leaf_sharing: bool = False,
leaf_share_factor: int = 2) -> dom.Node:
leaf_share_factor: int = 2,
depth_balanced: bool = False) -> dom.Node:
"""Stack upper storeys onto an evolved single-storey base (DESIGN.md §11.3).
Stage 2 seeder: the Stage-1 base is the credible ground floor and is left
@ -773,7 +798,13 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
target_total = len(rooms) + 1 + n_circ
n_free_target = target_total - (1 if core_node is not None else 0)
while len(_free()) < n_free_target:
leaf = _pick(rng, _free())
frees = _free()
if depth_balanced:
fd = [(l, d) for l, d in _leaves_with_depth(dup) if l in frees]
dmin = min(d for _l, d in fd)
leaf = _pick(rng, [l for l, d in fd if d == dmin])
else:
leaf = _pick(rng, frees)
leaf.division = [0.5, 0.5]
leaf.rotation = int(rng.integers(4))
leaf.left = dom.Node(type=leaf.type)
@ -789,7 +820,13 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
if core_node is None:
assign.append("C") # no inherited core to reuse — make one
while len(_free()) < len(assign):
leaf = _pick(rng, _free())
frees = _free()
if depth_balanced:
fd = [(l, d) for l, d in _leaves_with_depth(dup) if l in frees]
dmin = min(d for _l, d in fd)
leaf = _pick(rng, [l for l, d in fd if d == dmin])
else:
leaf = _pick(rng, frees)
leaf.division = [0.5, 0.5]
leaf.rotation = int(rng.integers(4))
leaf.left = dom.Node(type=leaf.type)