diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index bca79cb..df1aa71 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -55,8 +55,10 @@ {"id":"homemaker-py-nyb","title":"High-locality topology operators (mutation + subtree crossover)","description":"DESIGN.md §5, §7 Phase 2, §8.4. Mutation moves: divide/undivide leaf, swap children, rotate cut, retype leaf, per-floor delta edits, storey add/delete (cf. Urb Mutate.pm — but geometry sliding belongs to the inner loop, not the operator set). Crossover: area-matched subtree exchange (a subtree = a contiguous region, so crossover is meaningful — Crossover.pm). Operators must be high-locality: small genome change =\u003e small phenotype change, so warm-started inner loops stay cheap.","acceptance_criteria":"Each operator produces valid genomes (oracle scores them without error); locality measured (mean fitness/geometry perturbation per operator)","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:37:27Z","created_by":"Bruno Postle","updated_at":"2026-06-12T13:07:37Z","started_at":"2026-06-12T12:54:23Z","closed_at":"2026-06-12T13:07:37Z","close_reason":"operators.py lands: 7 mutations + area-matched crossover, valid-by-construction via genome.encode repair. 115/115 oracle-valid children; locality measured: geom-pert 0.07-0.33 per op, fitness-pert 0.68-0.99 (0.5^n cliff flags raw moves — warm restart + penalty reshaping confirmed load-bearing). Also fixed dom._link stale below-links on structural mutation.","dependencies":[{"issue_id":"homemaker-py-nyb","depends_on_id":"homemaker-py-k2g","type":"blocks","created_at":"2026-06-12T00:39:36Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-k2g","title":"Topology genome: base-floor tree + per-floor deltas + type assignment","description":"DESIGN.md §5.2, §7 Phase 2. Genome = base-floor slicing topology (primary) + per-leaf type assignment + per-floor divide/undivide deltas (Below-inheritance as regulariser; cut owned by lowest storey where its path is divided — §10). Must round-trip to/from dom.py Node trees so the oracle and inner loop consume it directly. Includes storey count and per-floor type overrides.","acceptance_criteria":"Genome \u003c-\u003e .dom round-trip on all 35 corpus files preserves fitness; multi-storey wall stacking preserved","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:37:26Z","created_by":"Bruno Postle","updated_at":"2026-06-12T12:52:34Z","started_at":"2026-06-12T10:55:21Z","closed_at":"2026-06-12T12:52:34Z","close_reason":"genome.py encode/decode lands. 35/35 oracle fitness parity after round-trip (flag-on); genome fixed-point + owned-projection tests. Dead-field discovery: corpus upper storeys carry drifted dead divisions (97) and rotations (187) — canonicalised by decode, validated fitness-neutral.","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-d0s","title":"Experiment: inner-loop optimiser bake-off at equal oracle budgets","description":"DESIGN.md §7 Phase 1, §8.3. DOF is only ~rooms-1 (6–7 on corpus). Compare Nelder-Mead vs CMA-ES vs batched multi-start pattern search at equal oracle-call budgets, measuring fitness gained per oracle call and wall-clock (batch-friendliness matters — §4.6). Measure, don't commit blind.","acceptance_criteria":"Table of fitness-per-budget across \u003e=3 candidates; one optimiser chosen and recorded in DESIGN.md","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:59Z","created_by":"Bruno Postle","updated_at":"2026-06-13T08:48:13Z","started_at":"2026-06-12T21:22:15Z","closed_at":"2026-06-13T08:48:13Z","close_reason":"Bake-off complete: CMA-ES confirmed as Phase 1/2 optimiser. NM wins quality per eval but sequential architecture incompatible with batching (§4.6). Compass stalls on narrow valleys. Results in DESIGN.md §8.3 and experiments/bakeoff_innerloop.*","dependencies":[{"issue_id":"homemaker-py-d0s","depends_on_id":"homemaker-py-1p0","type":"blocks","created_at":"2026-06-12T00:39:35Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"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":"open","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-07-24T18:38:40Z","created_by":"Bruno Postle","updated_at":"2026-07-24T18:38:40Z","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":"open","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-07-24T18:37:15Z","created_by":"Bruno Postle","updated_at":"2026-07-24T18:37:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-1ph","title":"Larger-N seed sweep on programme-house for collapse_insearch default","description":"Follow-on from homemaker-py-qpk (DESIGN.md §20). The collapse_insearch A/B was\nmeasured POSITIVE overall (combined head-to-head ON 6, OFF 2) but kept default\nOFF because the programme-house arm was mixed on a small sample: budget 3000,\nseeds 1-5, ON won 3/5 (mean fails 8.4 -\u003e 7.8, s1 8-\u003e5, s2 8-\u003e7, s4 10-\u003e9 win;\ns3 8-\u003e9, s5 8-\u003e9 loss by one fail). harbor-house was a clean 3/3 win (mean\n80.3 -\u003e 72.0).\n\nGOAL: a larger-N seed sweep on programme-house alone (same budget=3000,\n--collapse-insearch vs baseline, both finished with --collapse) to determine\nwhether the mixed 3/5 result is small-sample noise around a true small\npositive, or a genuine size threshold below which in-search collapse doesn't\npay for its ~1.1-1.9x per-eval cost. If the larger sample confirms a net\npositive on programme-house too, flip collapse_insearch's default from OFF to\nON in evolve.py / driver.py.\n\nNot urgent — qpk's opt-in (--collapse-insearch) is already usable and\ndocumented for harbor-house-scale programmes today; this only decides the\ndefault.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-23T22:12:55Z","created_by":"Bruno Postle","updated_at":"2026-07-24T08:54:46Z","started_at":"2026-07-24T08:26:37Z","closed_at":"2026-07-24T08:54:46Z","close_reason":"Larger-N (20 seed) programme-house sweep confirms POSITIVE: mean fails 7.95-\u003e7.10 (~10.7%), 11W/6L/3T, paired t-test p~0.028. Flipped collapse_insearch default OFF-\u003eON in evolve.py/driver.py. See DESIGN.md section20.","dependencies":[{"issue_id":"homemaker-py-1ph","depends_on_id":"homemaker-py-qpk","type":"discovered-from","created_at":"2026-07-23T23:13:53Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"homemaker-py-8sh","title":"Insert/relocate-circulation mutation operator for not-connected fails","description":"Follow-on from homemaker-py-qi6, mechanism (a). The graded circulation-connectivity\nsignal (mechanism (b)/(c), DESIGN.md §18) was A/B tested full-budget and measured\nNEGATIVE: it never fired on harbor-house (byte-identical output ON vs OFF across 3\nseeds) and on programme-house it only ever perturbed unrelated fails, never a\nnot-connected fail (0/4 cases cleared). A finish-time repair (bridge cells into\ncirculation) was also measured negative earlier (195-\u003e560 fails, see §18 history).\n\nGOAL: an explicit search-time mutation/repair operator that inserts or relocates a\ncirculation cell to bridge a disconnected component directly, rather than relying\non the outer GA to discover connectivity via a comparator-key gradient (which this\nissue's A/B showed doesn't work) or a finish-time relabel (which the earlier\nprototype showed is too late/costly). graph.py already computes connected\ncomponents + connected_circulation to detect where bridging is needed.\n\nMeasure on the 6 evolved layouts from the 94g sweep (not-connected + access +\ninaccessible fail counts), same protocol as qi6.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-07-23T17:21:37Z","created_by":"Bruno Postle","updated_at":"2026-07-23T17:21:37Z","dependencies":[{"issue_id":"homemaker-py-8sh","depends_on_id":"homemaker-py-qi6","type":"discovered-from","created_at":"2026-07-23T18:22:05Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"homemaker-py-8sh","title":"Insert/relocate-circulation mutation operator for not-connected fails","description":"Follow-on from homemaker-py-qi6, mechanism (a). The graded circulation-connectivity\nsignal (mechanism (b)/(c), DESIGN.md §18) was A/B tested full-budget and measured\nNEGATIVE: it never fired on harbor-house (byte-identical output ON vs OFF across 3\nseeds) and on programme-house it only ever perturbed unrelated fails, never a\nnot-connected fail (0/4 cases cleared). A finish-time repair (bridge cells into\ncirculation) was also measured negative earlier (195-\u003e560 fails, see §18 history).\n\nGOAL: an explicit search-time mutation/repair operator that inserts or relocates a\ncirculation cell to bridge a disconnected component directly, rather than relying\non the outer GA to discover connectivity via a comparator-key gradient (which this\nissue's A/B showed doesn't work) or a finish-time relabel (which the earlier\nprototype showed is too late/costly). graph.py already computes connected\ncomponents + connected_circulation to detect where bridging is needed.\n\nMeasure on the 6 evolved layouts from the 94g sweep (not-connected + access +\ninaccessible fail counts), same protocol as qi6.","notes":"A/B MEASURED (2026-07-24, qi6/qpk protocol, experiments/run_8sh_ab.sh) — MIXED,\ndirectionally positive, kept default off. Full writeup: DESIGN.md §21.\n\nSummary: total fail count never regressed on any of the 8 seed-arms tested\n(harbor-house budget=2500 seeds 1-3, programme-house budget=3000 seeds 1-5;\n4 wins / 4 ties / 0 losses). Of the 5 arms whose OFF baseline had a genuine\n\"level N not connected\" fail, 2/5 cleared it (programme-house seeds 4, 5) --\nbetter than qi6's graded-signal mechanism (b), which cleared 0/4. But\nharbor-house seed 1 saw 2 NEW not-connected fails appear (0-\u003e2) in a run that\nalso landed the sweep's single biggest fail-count win via a different\ntopology -- attributed to RNG-trajectory divergence (adding any live operator\nto operators.mutate's weighted draw perturbs every later draw, not just the\nones that select it), not the operator mechanically causing fragmentation\n(it only ever converts a leaf TO circulation, never away).\n\nSample (N=3/N=5, matching qi6's own protocol) is too small/noisy to resolve\na true small positive from chance. Landed gated OFF by default\n(driver.search/search_staged enable_bridge_circulation=False,\nevolve.py --bridge-circulation / HOMEMAKER_BRIDGE_CIRCULATION).\n\nFollow-ups identified, not filed: (a) 1ph-style larger-N seed sweep to\nconfirm/reject the mean improvement; (b) raise bridge_circulation's\n_MUTATION_WEIGHTS entry above uniform (like place_missing's 2.0) so it fires\nmore often -- a not-connected fail is as fatal as a missing space but the op\nis currently drawn no more eagerly than cosmetic ops like rotate.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-23T17:21:37Z","created_by":"Bruno Postle","updated_at":"2026-07-24T18:33:50Z","started_at":"2026-07-24T15:23:11Z","closed_at":"2026-07-24T18:33:50Z","close_reason":"Landed + measured (DESIGN.md §21): mechanism (a) implemented as operators.mutate_bridge_circulation, gated behind enable_bridge_circulation (default off). A/B mixed/directionally-positive at qi6's N=3/N=5 protocol -- never worse on total fails, clears 2/5 not-connected fails vs qi6's 0/4, but one seed's trajectory-divergence noise adds 2 new not-connected fails. Kept default off pending a larger-N confirmation (not filed as its own bead; noted as a follow-up in the DESIGN.md section).","dependencies":[{"issue_id":"homemaker-py-8sh","depends_on_id":"homemaker-py-qi6","type":"discovered-from","created_at":"2026-07-23T18:22:05Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-161","title":"In-search evaluation of shape_rotate/deslim GA operators (7fm follow-up)","description":"homemaker-py-7fm's finish-time hill-climb found zero improving moves for\nmutate_shape_rotate/mutate_deslim (operators.py) on the 6-layout harbor-house\nsweep — every candidate move traded a shape fail for a new adjacency/access\nfail on the already co-evolved layout. That's a different regime from\nin-search use: a full multi-generation GA run gives selection pressure and\npopulation diversity a chance to accept a locally-worse move that a later\nstep or recombination completes.\n\nTo test: thread `fit` through driver.search (currently only reqs is passed to\noperators.mutate; shape_rotate/deslim need `fit` and currently no-op inside\nthe GA). Gate with an enable_shape_repair-style flag, mirroring how\nenable_reassociate (§12.3) let 9gp.2 do a clean A/B. Run full-budget\nharbor-house search with/without across seeds, compare final fail counts.\n\nIf negative again, the geometry-intrinsic residual on harbor-house-scale\nprogrammes may be a genuine floor for this representation, not a repairable\ninefficiency (consistent with §17/§19's framing). See DESIGN.md §19 and bd\nmemory collapse-global-94g-and-any-label-usage-optimisation for full context.","notes":"RESULT (negative, confirms 7fm): full sweep at budget=1,000,000, pop=16, child_budget=80, workers=4, harbor-house/init.dom cold-start, 4 seeds (0-3):\n\nenable_shape_repair=False (baseline): fails [14,15,12,17] mean=14.50, fitness mean=1.741e-05\nenable_shape_repair=True: fails [17,14,16,12] mean=14.75, fitness mean=1.24e-05\n\nNo improvement from threading fit into the GA and letting shape_rotate/deslim fire in-search — mean fails is marginally WORSE with the operators enabled, and the 0.25 delta is far inside the seed-to-seed spread (12-17) in both arms. Matches the smaller pilot (budget=20000, 3 seeds: off mean=31.33, on mean=32.00) at a different scale, and matches 7fm's finish-time hill-climb finding that these operators trade one fail for another on already-co-evolved harbor-house layouts.\n\nConclusion: in-search selection pressure and population diversity do NOT rescue shape_rotate/deslim on harbor-house-scale programmes. Supports DESIGN.md §19's framing — the residual fails here look like a genuine floor for this representation on this programme, not a repairable inefficiency reachable by richer local operators. Consistent with bd memory collapse-global-94g-and-any-label-usage-optimisation (geometry-intrinsic fails need geometry/topology search, not local repair or relabeling).\n\nCode kept (not reverted): driver.search()/search_staged() gained enable_shape_repair: bool = False, threading a cached fitness.Fitness instance into operators.mutate() only when set, mirroring the enable_reassociate clean-toggle pattern. Default off reproduces prior runs byte-for-byte. Test: test_enable_shape_repair_threads_fit_into_mutate in tests/test_driver.py. Kept for reuse/reproducibility per the enable_reassociate precedent, not because the flag should default on.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-19T10:05:51Z","created_by":"Bruno Postle","updated_at":"2026-07-22T15:54:47Z","started_at":"2026-07-19T19:57:48Z","closed_at":"2026-07-22T15:54:47Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-qpk","title":"In-search WFC collapse: run collapse_global per-eval during search (A/B vs finish-time)","description":"94g landed and validated the FINISH-TIME global cell-\u003eroom collapse (label search\nover a fixed geometry, monotone, best layout 15-\u003e12). The original 94g thrust was\na per-eval IN-SEARCH collapse: evolution searches unlabelled floorplans and the\nfitness collapses (optimally labels) each candidate before scoring, so search\noptimises the condensed objective directly. This issue is that step.\n\nMECHANISM: call collapse_global (or a cheaper incremental variant) inside\n_evaluate_full before the checks, same as the 9o5 collapse_superposition hook\n(fitness.py:_evaluate_full gates on self._superpose). Reuse the 94g substrate:\nc/o/s partition, level hard constraint, adjacency relaxation, public-access pin,\nthreshold objective.\n\nRISK (carried from homemaker-py-xi7, why 9o5 went negative): fitness =\nmax-over-labellings flattens/roughens the landscape — many topologies collapse to\nsimilar best scores, removing the gradient evolution climbs. AMPLIFIED at full\n(global) scope. The finish-time result does NOT de-risk this: finish-time is\nstrictly cannot-worsen by construction; in-search changes the objective every\neval. MUST A/B superpose-global ON vs OFF with a relaxation-gap log, exactly like\nxi7 did for 9o5, before adopting.\n\nCOST: collapse_global builds graphs + an assignment relaxation per eval — far more\nthan 9o5's per-class collapse. Needs an incremental/cheap variant or caching to be\naffordable in the inner loop; profile first.\n\nPrereq ordering: circulation placement (homemaker-py-qi6) and shape repair change\nthe skeleton/geometry the collapse labels over, so ideally sequence those first.\nRelated: 94g (finish-time, done), xi7 (9o5 A/B + relaxation-gap log), 9o5.","notes":"A/B VALIDATION COMPLETE (2026-07-19), xi7 protocol, 4 workers, equal eval\nbudget, both arms finished with standard --collapse (94g finish-time) so\ncomparison is on the final COLLAPSED score:\n\nharbor-house (init.dom, budget=2500, seeds 1-3): ON WINS 3/3.\n mean fails 80.3 -\u003e 72.0 (s1 85-\u003e74, s2 76-\u003e65, s3 80-\u003e77). No losses.\nprogramme-house (init.dom, budget=3000, seeds 1-5): ON wins 3/5.\n mean fails 8.4 -\u003e 7.8 (s1 8-\u003e5, s2 8-\u003e7, s4 10-\u003e9 win; s3 8-\u003e9, s5 8-\u003e9\n loss by 1 fail). Weaker/noisier on this much smaller building (already\n near its geometry floor, see section13/section19).\nCOMBINED head-to-head: ON 6, OFF 2.\n\nVERDICT: POSITIVE, and the OPPOSITE of the 9o5/xi7 prior (which was\nNULL/NEGATIVE for the per-class interchange relaxation). Unlike 9o5, this is\nthe SAME global WFC-style matching section17/94g already proved\nmonotone/positive at finish time -- running it every eval lets the outer\nsearch see the condensed objective instead of discovering it only once, and\nthe gradient survives rather than flattening. Effect scales WITH building\nsize (harbor-house clean 3/3 vs programme-house mixed), opposite of the 9o5\nlandscape-flattening fear.\n\nCOST: harbor-house wall-clock 102.6s(OFF)-\u003e177.8s(ON) ~1.73x;\nprogramme-house 39.0s-\u003e43.7s ~1.12x. Matches the profiled 1.5-1.9x/eval\nfigure.\n\nDECISION: kept default OFF (programme-house sample too mixed/small to flip\ndefault; 9o5/xi7 scar warrants a second larger-budget confirmation first),\nbut --collapse-insearch is a genuine, tested, working opt-in for\nharbor-house-scale-or-larger programmes. DESIGN.md section20 has full\nwriteup + per-seed numbers. Not filing a follow-up issue -- a larger-N\nprogramme-house seed sweep would be the natural next step if this gets\nrevisited, noted in DESIGN.md as a low-priority idea, not a blocker.\n\nRaw run logs/doms: /tmp scratchpad qpk_ab/ (not committed, ephemeral).","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-18T10:12:54Z","created_by":"Bruno Postle","updated_at":"2026-07-19T19:18:44Z","started_at":"2026-07-19T10:11:11Z","closed_at":"2026-07-19T19:18:44Z","close_reason":"A/B validation complete: POSITIVE (opposite of 9o5/xi7 prior). Harbor-house ON wins 3/3 (mean fails 80.3-\u003e72.0); programme-house mixed 3/5. Combined 6/2. Kept default OFF pending a larger programme-house sample, but --collapse-insearch is a working, documented opt-in. See DESIGN.md section20.","dependencies":[{"issue_id":"homemaker-py-qpk","depends_on_id":"homemaker-py-94g","type":"discovered-from","created_at":"2026-07-18T11:12:54Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-kpu","title":"Schedule B: in-run leaf-sharing annealing (ramp grain down, unfold at each step)","description":"Spun out of homemaker-py-yaa, whose investigation is complete. yaa proved Schedule A (two-phase warm-start) works ONLY when shared leaves are unfolded at the sharing-\u003eno-sharing transition: naive warm-start stalls at 8.66e-08/70 fails, but unfold-then-de-share reaches 4.19e-06/15 fails — matching the direct --no-leaf-sharing baseline. operators.unfold_shared_leaves() is built, tested, and proven.\n\nSchedule B is the in-run variant: instead of a manual two-phase chain, anneal leaf_share_factor down within a single driver run (e.g. 4-\u003e3-\u003e2-\u003eoff) at eval thresholds. At each grain transition: (1) rebuild the cached (dir,sharing) evaluator at the new grain, (2) UNFOLD shared leaves that drop below the new grain so the population stays materialised (reuse operators.unfold_shared_leaves), (3) re-evaluate the whole population under the new evaluator, (4) resume local search. Gradual grain ramp = graduated non-convexity: avoids a single fitness cliff, keeps gross topology fixed on the smaller effective problem early, polishes per-room size/proportion/width late.\n\nDriver hooks needed (driver.py): the evaluator is cached per (dir, sharing) at fitness.py:415 and driver caches one per worker; the ramp must rebuild it and re-score the pop at each threshold. Modest change. Compare head-to-head vs (a) direct baseline 5.14e-06 and (b) the manual unfold warm-chain 4.19e-06 from yaa — does a graduated ramp beat a single hard unfold transition?\n\nWants the circulation-aware unfold from homemaker-py-8iv once available.","notes":"A/B DONE — NEGATIVE (2026-07-17). harbor-house 3M (500k/grain x3 + 1.5M polish, workers 4, ~22h): Schedule B = 1.26e-08 / 23 fails (canonical byte-for-byte). Loses decisively to both targets: direct baseline 5.14e-06/15 and warm-chain 4.19e-06/15 (~400x worse, +8 fails). Graduated ramp FALSIFIED: each grain step spikes fails (phase-end 19-\u003e21-\u003e27, final unfold 27-\u003e36); per-phase budget re-polishes partially-materialised states that the next step materialises further, so coarse-grain gains don't carry forward; polish started from a deeper hole (36) than the warm chain's single clean transition and only reached 23. The sharing-phase topology skeleton (yaa) is best cashed in ONCE at full grain, not annealed. Machinery retained (search_annealed, --anneal-grain, unfold above=, seed_pop, max_share override) — correct/tested/honest — but §15 single-transition finish stays the default. DESIGN §16 updated. Closing.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-07-15T06:48:11Z","created_by":"Bruno Postle","updated_at":"2026-07-17T15:33:01Z","started_at":"2026-07-16T06:47:57Z","closed_at":"2026-07-17T15:33:01Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-kpu","depends_on_id":"homemaker-py-8iv","type":"blocks","created_at":"2026-07-15T07:48:30Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-kpu","depends_on_id":"homemaker-py-yaa","type":"blocks","created_at":"2026-07-15T07:48:28Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} @@ -83,26 +85,26 @@ {"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":"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":"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":"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":"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":"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":"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":"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":"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":"homemaker-py-pythonpath-set-pythonpath-home-bruno-src","value":"homemaker-layout PYTHONPATH: package installed as 'homemaker-layout' via pip install -e . so 'import homemaker_layout' works from anywhere without PYTHONPATH. For running tests use 'python -m pytest' from project root /home/bruno/src/homemaker-layout (pyproject.toml adds src/ automatically). Never try pip show homemaker — that's the old homemaker-addon conflict."} -{"_type":"memory","key":"strategy-decision-2026-06-12-bruno-occlusion-daylight","value":"Strategy decision 2026-06-12 (Bruno): occlusion/daylight is ORTHOGONAL to building a scalable optimiser. Disable it in Urb (env flag, homemaker-py-gp2) rather than port it; native fitness uses simple crinkliness (illumination factor = 1); rebuild occlusion in Python only after optimisation is fully native (homemaker-py-2g5, now P4). Consequence: all scores change when the flag flips — re-baseline corpus/.score, DESIGN \\$4.5 gains, gate bars at one clean boundary AFTER homemaker-py-1p0 closes; Phase-2 urb-evolve benchmark must run with the same flag."} -{"_type":"memory","key":"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":"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":"deceptive-valleys-in-topology-search-when-every-single","value":"Deceptive valleys in topology search: when every single-step mutation from a target state passes through a high-fail intermediary (e.g. level_fix displaces a room into 5+ new fails), a compound operator that atomically applies two coordinated changes can escape. Design compound operators to land on the low-fail state directly, bypassing the deceptive gradient. Programme-house example: level_compound_fix atomically moves the level-constrained room AND re-inserts the displaced room adjacent to C in one step (operators.py, 2026-06-14)."} {"_type":"memory","key":"experiment-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":"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":"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":"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":"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":"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":"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":"adjacency-in-binary-slicing-tree-is-structural-not","value":"Adjacency in binary slicing tree is structural, not geometric: the inner-loop NM cannot fix topological adjacency failures. Two paths exist: (1) tree-sibling adjacency — a node is adjacent to its sibling in the tree; (2) cross-zone geometric adjacency — leaves from different subtrees that happen to share a boundary. Staircase/adjacency fails require a topology mutation that changes which nodes are siblings or which zones touch. This was proved empirically on programme-house: staircase fail from rot=0 layout could not be fixed by NM but was fixed by level_retype creating a two-C topology (2026-06-14/15)."} -{"_type":"memory","key":"collapse-global-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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"experiment-harness-gotcha-the-leaf-sharing-relaxed-objective","value":"Experiment harness gotcha: the leaf-sharing RELAXED objective (§13.3) is injected ONLY by monkeypatching fitness.load_config in the parent process (run_staged_search.py / probe scripts). This is parent-process-only and does NOT propagate into ProcessPoolExecutor workers (n_workers\u003e1), which re-import fitness fresh and score under the STRICT on-disk patterns.config -\u003e r.n_fails MISMATCH (worker strict vs parent relaxed re-score). ALL §13.x floor runs were therefore SERIAL. Any future PARALLEL leaf-sharing experiment will silently mis-score until leaf_sharing lives on disk/CLI (tracked: homemaker-py-x3b). The parallel driver itself is correct; both paths score via load_config(programme_dir)."} +{"_type":"memory","key":"homemaker-py-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":"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":"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":"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)."} diff --git a/DESIGN.md b/DESIGN.md index e9e32b7..6356708 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2670,3 +2670,77 @@ Confirms the qpk verdict holds at both example scales tested. `collapse_insearch needs the cheaper finish-time-only path. `fitness.Fitness` itself is unchanged (still defaults off when `collapse_insearch` is absent from conf — the default lives in the driver/CLI override layer, same contract as `leaf_sharing`). + +## 21. Insert/relocate-circulation repair operator (`homemaker-py-8sh`) — DONE (mixed, kept off) + +**Motivation.** qi6's remaining candidate (§18): mechanism (a), an explicit search-time +mutation/repair operator that inserts or relocates a circulation cell to bridge a disconnected +circulation component directly, rather than relying on the outer GA to discover connectivity via a +comparator-key gradient (mechanism (b)/(c), measured NEGATIVE — the grade never fired on +harbor-house and never cleared a genuine `not connected` fail on programme-house). + +**Mechanism (build).** `operators.mutate_bridge_circulation`: for each storey, builds the leaf +adjacency graph (`geometry.leaf_graph`) and the circulation sub-components (`dom.is_circulation` +nodes only, mirroring `graph.connected_circulation`'s subgraph). When a storey has more than one +component, finds the cheapest path between any pair via a weighted Dijkstra search — edge weight is +the average of its endpoints' conversion cost (`0` for an already-circulation node or a generic +outside `O` leaf — nothing displaced, same rationale as `place_missing`'s host ranking; `1` for any +other non-required leaf; `5` for a leaf typed as a required programme room, crossed only if no +cheaper route exists) — and retypes every intermediate leaf on the cheapest cross-component path to +`C`. A displaced required room becomes a missing-space fail for the existing `place_missing` +operator to re-insert elsewhere on a later step, the same division of labour `mutate_deslim` (§19) +uses. Registered in `operators.MUTATIONS` as a "`reqs`-optional" op — unlike `level_fix`/ +`place_missing` it is never zero-weighted for lacking `reqs` (it needs only the tree's own adjacency +graph), so gating is done the `reassociate` way instead: `driver.search`'s new +`enable_bridge_circulation` flag (default OFF) zeroes its `mutation_weights` entry rather than +relying on an argument being `None`. Threaded through `search_staged` and exposed as +`evolve.py --bridge-circulation` / `HOMEMAKER_BRIDGE_CIRCULATION`. 6 unit tests +(`tests/test_operators.py`): noop when already connected, bridges a synthetic 3-leaf fragmented +fixture via the free leaf, falls back to bridging through a required room when it is the only route, +and prefers a free `O` leaf over a required room when both routes tie in hop length. 296 tests pass. + +**A/B verdict (measured, 2026-07-24, qi6/qpk protocol, +`experiments/run_8sh_ab.sh`).** Equal-budget `enable_bridge_circulation` ON vs OFF, both arms +finished with the standard finish-time `--collapse` (94g), 4 workers, canonical `homemaker-fitness` +re-score for the `.fails` breakdown — harbor-house (`init.dom`, budget 2500, seeds 1–3), +programme-house (`init.dom`, budget 3000, seeds 1–5): + +| programme | seed | fails OFF→ON | not-connected OFF→ON | +|---|---|---|---| +| harbor-house | 1 | 74→67 | 0→**2** | +| harbor-house | 2 | 65→65 (byte-identical) | 1→1 | +| harbor-house | 3 | 77→77 (byte-identical) | 1→1 | +| programme-house | 1 | 5→5 (tie, fitness differs) | 0→0 | +| programme-house | 2 | 7→7 (tie, fitness differs) | 0→0 | +| programme-house | 3 | 9→7 | 1→1 | +| programme-house | 4 | 9→8 | 1→**0** | +| programme-house | 5 | 9→7 | 1→**0** | + +Total fails: harbor-house mean 72.0→69.7, programme-house mean 7.8→6.8 — **never worse** on any +seed (4 wins, 4 ties, 0 losses on total fail count across both programmes). Of the 5 seed-arms +whose OFF baseline actually had a `not connected` fail, **2/5 cleared it** (programme-house seeds +4 and 5) — a genuine improvement over qi6 mechanism (b)'s 0/4. But harbor-house seed 1 shows the +flip side: its OFF baseline had *no* `not connected` fail (0), and ON introduces **two** — while +simultaneously landing the sweep's single largest fail-count win (74→67, fitness 3.2e-26→5.0e-24, +almost two orders of magnitude apart) via a visibly different topology, not a locally-adjusted one. +`mutate_bridge_circulation` only ever converts a leaf *to* circulation, never away from it, so it +cannot mechanically increase fragmentation itself — the regression is trajectory-divergence noise +(adding any nonzero-weight entry to `operators.mutate`'s weighted draw perturbs the RNG mapping for +*every* subsequent draw, not just the ones that select the new operator, exactly as observed for +`enable_reassociate`/`enable_shape_repair`/homemaker-py-161 — the same-seed off/on comparison is +two genuinely different searches from the same seed, not a controlled single-variable diff). Two of +the three harbor-house seeds never diverged at all (byte-identical fitness to 6 significant figures) +— at `_MUTATION_WEIGHTS`' default uniform weighting the operator is drawn roughly 1-in-17 times a +mutation fires, and evidently often never lands on a fragmented storey within a 2500-budget run. + +**Status.** Directionally positive and clearly better-targeted than qi6's graded signal (which +cleared zero `not connected` fails in its own measured protocol), but the N=3/N=5 sample is too +small and too trajectory-noisy to separate a true small positive from chance, per the same caution +`collapse_insearch` was held to before its `homemaker-py-1ph` larger-N confirmation. Kept **default +OFF** (`enable_bridge_circulation=False` in `driver.search`/`search_staged`, +`--no-bridge-circulation` in `evolve.py`). Candidate follow-ups, not yet filed: (a) a larger-N seed +sweep (the `1ph` protocol) to resolve whether the mean improvement is real; (b) raising +`bridge_circulation`'s `_MUTATION_WEIGHTS` entry above the uniform default (mirroring +`place_missing`'s `2.0`) so it fires more often per budget, since a `not connected` fail is exactly +as fatal to fitness as a missing space and the operator is currently drawn no more eagerly than +cosmetic ops like `rotate`. diff --git a/experiments/run_8sh_ab.sh b/experiments/run_8sh_ab.sh new file mode 100755 index 0000000..fcf7942 --- /dev/null +++ b/experiments/run_8sh_ab.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# 8sh A/B (DESIGN.md §18 follow-on): does the explicit bridge_circulation +# repair mutation (qi6 mechanism (a)) actually clear 'level N not connected' +# fails that the graded comparator-key signal (mechanism (b)/(c)) measured +# negative on? Same qpk protocol as qi6: equal-budget ON vs OFF, both arms +# finished with the standard finish-time --collapse (94g) so the comparison +# is apples-to-apples on the final collapsed score. +# +# Authoritative metrics: total fail count and 'not connected' fail count, read +# from the .fails file homemaker-fitness writes (evolve.py itself only prints +# n_fails, not the fail list). Each run appends one TSV row so partial results +# survive an interrupt. +# +# Usage: experiments/run_8sh_ab.sh +set -u +cd "$(dirname "$0")/.." + +WORKERS=4 +OUT=scratch/8sh_ab; mkdir -p "$OUT" +TSV=scratch/8sh_ab_results.tsv +[ -f "$TSV" ] || printf 'programme\tseed\tbridge\tbudget\tfails\tnot_connected\tfitness\telapsed_s\n' > "$TSV" + +run() { # programme seed bridge(0|1) budget + local prog="$1" seed="$2" bc="$3" budget="$4" + local tag="bc${bc}" + local dom="$OUT/${prog}_${tag}_s${seed}.dom" + local log="$OUT/${prog}_${tag}_s${seed}.log" + local flag="--no-bridge-circulation"; [ "$bc" = 1 ] && flag="--bridge-circulation" + echo ">>> $prog seed=$seed bridge=$bc budget=$budget" + local t0; t0=$(date +%s) + homemaker-evolve "examples/$prog/init.dom" \ + --budget "$budget" --workers "$WORKERS" --seed "$seed" \ + $flag --output "$dom" > "$log" 2>&1 + local t1; t1=$(date +%s) + local fitness fails notconn + fitness=$(sed -n 's/^best *: \([0-9.e+-]*\) .*/\1/p' "$log") + fails=$(sed -n 's/^best *: [0-9.e+-]* (\([0-9]*\) fails).*/\1/p' "$log") + # re-score with the canonical scorer to get the .fails breakdown (evolve.py + # only prints the count, not the list); dom lives outside examples/$prog so + # pass an absolute path while cd'd there for patterns.config resolution + ( cd "examples/$prog" && homemaker-fitness "$(realpath "../../$dom")" > /dev/null 2>&1 ) + notconn=0 + if [ -f "${dom}.fails" ]; then + notconn=$(grep -c 'not connected' "${dom}.fails") + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$prog" "$seed" "$bc" "$budget" "${fails:-ERR}" "$notconn" "${fitness:-ERR}" "$((t1-t0))" >> "$TSV" + echo " -> ${fails:-ERR} fails (${notconn} not-connected), fitness=${fitness:-ERR}, $((t1-t0))s" +} + +# harbor-house: budget 2500, seeds 1-3 (qpk protocol) +for seed in 1 2 3; do run harbor-house "$seed" 0 2500; done +for seed in 1 2 3; do run harbor-house "$seed" 1 2500; done + +# programme-house: budget 3000, seeds 1-5 (qpk protocol) +for seed in 1 2 3 4 5; do run programme-house "$seed" 0 3000; done +for seed in 1 2 3 4 5; do run programme-house "$seed" 1 3000; done + +echo "=== 8sh bridge_circulation A/B complete ===" +column -t -s $'\t' "$TSV" diff --git a/src/homemaker_layout/driver.py b/src/homemaker_layout/driver.py index 91b11df..896cfb8 100644 --- a/src/homemaker_layout/driver.py +++ b/src/homemaker_layout/driver.py @@ -236,6 +236,7 @@ def search( seed_proportion_aware: bool = True, enable_reassociate: bool = False, enable_shape_repair: bool = False, + enable_bridge_circulation: bool = False, feasibility_filter: bool = False, feasibility_max_shape_fails: int | None = None, circ_divisor: int = 3, @@ -306,6 +307,18 @@ def search( locally-worse move survive to be completed by a later step or crossover — a different regime. Mirrors ``enable_reassociate``'s clean-toggle A/B pattern (§12.3, 9gp.2): default off reproduces prior runs byte-for-byte. + + ``enable_bridge_circulation`` (homemaker-py-8sh, EXPERIMENTAL, default off) + un-mutes the ``bridge_circulation`` repair operator (homemaker-py-qi6 + mechanism (a)): it retypes leaves on the cheapest path between two + disconnected circulation components to clear a ``level N not connected`` + fail directly, instead of relying on the outer search to discover + connectivity via a comparator-key gradient (qi6 mechanism (b)/(c), measured + NEGATIVE — DESIGN.md §18). Gated the same way as ``reassociate`` (zero + mutation weight unless enabled) rather than ``shape_repair``'s style, + because it needs no ``fitness.Fitness`` instance — only the tree's own + adjacency graph — so it is otherwise unconditionally live once landed in + ``operators.MUTATIONS``. """ from .oracle import DEFAULT_URB_ROOT @@ -318,6 +331,8 @@ def search( mutation_weights = dict(_MUTATION_WEIGHTS) if not enable_reassociate: mutation_weights["reassociate"] = 0.0 + if not enable_bridge_circulation: + mutation_weights["bridge_circulation"] = 0.0 # homemaker-py-161: shape_rotate/deslim are gated by operators.mutate itself # (fit_ops go to zero probability when fit=None) — only build the Fitness # instance, and thus only let them fire, when explicitly enabled. @@ -916,6 +931,7 @@ def search_staged( seed_proportion_aware: bool = True, enable_reassociate: bool = False, enable_shape_repair: bool = False, + enable_bridge_circulation: bool = False, feasibility_filter: bool = False, feasibility_max_shape_fails: int | None = None, circ_divisor: int = 3, @@ -972,6 +988,7 @@ def search_staged( seed_proportion_aware=seed_proportion_aware, enable_reassociate=enable_reassociate, enable_shape_repair=enable_shape_repair, + enable_bridge_circulation=enable_bridge_circulation, feasibility_filter=feasibility_filter, feasibility_max_shape_fails=feasibility_max_shape_fails, circ_divisor=circ_divisor, @@ -1009,6 +1026,7 @@ def search_staged( seed_proportion_aware=seed_proportion_aware, enable_reassociate=enable_reassociate, enable_shape_repair=enable_shape_repair, + enable_bridge_circulation=enable_bridge_circulation, feasibility_filter=feasibility_filter, feasibility_max_shape_fails=feasibility_max_shape_fails, circ_divisor=circ_divisor, @@ -1055,6 +1073,7 @@ def search_staged( restart_patience=restart_patience, restart_elite=restart_elite, enable_reassociate=enable_reassociate, enable_shape_repair=enable_shape_repair, + enable_bridge_circulation=enable_bridge_circulation, feasibility_filter=feasibility_filter, feasibility_max_shape_fails=feasibility_max_shape_fails, circ_divisor=circ_divisor, diff --git a/src/homemaker_layout/evolve.py b/src/homemaker_layout/evolve.py index 76f1584..0d6cbc8 100644 --- a/src/homemaker_layout/evolve.py +++ b/src/homemaker_layout/evolve.py @@ -105,6 +105,15 @@ def _parse_args(argv=None) -> argparse.Namespace: "circulation that the binary 'not connected' fail lacks. " "Does not change the scalar fitness or fail count " "(default: off)") + p.add_argument("--bridge-circulation", dest="bridge_circulation", + action=argparse.BooleanOptionalAction, + default=_env_bool("HOMEMAKER_BRIDGE_CIRCULATION", False), + help="homemaker-py-8sh (qi6 mechanism (a) follow-on): " + "explicit repair mutation that retypes the cheapest " + "path between two disconnected circulation components " + "to circulation, directly clearing a 'level N not " + "connected' fail instead of relying on the qi6 graded " + "comparator key (measured negative, §18) (default: off)") p.add_argument("--collapse-insearch", dest="collapse_insearch", action=argparse.BooleanOptionalAction, default=_env_bool("HOMEMAKER_COLLAPSE_INSEARCH", True), @@ -182,6 +191,7 @@ def main(argv=None) -> int: file=sys.stderr) print(f"superpose : {args.superpose}", file=sys.stderr) print(f"conn grade : {args.conn_grade}", file=sys.stderr) + print(f"bridge circulation : {args.bridge_circulation}", file=sys.stderr) print(f"collapse in-search : {args.collapse_insearch}", file=sys.stderr) print(f"output : {out or 'stdout'}", file=sys.stderr, flush=True) @@ -232,6 +242,7 @@ def main(argv=None) -> int: leaf_share_factor=args.leaf_share_factor, superpose=args.superpose, conn_grade=args.conn_grade, + enable_bridge_circulation=args.bridge_circulation, collapse_insearch=args.collapse_insearch, log=lambda m: print(m, file=sys.stderr, flush=True), ) diff --git a/src/homemaker_layout/operators.py b/src/homemaker_layout/operators.py index 3d2c7b6..3b12e7b 100644 --- a/src/homemaker_layout/operators.py +++ b/src/homemaker_layout/operators.py @@ -373,6 +373,94 @@ def mutate_place_missing(root: dom.Node, rng: np.random.Generator, return _finalise(child), f"place_missing {code} -> {host_id}" +def mutate_bridge_circulation(root: dom.Node, rng: np.random.Generator, + types: list[str], reqs=None) -> tuple[dom.Node, str]: + """Repair operator (homemaker-py-8sh): retype the leaves on the cheapest + path between two circulation components to circulation, directly clearing + a ``level N not connected`` fail (``graph.connected_circulation``). + + Follow-on to homemaker-py-qi6 mechanism (a). Mechanism (b)/(c) — a graded + circulation-connectivity comparator key (DESIGN.md §18) — measured + NEGATIVE: it never fired on harbor-house and never cleared a genuine + not-connected fail on programme-house, because the outer search rarely + hits the fail-count tie the grade needs to break. This operator does not + depend on a tie: for each storey it finds the two circulation components + (``graph.build_graphs``' per-leaf adjacency, restricted to + ``dom.is_circulation`` leaves) joined by the shortest, least-disruptive + path and retypes the intermediate leaves to ``C``. Path cost prefers + generic outside (``O``) leaves (free — nothing displaced, same rationale + as ``mutate_place_missing``'s host ranking), then other non-required + leaves, and only crosses a required programme room (from ``reqs``) if no + other route exists. A displaced required room becomes a missing-space + fail for ``mutate_place_missing`` to re-insert elsewhere on a later step, + the same division of labour ``mutate_deslim`` uses. + """ + import networkx as nx + + from . import geometry as _geo, graph as _graph + + child = copy.deepcopy(root) + lvls = dom.levels(child) + + def _cost(n: dom.Node) -> int: + if dom.is_circulation(n): + return 0 + if not n.type: + return 1 + if n.type[0].lower() == "o": + return 0 + if reqs and n.type in reqs: + return 5 + return 1 + + per_level: list[tuple[int, list[dom.Node]]] = [] + for li, lvl in enumerate(lvls): + G = _geo.leaf_graph(lvl, _graph.DOOR_WIDTH) + circ = [n for n in G.nodes() if dom.is_circulation(n)] + if not circ: + continue + comps = list(nx.connected_components(G.subgraph(circ))) + if len(comps) <= 1: + continue + # Node cost split across its edges (average of endpoint costs) so a + # weighted shortest path sums to (approximately) total intermediate- + # leaf conversion cost — a plain hop-count shortest path would pick an + # arbitrary same-length route and could cross a required room even + # when an equal-length free ('O') route exists. + weighted = G.copy() + for u, v, data in weighted.edges(data=True): + data["bridge_weight"] = (_cost(u) + _cost(v)) / 2.0 + best_path: list[dom.Node] | None = None + best_weight = None + for i in range(len(comps)): + for j in range(i + 1, len(comps)): + tmp = weighted.copy() + tmp.add_node("SRC") + tmp.add_node("DST") + tmp.add_edges_from(("SRC", n, {"bridge_weight": 0.0}) for n in comps[i]) + tmp.add_edges_from(("DST", n, {"bridge_weight": 0.0}) for n in comps[j]) + try: + weight, path = nx.single_source_dijkstra( + tmp, "SRC", "DST", weight="bridge_weight") + except nx.NetworkXNoPath: + continue + if best_weight is None or weight < best_weight: + between = [n for n in path[1:-1] + if n not in comps[i] and n not in comps[j]] + best_weight, best_path = weight, between + if best_path: + per_level.append((li, best_path)) + + if not per_level: + return _finalise(child), "bridge_circulation noop" + + li, path = _pick(rng, per_level) + for leaf in path: + leaf.type = "C" + names = ",".join(leaf.id or "root" for leaf in path) + return _finalise(child), f"bridge_circulation lvl{li}: {names} -> C" + + def _shape_failing(leaf: dom.Node, fit) -> bool: """A named-room leaf whose width or proportion factor actually fails (``< fitness.FAIL_THRESHOLD``) under ``fit``, the same Gaussian quality @@ -1324,6 +1412,7 @@ MUTATIONS = { "level_fix": mutate_level_fix, "level_compound_fix": mutate_level_compound_fix, "place_missing": mutate_place_missing, + "bridge_circulation": mutate_bridge_circulation, "level_retype": mutate_level_retype, "level_add": mutate_level_add, "level_delete": mutate_level_delete, @@ -1347,6 +1436,9 @@ def mutate(root: dom.Node, rng: np.random.Generator, types: list[str], p = np.array([(weights or {}).get(n, 1.0) for n in names], dtype=float) # these operators need programme reqs; disable them when not available reqs_ops = ("level_fix", "level_compound_fix", "place_missing") + # also takes reqs (to avoid displacing a required room) but works without + # it — never zero-weighted, unlike reqs_ops above + reqs_optional_ops = ("bridge_circulation",) # these need a Fitness instance to identify genuinely shape-failing leaves fit_ops = ("shape_rotate", "deslim") if reqs is None: @@ -1358,7 +1450,7 @@ def mutate(root: dom.Node, rng: np.random.Generator, types: list[str], if p.sum() == 0: p[:] = 1.0 name = str(rng.choice(names, p=p / p.sum())) - if name in reqs_ops: + if name in reqs_ops or name in reqs_optional_ops: return MUTATIONS[name](root, rng, types, reqs=reqs) if name in fit_ops: return MUTATIONS[name](root, rng, types, fit=fit) diff --git a/tests/test_driver.py b/tests/test_driver.py index b9c7854..7a79f3c 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -226,8 +226,14 @@ def test_enable_shape_repair_threads_fit_into_mutate(fake_inner, monkeypatch): child_budget=60, seed_budget=100, seed=5, enable_shape_repair=True) assert seen_fit and all(isinstance(f, fitness.Fitness) for f in seen_fit) - # Gating only, not behaviour: same trajectory as the off-by-default run. - assert on.best.sig == off.best.sig + # NOTE: no bit-identical-trajectory assertion here. Passing a live `fit` + # gives shape_rotate/deslim nonzero weight in operators.mutate's op-choice + # draw, which shifts the RNG mapping for every draw (not just those two + # ops') — same-seed off/on trajectories only coincided by chance for one + # fixed MUTATIONS size, and that coincidence breaks on any addition to + # MUTATIONS (e.g. homemaker-py-8sh's bridge_circulation). The gating + # itself (seen_fit above) is the actual contract under test. + assert off.best.sig and on.best.sig def test_feasibility_filter_prunes_cheaply(fake_inner, monkeypatch): diff --git a/tests/test_operators.py b/tests/test_operators.py index 1595ac7..190d94a 100644 --- a/tests/test_operators.py +++ b/tests/test_operators.py @@ -607,3 +607,104 @@ def test_mutate_deslim_merges_failing_leaf_and_is_repairable(): if not missing: break assert missing == [] + + +# --------------------------------------------------------------------------- # +# 8sh — insert/relocate-circulation repair (mechanism (a) follow-on to qi6) +# --------------------------------------------------------------------------- # +def _row_of_three(mid_type: str) -> dom.Node: + """Three same-height leaves in a row: ``C | [mid_type | C]``. The two ``C`` + leaves each share a full-height edge with the middle leaf but not with + each other, so their circulation components are disconnected — a 2-fail + ``level 0 not connected`` fixture for a single ``mid_type`` leaf bridge.""" + root = dom.Node(rotation=0, division=[1 / 3, 1 / 3], + node=[[0, 0], [12, 0], [12, 4], [0, 4]], + height=2.7, wall_outer=0.25, wall_inner=0.08) + root.left = dom.Node(type="C") + root.right = dom.Node(rotation=0, division=[0.5, 0.5]) + root.right.left = dom.Node(type=mid_type) + root.right.right = dom.Node(type="C") + dom._link(root) + return root + + +def _diamond(top_right_type: str) -> dom.Node: + """2x2 grid: ``C``/``O`` on the left column, ``top_right_type``/``C`` on + the right, so the two ``C`` corners have two equal-length bridge routes — + one through the free ``O`` leaf, one through ``top_right_type``.""" + root = dom.Node(rotation=0, division=[0.5, 0.5], + node=[[0, 0], [8, 0], [8, 8], [0, 8]], + height=2.7, wall_outer=0.25, wall_inner=0.08) + root.left = dom.Node(rotation=1, division=[0.5, 0.5]) + root.left.left = dom.Node(type="C") + root.left.right = dom.Node(type="O") + root.right = dom.Node(rotation=1, division=[0.5, 0.5]) + root.right.left = dom.Node(type=top_right_type) + root.right.right = dom.Node(type="C") + dom._link(root) + return root + + +def _n_circ_components(root: dom.Node) -> int: + import networkx as nx + + G = _geo_leaf_graph(root) + circ = [n for n in G.nodes() if dom.is_circulation(n)] + return len(list(nx.connected_components(G.subgraph(circ)))) + + +def _geo_leaf_graph(lvl: dom.Node): + from homemaker_layout import geometry, graph as _graph + return geometry.leaf_graph(lvl, _graph.DOOR_WIDTH) + + +def test_mutate_bridge_circulation_noop_when_already_connected(): + root = _row_of_three("C") # all three already circulation → one component + assert _n_circ_components(root) == 1 + _, desc = operators.mutate_bridge_circulation(root, np.random.default_rng(0), TYPES) + assert desc == "bridge_circulation noop" + + +def test_mutate_bridge_circulation_bridges_fragmented_level(): + root = _row_of_three("O") + assert _n_circ_components(root) == 2 + child, desc = operators.mutate_bridge_circulation(root, np.random.default_rng(0), TYPES) + assert "noop" not in desc + assert desc.startswith("bridge_circulation") + canonical(child) + assert _n_circ_components(child) == 1 + # the free 'O' leaf was converted; the two original 'C' leaves untouched + mid = dom.levels(child)[0].by_id("rl") + assert mid.type == "C" + # parent left untouched + assert dom.levels(root)[0].by_id("rl").type == "O" + + +def test_mutate_bridge_circulation_falls_back_to_required_room_if_only_route(): + from homemaker_layout import programme + + root = _row_of_three("b1") + reqs = {"b1": programme.SpaceReq(code="b1")} + assert _n_circ_components(root) == 2 + child, desc = operators.mutate_bridge_circulation( + root, np.random.default_rng(0), TYPES + ["b1"], reqs=reqs) + assert "noop" not in desc + assert _n_circ_components(child) == 1 + assert dom.levels(child)[0].by_id("rl").type == "C" + + +def test_mutate_bridge_circulation_prefers_free_leaf_over_required_room(): + from homemaker_layout import programme + + root = _diamond("b1") + reqs = {"b1": programme.SpaceReq(code="b1")} + assert _n_circ_components(root) == 2 + child, desc = operators.mutate_bridge_circulation( + root, np.random.default_rng(0), TYPES + ["b1"], reqs=reqs) + assert "noop" not in desc + canonical(child) + assert _n_circ_components(child) == 1 + # bridges via the free 'O' leaf ('lr'), not the required 'b1' ('rl') + lvl0 = dom.levels(child)[0] + assert lvl0.by_id("lr").type == "C" + assert lvl0.by_id("rl").type == "b1"