Spike: torch-autodiff inner-loop ratio optimisation (homemaker-py-2ax) — negative
Build a torch-differentiable local proxy for the ratio-to-fitness path (exact port of geometry.py's coordinate recursion + the 5 continuous per-leaf quality factors, with discrete/structural facts frozen from a real fitness.py snapshot and the 0.5^n cliff relaxed to a sigmoid) and compare Adam ascent against nm_search on frozen topologies from programme-house and harbor-house. Result: ~30-35x slower per unit of search progress than nm_search at both 6 DOF and 36 DOF (per-op torch tensor dispatch overhead with no batching opportunity, plus snapshot/resnapshot cost on par with a full oracle eval), and no better quality at matched budget. A step-size sensitivity check confirmed the flagged 0.5^n cliff risk is real, but autodiff doesn't make the gradient direction any cheaper to obtain here. Not recommended; kept as reference only, not wired into innerloop.py. Full writeup in DESIGN.md §34. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8agJBT2ZpmF3ErW7wi2wY
This commit is contained in:
parent
389437a7d1
commit
0ec404c73d
3 changed files with 663 additions and 17 deletions
|
|
@ -65,7 +65,7 @@
|
|||
{"id":"homemaker-py-cdl","title":"Consider defaulting collapse_global local_search on + expose on evolve --collapse","description":"homemaker-py-9wi added Fitness._two_opt_adjacency_polish (2-opt swap search after the Jacobi adjacency fixpoint), gated behind collapse_global(local_search=True)/homemaker-collapse --local-search, default OFF. Empirical sweep over the 11 harbor-house .dom files: 0 regressions, 1 real improvement (evolved-anneal-3M.dom 21-\u003e19 fails, a genuine mutual-adjacency miss the Jacobi loop couldn't reach). Monotone by construction (only strictly-improving swaps kept) and cheap (\u003c1s on the largest file), so it looks safe to default on, but the sample is small (11 files, one non-synthetic dataset) and evolve.py's --collapse hook (driver.collapse_best) does not expose the flag at all yet. Before flipping the default: (1) run a broader sweep (programme-house + any other example sets) to confirm no regressions elsewhere, (2) add a --collapse-local-search passthrough to evolve.py's CLI alongside driver.collapse_best's **collapse_kw. Low priority -- collapse_finish's keep-better wrapper already makes the current opt-in flag safe to use standalone via homemaker-collapse.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-26T19:57:32Z","created_by":"Bruno Postle","updated_at":"2026-07-26T22:32:24Z","started_at":"2026-07-26T22:20:41Z","closed_at":"2026-07-26T22:32:24Z","close_reason":"Ran a 46-file A/B sweep (local_search=False vs True in collapse_finish) across harbor-house (12 files) and programme-house (34 files): 0 regressions, 2 improvements (evolved-anneal-3M.dom 21-\u003e19, a82f07068e4408fdd0d5e3dc469a8dee.dom 3-\u003e2 fails), rest identical. Did NOT flip collapse_global's own default (still False) because it is also called every fitness eval via collapse_insearch (qpk) on the unmerged tree -- that hot per-eval path should stay cheap. Instead flipped the two one-shot finish-time call sites to default local_search=True explicitly: homemaker-collapse --local-search (collapse_cmd.py) and new homemaker-evolve --collapse-local-search (evolve.py, threaded through driver.collapse_best's **collapse_kw). All 298 tests pass.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-xyu","title":"Resolve n=18 ruin_recreate trend: larger-N or a non-synthetic third example (homemaker-py-y51 follow-up)","description":"y51's synthetic room-count sweep (DESIGN.md section 24) found no clean monotonic size threshold for ruin_recreate's benefit: n=10 mild win (N=5, ns), n=14 clean null (N=10), n=18 strongest trend (7W/2L/1T, +9.3% mean fails, Wilcoxon p=0.098, N=10) despite sitting between two weaker sizes, n=22 near-null (N=5). Not significant at conventional levels but the largest effect of the four, sandwiched non-monotonically.\n\nTwo possible follow-ups, not mutually exclusive:\n(a) Extend n=18 to N=15+ seeds (matching the N needed for f1d's own programme-house confirmation) to see if the trend firms up or was noise.\n(b) The synthetic sweep scales room count by duplicating already-interchangeable room codes (count: on b1/t1/b2/t2) -- the same mechanism harbor-house itself uses 'to reduce complexity'. A genuinely distinct third example programme (real room-type diversity at an intermediate room count, not a duplicated-code scale-up) would avoid this confound and better isolate room count as the driving variable behind the wing-rebuild-fraction hypothesis.\n\nLow priority -- enable_ruin_recreate's default-OFF status and existing programme-house-scale guidance are unaffected either way.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-26T14:33:20Z","created_by":"Bruno Postle","updated_at":"2026-07-29T09:08:13Z","started_at":"2026-07-29T06:31:41Z","closed_at":"2026-07-29T09:08:13Z","close_reason":"N=15 confirmation done (option a); DESIGN.md §31. Trend weakened but not resolved; option (b) refiled as homemaker-py-9yx.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-y51","title":"Locate the size threshold where ruin_recreate stops helping (homemaker-py-f1d follow-up)","description":"f1d (DESIGN.md §23) landed operators.mutate_ruin_recreate (LNS wing rebuild via the adjacency-aware\nconstructor) and validated it at weight=3.0: a statistically significant win on programme-house\nacross 15 seeds (8W/1L/6T, mean fails 7.07-\u003e6.00, Wilcoxon p=0.041) but no consistent effect on\nharbor-house across 8 seeds (3W/2L/3T, mean fails 73.0-\u003e74.5, slight negative lean). Kept\nenable_ruin_recreate default OFF because the two example programmes disagree on direction and only\ntwo sizes were tested -- same conservative bar homemaker-py-qpk applied before its own 1ph larger-N\nconfirmation.\n\nProposal: locate the threshold this size split implies rather than inferring it from two data\npoints. Either (a) a third example programme sized between programme-house and harbor-house's room\ncounts, run through the same qpk protocol (weight=3.0 ON vs OFF, both arms finished with\n--collapse), or (b) a synthetic room-count sweep on programme-house's own patterns.config (scaling\nrequired-space counts up) if a natural third example isn't available. Candidate hypothesis from\n§23's interpretation: the wing-rebuild's benefit tracks how large a fraction of the whole floor's\ntopology one wing move samples, which shrinks as room count grows -- worth checking directly against\nroom count rather than just building size.\n\nIf a threshold is found, flip enable_ruin_recreate's default per-programme-size (or document the\ncutoff for users to opt in below it) instead of leaving it a blanket manual flag.","notes":"Measured (2026-07-26): synthetic room-count sweep (10/14/18/22 rooms, programme-house-derived, budget=3000, weight=3.0 ON vs OFF). No clean monotonic threshold found -- n=10 mild win (N=5, ns), n=14 clean null after N=10 confirmation, n=18 strongest trend (7W/2L/1T, +9.3%, p=0.098, N=10) despite sitting between two weaker sizes, n=22 near-null (N=5). Full results + interpretation in DESIGN.md section 24. enable_ruin_recreate stays default OFF; no per-size flip supported. Caveat: sweep scales room count via duplicated interchangeable room codes (same mechanism harbor-house uses to reduce complexity), which may not isolate the same variable as a genuinely diverse third example programme would.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-26T08:36:17Z","created_by":"Bruno Postle","updated_at":"2026-07-26T14:33:29Z","started_at":"2026-07-26T09:34:23Z","closed_at":"2026-07-26T14:33:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-2ax","title":"Spike: autodiff/gradient-based inner-loop ratio optimisation","description":"innerloop.py's default ratio optimiser is multi-start Nelder-Mead (nm_search), chosen because it 'outperforms CMA-ES across harbor-house scale' -- both derivative-free, a legacy of the Perl-subprocess oracle era when the fitness function was not differentiable. Fitness is now a native Python port (fitness.py) with geometry (geometry.py) built from ordinary arithmetic (Heron's-formula areas etc.) that is plausibly differentiable end-to-end. Nobody has revisited derivative-based optimisation now that this is possible.\n\nReal risk to test rather than assume: the deliberately-preserved 0.5^n failure-count penalty cliff (DESIGN.md 4.5, kept specifically to protect the inner loop from trading into new failures) is a sharp discontinuity by design, which could make raw gradients unreliable or misleading near failure boundaries.\n\nScope as a SPIKE: implement the ratio-to-fitness path in a differentiable form (JAX or PyTorch, autodiff over cut ratios for a frozen topology), compare convergence speed/quality against nm_search on a handful of frozen topologies from programme-house and harbor-house before deciding whether to invest further. If gradients are usable, the payoff is a much faster inner loop, which frees budget for more topology exploration elsewhere -- but per 12.3's finding that the current residual is NOT search/eval-count bound, this may only pay off in future phases at larger scale, not on the current residual.","status":"open","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-07-25T20:18:27Z","created_by":"Bruno Postle","updated_at":"2026-07-25T20:18:27Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-2ax","title":"Spike: autodiff/gradient-based inner-loop ratio optimisation","description":"innerloop.py's default ratio optimiser is multi-start Nelder-Mead (nm_search), chosen because it 'outperforms CMA-ES across harbor-house scale' -- both derivative-free, a legacy of the Perl-subprocess oracle era when the fitness function was not differentiable. Fitness is now a native Python port (fitness.py) with geometry (geometry.py) built from ordinary arithmetic (Heron's-formula areas etc.) that is plausibly differentiable end-to-end. Nobody has revisited derivative-based optimisation now that this is possible.\n\nReal risk to test rather than assume: the deliberately-preserved 0.5^n failure-count penalty cliff (DESIGN.md 4.5, kept specifically to protect the inner loop from trading into new failures) is a sharp discontinuity by design, which could make raw gradients unreliable or misleading near failure boundaries.\n\nScope as a SPIKE: implement the ratio-to-fitness path in a differentiable form (JAX or PyTorch, autodiff over cut ratios for a frozen topology), compare convergence speed/quality against nm_search on a handful of frozen topologies from programme-house and harbor-house before deciding whether to invest further. If gradients are usable, the payoff is a much faster inner loop, which frees budget for more topology exploration elsewhere -- but per 12.3's finding that the current residual is NOT search/eval-count bound, this may only pay off in future phases at larger scale, not on the current residual.","notes":"DONE (negative, wall-clock). Full writeup: DESIGN.md §34.\n\nBuilt experiments/autodiff_spike.py: torch mirror of geometry.py's coordinate\nrecursion driving the 5 continuous per-leaf quality factors + cost/value sums,\nwith all structural facts (adjacency set, types, non-continuous fails,\nbuilding_factor) frozen from a real fitness.py snapshot, and the 0.5^n cliff\nrelaxed to a steep sigmoid so the proxy is smooth everywhere.\n\nMeasured on two frozen topologies:\n- programme-house/candidate-002.dom (6 DOF): nm_search 200 evals/3.0s -\u003e\n 0.0142 fitness (2 fails) vs torch 200 Adam steps/106s -\u003e 0.0041 (3 fails).\n ~35x slower AND worse.\n- harbor-house/3m.dom (36 DOF): nm_search 200 evals/14.6s vs torch ~2.1s per\n single fwd+bwd step (~420s projected for 200 steps). ~29x slower per unit\n of progress.\n\nRoot cause: per-op torch tensor dispatch overhead with no batching (each leaf\nis a handful of scalar ops, nothing here is matmul-shaped), plus snapshot/\nresnapshot cost comparable to a full oracle eval paid on top of each gradient\nstep rather than instead of it. A step-size sensitivity check (lr 0.01 vs 0.03\nvs 0.1 from the same x0) confirmed the flagged 0.5^n cliff risk concretely:\n0.03 improved true fitness, 0.01 and 0.1 from the same descent direction both\ncrossed into a new failure and scored worse -- gradient direction carries real\nlocal signal but is exactly as fragile near the cliff as predicted, and\nautodiff doesn't make it cheaper here.\n\nVerdict: not recommended, no further investment at current 6-40 DOF scale.\nScript kept as reference, not wired into innerloop.py.","status":"in_progress","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-25T20:18:27Z","created_by":"Bruno Postle","updated_at":"2026-08-01T09:25:16Z","started_at":"2026-08-01T08:53:43Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-c94","title":"Beam/best-first search over adjacency-aware construction decisions","description":"operators._assign_adjacency_aware builds a seed topology via a single greedy pass: greedy connected-dominating-set circulation spine, then constraint-hardest-first room placement onto dominated leaves. Since construction quality is the one lever with a consistent positive track record (11.6, 11.7, 12.2), a width-K beam/best-first search over the SAME construction decisions (which leaf a room lands on, tie-broken today by stochastic order) -- keeping several partial constructions alive and expanding the most promising by a cheap proxy (e.g. operators.predicted_shape_fails or partial fail count) -- may find meaningfully better seeds than one greedy pass per bootstrap individual, without touching the outer topology-search loop at all.\n\nScope: prototype as an alternate constructive_topology path (env-flagged, default off, matching the project's existing A/B convention), measure raw-seed quality (adjacency-to-c / access fail counts, per 11.6's own before/after table) and end-to-end fails at budget on programme-house + harbor-house before considering it as a default.","notes":"CORRECTED 2026-07-28: the close reason's 'byte-identical at every width, no headroom' claim was wrong -- it was based only on a raw-seed (single construction) check, which never diverged. Prompted by the user asking to verify via an actual end-to-end run: driver.search from a clean bootstrap (5 seeds/programme, budget 1500, n_workers=1) DOES diverge on harbor-house (2 wins/1 loss/2 ties vs greedy, mean fails 56.8-\u003e55.4) because a full pop_size population hits beam-vs-greedy tie-breaks a lone raw seed sample missed; programme-house stayed tied 5/5. Verdict is now INCONCLUSIVE on harbor-house (small-N, mixed direction, not a validated win) rather than a confident null, though the practical disposition is unchanged: construction_beam_width stays default 1. See corrected DESIGN.md section 29.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-25T20:18:26Z","created_by":"Bruno Postle","updated_at":"2026-07-28T06:52:44Z","started_at":"2026-07-27T21:26:09Z","closed_at":"2026-07-27T22:58:42Z","close_reason":"DONE (null): implemented beam/best-first search over adjacency-aware room placement (operators._beam_place_rooms, beam_width param, default 1 = exact prior greedy behaviour). Verified functioning on an adversarial synthetic case (beam_width\u003e=2 recovers an adjacency greedy misses), but on both example programmes (programme-house, harbor-house) raw-seed output is byte-identical to greedy at every width tested (1/4/8/20) -- the circulation-spine construction already gives most rooms interchangeable neighbour options, so there's no headroom for the beam to find. See DESIGN.md section 29.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-lj3","title":"Try higher _MUTATION_WEIGHTS for bridge_circulation to fire more often (homemaker-py-8sh)","description":"homemaker-py-8sh's A/B (DESIGN.md §21) found two of three harbor-house seeds\nnever even fired mutate_bridge_circulation (byte-identical fitness to 6 sig\nfigs ON vs OFF) within a 2500-eval budget -- at the uniform default weight in\ndriver._MUTATION_WEIGHTS the operator is drawn ~1-in-17 times a mutation\nfires, same priority as cosmetic ops like rotate, despite a 'not connected'\nfail being exactly as fatal to the fail-count-first comparator key as a\nmissing required space (which place_missing already gets a 2.0 weight for).\n\nGOAL: re-run the qi6-protocol A/B (experiments/run_8sh_ab.sh) with\nbridge_circulation's _MUTATION_WEIGHTS entry raised (try 2.0, matching\nplace_missing) to see whether firing it more often increases the\nnot-connected clear rate (currently 2/5) without amplifying the\ntrajectory-divergence noise seen on harbor-house seed 1. Depends on / should\nbe run alongside the larger-N confirmation sweep (homemaker-py-qjg) --\nweight and sample-size are separate variables, ideally tested together in\none combined larger-N sweep with the higher weight, not two separate small\nsweeps.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-24T18:38:40Z","created_by":"Bruno Postle","updated_at":"2026-07-25T09:15:54Z","started_at":"2026-07-24T18:59:06Z","closed_at":"2026-07-25T09:15:54Z","close_reason":"Measured negative (DESIGN.md §22): raising bridge_circulation's _MUTATION_WEIGHTS to 2.0 (matching place_missing) tested together with the qjg larger-N sweep. No total-fail benefit (p=0.71 programme-house N=20, p=0.69 harbor-house N=12) and MORE trajectory-divergence-induced new not-connected fails than at uniform weight (3/20 vs 0/5 at the original small-N weight). Reverted to implicit uniform weight.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-qjg","title":"Larger-N seed sweep to confirm bridge_circulation's mixed A/B (homemaker-py-8sh)","description":"homemaker-py-8sh landed operators.mutate_bridge_circulation (qi6 mechanism (a):\nexplicit repair op that bridges disconnected circulation components) gated\nbehind driver.search's enable_bridge_circulation flag, default off. The\nqi6-protocol A/B (N=3 harbor-house seeds, N=5 programme-house seeds,\nexperiments/run_8sh_ab.sh, DESIGN.md §21) was directionally positive --\ntotal fails never regressed (4 wins/4 ties/0 losses) and 2/5 baseline\nnot-connected fails were cleared (vs 0/4 for qi6's graded-signal mechanism)\n-- but one seed (harbor-house seed 1) saw 2 NEW not-connected fails appear\nalongside its biggest fail-count win, attributed to RNG-trajectory\ndivergence rather than the operator itself (it can only ever convert a leaf\nTO circulation, never away).\n\nGOAL: resolve whether the mean fail-count improvement (harbor 72.0-\u003e69.7,\nprogramme 7.8-\u003e6.8) is a true small positive or small-sample noise, the same\nquestion homemaker-py-1ph answered for collapse_insearch/qpk. Re-run the\nprogramme-house arm (and/or harbor-house) at ~4x the sample (e.g. 20 seeds)\nusing the same protocol (experiments/run_8sh_ab.sh, or a copy), paired\nt-test on per-seed fail-count diffs. If confirmed positive, flip\nenable_bridge_circulation's default to on in driver.py/evolve.py, mirroring\n1ph's flip of collapse_insearch.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-24T18:37:15Z","created_by":"Bruno Postle","updated_at":"2026-07-25T09:16:17Z","started_at":"2026-07-24T18:59:28Z","closed_at":"2026-07-25T09:16:17Z","close_reason":"Measured null (DESIGN.md §22), opposite of 8sh's directional signal: larger-N sweep (programme-house N=20, harbor-house N=12, 1ph protocol) found the 8sh A/B's small positive was small-sample noise, not a true effect -- paired t-test p=0.71 (programme-house) and p=0.69 (harbor-house), both indistinguishable from zero. enable_bridge_circulation stays default OFF.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
|
@ -98,27 +98,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":"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":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."}
|
||||
{"_type":"memory","key":"experiment-harness-gotcha-the-leaf-sharing-relaxed-objective","value":"Experiment harness gotcha: the leaf-sharing RELAXED objective (§13.3) is injected ONLY by monkeypatching fitness.load_config in the parent process (run_staged_search.py / probe scripts). This is parent-process-only and does NOT propagate into ProcessPoolExecutor workers (n_workers\u003e1), which re-import fitness fresh and score under the STRICT on-disk patterns.config -\u003e r.n_fails MISMATCH (worker strict vs parent relaxed re-score). ALL §13.x floor runs were therefore SERIAL. Any future PARALLEL leaf-sharing experiment will silently mis-score until leaf_sharing lives on disk/CLI (tracked: homemaker-py-x3b). The parallel driver itself is correct; both paths score via load_config(programme_dir)."}
|
||||
{"_type":"memory","key":"cli-tool-style-prefer-python-m-homemaker-module","value":"CLI tool style: prefer python -m homemaker.module --parameters pattern, installable via pip install -e . with pyproject.toml entry_points. Not standalone bin/ scripts."}
|
||||
{"_type":"memory","key":"experiment-seeding-pitfall-run-search-scaled-py-s","value":"Experiment seeding pitfall: run_search_scaled.py's default PH_SEED (c964…dom) is a FINISHED programme-house design — passing it warm-starts and floors at ~3 fails, NOT a blank-slate topology search. For blank-slate runs comparable to §11.5/§11.6 baselines, seed from examples/programme-house/init.dom (a bare undivided plot; driver bootstrap auto-triggers only on bare plots). Bit the 6zy sweep — first pass used c964 and falsely showed 3-fail floor across the whole grid."}
|
||||
{"_type":"memory","key":"homemaker-py-3l6-fix-leaf-sharing-evolve-runs","value":"homemaker-py-3l6 fix: leaf-sharing evolve runs now auto-finish before write via driver.polish_finish — unfold_shared_leaves() then a warm-started leaf_sharing=False polish search (--polish-budget, default budget//2). Makes the written .dom honest under canonical homemaker-fitness (internal==canonical when leaf_sharing off). Interrupt path forces polish_budget=0 (unfold+rescore only). This is yaa's unfold-then-polish, made automatic; Schedule B annealing is still kpu."}
|
||||
{"_type":"memory","key":"ld2-13-6-interior-o-seed-diagnostic-all","value":"ld2/§13.6 interior-O seed diagnostic: ALL crinkliness fails in the constructed bal+share seed are UNDER-exposed (crink\u003c0.62, landlocked rooms with no facade + no uncovered-O neighbour) — zero over-exposed sliver fails. So the erc crinkliness residual is genuine under-daylighting, validating the interior light-well premise. Default outside_divisor=6 was too sparse (null: harbor 147-\u003e142, crinkliness even rose). odiv=3 is the seed-optimal joint setting: harbor seed fails 147-\u003e129 (-18), maple 219-\u003e206 (-14), landlocked fails drop, at cost of more leaves (harbor +4, maple +8). Because it ADDS leaves it carries the §13.4 wash-out risk; A/B to convergence pending."}
|
||||
{"_type":"memory","key":"strategy-decision-2026-06-12-bruno-occlusion-daylight","value":"Strategy decision 2026-06-12 (Bruno): occlusion/daylight is ORTHOGONAL to building a scalable optimiser. Disable it in Urb (env flag, homemaker-py-gp2) rather than port it; native fitness uses simple crinkliness (illumination factor = 1); rebuild occlusion in Python only after optimisation is fully native (homemaker-py-2g5, now P4). Consequence: all scores change when the flag flips — re-baseline corpus/.score, DESIGN \\$4.5 gains, gate bars at one clean boundary AFTER homemaker-py-1p0 closes; Phase-2 urb-evolve benchmark must run with the same flag."}
|
||||
{"_type":"memory","key":"urb-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":"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":"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":"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":"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":"island-model-psk-14-is-a-null-priming","value":"Island model (psk, §14) is a NULL: priming a population from N converged independent elites + crossover-heavy migration does not beat best-of-N at equal total budget (maple island 124 vs control 116). The child_probe instrument shows WHY: area-matched crossover across independently-converged elites almost never synthesizes (1-3 of ~64 children beat the better parent, max drop 2-5) because the slicing encoding is non-canonical (9gp), so splices are disruptive not combinatorial. Search-machinery null #3 after graded-objective and niching/restarts; residual stays geometry/shape-bound."}
|
||||
{"_type":"memory","key":"never-use-corpus-filenames-candidate-001-dom-candidate","value":"Never use corpus filenames (candidate-001.dom, candidate-002.dom, generated.dom, init.dom, etc.) as --output targets when running experiments. These are test fixtures. Always write experimental outputs to scratch/ or a timestamped path. Lesson from 2026-06-14: warm-start runs overwrote candidate-001/002.dom and broke graph tests."}
|
||||
{"_type":"memory","key":"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":"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":"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":"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-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":"9o5-multi-use-leaves-is-path-a-superposition","value":"9o5 multi-use leaves is path (a) — superposition as SEARCH RELAXATION that COLLAPSES to specific usage at the end, NOT path (b) loose-fit/no-collapse. Bruno's intent: codes with SIMILAR leaf requirements form an interchangeable equivalence class; during evolution the solver doesn't commit which leaf serves which specific usage (smoother landscape, no fighting over exact leaf usage); at the end the layout is CONDENSED to specific usages by brute-forcing the in-class assignment (3 interchangeable usages over 3 leaves = 3! = 6 combinations to check, pick best). 'Derive automatically' compatibility = requirement-similarity grouping. This reverses the issue's stated 'path b preferred' note."}
|
||||
{"_type":"memory","key":"collapse-global-94g-and-any-label-usage-optimisation","value":"collapse_global (94g) and any label/usage optimisation CANNOT fix geometry-intrinsic fails. The harbor-house 15-fail best layout contains long-thin cells that are useless whatever room usage is assigned — their width/proportion/crinkliness fails are shape-bound, not label slack. Two consequences: (1) do not over-claim collapse gains — only ~2-3 of that layout's fails are reclaimable relabel slack, the rest are geometry- or building-level bound; (2) the threshold objective must not be tuned to 'pass' a degenerate cell via a permissive room type — a metric-pass on a physically useless space is gaming, not a fix. Real remedies for these are geometry/topology search (cell shape) and circulation placement, filed separately, not the collapse."}
|
||||
{"_type":"memory","key":"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":"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":"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":"cli-tool-style-prefer-python-m-homemaker-module","value":"CLI tool style: prefer python -m homemaker.module --parameters pattern, installable via pip install -e . with pyproject.toml entry_points. Not standalone bin/ scripts."}
|
||||
{"_type":"memory","key":"homemaker-py-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":"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":"experiment-harness-gotcha-the-leaf-sharing-relaxed-objective","value":"Experiment harness gotcha: the leaf-sharing RELAXED objective (§13.3) is injected ONLY by monkeypatching fitness.load_config in the parent process (run_staged_search.py / probe scripts). This is parent-process-only and does NOT propagate into ProcessPoolExecutor workers (n_workers\u003e1), which re-import fitness fresh and score under the STRICT on-disk patterns.config -\u003e r.n_fails MISMATCH (worker strict vs parent relaxed re-score). ALL §13.x floor runs were therefore SERIAL. Any future PARALLEL leaf-sharing experiment will silently mis-score until leaf_sharing lives on disk/CLI (tracked: homemaker-py-x3b). The parallel driver itself is correct; both paths score via load_config(programme_dir)."}
|
||||
{"_type":"memory","key":"experiment-seeding-pitfall-run-search-scaled-py-s","value":"Experiment seeding pitfall: run_search_scaled.py's default PH_SEED (c964…dom) is a FINISHED programme-house design — passing it warm-starts and floors at ~3 fails, NOT a blank-slate topology search. For blank-slate runs comparable to §11.5/§11.6 baselines, seed from examples/programme-house/init.dom (a bare undivided plot; driver bootstrap auto-triggers only on bare plots). Bit the 6zy sweep — first pass used c964 and falsely showed 3-fail floor across the whole grid."}
|
||||
{"_type":"memory","key":"urb-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":"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":"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."}
|
||||
|
|
|
|||
58
DESIGN.md
58
DESIGN.md
|
|
@ -3583,3 +3583,61 @@ it manually despite the null aggregate result — but no further investment (def
|
|||
combination strategies, or a larger sweep) is planned. This closes out `homemaker-py-1s3` and, with it, both
|
||||
halves of §26's original multi-use-leaves question: path (a) (search relaxation) was NULL/NEGATIVE, path (b)
|
||||
(permanent fusion) is NULL after replication.
|
||||
|
||||
## 34. Spike: autodiff/gradient-based inner-loop ratio optimisation (`homemaker-py-2ax`) — DONE (negative, wall-clock)
|
||||
|
||||
**Motivation.** `innerloop.py`'s default inner-loop optimiser (`nm_search`, multi-start Nelder-Mead) is
|
||||
derivative-free — a legacy of the Perl-subprocess oracle era when fitness was not differentiable. Fitness is
|
||||
now a native Python port (`fitness.py`) built from ordinary arithmetic (Heron's-formula areas, Gaussian
|
||||
quality terms), plausibly differentiable. Nobody had tried gradient-based optimisation since the port. Real
|
||||
risk flagged going in: the deliberately-preserved `0.5^n` failure-count penalty cliff (§4.5) is a sharp
|
||||
discontinuity by design, which could make raw gradients unreliable near failure boundaries.
|
||||
|
||||
**What was actually built.** The full fitness pipeline (`_evaluate_full`, 1700+ lines) is not literally
|
||||
differentiable end-to-end regardless of the geometry — staircase fit truncates to integers
|
||||
(`_risers_number`/`_ideal_going`/`_*_turn`), physical adjacency is a `door_width` threshold on wall overlap,
|
||||
`access` is a categorical neighbour-type test, and `check_space_counts`/`check_adjacency`/etc. are graph
|
||||
algorithms over discrete labels. Porting all of that to an autodiff framework was out of scope for a spike
|
||||
and would still bottom out in the same non-smooth primitives. Built instead: `experiments/autodiff_spike.py`,
|
||||
a torch mirror of `geometry.py`'s coordinate recursion (`coordinate`/`coord_a`/`coord_b`/`area`/
|
||||
`edge_length`/`angle`/`aspect`, exact port, tensors instead of floats) driving the 5 per-leaf quality factors
|
||||
that vary continuously with the ratios (perpendicular, proportion, size, width, crinkliness) plus the
|
||||
cost/value accumulation (leaf cost, edge cost, outside-edge cost). Every *structural* fact that doesn't vary
|
||||
continuously for a frozen topology — which leaves are adjacent, boundary ids, leaf types/params, which fails
|
||||
are structural (missing/adjacency/level/vertical/access/staircase/storey/edge-too-long) — is snapshotted
|
||||
ONCE from a real `fitness.py` evaluation at the start ratios (`TorchTopology._snapshot`) and held frozen;
|
||||
`building_factor` (programme area-ratio Gaussians, staircase volume, storey/public-access checks) is folded
|
||||
into one calibration constant rather than ported. The `0.5^n` cliff itself is relaxed to a steep sigmoid
|
||||
(`soft_fail`, steepness 60) on each continuous factor's `FAIL_THRESHOLD` test, so the proxy is smooth
|
||||
everywhere — this directly probes the flagged risk rather than assuming it away. `torch.optim.Adam` ascends
|
||||
the proxy; the true fitness (`NativeEvaluator`-equivalent) is re-checked and the topology re-snapshotted
|
||||
periodically, a trust-region-style refresh since the frozen adjacency set can in principle drift as ratios
|
||||
move.
|
||||
|
||||
**Measured, two frozen topologies (CPU, no GPU in this environment):**
|
||||
|
||||
| topology | DOF | nm_search (200 evals) | torch: 1 fwd+bwd step | ratio |
|
||||
|---|---:|---|---|---:|
|
||||
| `programme-house/candidate-002.dom` | 6 | 200 evals / **3.0 s**, fitness 0.0142 (2 fails) | 200 Adam steps (10 resnaps) / **106 s**, fitness 0.0041 (3 fails) — worse on both axes | **~35×** slower, worse result |
|
||||
| `harbor-house/3m.dom` | 36 | 200 evals / **14.6 s** | 1 step ≈ **2.1 s** (200 steps ⇒ ~420 s projected, before resnapshot overhead) | **~29×** slower per unit of search progress |
|
||||
|
||||
The slowdown is per-op tensor dispatch overhead (each leaf/edge is a handful of scalar torch ops, no
|
||||
batching across leaves — nothing here is a large matmul torch is built to accelerate) plus the snapshot/
|
||||
re-snapshot cost (a real `fitness.py` evaluation on a deep copy, same cost class as one `nm_search` eval, but
|
||||
paid on top of the gradient step rather than instead of it). A small-step gradient test (`lr` 0.01/0.03/0.1
|
||||
from the same `x0`) confirmed the flagged cliff risk concretely: 0.03 improved true fitness, but 0.01 and 0.1
|
||||
from the *same descent direction* both crossed into a new failure and scored worse than the start —
|
||||
gradient *direction* carries real local signal, but step size right next to the cliff is as fragile as the
|
||||
issue predicted, and nothing about autodiff removes that fragility (it only makes the direction cheaper to
|
||||
compute, and the wall-clock numbers show it isn't even cheaper here).
|
||||
|
||||
**Verdict.** NULL/NEGATIVE — not recommended. Even setting aside the failure-cliff sensitivity, the
|
||||
autodiff path is decisively slower per unit of progress than `nm_search` at both scales tested, and does
|
||||
not reach a better fitness in the equal-"budget" comparison at the small scale. The theoretical case for
|
||||
autodiff (avoid the ~DOF-proportional cost of finite-difference-style multi-start search) does not survive
|
||||
contact with this problem's actual shape: very few, cheap-to-evaluate scalar dimensions per leaf, no
|
||||
batching opportunity, and a real per-step evaluation cost (snapshot refresh) comparable to a full oracle
|
||||
call anyway. `experiments/autodiff_spike.py` is kept as a reference/starting point (not wired into
|
||||
`innerloop.py`) should a future architect want to revisit this at a very different scale (e.g. thousands of
|
||||
DOF, where nm_search's `O(DOF)` per-iteration cost would start to dominate) — not worth further investment
|
||||
at current programme/topology sizes (6-40 DOF).
|
||||
|
|
|
|||
588
experiments/autodiff_spike.py
Normal file
588
experiments/autodiff_spike.py
Normal file
|
|
@ -0,0 +1,588 @@
|
|||
"""Spike (homemaker-py-2ax): torch-autodiff ratio optimisation vs nm_search.
|
||||
|
||||
DESIGN.md 4.5's ``0.5^n`` failure-count cliff and the discrete adjacency/access/
|
||||
merge machinery around it mean the FULL fitness is not literally differentiable
|
||||
end-to-end (staircase fit truncates to integers, adjacency existence is a
|
||||
door_width threshold on wall overlap, access is a categorical neighbour-type
|
||||
test). So this does not attempt a faithful whole-pipeline port. Instead it
|
||||
builds a genuinely differentiable LOCAL proxy for a frozen topology:
|
||||
|
||||
* Geometry (``coordinate``/``area``/``edge_length``/``angle``/``aspect``,
|
||||
geometry.py) is ordinary arithmetic on the free cut ratios and is ported
|
||||
to torch exactly.
|
||||
* All *structural* facts that do not vary continuously with the ratios for a
|
||||
frozen topology -- which leaves are adjacent (door_width-thresholded wall
|
||||
overlap), boundary ids, leaf types/params, which fails are structural
|
||||
(missing/adjacency/level/vertical/access/staircase/storey/edge-too-long) --
|
||||
are snapshotted ONCE from a real ``fitness.py`` evaluation at the start
|
||||
ratios and held frozen.
|
||||
* The 5 per-leaf quality factors that vary continuously with geometry
|
||||
(perpendicular, proportion, size, width, crinkliness) are recomputed from
|
||||
torch geometry every step, and their FAIL_THRESHOLD tests are relaxed to a
|
||||
steep sigmoid so the ``0.5^n`` cliff itself is smoothly differentiable --
|
||||
this directly probes the risk flagged in the issue.
|
||||
* ``building_factor`` (programme area-ratio Gaussians, staircase volume,
|
||||
storey/public-access checks) is frozen as a single calibration constant --
|
||||
it is a second-order function of the ratios (areas shift slightly) that
|
||||
was out of scope to port faithfully for a spike.
|
||||
|
||||
The comparison: for each frozen topology, run ``innerloop.nm_search`` (real
|
||||
fitness, one oracle call per eval) and this torch proxy (Adam over the proxy,
|
||||
re-evaluated against real fitness periodically, with periodic structural
|
||||
resnapshots) for a matched evaluation/gradient-step budget, and report the
|
||||
resulting TRUE fitness and wall-clock time for each.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from homemaker_layout import dom as dom_mod # noqa: E402
|
||||
from homemaker_layout import fitness as fit_mod # noqa: E402
|
||||
from homemaker_layout import geometry # noqa: E402
|
||||
from homemaker_layout import graph as graph_mod # noqa: E402
|
||||
from homemaker_layout import innerloop as il # noqa: E402
|
||||
from homemaker_layout import solver # noqa: E402
|
||||
|
||||
torch.set_default_dtype(torch.float64)
|
||||
|
||||
_EPS = il._EPS
|
||||
FAIL_THRESHOLD = fit_mod.FAIL_THRESHOLD
|
||||
_E = fit_mod._E
|
||||
_EXTERNAL = geometry._EXTERNAL
|
||||
_CONTINUOUS_SUFFIXES = ("perpendicular", "proportion", "size", "width", "crinkliness")
|
||||
_STEEPNESS = 60.0 # sigmoid steepness for the soft fail-threshold relaxation
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# torch mirrors of geometry.py -- same recursion, tensors instead of floats.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _interp_t(a, b, t):
|
||||
return a * (1 - t) + b * t
|
||||
|
||||
|
||||
def _dist_t(a, b):
|
||||
return torch.sqrt(torch.clamp((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2, min=1e-24))
|
||||
|
||||
|
||||
def _triangle_area_t(a, b, c):
|
||||
da, db, dc = _dist_t(b, c), _dist_t(a, c), _dist_t(a, b)
|
||||
s = (da + db + dc) / 2
|
||||
return torch.sqrt(torch.clamp(s * (s - da) * (s - db) * (s - dc), min=0.0))
|
||||
|
||||
|
||||
class TorchGeom:
|
||||
"""Differentiable ``coordinate``/``coord_a``/``coord_b`` recursion.
|
||||
|
||||
``xmap`` holds one torch scalar per FREE branch (``innerloop.free_with_keys``
|
||||
order); every other divided node's division is never read by the real
|
||||
recursion either (it delegates to ``below``), so nothing else is needed.
|
||||
"""
|
||||
|
||||
def __init__(self, xmap: dict[int, torch.Tensor]):
|
||||
self.xmap = xmap
|
||||
self._cache: dict = {}
|
||||
|
||||
def coordinate(self, n, idx: int):
|
||||
key = (id(n), idx)
|
||||
hit = self._cache.get(key)
|
||||
if hit is not None:
|
||||
return hit
|
||||
if n.below is not None:
|
||||
result = self.coordinate(n.below, idx)
|
||||
else:
|
||||
rid = (idx + n.rotation) % 4
|
||||
if n.parent is None:
|
||||
pt = n.node[rid]
|
||||
result = torch.tensor([float(pt[0]), float(pt[1])])
|
||||
else:
|
||||
p = n.parent
|
||||
if n.position == "l":
|
||||
result = {0: self.coordinate(p, 0), 1: self.coord_a(p),
|
||||
2: self.coord_b(p), 3: self.coordinate(p, 3)}[rid]
|
||||
else:
|
||||
result = {0: self.coord_a(p), 1: self.coordinate(p, 1),
|
||||
2: self.coordinate(p, 2), 3: self.coord_b(p)}[rid]
|
||||
self._cache[key] = result
|
||||
return result
|
||||
|
||||
def coord_a(self, n):
|
||||
key = (id(n), "a")
|
||||
hit = self._cache.get(key)
|
||||
if hit is not None:
|
||||
return hit
|
||||
if n.below is not None and n.below.divided:
|
||||
result = self.coord_a(n.below)
|
||||
else:
|
||||
t = self.xmap[id(n)]
|
||||
result = _interp_t(self.coordinate(n, 0), self.coordinate(n, 1), t)
|
||||
self._cache[key] = result
|
||||
return result
|
||||
|
||||
def coord_b(self, n):
|
||||
key = (id(n), "b")
|
||||
hit = self._cache.get(key)
|
||||
if hit is not None:
|
||||
return hit
|
||||
if n.below is not None and n.below.divided:
|
||||
result = self.coord_b(n.below)
|
||||
else:
|
||||
t = self.xmap[id(n)]
|
||||
result = _interp_t(self.coordinate(n, 3), self.coordinate(n, 2), t)
|
||||
self._cache[key] = result
|
||||
return result
|
||||
|
||||
def area(self, n):
|
||||
c = [self.coordinate(n, i) for i in range(4)]
|
||||
return _triangle_area_t(c[0], c[1], c[2]) + _triangle_area_t(c[0], c[2], c[3])
|
||||
|
||||
def edge_length(self, n, idx: int):
|
||||
return _dist_t(self.coordinate(n, idx), self.coordinate(n, (idx + 1) % 4))
|
||||
|
||||
def angle(self, n, idx: int):
|
||||
a = self.edge_length(n, idx)
|
||||
b = self.edge_length(n, (idx + 3) % 4)
|
||||
c = _dist_t(self.coordinate(n, (idx + 1) % 4), self.coordinate(n, (idx + 3) % 4))
|
||||
cos_t = torch.clamp((a * a + b * b - c * c) / (2 * a * b), -1.0 + 1e-9, 1.0 - 1e-9)
|
||||
return torch.acos(cos_t)
|
||||
|
||||
def aspect(self, n):
|
||||
asp = (self.edge_length(n, 0) + self.edge_length(n, 2)) / (
|
||||
self.edge_length(n, 1) + self.edge_length(n, 3))
|
||||
return torch.where(asp < 1, 1.0 / asp, asp)
|
||||
|
||||
def length_narrowest(self, n):
|
||||
return torch.min(torch.stack([self.edge_length(n, i) for i in range(4)]))
|
||||
|
||||
|
||||
def gaussian_t(x, a, b, c):
|
||||
return a * torch.exp(-((x - b) ** 2) / (2 * c * c))
|
||||
|
||||
|
||||
def clipped_gaussian_t(x, target: float, sigma: float, good_side: str):
|
||||
gauss = gaussian_t(x, 1.0, target, sigma)
|
||||
flat = x > target if good_side == "above" else x < target
|
||||
return torch.where(flat, torch.ones_like(gauss), gauss)
|
||||
|
||||
|
||||
def soft_fail(value: torch.Tensor) -> torch.Tensor:
|
||||
"""Smooth relaxation of ``value < FAIL_THRESHOLD``: ~1 well below the
|
||||
threshold, ~0 well above, matching sign/shape but differentiable everywhere
|
||||
-- this is the part of the spike that stands in for the real 0.5^n cliff."""
|
||||
return torch.sigmoid(_STEEPNESS * (FAIL_THRESHOLD - value))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Frozen-structure snapshot + differentiable proxy objective
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TorchTopology:
|
||||
def __init__(self, root, programme_dir, conf_overrides: dict | None = None):
|
||||
self.programme_dir = programme_dir
|
||||
conf, cost = fit_mod.load_config(programme_dir, overrides=conf_overrides)
|
||||
self.fit = fit_mod.Fitness(conf, cost)
|
||||
self.root = root
|
||||
self.free = solver.free_branches(root)
|
||||
self._snapshot()
|
||||
|
||||
# -- structural snapshot (all plain python/numpy, no torch) -- #
|
||||
def _snapshot(self) -> None:
|
||||
geometry.clear_cache()
|
||||
root = self.root
|
||||
self.fit.preprocess_building(root)
|
||||
door_width = self.fit.conf("door_width") or 1.2
|
||||
|
||||
levels = dom_mod.levels(root)
|
||||
self.levels = levels
|
||||
graph_base = graph_mod.build_graphs(root, door_width)
|
||||
self.graph_base = graph_base
|
||||
|
||||
# true full evaluation (fresh deep copy) -- ground truth at this x, and
|
||||
# the source of the frozen structural fail count.
|
||||
true_score, true_fails = self.fit.score_with_fails(copy.deepcopy(root))
|
||||
self.x0_true_score = true_score
|
||||
self.x0_true_fails = true_fails
|
||||
n_continuous = sum(
|
||||
1 for f in true_fails if f.rsplit(" ", 1)[-1] in _CONTINUOUS_SUFFIXES
|
||||
)
|
||||
self.n_fails_frozen = len(true_fails) - n_continuous
|
||||
|
||||
self.plot_cost_frozen = self.fit.plot_cost(levels[0] if levels[0].below is None else
|
||||
_lowest(levels[0]))
|
||||
|
||||
self.leaf_facts: list[dict] = [] # one entry per usable leaf
|
||||
self.leaf_cost_facts: list[dict] = [] # one entry per ALL leaves (usable or not)
|
||||
self.edge_facts: list[dict] = [] # frozen adjacency edges (all levels)
|
||||
self.outside_edge_facts: list[dict] = [] # frozen per-leaf external edges
|
||||
|
||||
for li, lvl in enumerate(levels):
|
||||
groups = geometry.boundary_groups(lvl)
|
||||
G = graph_base[li]
|
||||
leaves = lvl.leaves()
|
||||
|
||||
for leaf in leaves:
|
||||
self.leaf_cost_facts.append(self._leaf_cost_fact(leaf))
|
||||
if not dom_mod.is_usable(leaf):
|
||||
continue
|
||||
self.leaf_facts.append(self._leaf_quality_facts(leaf, li, G, groups))
|
||||
self.outside_edge_facts.append(self._outside_edge_fact(leaf))
|
||||
|
||||
for a, b in G.edges():
|
||||
edge_a, edge_b = _find_edge_indices(groups, a, b)
|
||||
if edge_a is None:
|
||||
continue # defensive; should not happen for a real G edge
|
||||
a_out, b_out = dom_mod.is_outside(a), dom_mod.is_outside(b)
|
||||
if a_out and b_out:
|
||||
rate = 0.0
|
||||
else:
|
||||
rate = (self.fit.cost("interior_wall") if not a_out and not b_out
|
||||
else self.fit.cost("exterior_wall"))
|
||||
self.edge_facts.append(dict(
|
||||
a=a, edge_a=edge_a, b=b, edge_b=edge_b,
|
||||
rate=rate, height=fit_mod._height(a),
|
||||
))
|
||||
|
||||
# building_factor calibration: fold the un-ported (mostly x-insensitive
|
||||
# for a frozen topology) building-level factor into one constant so the
|
||||
# proxy's absolute scale is comparable to the real score.
|
||||
proxy0 = self._raw_value_cost(self.free)
|
||||
raw_score0 = (proxy0["value"] / proxy0["cost"]) if proxy0["cost"] else 0.0
|
||||
self.calibration = (true_score / raw_score0) if raw_score0 else 1.0
|
||||
|
||||
def _leaf_cost_fact(self, leaf) -> dict:
|
||||
if dom_mod.is_outside(leaf):
|
||||
covered, supported = dom_mod.is_covered(leaf), dom_mod.is_supported(leaf)
|
||||
if covered and supported:
|
||||
rate = self.fit.cost("outside_covered_supported")
|
||||
elif covered:
|
||||
rate = self.fit.cost("outside_covered")
|
||||
elif supported:
|
||||
rate = self.fit.cost("outside_supported")
|
||||
else:
|
||||
rate = self.fit.cost("outside")
|
||||
else:
|
||||
rate = self.fit.cost("inside")
|
||||
return dict(leaf=leaf, rate=rate)
|
||||
|
||||
def _outside_edge_fact(self, leaf) -> dict:
|
||||
rate = self.fit.cost("boundary") if dom_mod.is_outside(leaf) else self.fit.cost("boundary_wall")
|
||||
edges = [e for e in range(4) if geometry.boundary_id(leaf, e) in _EXTERNAL]
|
||||
return dict(leaf=leaf, rate=rate, edges=edges)
|
||||
|
||||
def _leaf_quality_facts(self, leaf, level_id: int, G, groups) -> dict:
|
||||
t0 = fit_mod._t0(leaf)
|
||||
is_out = dom_mod.is_outside(leaf)
|
||||
|
||||
# perpendicular: sigma scalar, target 1.570796, product over 4 corners
|
||||
perp_sigma = self.fit.conf("perpendicular_outside" if is_out else "perpendicular_inside")
|
||||
|
||||
# proportion
|
||||
if t0 in ("o", "s"):
|
||||
prop_params = self.fit.conf("proportion_outside")
|
||||
elif t0 == "c":
|
||||
prop_params = self.fit.conf("proportion_circulation")
|
||||
else:
|
||||
prop_params = self.fit.get_space_params(leaf.type, "proportion")
|
||||
|
||||
# size: 'constant_one' for outside/sahn; symmetric gaussian otherwise
|
||||
size_const = t0 in ("o", "s")
|
||||
if not size_const:
|
||||
size_params = (self.fit.conf("size_circulation") if t0 == "c"
|
||||
else self.fit.get_space_params(leaf.type, "size"))
|
||||
else:
|
||||
size_params = None
|
||||
|
||||
# width: 'constant_one' special case, else clipped-above gaussian
|
||||
width_const = (
|
||||
t0 in ("o", "s") and not dom_mod.is_covered(leaf)
|
||||
and not dom_mod.is_supported(leaf) and dom_mod.level_of(leaf)
|
||||
)
|
||||
if not width_const:
|
||||
if t0 in ("o", "s"):
|
||||
width_params = self.fit.conf("width_outside")
|
||||
elif t0 == "c":
|
||||
width_params = self.fit.conf("width_circulation")
|
||||
else:
|
||||
width_params = self.fit.get_space_params(leaf.type, "width")
|
||||
else:
|
||||
width_params = None
|
||||
|
||||
# crinkliness / uncrinkliness
|
||||
crink_const = is_out and not dom_mod.is_covered(leaf)
|
||||
if not crink_const:
|
||||
key = "uncrinkliness_circulation" if dom_mod.is_circulation(leaf) else "uncrinkliness"
|
||||
crink_params = self.fit.conf(key)
|
||||
# frozen neighbour/edge-index facts for area_outside
|
||||
nb_edges = []
|
||||
for nb in G.neighbors(leaf):
|
||||
if not dom_mod.is_outside(nb) or dom_mod.is_covered(nb):
|
||||
continue
|
||||
if geometry.boundary_pair_overlap(groups_contributors(groups), leaf, nb) <= 0:
|
||||
pass
|
||||
ea, eb = _find_edge_indices(groups, leaf, nb)
|
||||
if ea is not None:
|
||||
nb_edges.append((nb, ea, eb))
|
||||
own_edges = []
|
||||
perimeter = fit_mod._perimeter(leaf)
|
||||
for e in range(4):
|
||||
bid = geometry.boundary_id(leaf, e)
|
||||
if bid not in _EXTERNAL:
|
||||
continue
|
||||
ptype = (perimeter.get(bid) or "").lower()
|
||||
if ptype in ("private", "fortified"):
|
||||
continue
|
||||
own_edges.append(e)
|
||||
else:
|
||||
crink_params = None
|
||||
nb_edges = []
|
||||
own_edges = []
|
||||
|
||||
# access: frozen boolean (adjacency SET + types are both frozen)
|
||||
access_ok = len(self.fit.access(leaf, G)) > 0 or (
|
||||
not dom_mod.level_of(leaf) and is_out
|
||||
)
|
||||
access_factor = 1.0 if access_ok else 0.01
|
||||
|
||||
rate = self.fit.value_rate(leaf)
|
||||
height = fit_mod._height(leaf)
|
||||
|
||||
return dict(
|
||||
leaf=leaf, level=level_id, is_out=is_out,
|
||||
perp_sigma=perp_sigma,
|
||||
prop_target=prop_params[0], prop_sigma=prop_params[1],
|
||||
size_const=size_const, size_target=(size_params[0] if size_params else None),
|
||||
size_sigma=(size_params[1] if size_params else None),
|
||||
width_const=width_const, width_target=(width_params[0] if width_params else None),
|
||||
width_sigma=(width_params[1] if width_params else None),
|
||||
crink_const=crink_const,
|
||||
crink_target=(crink_params[0] if crink_params else None),
|
||||
crink_sigma=(crink_params[1] if crink_params else None),
|
||||
nb_edges=nb_edges, own_edges=own_edges, height=height,
|
||||
access_factor=access_factor, rate=rate,
|
||||
)
|
||||
|
||||
# -- differentiable proxy -- #
|
||||
def _raw_value_cost(self, free_nodes, x: torch.Tensor | None = None) -> dict:
|
||||
"""value/cost BEFORE calibration, at ``x`` (defaults to the frozen
|
||||
ratios currently stored on the tree, i.e. x0)."""
|
||||
if x is None:
|
||||
x = torch.tensor([float(b.division[0]) for b in free_nodes])
|
||||
xmap = {id(b): x[j] for j, b in enumerate(free_nodes)}
|
||||
geo = TorchGeom(xmap)
|
||||
|
||||
value = torch.zeros(())
|
||||
soft_n_fails = torch.zeros(())
|
||||
for lf in self.leaf_facts:
|
||||
leaf = lf["leaf"]
|
||||
area = geo.area(leaf)
|
||||
|
||||
perp = torch.ones(())
|
||||
for i in range(4):
|
||||
perp = perp * gaussian_t(geo.angle(leaf, i), 1.0, 1.570796, lf["perp_sigma"])
|
||||
soft_n_fails = soft_n_fails + soft_fail(perp)
|
||||
|
||||
aspect = geo.aspect(leaf)
|
||||
prop = clipped_gaussian_t(aspect, lf["prop_target"], lf["prop_sigma"], "below")
|
||||
soft_n_fails = soft_n_fails + soft_fail(prop)
|
||||
|
||||
if lf["size_const"]:
|
||||
size = torch.ones(())
|
||||
else:
|
||||
size = gaussian_t(area, 1.0, lf["size_target"], lf["size_sigma"])
|
||||
soft_n_fails = soft_n_fails + soft_fail(size)
|
||||
|
||||
if lf["width_const"]:
|
||||
width = torch.ones(())
|
||||
else:
|
||||
w = geo.length_narrowest(leaf)
|
||||
width = clipped_gaussian_t(w, lf["width_target"], lf["width_sigma"], "above")
|
||||
soft_n_fails = soft_n_fails + soft_fail(width)
|
||||
|
||||
if lf["crink_const"]:
|
||||
crink_q = torch.ones(())
|
||||
else:
|
||||
outside_len = torch.zeros(())
|
||||
for nb, ea, eb in lf["nb_edges"]:
|
||||
outside_len = outside_len + _edge_overlap_t(geo, lf["leaf"], ea, nb, eb)
|
||||
for e in lf["own_edges"]:
|
||||
outside_len = outside_len + geo.edge_length(lf["leaf"], e)
|
||||
area_outside = outside_len * lf["height"]
|
||||
crink = area_outside / torch.clamp(area, min=1e-9)
|
||||
inv_crink = torch.where(crink > 1e-9, 1.0 / torch.clamp(crink, min=1e-9),
|
||||
torch.zeros(()))
|
||||
crink_q = torch.where(
|
||||
crink > 1e-9,
|
||||
gaussian_t(inv_crink, 1.0, lf["crink_target"], lf["crink_sigma"]),
|
||||
torch.zeros(()),
|
||||
)
|
||||
soft_n_fails = soft_n_fails + soft_fail(crink_q)
|
||||
|
||||
quality = perp * prop * size * width * crink_q * lf["access_factor"]
|
||||
value = value + quality * lf["rate"] * area
|
||||
|
||||
cost = torch.tensor(float(self.plot_cost_frozen))
|
||||
for cf in self.leaf_cost_facts:
|
||||
cost = cost + cf["rate"] * geo.area(cf["leaf"])
|
||||
for ef in self.edge_facts:
|
||||
if ef["rate"] == 0.0:
|
||||
continue
|
||||
width = _edge_overlap_t(geo, ef["a"], ef["edge_a"], ef["b"], ef["edge_b"])
|
||||
cost = cost + ef["rate"] * width * ef["height"]
|
||||
for of in self.outside_edge_facts:
|
||||
length = torch.zeros(())
|
||||
for e in of["edges"]:
|
||||
length = length + geo.edge_length(of["leaf"], e)
|
||||
cost = cost + of["rate"] * length * fit_mod._height(of["leaf"])
|
||||
|
||||
penalty = torch.exp((self.n_fails_frozen + soft_n_fails) * math.log(0.5))
|
||||
return dict(value=value * penalty, cost=cost, soft_n_fails=soft_n_fails)
|
||||
|
||||
def proxy_score(self, x: torch.Tensor) -> torch.Tensor:
|
||||
rc = self._raw_value_cost(self.free, x)
|
||||
score = rc["value"] / torch.clamp(rc["cost"], min=1e-9)
|
||||
return score * self.calibration
|
||||
|
||||
def true_score(self, x: np.ndarray) -> tuple[float, int]:
|
||||
for j, b in enumerate(self.free):
|
||||
b.division = [float(x[j]), float(x[j])]
|
||||
s, fails = self.fit.score_with_fails(copy.deepcopy(self.root))
|
||||
return s, len(fails)
|
||||
|
||||
|
||||
def groups_contributors(groups):
|
||||
out = []
|
||||
for v in groups.values():
|
||||
out.extend(v)
|
||||
return out
|
||||
|
||||
|
||||
def _find_edge_indices(groups, a, b):
|
||||
best = None
|
||||
best_w = -1.0
|
||||
for contributors in groups.values():
|
||||
ea = eb = None
|
||||
for leaf, edge in contributors:
|
||||
if leaf is a:
|
||||
ea = edge
|
||||
if leaf is b:
|
||||
eb = edge
|
||||
if ea is not None and eb is not None:
|
||||
w = geometry._edge_overlap(a, ea, b, eb)
|
||||
if w > best_w:
|
||||
best, best_w = (ea, eb), w
|
||||
return best if best is not None else (None, None)
|
||||
|
||||
|
||||
def _edge_overlap_t(geo: TorchGeom, a, edge_a: int, b, edge_b: int):
|
||||
p_a0, p_a1 = geo.coordinate(a, edge_a), geo.coordinate(a, (edge_a + 1) % 4)
|
||||
p_b0, p_b1 = geo.coordinate(b, edge_b), geo.coordinate(b, (edge_b + 1) % 4)
|
||||
len_a, len_b = _dist_t(p_a0, p_a1), _dist_t(p_b0, p_b1)
|
||||
dists = torch.stack([_dist_t(p_a0, p_b0), _dist_t(p_a0, p_b1),
|
||||
_dist_t(p_a1, p_b0), _dist_t(p_a1, p_b1)])
|
||||
max_dist = torch.max(dists)
|
||||
return torch.where(
|
||||
max_dist <= len_b, len_a,
|
||||
torch.where(max_dist <= len_a, len_b,
|
||||
torch.clamp(len_a + len_b - max_dist, min=0.0)),
|
||||
)
|
||||
|
||||
|
||||
def _lowest(n):
|
||||
while n.below is not None:
|
||||
n = n.below
|
||||
return n
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Optimiser drivers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def run_nm(root, programme_dir, x0: np.ndarray, budget: int) -> dict:
|
||||
root_c = copy.deepcopy(root)
|
||||
for j, b in enumerate(solver.free_branches(root_c)):
|
||||
b.division = [float(x0[j]), float(x0[j])]
|
||||
t0 = time.perf_counter()
|
||||
result = il.optimise(root_c, programme_dir, x0=x0.copy(), budget=budget, method="nm")
|
||||
dt = time.perf_counter() - t0
|
||||
return dict(fitness=result.fitness, n_fails=result.n_fails, evals=result.n_evals, seconds=dt)
|
||||
|
||||
|
||||
def run_torch(root, programme_dir, x0: np.ndarray, steps: int, resnapshot_every: int,
|
||||
lr: float = 0.05) -> dict:
|
||||
root_c = copy.deepcopy(root)
|
||||
for j, b in enumerate(solver.free_branches(root_c)):
|
||||
b.division = [float(x0[j]), float(x0[j])]
|
||||
|
||||
t0 = time.perf_counter()
|
||||
topo = TorchTopology(root_c, programme_dir)
|
||||
x_raw = torch.tensor(x0.copy(), requires_grad=True)
|
||||
opt = torch.optim.Adam([x_raw], lr=lr, maximize=True)
|
||||
|
||||
best_true, best_fails = topo.x0_true_score, len(topo.x0_true_fails)
|
||||
best_x = x0.copy()
|
||||
n_evals = 1 # the initial snapshot's true eval
|
||||
|
||||
for step in range(steps):
|
||||
opt.zero_grad()
|
||||
score = topo.proxy_score(x_raw)
|
||||
(-score).backward()
|
||||
opt.step()
|
||||
with torch.no_grad():
|
||||
x_raw.clamp_(_EPS, 1 - _EPS)
|
||||
|
||||
if resnapshot_every and (step + 1) % resnapshot_every == 0:
|
||||
x_np = x_raw.detach().numpy()
|
||||
true_s, n_fails = topo.true_score(x_np)
|
||||
n_evals += 1
|
||||
if true_s > best_true:
|
||||
best_true, best_fails, best_x = true_s, n_fails, x_np.copy()
|
||||
topo = TorchTopology(root_c, programme_dir) # re-snapshot at current x
|
||||
x_raw = torch.tensor(x_np, requires_grad=True)
|
||||
opt = torch.optim.Adam([x_raw], lr=lr, maximize=True)
|
||||
|
||||
x_np = x_raw.detach().numpy()
|
||||
true_s, n_fails = topo.true_score(x_np)
|
||||
n_evals += 1
|
||||
if true_s > best_true:
|
||||
best_true, best_fails, best_x = true_s, n_fails, x_np.copy()
|
||||
dt = time.perf_counter() - t0
|
||||
return dict(fitness=best_true, n_fails=best_fails, evals=n_evals, seconds=dt)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cases = [
|
||||
("programme-house/candidate-002.dom", "examples/programme-house", 200),
|
||||
("programme-house/cf0b8a77e8b2325f92a7e7d150184a55.dom", "examples/programme-house", 200),
|
||||
("harbor-house/3m.dom", "examples/harbor-house", 400),
|
||||
]
|
||||
repo = Path(__file__).resolve().parents[1]
|
||||
for rel, prog_dir, budget in cases:
|
||||
path = repo / "examples" / rel
|
||||
root = dom_mod.load(str(path))
|
||||
free = solver.free_branches(root)
|
||||
x0 = np.array([(b.division[0] + b.division[1]) / 2 for b in free], dtype=float)
|
||||
print(f"\n=== {rel} (DOF={len(x0)}, budget={budget}) ===")
|
||||
|
||||
nm = run_nm(root, str(repo / prog_dir), x0, budget)
|
||||
print(f"nm_search fitness={nm['fitness']:.6g} fails={nm['n_fails']} "
|
||||
f"evals={nm['evals']} time={nm['seconds']:.2f}s")
|
||||
|
||||
for resnap in (max(budget // 10, 5),):
|
||||
td = run_torch(root, str(repo / prog_dir), x0, steps=budget,
|
||||
resnapshot_every=resnap)
|
||||
print(f"torch(Adam) fitness={td['fitness']:.6g} fails={td['n_fails']} "
|
||||
f"evals={td['evals']} steps={budget} resnap={resnap} time={td['seconds']:.2f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Reference in a new issue