Compare commits

..

No commits in common. "73b9e2ae236052570f5d2e2a8e7db3ccfef40ef6" and "22ad9ed914347a2feb67f2cfdffae0f66950c48e" have entirely different histories.

6 changed files with 49 additions and 185 deletions

View file

@ -27,7 +27,7 @@
{"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-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-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 (67 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-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 (67 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-ld5","title":"Adjacency-aware lift_base_to_storeys + secondary adjacencies","description":"Follow-up to s44 (DESIGN.md §11.6). s44 made constructive_topology cluster rooms around a connected-dominating-set circulation spine (geometric leaf_graph), cutting harbor single-stage fails 110-\u003e90.7 mean and beating the staged §11.3 best of 95. Two gaps remain: (1) lift_base_to_storeys (staged Stage-2 upper floors) still assigns leaf types at RANDOM — port the _assign_adjacency_aware CDS approach to it so staged search benefits too. (2) Secondary adjacencies (k1\u003c-\u003eda1, da1\u003c-\u003eo, etc., ~4 harbor rooms) are not clustered — extend _assign_adjacency_aware to place rooms with non-c adjacency reqs next to their required neighbour after the c-spine is laid.","notes":"DONE positive, DESIGN.md §11.7. Adjacency-aware lift (CDS seeded from inherited core) + secondary-adjacency room placement. Staged harbor 20k evals: ADJ0 mean 99.0 (=§11.4 baseline), ADJ1 mean 85.3 (-14%, best 78). New best harbor overall. operators 22 tests pass.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-19T08:12:11Z","created_by":"Bruno Postle","updated_at":"2026-06-19T10:41:14Z","started_at":"2026-06-19T08:33:43Z","closed_at":"2026-06-19T10:41:14Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-ld5","title":"Adjacency-aware lift_base_to_storeys + secondary adjacencies","description":"Follow-up to s44 (DESIGN.md §11.6). s44 made constructive_topology cluster rooms around a connected-dominating-set circulation spine (geometric leaf_graph), cutting harbor single-stage fails 110-\u003e90.7 mean and beating the staged §11.3 best of 95. Two gaps remain: (1) lift_base_to_storeys (staged Stage-2 upper floors) still assigns leaf types at RANDOM — port the _assign_adjacency_aware CDS approach to it so staged search benefits too. (2) Secondary adjacencies (k1\u003c-\u003eda1, da1\u003c-\u003eo, etc., ~4 harbor rooms) are not clustered — extend _assign_adjacency_aware to place rooms with non-c adjacency reqs next to their required neighbour after the c-spine is laid.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-19T08:12:11Z","created_by":"Bruno Postle","updated_at":"2026-06-19T08:12:11Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"homemaker-py-n5k","title":"Config inheritance: load parent patterns.config/costs.config as base layer","description":"urb-evolve.pl walks up one directory level and loads ../patterns.config and ../costs.config as a base configuration before merging the programme directory's own files on top (local keys win). homemaker-evolve and fitness.load_config should replicate this: when loading a programme directory, first check the parent for each config file and load it, then deep-merge the local file over the top. This lets shared defaults live in a project root while individual programmes only override what differs.","status":"closed","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-14T06:22:38Z","created_by":"Bruno Postle","updated_at":"2026-06-14T06:50:27Z","closed_at":"2026-06-14T06:50:27Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-n5k","title":"Config inheritance: load parent patterns.config/costs.config as base layer","description":"urb-evolve.pl walks up one directory level and loads ../patterns.config and ../costs.config as a base configuration before merging the programme directory's own files on top (local keys win). homemaker-evolve and fitness.load_config should replicate this: when loading a programme directory, first check the parent for each config file and load it, then deep-merge the local file over the top. This lets shared defaults live in a project root while individual programmes only override what differs.","status":"closed","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-14T06:22:38Z","created_by":"Bruno Postle","updated_at":"2026-06-14T06:50:27Z","closed_at":"2026-06-14T06:50:27Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"homemaker-py-9t6","title":"Package install: pyproject.toml with entry points","description":"The project currently requires PYTHONPATH=/home/bruno/src/homemaker-py/src and is run via 'python3 experiments/...'. There is no installable package. Add a pyproject.toml with: package discovery for src/homemaker/, a [project.scripts] entry point for homemaker-evolve (homemaker-py-2wc), and minimal metadata. After 'pip install -e .' the tool should be on PATH and importable without PYTHONPATH. Keep the existing pyproject.toml if one exists and extend it.","acceptance_criteria":"'pip install -e .' succeeds; 'homemaker-evolve --help' works from any directory; 'import homemaker' works without PYTHONPATH","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T21:52:35Z","created_by":"Bruno Postle","updated_at":"2026-06-14T07:18:42Z","started_at":"2026-06-14T06:52:28Z","closed_at":"2026-06-14T07:18:42Z","close_reason":"pyproject.toml already had entry point; renamed package to homemaker-layout throughout, GitHub repo renamed, pip install -e . verified","dependencies":[{"issue_id":"homemaker-py-9t6","depends_on_id":"homemaker-py-2wc","type":"blocks","created_at":"2026-06-13T22:52:41Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-9t6","title":"Package install: pyproject.toml with entry points","description":"The project currently requires PYTHONPATH=/home/bruno/src/homemaker-py/src and is run via 'python3 experiments/...'. There is no installable package. Add a pyproject.toml with: package discovery for src/homemaker/, a [project.scripts] entry point for homemaker-evolve (homemaker-py-2wc), and minimal metadata. After 'pip install -e .' the tool should be on PATH and importable without PYTHONPATH. Keep the existing pyproject.toml if one exists and extend it.","acceptance_criteria":"'pip install -e .' succeeds; 'homemaker-evolve --help' works from any directory; 'import homemaker' works without PYTHONPATH","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T21:52:35Z","created_by":"Bruno Postle","updated_at":"2026-06-14T07:18:42Z","started_at":"2026-06-14T06:52:28Z","closed_at":"2026-06-14T07:18:42Z","close_reason":"pyproject.toml already had entry point; renamed package to homemaker-layout throughout, GitHub repo renamed, pip install -e . verified","dependencies":[{"issue_id":"homemaker-py-9t6","depends_on_id":"homemaker-py-2wc","type":"blocks","created_at":"2026-06-13T22:52:41Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"homemaker-py-gug","title":"Test suite","description":"There are no automated tests. Validation has been done entirely through experiment scripts and the 35-file corpus parity check (homemaker-py-uxz). This is acceptable during exploration but fragile as the codebase grows. Need pytest-based unit tests covering: geometry port correctness (vs known values, not just vs oracle), fitness term correctness (size/width/proportion/adjacency/access/crinkliness/stair terms individually), genome operators (mutations preserve tree invariants), inner loop (convergence on known landscape), and a fast corpus smoke test (subset of the 35 files, score within tolerance). The corpus parity experiment can be the integration test baseline.","acceptance_criteria":"pytest runs clean; geometry, fitness terms, operators, and inner loop each have unit tests; corpus smoke test covers at least 5 files","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T21:52:31Z","created_by":"Bruno Postle","updated_at":"2026-06-13T22:51:04Z","started_at":"2026-06-13T22:40:56Z","closed_at":"2026-06-13T22:51:04Z","close_reason":"Added test_geometry.py (26 tests) and test_fitness.py (35 tests); full suite now 175 tests, all passing","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-gug","title":"Test suite","description":"There are no automated tests. Validation has been done entirely through experiment scripts and the 35-file corpus parity check (homemaker-py-uxz). This is acceptable during exploration but fragile as the codebase grows. Need pytest-based unit tests covering: geometry port correctness (vs known values, not just vs oracle), fitness term correctness (size/width/proportion/adjacency/access/crinkliness/stair terms individually), genome operators (mutations preserve tree invariants), inner loop (convergence on known landscape), and a fast corpus smoke test (subset of the 35 files, score within tolerance). The corpus parity experiment can be the integration test baseline.","acceptance_criteria":"pytest runs clean; geometry, fitness terms, operators, and inner loop each have unit tests; corpus smoke test covers at least 5 files","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T21:52:31Z","created_by":"Bruno Postle","updated_at":"2026-06-13T22:51:04Z","started_at":"2026-06-13T22:40:56Z","closed_at":"2026-06-13T22:51:04Z","close_reason":"Added test_geometry.py (26 tests) and test_fitness.py (35 tests); full suite now 175 tests, all passing","dependency_count":0,"dependent_count":0,"comment_count":0}
@ -39,16 +39,16 @@
{"id":"homemaker-py-can","title":"Programme width defaults: t3 contradiction (impossible width_inside default)","description":"DESIGN.md §8.2, confirmed in source. t3 (3 m2 WC) has no width spec so inherits width_inside [4.0, 1.0] (Fitness/Base.pm:60) — geometrically impossible; designs 'pass' only by failing size instead. Fix AFTER faithful-port validation (port-faithfully-first policy, §8.1): a sane width default scaled to area (e.g. sqrt(area/proportion)) or per-room widths in patterns.config. Applies to native fitness; optionally upstream to Urb.","acceptance_criteria":"No programme space has a default width incompatible with its target area; corpus re-scored and effect documented","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:01Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:21:37Z","started_at":"2026-06-13T21:16:11Z","closed_at":"2026-06-13T21:21:37Z","close_reason":"Fixed in get_space_params: when a programme space has no explicit 'width', derive target from sqrt(size/proportion) instead of falling back to width_inside [4.0, 1.0]. Re-scored 35-file corpus: 32 files improved (+1-121%), 5 files lost spurious width fails. All 109 tests pass. Upstream Perl fix tracked as homemaker-py-8fe.","dependencies":[{"issue_id":"homemaker-py-can","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:47Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-can","title":"Programme width defaults: t3 contradiction (impossible width_inside default)","description":"DESIGN.md §8.2, confirmed in source. t3 (3 m2 WC) has no width spec so inherits width_inside [4.0, 1.0] (Fitness/Base.pm:60) — geometrically impossible; designs 'pass' only by failing size instead. Fix AFTER faithful-port validation (port-faithfully-first policy, §8.1): a sane width default scaled to area (e.g. sqrt(area/proportion)) or per-room widths in patterns.config. Applies to native fitness; optionally upstream to Urb.","acceptance_criteria":"No programme space has a default width incompatible with its target area; corpus re-scored and effect documented","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:01Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:21:37Z","started_at":"2026-06-13T21:16:11Z","closed_at":"2026-06-13T21:21:37Z","close_reason":"Fixed in get_space_params: when a programme space has no explicit 'width', derive target from sqrt(size/proportion) instead of falling back to width_inside [4.0, 1.0]. Re-scored 35-file corpus: 32 files improved (+1-121%), 5 files lost spurious width fails. All 109 tests pass. Upstream Perl fix tracked as homemaker-py-8fe.","dependencies":[{"issue_id":"homemaker-py-can","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:47Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"homemaker-py-yg5","title":"Penalty reshaping: replace 0.5^n while preserving inner-loop protection","description":"DESIGN.md §4.7, §5.4, §7 Phase 4, §8.5. The 0.5^n cliff gives the outer search no gradient and rewards flag-count over geometry, but it also PROTECTS the inner loop from trading into new failures (§4.5). One fitness shape cannot naively be both soft outside and cliff-protected inside. Candidates: cliff-inside-inner-loop only, lexicographic (failure count first, score second), additive/soft, multi-objective Pareto. Must preserve the missing-space failure hierarchy (worse to drop a room than to have a poor one). Measure landscape + search outcomes; this helps Urb today too.","acceptance_criteria":"Chosen scheme documented with measurements: search improves while inner loop still never trades into new failures","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:00Z","created_by":"Bruno Postle","updated_at":"2026-06-14T08:16:14Z","started_at":"2026-06-14T07:55:32Z","closed_at":"2026-06-14T08:16:14Z","close_reason":"Implemented lexicographic outer-search comparison (-n_fails, fitness). Inner loop unchanged (0.5^n cliff protection preserved). Experiment penalty_reshape.py confirms 0/9 fail regressions in inner loop and shows lex avoids the 3-fail trap that scalar hits 1/3 of the time. Fixed stale _CHILD_INNER_KW sigmas entry.","dependencies":[{"issue_id":"homemaker-py-yg5","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-yg5","title":"Penalty reshaping: replace 0.5^n while preserving inner-loop protection","description":"DESIGN.md §4.7, §5.4, §7 Phase 4, §8.5. The 0.5^n cliff gives the outer search no gradient and rewards flag-count over geometry, but it also PROTECTS the inner loop from trading into new failures (§4.5). One fitness shape cannot naively be both soft outside and cliff-protected inside. Candidates: cliff-inside-inner-loop only, lexicographic (failure count first, score second), additive/soft, multi-objective Pareto. Must preserve the missing-space failure hierarchy (worse to drop a room than to have a poor one). Measure landscape + search outcomes; this helps Urb today too.","acceptance_criteria":"Chosen scheme documented with measurements: search improves while inner loop still never trades into new failures","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:00Z","created_by":"Bruno Postle","updated_at":"2026-06-14T08:16:14Z","started_at":"2026-06-14T07:55:32Z","closed_at":"2026-06-14T08:16:14Z","close_reason":"Implemented lexicographic outer-search comparison (-n_fails, fitness). Inner loop unchanged (0.5^n cliff protection preserved). Experiment penalty_reshape.py confirms 0/9 fail regressions in inner loop and shows lex avoids the 3-fail trap that scalar hits 1/3 of the time. Fixed stale _CHILD_INNER_KW sigmas entry.","dependencies":[{"issue_id":"homemaker-py-yg5","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"homemaker-py-2g5","title":"Rebuild occlusion/daylight/sun subsystem in Python (post-Phase-5, after optimisation fully native)","description":"DESIGN.md §6 port scope — a whole subsystem, not a term. quality_daylight (Leaf.pm:281-296) needs Urb::Misc::Sun + Urb::Field::Occlusion (+CIESky); quality_uncrinkliness also takes the occlusion object. Indoor spaces return 1 for daylight; cost is outdoor spaces + crinkliness. Port Sun_horizontal (262980-minute normalisation) and the occlusion wall set from Dom-\u003eWalls.","acceptance_criteria":"Daylight and crinkliness factors match Perl (float tolerance) across the corpus, including multi-storey cases","notes":"Re-scoped 2026-06-12: occlusion disabled in the Urb oracle instead of ported (see homemaker-py-gp2). Native fitness ships with simple crinkliness (illumination factor = 1, in homemaker-py-gnw). This issue is now the eventual Python occlusion rebuild, only after optimisation works entirely in Python. Restores outdoor-daylight and shaded-wall selection pressure.\nReframed 2026-06-17: orthogonal to epic homemaker-py-c4c. This is fitness FIDELITY (restoring daylight + shaded-wall selection pressure to match Perl), not search CAPABILITY — it changes what 'good' means, not the search's ability to find good. It will NOT improve final designs in the sense currently sought. Stays P4, deferred until the topology-search-quality epic lands and optimisation is fully native.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:25Z","created_by":"Bruno Postle","updated_at":"2026-06-17T19:14:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-2g5","title":"Rebuild occlusion/daylight/sun subsystem in Python (post-Phase-5, after optimisation fully native)","description":"DESIGN.md §6 port scope — a whole subsystem, not a term. quality_daylight (Leaf.pm:281-296) needs Urb::Misc::Sun + Urb::Field::Occlusion (+CIESky); quality_uncrinkliness also takes the occlusion object. Indoor spaces return 1 for daylight; cost is outdoor spaces + crinkliness. Port Sun_horizontal (262980-minute normalisation) and the occlusion wall set from Dom-\u003eWalls.","acceptance_criteria":"Daylight and crinkliness factors match Perl (float tolerance) across the corpus, including multi-storey cases","notes":"Re-scoped 2026-06-12: occlusion disabled in the Urb oracle instead of ported (see homemaker-py-gp2). Native fitness ships with simple crinkliness (illumination factor = 1, in homemaker-py-gnw). This issue is now the eventual Python occlusion rebuild, only after optimisation works entirely in Python. Restores outdoor-daylight and shaded-wall selection pressure.\nReframed 2026-06-17: orthogonal to epic homemaker-py-c4c. This is fitness FIDELITY (restoring daylight + shaded-wall selection pressure to match Perl), not search CAPABILITY — it changes what 'good' means, not the search's ability to find good. It will NOT improve final designs in the sense currently sought. Stays P4, deferred until the topology-search-quality epic lands and optimisation is fully native.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:25Z","created_by":"Bruno Postle","updated_at":"2026-06-17T19:14:48Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"memory","key":"multi-storey-staircase-consistency-when-dividing-or-retyping","value":"Multi-storey staircase consistency: when dividing or retyping a circulation (C) leaf at one level, the same structural change should be propagated to the matching leaf on ALL other storeys so the stair core path is maintained. The optimizer cannot fix staircase disruptions through trial-and-error geometry alone — it requires a synchronized multi-level operator that applies the same topology change to every storey simultaneously."}
{"_type":"memory","key":"never-use-corpus-filenames-candidate-001-dom-candidate","value":"Never use corpus filenames (candidate-001.dom, candidate-002.dom, generated.dom, init.dom, etc.) as --output targets when running experiments. These are test fixtures. Always write experimental outputs to scratch/ or a timestamped path. Lesson from 2026-06-14: warm-start runs overwrote candidate-001/002.dom and broke graph tests."}
{"_type":"memory","key":"warm-x0-initialization-bug-pattern-when-a-topology","value":"warm_x0 initialization bug pattern: when a topology operator explicitly sets division ratios on a newly-created node (e.g. compound_fix sets node.division=[0.25,0.25] for t3), parent.ratios has no entry for that node (it was a leaf). warm_x0 defaults it to 0.5, corrupting the inner loop's starting point and making the operator invisible to lex comparison. Fix: only propagate child ratios for nodes where the parent node was NOT already divided; stale hidden nodes revealed by structural mutations (swap flipping b.below) must NOT contribute their pre-writeback values. See driver.py lines 259-267 (fixed 2026-06-14)."}
{"_type":"memory","key":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."}
{"_type":"memory","key":"programme-house-optimisation-result-2026-06-14-15","value":"Programme-house optimisation result (2026-06-14/15): best achievable is 1 fail (l1 wrong level, score ~0.005). 0 fails is geometrically impossible: l1 (min 27m²) must occupy ll (~23m²) at level 0, which eliminates the t3-adj-C provider; dividing ll into lll(l1)+llr(C) gives llr proportion ~6:1 (fails). Python memetic optimizer achieves 1 fail in 50k evals vs Perl optimiser's 2-3 fails. Winning topology: TWO C nodes at level 0 — ll(C) for t3-adj-C via geometric contact, rl(C) for staircase via tree-sibling adjacency to rrr(O). Best .dom: scratch/from-warmstart-fixed.dom and scratch/from-compound3-fixed.dom."}
{"_type":"memory","key":"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":"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":"adjacency-in-binary-slicing-tree-is-structural-not","value":"Adjacency in binary slicing tree is structural, not geometric: the inner-loop NM cannot fix topological adjacency failures. Two paths exist: (1) tree-sibling adjacency — a node is adjacent to its sibling in the tree; (2) cross-zone geometric adjacency — leaves from different subtrees that happen to share a boundary. Staircase/adjacency fails require a topology mutation that changes which nodes are siblings or which zones touch. This was proved empirically on programme-house: staircase fail from rot=0 layout could not be fixed by NM but was fixed by level_retype creating a two-C topology (2026-06-14/15)."}
{"_type":"memory","key":"cli-tool-style-prefer-python-m-homemaker-module","value":"CLI tool style: prefer python -m homemaker.module --parameters pattern, installable via pip install -e . with pyproject.toml entry_points. Not standalone bin/ scripts."} {"_type":"memory","key":"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":"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":"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":"deceptive-valleys-in-topology-search-when-every-single","value":"Deceptive valleys in topology search: when every single-step mutation from a target state passes through a high-fail intermediary (e.g. level_fix displaces a room into 5+ new fails), a compound operator that atomically applies two coordinated changes can escape. Design compound operators to land on the low-fail state directly, bypassing the deceptive gradient. Programme-house example: level_compound_fix atomically moves the level-constrained room AND re-inserts the displaced room adjacent to C in one step (operators.py, 2026-06-14)."}
{"_type":"memory","key":"homemaker-py-pythonpath-set-pythonpath-home-bruno-src","value":"homemaker-layout PYTHONPATH: package installed as 'homemaker-layout' via pip install -e . so 'import homemaker_layout' works from anywhere without PYTHONPATH. For running tests use 'python -m pytest' from project root /home/bruno/src/homemaker-layout (pyproject.toml adds src/ automatically). Never try pip show homemaker — that's the old homemaker-addon conflict."} {"_type":"memory","key":"homemaker-py-pythonpath-set-pythonpath-home-bruno-src","value":"homemaker-layout PYTHONPATH: package installed as 'homemaker-layout' via pip install -e . so 'import homemaker_layout' works from anywhere without PYTHONPATH. For running tests use 'python -m pytest' from project root /home/bruno/src/homemaker-layout (pyproject.toml adds src/ automatically). Never try pip show homemaker — that's the old homemaker-addon conflict."}
{"_type":"memory","key":"multi-storey-staircase-consistency-when-dividing-or-retyping","value":"Multi-storey staircase consistency: when dividing or retyping a circulation (C) leaf at one level, the same structural change should be propagated to the matching leaf on ALL other storeys so the stair core path is maintained. The optimizer cannot fix staircase disruptions through trial-and-error geometry alone — it requires a synchronized multi-level operator that applies the same topology change to every storey simultaneously."}
{"_type":"memory","key":"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":"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":"user-preference-bruno-this-is-a-fedora-system","value":"User preference (Bruno): this is a Fedora system — NEVER install Python packages via pip without asking first; always ask whether to install the rpm via dnf (e.g. python3-cma) before considering pip. Applies to any dependency additions."}
{"_type":"memory","key":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."}
{"_type":"memory","key":"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":"urb-fitness-bug-found-fixed-2026-06-12","value":"Urb fitness bug found+fixed 2026-06-12 (patch in /home/bruno/src/urb, uncommitted): ProgrammeDriven.pm ratio_o/ratio_type grepped case-insensitively over the ratios hash and took the FIRST key — nondeterministic (x4.5 score swings) for designs with mixed-case type classes (both 'c' circulation and 'C' covered). Fixed to SUM the class (matches Is_Circulation//Is_Outside semantics); 35/35 corpus scores unchanged. CRITICAL for homemaker-py-3y7/gnw: the native port must implement class-SUM ratios. Building.pm has the same unpatched pattern (site-driven path, not used by our oracle). Also: the memetic search reward-hacked this bug before the fix — search results predating it are noise artifacts."}
{"_type":"memory","key":"urb-oracle-nondeterminism-urb-fitness-pl-output-varies","value":"Urb oracle nondeterminism: urb-fitness.pl output varies run-to-run from Perl hash-order randomisation — .fails line ORDER shuffles (compare sorted, use oracle.Score.fail_lines) and the score float can flip by ~1 ULP (compare with math.isclose rel_tol=1e-12, never ==). Not a batching artifact; affects single runs too. Matters for the Phase 3 native-fitness parity gate (homemaker-py-uxz)."}
{"_type":"memory","key":"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)."}

View file

@ -1061,49 +1061,9 @@ flag on `constructive_topology` (env `ADJ` in `run_search_scaled.py`) for the A/
- *Verdict: keep adjacency-aware seeding as the default.* It is the first lever in - *Verdict: keep adjacency-aware seeding as the default.* It is the first lever in
Phase 6 to move the fail count on both programmes. The win is the dominant Phase 6 to move the fail count on both programmes. The win is the dominant
adjacency-to-`c` / access load; secondary adjacencies and the staged adjacency-to-`c` / access load; secondary adjacencies (`k1↔da1`, `da1↔o`, ~4
`lift_base_to_storeys` upper floors are picked up in §11.7 (`homemaker-py-ld5`). rooms on harbor) are not yet clustered, and `lift_base_to_storeys` (staged
Stage-2 upper floors) still assigns randomly — both are follow-ups
### 11.7 Adjacency-aware lift + secondary adjacencies (`homemaker-py-ld5`) — DONE (positive) (`homemaker-py-s44` notes). The residual ~90-fail harbor load is now geometry-
and secondary-constraint-bound, consistent with the §11.4/§11.5 reachability
Two gaps left by §11.6: (a) `lift_base_to_storeys` — the staged Stage-2 seeder — conclusion.
still typed upper-floor leaves at random, so staged search did not get the
adjacency win; (b) secondary adjacencies (`k1↔da1`, `da1↔o`, ~4 harbor rooms)
were ignored.
**Implementation.** `_assign_adjacency_aware` gained a `fixed_circ` parameter: the
dominating-set search is *seeded from* given circulation leaves, so on an upper
floor the spine grows off the **inherited vertical core** rather than from
scratch (preserving the §11.3 anti-bungalow core-alignment invariant). Room
placement is now constraint-ordered: codes with the most non-`c` adjacency
requirements are placed first, each onto the open slot that satisfies the most of
its requirements against already-typed neighbours (circulation + rooms placed so
far), clustering `k1↔da1`, `da1↔o`, etc. `lift_base_to_storeys(reqs=…,
adjacency_aware=True)` grows a per-floor circulation budget and calls it with the
core as `fixed_circ`; threaded through `search_staged(seed_adjacency_aware=True)`
(`ADJ` env in `run_staged_search.py`).
- *Seed quality (harbor lift, 8 seeds, raw seed):* adjacency-to-`c` **16.1 → 7.6**,
access **16.2 → 7.2** on the lifted upper floor.
- *End-to-end (harbor, staged, 20000 evals, total fails at budget):*
| seed | staged before (`ADJ=0`) | staged after (`ADJ=1`) |
|-----:|------------------------:|-----------------------:|
| 0 | 95 | 97 |
| 1 | 96 | **78** |
| 2 | 106 | 81 |
| mean | **99.0** | **85.3** |
`ADJ=0` reproduces the §11.4 staged lex baseline **exactly** (95/96/106, mean
99.0 — clean control). Staged adjacency-aware is **13.7 fails (14 %)** and is
now the **best harbor configuration overall**: staged baseline 99.0 → single-
stage adjacency-aware (§11.6) 90.7 → **staged + adjacency-aware lift 85.3**
(best **78**, seed 1). Staging and adjacency-aware seeding compose: the
credible Stage-1 base and the core-seeded upper spine each contribute.
- *Verdict: keep adjacency-aware lift + secondary clustering as defaults.* Harbor
is now ~85 fails, down from the 95/105 plateaus that opened Phase 6. The
residual is geometry- and shape-bound (size/proportion/crinkliness on the
denser, more-circulation layouts), which is the canonical-encoding /
shape-feasibility territory of `homemaker-py-9gp`.

View file

@ -55,7 +55,6 @@ def main() -> int:
niche = os.environ.get("NICHE", "0") == "1" # §11.5 structural niching A/B niche = os.environ.get("NICHE", "0") == "1" # §11.5 structural niching A/B
rp = os.environ.get("RESTART_PATIENCE") rp = os.environ.get("RESTART_PATIENCE")
restart_patience = int(rp) if rp else None restart_patience = int(rp) if rp else None
adj = os.environ.get("ADJ", "1") == "1" # s44/ld5 adjacency-aware seeding A/B
print(f"programme : {programme_dir.name}") print(f"programme : {programme_dir.name}")
print(f"seed : {seed_file.name}") print(f"seed : {seed_file.name}")
@ -64,7 +63,6 @@ def main() -> int:
print(f"use_grade : {use_grade}") print(f"use_grade : {use_grade}")
print(f"niche : {niche}") print(f"niche : {niche}")
print(f"restart_p : {restart_patience}") print(f"restart_p : {restart_patience}")
print(f"adj_aware : {adj}")
print(flush=True) print(flush=True)
seed_root = dom.load(str(seed_file)) seed_root = dom.load(str(seed_file))
@ -85,7 +83,6 @@ def main() -> int:
use_grade=use_grade, use_grade=use_grade,
niche_by_signature=niche, niche_by_signature=niche,
restart_patience=restart_patience, restart_patience=restart_patience,
seed_adjacency_aware=adj,
) )
elapsed = time.perf_counter() - t0 elapsed = time.perf_counter() - t0

View file

@ -446,7 +446,6 @@ def search_staged(
niche_by_signature: bool = False, niche_by_signature: bool = False,
restart_patience: int | None = None, restart_patience: int | None = None,
restart_elite: int = 1, restart_elite: int = 1,
seed_adjacency_aware: bool = True,
) -> SearchResult: ) -> SearchResult:
"""Staged per-floor topology search (DESIGN.md §11.3, ``homemaker-py-c4c.3``). """Staged per-floor topology search (DESIGN.md §11.3, ``homemaker-py-c4c.3``).
@ -484,8 +483,7 @@ def search_staged(
p_crossover=p_crossover, seed=seed, types=types, p_crossover=p_crossover, seed=seed, types=types,
inner_kw=inner_kw, log=log, n_workers=n_workers, inner_kw=inner_kw, log=log, n_workers=n_workers,
use_grade=use_grade, niche_by_signature=niche_by_signature, use_grade=use_grade, niche_by_signature=niche_by_signature,
restart_patience=restart_patience, restart_elite=restart_elite, restart_patience=restart_patience, restart_elite=restart_elite)
seed_adjacency_aware=seed_adjacency_aware)
if types is None: if types is None:
types = sorted(reqs) + ["C", "O"] types = sorted(reqs) + ["C", "O"]
@ -509,7 +507,6 @@ def search_staged(
rank_bonus_weight=rank_bonus_weight, rank_bonus_weight=rank_bonus_weight,
niche_by_signature=niche_by_signature, niche_by_signature=niche_by_signature,
restart_patience=restart_patience, restart_elite=restart_elite, restart_patience=restart_patience, restart_elite=restart_elite,
seed_adjacency_aware=seed_adjacency_aware,
) )
best_base = r1.best.root best_base = r1.best.root
_log(f"[staged] stage 1 done: base {r1.best.fitness:.6g} " _log(f"[staged] stage 1 done: base {r1.best.fitness:.6g} "
@ -523,9 +520,7 @@ def search_staged(
upper = buckets[1:] upper = buckets[1:]
def _seed_factory(rng2): def _seed_factory(rng2):
return operators.lift_base_to_storeys( return operators.lift_base_to_storeys(best_base, upper, rng2, types)
best_base, upper, rng2, types, reqs=reqs,
adjacency_aware=seed_adjacency_aware)
_log(f"[staged] stage 2: upper floors as deltas, budget {b2}, base_p {base_p}") _log(f"[staged] stage 2: upper floors as deltas, budget {b2}, base_p {base_p}")
r2 = search( r2 = search(

View file

@ -385,8 +385,7 @@ def _grow_leaves(lvl: dom.Node, n_leaves: int, rng: np.random.Generator) -> None
def _assign_adjacency_aware(lvl: dom.Node, room_codes: list[str], reqs, def _assign_adjacency_aware(lvl: dom.Node, room_codes: list[str], reqs,
rng: np.random.Generator, door_width: float = 1.2, rng: np.random.Generator, door_width: float = 1.2) -> None:
fixed_circ: "list[dom.Node] | None" = None) -> None:
"""Assign leaf types so rooms cluster around a connected circulation spine. """Assign leaf types so rooms cluster around a connected circulation spine.
s44 (DESIGN.md §11.2 follow-up): random type assignment leaves rooms stranded s44 (DESIGN.md §11.2 follow-up): random type assignment leaves rooms stranded
@ -398,26 +397,17 @@ def _assign_adjacency_aware(lvl: dom.Node, room_codes: list[str], reqs,
satisfied by construction at the seed geometry. Rooms are placed on dominated satisfied by construction at the seed geometry. Rooms are placed on dominated
leaves; one peripheral leaf becomes the outside ``O``. leaves; one peripheral leaf becomes the outside ``O``.
``fixed_circ`` (ld5, §11.7): leaves that must stay circulation and seed the
dominating set the inherited vertical core when lifting upper storeys, so
the spine grows *off the core* rather than from scratch. Rooms with a
secondary adjacency requirement (beyond ``c``, e.g. ``k1da1``, ``da1o``)
are then placed next to an already-typed neighbour of the required code.
``lvl`` already has the right number of leaves grown; their types are ``lvl`` already has the right number of leaves grown; their types are
(re)written in place. Stochastic where it is free (room order, tie-breaks) so (re)written in place. Stochastic where it is free (room order, tie-breaks) so
a bootstrap batch stays diverse. a bootstrap batch stays diverse.
""" """
from . import geometry from . import geometry
reqs = reqs or {}
leaves = lvl.leaves() leaves = lvl.leaves()
n = len(leaves) n = len(leaves)
idx = {leaf: i for i, leaf in enumerate(leaves)} idx = {leaf: i for i, leaf in enumerate(leaves)}
R = len(room_codes) R = len(room_codes)
n_circ = max(1, n - (R + 1)) # leftover after rooms + one outside n_circ = max(1, n - (R + 1)) # leftover after rooms + one outside
seeds = [c for c in (fixed_circ or []) if c in idx]
n_circ = max(n_circ, len(seeds)) # never fewer circ leaves than the fixed core
# Geometry is type-independent (coords derive from divisions/rotations/plot); # Geometry is type-independent (coords derive from divisions/rotations/plot);
# clear the id-keyed cache so freshly grown leaves never hit stale entries. # clear the id-keyed cache so freshly grown leaves never hit stale entries.
@ -428,11 +418,12 @@ def _assign_adjacency_aware(lvl: dom.Node, room_codes: list[str], reqs,
def _nbrs(leaf): def _nbrs(leaf):
return set(G.neighbors(leaf)) if G.has_node(leaf) else set() return set(G.neighbors(leaf)) if G.has_node(leaf) else set()
# Greedy connected dominating set of size n_circ: seed from the fixed core (or # Greedy connected dominating set of size n_circ: start at the most central
# the most central leaf), then repeatedly add the frontier leaf that newly # (highest-degree) leaf, then repeatedly add the frontier leaf that newly
# dominates the most leaves (keeping the set connected). # dominates the most leaves (keeping the set connected).
circ = set(seeds) if seeds else {max(leaves, key=lambda L: (deg.get(L, 0), -idx[L]))} start = max(leaves, key=lambda L: (deg.get(L, 0), -idx[L]))
dominated = set().union(*( _nbrs(s) | {s} for s in circ)) circ = {start}
dominated = _nbrs(start) | {start}
while len(circ) < n_circ: while len(circ) < n_circ:
frontier = (set().union(*(_nbrs(s) for s in circ)) - circ) if circ else set() frontier = (set().union(*(_nbrs(s) for s in circ)) - circ) if circ else set()
if frontier: if frontier:
@ -457,38 +448,13 @@ def _assign_adjacency_aware(lvl: dom.Node, room_codes: list[str], reqs,
deg.get(L, 0), idx[L])) deg.get(L, 0), idx[L]))
o_leaf.type = "O" o_leaf.type = "O"
# Rooms onto the remaining leaves, dominated (circulation-adjacent) slots # Rooms onto the remaining leaves, dominated (circulation-adjacent) leaves
# first so adjacency-to-c holds. Codes are placed hardest-constrained first # first so adjacency-to-c is satisfied; codes shuffled for diversity.
# (most adjacency requirements), each onto the open slot that satisfies the
# most of its requirements against already-typed neighbours (circulation and
# rooms placed so far) — clustering k1↔da1, da1↔o, etc. Ties broken randomly.
room_slots = [L for L in noncirc if L is not o_leaf] room_slots = [L for L in noncirc if L is not o_leaf]
open_slots = sorted(room_slots, room_slots.sort(key=lambda L: (L in dominated, deg.get(L, 0), -idx[L]), reverse=True)
key=lambda L: (L in dominated, deg.get(L, 0), -idx[L]),
reverse=True)
codes = [room_codes[i] for i in rng.permutation(len(room_codes))] codes = [room_codes[i] for i in rng.permutation(len(room_codes))]
for slot, leaf in enumerate(room_slots):
def _n_secondary(code: str) -> int: leaf.type = codes[slot] if slot < len(codes) else "O"
r = reqs.get(code)
return len([a for a in (r.adjacency if r else []) if a and a[0].lower() != "c"])
codes.sort(key=_n_secondary, reverse=True)
for code in codes:
if not open_slots:
break
req_adj = [a[0].lower() for a in (reqs.get(code).adjacency if reqs.get(code) else [])]
secondary = [a for a in req_adj if a != "c"]
def _sat(slot, secondary=secondary) -> int:
nb_types = {(nb.type or "")[:1].lower() for nb in _nbrs(slot) if nb.type}
return sum(1 for a in secondary if a in nb_types)
best = max(open_slots, key=lambda L: (_sat(L), L in dominated,
deg.get(L, 0), -idx[L]))
best.type = code
open_slots.remove(best)
for leaf in open_slots: # any leftover slot (count mismatch) → outside
leaf.type = "O"
def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator, def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
@ -560,8 +526,7 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]], def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]],
rng: np.random.Generator, types: list[str], rng: np.random.Generator, types: list[str]) -> dom.Node:
reqs=None, adjacency_aware: bool = True) -> dom.Node:
"""Stack upper storeys onto an evolved single-storey base (DESIGN.md §11.3). """Stack upper storeys onto an evolved single-storey base (DESIGN.md §11.3).
Stage 2 seeder: the Stage-1 base is the credible ground floor and is left Stage 2 seeder: the Stage-1 base is the credible ground floor and is left
@ -591,35 +556,15 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
dup.height = prev.height dup.height = prev.height
core_node = dup.by_id(core_path) if core_path is not None else None core_node = dup.by_id(core_path) if core_path is not None else None
rooms = [code for code, cnt in bucket.items() for _ in range(cnt)] assign = [code for code, cnt in bucket.items() for _ in range(cnt)]
assign.append("O") # courtyard / outside on the upper floor
if core_node is None:
assign.append("C") # no inherited core to reuse — make one
def _free() -> list[dom.Node]: def _free() -> list[dom.Node]:
return [lf for lf in dup.leaves() if lf is not core_node] return [lf for lf in dup.leaves() if lf is not core_node]
if adjacency_aware: # grow only non-core leaves so the inherited core footprint is preserved
# ld5 (§11.7): grow the upper floor a circulation spine (~one circ per
# 3 rooms, the inherited core counted) and assign rooms around it via
# the geometric leaf graph, seeding the dominating set from the
# inherited vertical core so the spine grows off the core, not anew.
n_circ = max(1, -(-len(rooms) // 3)) # ceil(rooms / 3)
target_total = len(rooms) + 1 + n_circ
n_free_target = target_total - (1 if core_node is not None else 0)
while len(_free()) < n_free_target:
leaf = _pick(rng, _free())
leaf.division = [0.5, 0.5]
leaf.rotation = int(rng.integers(4))
leaf.left = dom.Node(type=leaf.type)
leaf.right = dom.Node(type=leaf.type)
leaf.type = None
prev.above = dup
dom._link(child) # link so the upper storey's geometry is computable
_assign_adjacency_aware(
dup, rooms, reqs, rng,
fixed_circ=[core_node] if core_node is not None else None)
else:
assign = rooms + ["O"] # courtyard / outside on the upper floor
if core_node is None:
assign.append("C") # no inherited core to reuse — make one
while len(_free()) < len(assign): while len(_free()) < len(assign):
leaf = _pick(rng, _free()) leaf = _pick(rng, _free())
leaf.division = [0.5, 0.5] leaf.division = [0.5, 0.5]
@ -627,14 +572,15 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
leaf.left = dom.Node(type=leaf.type) leaf.left = dom.Node(type=leaf.type)
leaf.right = dom.Node(type=leaf.type) leaf.right = dom.Node(type=leaf.type)
leaf.type = None leaf.type = None
frees = _free() frees = _free()
order = rng.permutation(len(frees)) order = rng.permutation(len(frees))
for slot, leaf_idx in enumerate(order): for slot, leaf_idx in enumerate(order):
frees[int(leaf_idx)].type = assign[slot] if slot < len(assign) else "O" frees[int(leaf_idx)].type = assign[slot] if slot < len(assign) else "O"
if core_node is not None: if core_node is not None:
core_node.type = "C" # keep the inherited core as circulation core_node.type = "C" # keep the inherited core as circulation
prev.above = dup
prev.above = dup
prev = dup prev = dup
return _finalise(child) return _finalise(child)

View file

@ -153,40 +153,6 @@ def test_adjacency_aware_seeding_cuts_adjacency_access_fails():
assert adj_access(True) < adj_access(False) assert adj_access(True) < adj_access(False)
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
def test_adjacency_aware_lift_cuts_adjacency_access_fails():
# ld5: lift_base_to_storeys grows the upper-floor circulation spine off the
# inherited core and clusters rooms around it, cutting the same fail classes
# on the storeys above the base.
import copy
from homemaker_layout import fitness, programme
reqs = programme.load_programme_dir(str(HARBOR))
conf, cost = fitness.load_config(str(HARBOR))
fit = fitness.Fitness(conf, cost)
types = sorted(reqs) + ["C", "O"]
n_st = programme.n_storeys_required(reqs)
seed = dom.load(str(HARBOR / "init.dom"))
def adj_access(aware: bool) -> float:
total = 0
for trial in range(5):
rng = np.random.default_rng(trial)
buckets = programme.partition_rooms_by_storey(reqs, n_st, rng)
base = operators.constructive_topology(seed, reqs, rng, types)
base0 = dom.levels(base)[0]
base0.above = None
lifted = operators.lift_base_to_storeys(
base0, buckets[1:], rng, types, reqs=reqs, adjacency_aware=aware)
_, fails = fit.score_with_fails(copy.deepcopy(lifted))
total += sum(1 for f in fails if "adjacent" in f or "access" in f
or "inaccessible" in f)
return total / 5
assert adj_access(True) < adj_access(False)
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available") @pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
def test_place_missing_repairs_deficient_tree(): def test_place_missing_repairs_deficient_tree():
# §11.2 repair: iterating mutate_place_missing drives a deficient design's # §11.2 repair: iterating mutate_place_missing drives a deficient design's