From c3635634e8c5dd57e3ac87c6180274005bc8adda Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Tue, 30 Jun 2026 07:08:46 +0100 Subject: [PATCH] 9o5: type superposition + per-eval collapse (multi-use leaves) Interchangeable codes (similar size/width/proportion, compatible level/stack, no adjacency edge) form equivalence classes derived from the programme. With --superpose (default off), each fitness eval COLLAPSES every superposed leaf to its best in-class usage via an optimal supply->demand assignment (brute force <=C! within cap C=4, scipy Hungarian beyond), then scores the condensed types. Because collapse re-types on the unmerged tree before all checks, counts / adjacency / quality are unchanged downstream -- no Node field, no graph/operator changes -- and default OFF is bit-identical. - programme.py: derive_interchange_classes + interchangeable (S1-S4, locked thresholds R_SIZE=1.5/R_WIDTH=1.3/R_PROP=1.5, CLASS_CAP=4) - fitness.py: collapse_superposition, _best_assignment, _usage_quality; superpose/superpose_class_cap conf knobs; collapse hooked into _evaluate_full - driver.py/evolve.py: superpose flag plumbed beside leaf_sharing; --superpose - tests/test_superposition.py: 17 tests (derivation, assignment, end-to-end) Closes homemaker-py-9o5 (build); validation A/B is homemaker-py-xi7. Co-Authored-By: Claude Opus 4.8 --- .beads/issues.jsonl | 33 ++--- src/homemaker_layout/driver.py | 35 +++-- src/homemaker_layout/evolve.py | 8 ++ src/homemaker_layout/fitness.py | 119 ++++++++++++++++ src/homemaker_layout/programme.py | 85 ++++++++++++ tests/test_superposition.py | 216 ++++++++++++++++++++++++++++++ 6 files changed, 472 insertions(+), 24 deletions(-) create mode 100644 tests/test_superposition.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 285d17b..ba2edbb 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -27,7 +27,7 @@ {"id":"homemaker-py-71d.1","title":"Diagnostic: high-budget harbor floor on full default stack — does landlocked crinkliness still dominate after interior-O?","description":"71d go/no-go probe. 71d targets landlocked crinkliness (area_outside=0, ratio-invariant) which its named fix (interior O courtyards) addresses. interior_outside now ships default-ON (erc.8), so re-measure: run harbor full default stack at high budget (1M evals, n_workers=4, seed 0) and break down the at-convergence residual — fail-type histogram + landlocked-vs-under-exposed split of crinkliness fails. If landlocked still dominates -\u003e 71d worth it; if interior-O dissolved it -\u003e 71d redundant. Verdict to DESIGN.md.","notes":"VERDICT (DESIGN §13.7): NO-GO on 71d. 500k serial full-stack harbor probe (seed 0) -\u003e 20 fails. Crinkliness collapsed 13-\u003e4, landlocked crinkliness ~13-\u003e2 of 20. Interior-O (now default) IS 71d's named fix (interior O courtyards) and already dissolved the target block. Residual now diffuse (top class edge-too-long 6), no concentrated ratio-invariant block for a targeted operator. Recommend close 71d + 7u5/jrb/u8x as superseded-by-construction.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-28T06:57:44Z","created_by":"Bruno Postle","updated_at":"2026-06-28T13:19:08Z","started_at":"2026-06-28T06:58:10Z","closed_at":"2026-06-28T13:19:08Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-71d.1","depends_on_id":"homemaker-py-71d","type":"parent-child","created_at":"2026-06-28T07:57:44Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-erc.8","title":"Flip interior_outside (odiv=3) default to ON","description":"§13.6/ld2 confirmed interior-O light-well seeding positive on dense floors (harbor -16.4%, all seeds improve) and net-neutral on maple (-2.8%, mean improves, no programme regresses on mean). Mirror the pll flip after erc.7: change interior_outside default False-\u003eTrue in driver.search/search_staged and operators.constructive_topology/lift_base_to_storeys (outside_divisor stays 3). No test asserts fail counts so low-risk. Verify control runs still re-score OK.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-28T06:18:14Z","created_by":"Bruno Postle","updated_at":"2026-06-28T06:29:48Z","started_at":"2026-06-28T06:26:42Z","closed_at":"2026-06-28T06:29:48Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-erc.8","depends_on_id":"homemaker-py-erc","type":"parent-child","created_at":"2026-06-28T07:18:13Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-pll","title":"Flip depth_balanced + leaf_sharing (factor 3) defaults to ON","description":"erc.7/§13.5 verdict: depth_balanced + leaf_sharing (factor 3) is the winning Phase-8 stack (harbor -21%, maple -4.6% vs share-alone; factor 3 confirmed optimal). Both default OFF today. Make the bal+share stack the default in driver.search/search_staged (leaf_sharing=True, leaf_share_factor=3, depth_balanced=True) and update the affected tests (the §13.4 note records 214 tests pass with depth_balanced OFF — expect ordering/snapshot churn). Keep env-var overrides (DEPTHBAL/LEAFSHARE/LEAFSHAREFAC) for A/B. leaf_share_max stays 4 (covers factor\u003c=4, no missing-fail leak).","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-27T16:12:53Z","created_by":"Bruno Postle","updated_at":"2026-06-27T20:15:26Z","started_at":"2026-06-27T16:14:52Z","closed_at":"2026-06-27T20:15:26Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-pll","depends_on_id":"homemaker-py-erc","type":"parent-child","created_at":"2026-06-27T17:13:34Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"homemaker-py-9o5","title":"Multi-use leaves: one leaf satisfies several COMPATIBLE different codes (type superposition)","description":"A leaf that legitimately and simultaneously satisfies several DIFFERENT compatible programme requirements (e.g. study + guest bedroom, or kitchen + dining). Distinct from erc.3 leaf-sharing, which aggregates k instances of the SAME code; this is a strict generalisation across DIFFERENT codes. Idea from Bruno (this corresponds to Stewart Brand's 'How Buildings Learn' — loose-fit / long-life rooms whose use churns over a building's lifetime).\n\nWHY IT MATTERS\n1. Architectural deliverable: adaptable multi-use rooms (Brand loose-fit), not just an optimisation trick.\n2. Generalises the erc.3 floor-lowering lever to the SINGLETON (count:1) long tail that same-type sharing cannot reach: one leaf covering one X AND one Y removes a room-leaf, paying the ~1.8/leaf crinkliness tax (§13.1) once instead of twice. Crinkliness is scale-invariant, so a larger multi-use leaf is not penalised for size.\n\nTWO READINGS\n(a) Superposition as a SEARCH RELAXATION: carry a distribution/set of candidate types per leaf, evaluate a relaxed (expected/best-case) fitness for a smoother landscape, then COLLAPSE (argmax) at the end. Risks: relaxation gap (relaxed optimum need not sit near a good integer solution); collapse is itself a constrained rounding/assignment problem (cannot collapse 5 superposed leaves all to 'kitchen' when 1 is required); and search-machinery bets are 0/3 historically (§11-12) vs construction 4/4 — the floor is geometric, so pure search-easing may fight the wrong battle. LOWER PRIORITY framing.\n(b) Multi-use as the DESIGN GOAL (preferred): the leaf permanently serves a SET of compatible codes; no collapse needed, multi-use survives into the output. Mirrors erc.3's mechanism exactly but with a SET of codes instead of an integer count: stamp leaf with the codes it serves (type-guarded as in erc.3 leaf.share/share_type); fitness count credits each code in the set, size scored against the union/least-upper-bound of targets, width/proportion as today (scale-invariant), adjacency satisfied if the SET satisfies it.\n\nIMPLEMENTATION SKETCH (path b)\n- dom.Node: a set/list of served codes (generalises leaf.share/share_type from erc.3). Survives search via deepcopy; emit in .dom only when non-trivial (as with 'share').\n- graph.check_space_counts: a multi-use leaf credits coverage to EACH code in its set (type-guarded: honoured only while its served set is consistent with its assignment).\n- fitness size/width/proportion: score the multi-use leaf against the combined target (union/LUB) of its served codes; crinkliness/access unchanged.\n- construction: a new constructive option that fuses COMPATIBLE singleton rooms into shared multi-use leaves (analogous to operators._share_rooms but across codes), honouring adjacency/level.\n- default OFF; controls reproduce §12.2 baseline.\n\nKEY OPEN QUESTIONS (Bruno to spec)\n- Who declares type-COMPATIBILITY? A new architectural input, analogous to adjacency (e.g. a 'compatible:' / 'multiuse:' list per space in patterns.config). kitchen+bathroom is nonsensical; study+guestroom is fine.\n- Does the final design COLLAPSE to single uses or stay loose-fit (keep superposition as a deliverable)? Brand argues for keeping it.\n- How exactly to combine size/width/proportion targets for a leaf serving 2+ codes (max? union? a 'dominant use' target?).\n- Interaction with erc.3 same-type sharing and x3b per-code control — composable? (a leaf could be 'k of X' AND 'one Y').\n\nRELATES TO: erc.3 (same-type leaf-sharing, the special case), x3b (per-code shareable flag), erc.7 (factor/synergy sweep), erc epic (lower the geometry floor). Concept only — implement in a future session.","design":"# 9o5 DESIGN SPEC — Multi-use leaves as SUPERPOSITION + COLLAPSE (path a)\n\nDecisions locked with Bruno (2026-06-29):\n- READING: path (a) — superposition as a SEARCH RELAXATION that CONDENSES to a SPECIFIC\n usage at the end. NOT path (b) loose-fit. (This REVERSES the issue body's stated\n \"path b preferred / path a lower priority\" note — superseded by Bruno.)\n- COMPATIBILITY SOURCE: DERIVE AUTOMATICALLY. Interchangeability = SIMILARITY of leaf\n requirements; codes with similar size/width/proportion (and compatible level/adjacency)\n form an INTERCHANGEABLE EQUIVALENCE CLASS. No hand-authored list on the happy path.\n- RELAXED-PHASE SCORING: BEST-CASE per leaf (not the path-b MAX/LUB). During the relaxed\n phase each superposed leaf scores against whichever in-class usage it best fits; the\n COLLAPSE resolves the actual leaf-\u003eusage assignment. (Bruno's earlier MAX/LUB answer was\n given under the wrong path-b framing and is superseded for the relaxed phase.)\n\nDEFAULT OFF. With the feature disabled, every run reproduces the §12.2 / current baseline\nbit-for-bit (no equivalence classes, no relaxation, no collapse; fitness path untouched).\n\nPRIOR / HONESTY: the issue records search-machinery bets at 0/3 (§11-12) vs construction\n4/4, and lists path-(a) risks explicitly: (i) RELAXATION GAP — the relaxed optimum need\nnot sit near a good integer/condensed solution; (ii) COLLAPSE is a constrained assignment,\nnot free rounding — cannot condense 5 superposed leaves all to 'kitchen' when 1 is\nrequired. Treat a NULL result as the likely outcome and report honestly.\n\n------------------------------------------------------------------------------------\n## 1. Core mechanism\n\nEQUIVALENCE CLASS E = a maximal group of codes whose leaf requirements are SIMILAR (sec 2).\nDuring evolution, a leaf typed to any code in E is a SUPERPOSED leaf: it is not committed\nto a specific in-class usage. The set of required usages in E (the multiset of codes with\ntheir counts) is a fixed DEMAND; the leaves currently typed into E are the SUPPLY. The\nleaf\u003c-\u003eusage assignment within E is left FREE during search and resolved by COLLAPSE.\n\nCOLLAPSE / CONDENSE: enumerate the assignments of E's required usages to E's superposed\nleaves and pick the best-scoring condensed layout. Bruno's example: 3 interchangeable\nusages over 3 leaves =\u003e 3! = 6 permutations to check. Small classes =\u003e brute force; if a\nclass ever grows large, fall back to the assignment problem (Hungarian on the\nleaf x usage cost matrix). Output is SPECIFIC (each leaf gets exactly one usage), not\nloose-fit. Collapse runs at OUTPUT; see sec 3 for whether it also runs during search.\n\nWHY it can help: the solver stops being penalised for putting \"study\" where \"guest\" is\nexpected (or vice versa) when the two are interchangeable, smoothing the landscape so\ntopology/adjacency search isn't fighting an arbitrary usage label. The geometric win is\nincidental — this is a SEARCH-EASING bet (note the 0/3 prior above).\n\n------------------------------------------------------------------------------------\n## 2. Equivalence-class derivation (auto, by requirement similarity)\n\nPure function of programme.SpaceReq, computed once at programme load. Conservative:\nfalse-negatives are cheap (miss an interchange), false-positives corrupt the relaxation.\n\nTwo codes a, b are INTERCHANGEABLE iff ALL hold:\n S1. Both SIZED (has_size). Unsized circulation/outside never participate.\n S2. Requirement SIMILARITY: size, width, proportion targets pairwise within bounded\n ratios (max/min), so the leaves are genuinely substitutable, not merely fusible.\n LOCKED DEFAULTS (Bruno 2026-06-29), all tunable via constants:\n R_size = 1.5 (larger area target \u003c= 1.5x smaller; e.g. study 9 / guest 12 = 1.33 OK)\n R_width = 1.3 (clear-width targets vary less than areas; tighter band)\n R_prop = 1.5 (max length/width aspect targets within 1.5x)\n A pair is similar iff ALL THREE ratios hold. Conservative on purpose: a missed\n grouping is cheap, a wrong one corrupts the relaxation.\n S3. Compatible level / requires_below: equal or one None; no requires_below conflict.\n Service codes (requires_below / wet-stack — bathroom, kitchen, wc) only group with\n other service codes of matching stack — this keeps kitchen out of a study class.\n S4. NO direct adjacency edge between a and b (if the programme requires them adjacent\n they are distinct coexisting rooms, not interchangeable for one leaf).\n\nClass = connected component / clique under this relation. v1 may cap class size; the\ncollapse cost is |class|! for brute force, so cap (e.g. \u003c=4 =\u003e \u003c=24 perms) or switch to\nHungarian beyond the cap.\n\nESCAPE HATCH — DEFERRED (Bruno 2026-06-29): the `interchange: false` per-space opt-out\n(or top-level exclude list) is NOT in v1. The similarity (S2) + service-stack (S3) +\nadjacency (S4) guards stand alone for v1; add the veto hatch ONLY IF auto-derivation is\nobserved to misgroup on the real harbor-house / programme-house configs. File a follow-up\nissue at that point rather than pre-building it.\n\n------------------------------------------------------------------------------------\n## 3. Fitness during the relaxed phase (graph.py)\n\nThe CONSTRAINT that makes relaxation admissible (issue's \"can't collapse 5 leaves to\nkitchen\"): per class E, the superposed leaves must be ASSIGNABLE to E's demand. So:\n- COVERAGE (check_space_counts): score E as a CLASS, not per-code. The class is satisfied\n to the extent the supply leaves can cover the demand multiset (a feasibility/matching\n count, not naive per-code leaves_of). A class with 2 leaves and demand {study:1,guest:1}\n is fully covered; 3 leaves and demand {x:1,y:1} over-supplies (slack), 1 leaf under.\n- QUALITY (size/width/proportion): BEST-CASE per leaf — each superposed leaf scores against\n the in-class usage it best matches under the current relaxed (unassigned) state. Because\n S2 keeps in-class targets similar, best-case vs assigned-case differ little, bounding the\n relaxation gap. crinkliness / access / edge-too-long UNCHANGED (scale-invariant).\n\nCOLLAPSE CADENCE — LOCKED (Bruno 2026-06-29): collapse PER FITNESS EVAL, with a\nCLASS-SIZE CAP. Each candidate's fitness inner-loops the in-class assignment and returns\nthe best CONDENSED score, so search optimises the condensed objective directly and the\nrelaxation gap is removed (search and output agree by construction). The per-eval cost\nfactor is bounded because class size is capped AT DERIVATION (sec 2): default cap C=4\n(=\u003e \u003c=4! = 24 assignments/eval, worst case). Output-only collapse is NOT used.\n\n Cost note: the collapse objective is SEPARABLE — each leaf's score given its assigned\n usage (size/width/proportion/crinkliness) is independent of the other leaves'\n assignments; only feasibility (perfect matching of demand multiset to supply leaves)\n couples them. So the collapse is a LINEAR-SUM ASSIGNMENT and is EXACTLY solvable by\n Hungarian in O(n^3) with NO cap needed for optimality. v1 uses brute-force over \u003c=C!\n permutations for simplicity (C is tiny); the cap exists only to bound that brute force.\n If a real programme yields a class larger than C, derivation SPLITS it (or v1 switches\n that class to Hungarian) rather than dropping superposition — note for the build.\n\n------------------------------------------------------------------------------------\n## 4. Data model (dom.Node)\n\n- `class_id: int|None` or `interchange: tuple[str,...]` on a superposed leaf — the\n equivalence class it currently participates in (the set of in-class codes). Type-guarded\n like erc.3 share: honoured only while `type` is in that set (retype out of the class\n invalidates it). Survives deepcopy; emit in .dom only post-collapse as the FINAL specific\n type (superposition is a search-time state, not an output artifact — unlike path b).\n- The COLLAPSED output is an ordinary single-typed leaf, so the .dom format needs NO new\n persisted field for the deliverable (contrast path b, which needed a persisted `serves`).\n A debug/inspection emit of pre-collapse class membership is optional.\n\n------------------------------------------------------------------------------------\n## 5. Construction / operators\n\nUnlike path b, NO new \"fuse rooms\" constructor is required — superposition is about NOT\ncommitting usage, not about merging leaves. Construction stays as today; the relaxation\nlives in (i) equivalence-class derivation at load and (ii) the relaxed fitness + collapse.\nOperators may gain a cheap \"retype within class\" move (swap a leaf's usage to an in-class\nsibling) that is a no-op under relaxed scoring but keeps the genome honest at collapse.\n\n------------------------------------------------------------------------------------\n## 6. Controls / wiring\n\n- programme: derive classes once at load; hang off the parsed programme.\n- driver.search / search_staged: an `interchange`/`superpose` flag, DEFAULT OFF. Mirror\n the leaf_share_factor selector plumbing (x3b/§13.10).\n- fitness: collapse cadence knob (sec 3) behind the same flag.\n- CLI (evolve.py): expose the flag.\n- Golden test: flag OFF reproduces current .score on all fixtures.\n\n------------------------------------------------------------------------------------\n## 7. Validation plan (future BUILD session)\n\n1. Unit: class derivation on harbor-house + programme-house — assert service/non-service\n not grouped; assert a genuinely-similar pair IS grouped; assert the collapse of a\n hand-built 3-usage/3-leaf class enumerates 6 and picks the known best.\n2. Golden: flag OFF reproduces current .score on all fixtures.\n3. A/B at equal budget on a programme with an interchangeable class: superpose ON vs OFF.\n EXPECTED mechanism = smoother landscape lets topology search reach better layouts that\n the rigid usage labels blocked. PRIOR: search-easing bets 0/3 (§11-12) — expect null,\n report honestly. Measure both the relaxed score AND the COLLAPSED (final, specific)\n score; only the collapsed score counts.\n4. Watch the RELAXATION GAP explicitly: log relaxed-best vs collapsed-best divergence; a\n large gap is the diagnostic that path (a) is fighting the wrong battle (§11-12 framing).\n\nOPEN Qs — ALL RESOLVED (Bruno 2026-06-29). Spec is BUILD-READY.\n- [RESOLVED] Collapse cadence: PER-EVAL, class-size cap C=4 CONFIRMED. Sec 3.\n- [RESOLVED] Similarity thresholds: R_size=1.5, R_width=1.3, R_prop=1.5 (S2).\n- [RESOLVED] `interchange:false` veto hatch: DEFERRED — later fix only if auto-derivation\n misgroups on the real configs (file a follow-up then). Not in v1.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-24T21:11:03Z","created_by":"Bruno Postle","updated_at":"2026-06-29T22:20:58Z","dependencies":[{"issue_id":"homemaker-py-9o5","depends_on_id":"homemaker-py-erc.3","type":"related","created_at":"2026-06-24T22:11:48Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"homemaker-py-9o5","title":"Multi-use leaves: one leaf satisfies several COMPATIBLE different codes (type superposition)","description":"A leaf that legitimately and simultaneously satisfies several DIFFERENT compatible programme requirements (e.g. study + guest bedroom, or kitchen + dining). Distinct from erc.3 leaf-sharing, which aggregates k instances of the SAME code; this is a strict generalisation across DIFFERENT codes. Idea from Bruno (this corresponds to Stewart Brand's 'How Buildings Learn' — loose-fit / long-life rooms whose use churns over a building's lifetime).\n\nWHY IT MATTERS\n1. Architectural deliverable: adaptable multi-use rooms (Brand loose-fit), not just an optimisation trick.\n2. Generalises the erc.3 floor-lowering lever to the SINGLETON (count:1) long tail that same-type sharing cannot reach: one leaf covering one X AND one Y removes a room-leaf, paying the ~1.8/leaf crinkliness tax (§13.1) once instead of twice. Crinkliness is scale-invariant, so a larger multi-use leaf is not penalised for size.\n\nTWO READINGS\n(a) Superposition as a SEARCH RELAXATION: carry a distribution/set of candidate types per leaf, evaluate a relaxed (expected/best-case) fitness for a smoother landscape, then COLLAPSE (argmax) at the end. Risks: relaxation gap (relaxed optimum need not sit near a good integer solution); collapse is itself a constrained rounding/assignment problem (cannot collapse 5 superposed leaves all to 'kitchen' when 1 is required); and search-machinery bets are 0/3 historically (§11-12) vs construction 4/4 — the floor is geometric, so pure search-easing may fight the wrong battle. LOWER PRIORITY framing.\n(b) Multi-use as the DESIGN GOAL (preferred): the leaf permanently serves a SET of compatible codes; no collapse needed, multi-use survives into the output. Mirrors erc.3's mechanism exactly but with a SET of codes instead of an integer count: stamp leaf with the codes it serves (type-guarded as in erc.3 leaf.share/share_type); fitness count credits each code in the set, size scored against the union/least-upper-bound of targets, width/proportion as today (scale-invariant), adjacency satisfied if the SET satisfies it.\n\nIMPLEMENTATION SKETCH (path b)\n- dom.Node: a set/list of served codes (generalises leaf.share/share_type from erc.3). Survives search via deepcopy; emit in .dom only when non-trivial (as with 'share').\n- graph.check_space_counts: a multi-use leaf credits coverage to EACH code in its set (type-guarded: honoured only while its served set is consistent with its assignment).\n- fitness size/width/proportion: score the multi-use leaf against the combined target (union/LUB) of its served codes; crinkliness/access unchanged.\n- construction: a new constructive option that fuses COMPATIBLE singleton rooms into shared multi-use leaves (analogous to operators._share_rooms but across codes), honouring adjacency/level.\n- default OFF; controls reproduce §12.2 baseline.\n\nKEY OPEN QUESTIONS (Bruno to spec)\n- Who declares type-COMPATIBILITY? A new architectural input, analogous to adjacency (e.g. a 'compatible:' / 'multiuse:' list per space in patterns.config). kitchen+bathroom is nonsensical; study+guestroom is fine.\n- Does the final design COLLAPSE to single uses or stay loose-fit (keep superposition as a deliverable)? Brand argues for keeping it.\n- How exactly to combine size/width/proportion targets for a leaf serving 2+ codes (max? union? a 'dominant use' target?).\n- Interaction with erc.3 same-type sharing and x3b per-code control — composable? (a leaf could be 'k of X' AND 'one Y').\n\nRELATES TO: erc.3 (same-type leaf-sharing, the special case), x3b (per-code shareable flag), erc.7 (factor/synergy sweep), erc epic (lower the geometry floor). Concept only — implement in a future session.","design":"# 9o5 DESIGN SPEC — Multi-use leaves as SUPERPOSITION + COLLAPSE (path a)\n\nDecisions locked with Bruno (2026-06-29):\n- READING: path (a) — superposition as a SEARCH RELAXATION that CONDENSES to a SPECIFIC\n usage at the end. NOT path (b) loose-fit. (This REVERSES the issue body's stated\n \"path b preferred / path a lower priority\" note — superseded by Bruno.)\n- COMPATIBILITY SOURCE: DERIVE AUTOMATICALLY. Interchangeability = SIMILARITY of leaf\n requirements; codes with similar size/width/proportion (and compatible level/adjacency)\n form an INTERCHANGEABLE EQUIVALENCE CLASS. No hand-authored list on the happy path.\n- RELAXED-PHASE SCORING: BEST-CASE per leaf (not the path-b MAX/LUB). During the relaxed\n phase each superposed leaf scores against whichever in-class usage it best fits; the\n COLLAPSE resolves the actual leaf-\u003eusage assignment. (Bruno's earlier MAX/LUB answer was\n given under the wrong path-b framing and is superseded for the relaxed phase.)\n\nDEFAULT OFF. With the feature disabled, every run reproduces the §12.2 / current baseline\nbit-for-bit (no equivalence classes, no relaxation, no collapse; fitness path untouched).\n\nPRIOR / HONESTY: the issue records search-machinery bets at 0/3 (§11-12) vs construction\n4/4, and lists path-(a) risks explicitly: (i) RELAXATION GAP — the relaxed optimum need\nnot sit near a good integer/condensed solution; (ii) COLLAPSE is a constrained assignment,\nnot free rounding — cannot condense 5 superposed leaves all to 'kitchen' when 1 is\nrequired. Treat a NULL result as the likely outcome and report honestly.\n\n------------------------------------------------------------------------------------\n## 1. Core mechanism\n\nEQUIVALENCE CLASS E = a maximal group of codes whose leaf requirements are SIMILAR (sec 2).\nDuring evolution, a leaf typed to any code in E is a SUPERPOSED leaf: it is not committed\nto a specific in-class usage. The set of required usages in E (the multiset of codes with\ntheir counts) is a fixed DEMAND; the leaves currently typed into E are the SUPPLY. The\nleaf\u003c-\u003eusage assignment within E is left FREE during search and resolved by COLLAPSE.\n\nCOLLAPSE / CONDENSE: enumerate the assignments of E's required usages to E's superposed\nleaves and pick the best-scoring condensed layout. Bruno's example: 3 interchangeable\nusages over 3 leaves =\u003e 3! = 6 permutations to check. Small classes =\u003e brute force; if a\nclass ever grows large, fall back to the assignment problem (Hungarian on the\nleaf x usage cost matrix). Output is SPECIFIC (each leaf gets exactly one usage), not\nloose-fit. Collapse runs at OUTPUT; see sec 3 for whether it also runs during search.\n\nWHY it can help: the solver stops being penalised for putting \"study\" where \"guest\" is\nexpected (or vice versa) when the two are interchangeable, smoothing the landscape so\ntopology/adjacency search isn't fighting an arbitrary usage label. The geometric win is\nincidental — this is a SEARCH-EASING bet (note the 0/3 prior above).\n\n------------------------------------------------------------------------------------\n## 2. Equivalence-class derivation (auto, by requirement similarity)\n\nPure function of programme.SpaceReq, computed once at programme load. Conservative:\nfalse-negatives are cheap (miss an interchange), false-positives corrupt the relaxation.\n\nTwo codes a, b are INTERCHANGEABLE iff ALL hold:\n S1. Both SIZED (has_size). Unsized circulation/outside never participate.\n S2. Requirement SIMILARITY: size, width, proportion targets pairwise within bounded\n ratios (max/min), so the leaves are genuinely substitutable, not merely fusible.\n LOCKED DEFAULTS (Bruno 2026-06-29), all tunable via constants:\n R_size = 1.5 (larger area target \u003c= 1.5x smaller; e.g. study 9 / guest 12 = 1.33 OK)\n R_width = 1.3 (clear-width targets vary less than areas; tighter band)\n R_prop = 1.5 (max length/width aspect targets within 1.5x)\n A pair is similar iff ALL THREE ratios hold. Conservative on purpose: a missed\n grouping is cheap, a wrong one corrupts the relaxation.\n S3. Compatible level / requires_below: equal or one None; no requires_below conflict.\n Service codes (requires_below / wet-stack — bathroom, kitchen, wc) only group with\n other service codes of matching stack — this keeps kitchen out of a study class.\n S4. NO direct adjacency edge between a and b (if the programme requires them adjacent\n they are distinct coexisting rooms, not interchangeable for one leaf).\n\nClass = connected component / clique under this relation. v1 may cap class size; the\ncollapse cost is |class|! for brute force, so cap (e.g. \u003c=4 =\u003e \u003c=24 perms) or switch to\nHungarian beyond the cap.\n\nESCAPE HATCH — DEFERRED (Bruno 2026-06-29): the `interchange: false` per-space opt-out\n(or top-level exclude list) is NOT in v1. The similarity (S2) + service-stack (S3) +\nadjacency (S4) guards stand alone for v1; add the veto hatch ONLY IF auto-derivation is\nobserved to misgroup on the real harbor-house / programme-house configs. File a follow-up\nissue at that point rather than pre-building it.\n\n------------------------------------------------------------------------------------\n## 3. Fitness during the relaxed phase (graph.py)\n\nThe CONSTRAINT that makes relaxation admissible (issue's \"can't collapse 5 leaves to\nkitchen\"): per class E, the superposed leaves must be ASSIGNABLE to E's demand. So:\n- COVERAGE (check_space_counts): score E as a CLASS, not per-code. The class is satisfied\n to the extent the supply leaves can cover the demand multiset (a feasibility/matching\n count, not naive per-code leaves_of). A class with 2 leaves and demand {study:1,guest:1}\n is fully covered; 3 leaves and demand {x:1,y:1} over-supplies (slack), 1 leaf under.\n- QUALITY (size/width/proportion): BEST-CASE per leaf — each superposed leaf scores against\n the in-class usage it best matches under the current relaxed (unassigned) state. Because\n S2 keeps in-class targets similar, best-case vs assigned-case differ little, bounding the\n relaxation gap. crinkliness / access / edge-too-long UNCHANGED (scale-invariant).\n\nCOLLAPSE CADENCE — LOCKED (Bruno 2026-06-29): collapse PER FITNESS EVAL, with a\nCLASS-SIZE CAP. Each candidate's fitness inner-loops the in-class assignment and returns\nthe best CONDENSED score, so search optimises the condensed objective directly and the\nrelaxation gap is removed (search and output agree by construction). The per-eval cost\nfactor is bounded because class size is capped AT DERIVATION (sec 2): default cap C=4\n(=\u003e \u003c=4! = 24 assignments/eval, worst case). Output-only collapse is NOT used.\n\n Cost note: the collapse objective is SEPARABLE — each leaf's score given its assigned\n usage (size/width/proportion/crinkliness) is independent of the other leaves'\n assignments; only feasibility (perfect matching of demand multiset to supply leaves)\n couples them. So the collapse is a LINEAR-SUM ASSIGNMENT and is EXACTLY solvable by\n Hungarian in O(n^3) with NO cap needed for optimality. v1 uses brute-force over \u003c=C!\n permutations for simplicity (C is tiny); the cap exists only to bound that brute force.\n If a real programme yields a class larger than C, derivation SPLITS it (or v1 switches\n that class to Hungarian) rather than dropping superposition — note for the build.\n\n------------------------------------------------------------------------------------\n## 4. Data model (dom.Node)\n\n- `class_id: int|None` or `interchange: tuple[str,...]` on a superposed leaf — the\n equivalence class it currently participates in (the set of in-class codes). Type-guarded\n like erc.3 share: honoured only while `type` is in that set (retype out of the class\n invalidates it). Survives deepcopy; emit in .dom only post-collapse as the FINAL specific\n type (superposition is a search-time state, not an output artifact — unlike path b).\n- The COLLAPSED output is an ordinary single-typed leaf, so the .dom format needs NO new\n persisted field for the deliverable (contrast path b, which needed a persisted `serves`).\n A debug/inspection emit of pre-collapse class membership is optional.\n\n------------------------------------------------------------------------------------\n## 5. Construction / operators\n\nUnlike path b, NO new \"fuse rooms\" constructor is required — superposition is about NOT\ncommitting usage, not about merging leaves. Construction stays as today; the relaxation\nlives in (i) equivalence-class derivation at load and (ii) the relaxed fitness + collapse.\nOperators may gain a cheap \"retype within class\" move (swap a leaf's usage to an in-class\nsibling) that is a no-op under relaxed scoring but keeps the genome honest at collapse.\n\n------------------------------------------------------------------------------------\n## 6. Controls / wiring\n\n- programme: derive classes once at load; hang off the parsed programme.\n- driver.search / search_staged: an `interchange`/`superpose` flag, DEFAULT OFF. Mirror\n the leaf_share_factor selector plumbing (x3b/§13.10).\n- fitness: collapse cadence knob (sec 3) behind the same flag.\n- CLI (evolve.py): expose the flag.\n- Golden test: flag OFF reproduces current .score on all fixtures.\n\n------------------------------------------------------------------------------------\n## 7. Validation plan (future BUILD session)\n\n1. Unit: class derivation on harbor-house + programme-house — assert service/non-service\n not grouped; assert a genuinely-similar pair IS grouped; assert the collapse of a\n hand-built 3-usage/3-leaf class enumerates 6 and picks the known best.\n2. Golden: flag OFF reproduces current .score on all fixtures.\n3. A/B at equal budget on a programme with an interchangeable class: superpose ON vs OFF.\n EXPECTED mechanism = smoother landscape lets topology search reach better layouts that\n the rigid usage labels blocked. PRIOR: search-easing bets 0/3 (§11-12) — expect null,\n report honestly. Measure both the relaxed score AND the COLLAPSED (final, specific)\n score; only the collapsed score counts.\n4. Watch the RELAXATION GAP explicitly: log relaxed-best vs collapsed-best divergence; a\n large gap is the diagnostic that path (a) is fighting the wrong battle (§11-12 framing).\n\nOPEN Qs — ALL RESOLVED (Bruno 2026-06-29). Spec is BUILD-READY.\n- [RESOLVED] Collapse cadence: PER-EVAL, class-size cap C=4 CONFIRMED. Sec 3.\n- [RESOLVED] Similarity thresholds: R_size=1.5, R_width=1.3, R_prop=1.5 (S2).\n- [RESOLVED] `interchange:false` veto hatch: DEFERRED — later fix only if auto-derivation\n misgroups on the real configs (file a follow-up then). Not in v1.","notes":"BUILD COMPLETE (path a, per-eval collapse). Implemented:\n- programme.derive_interchange_classes + interchangeable(): S1-S4 relation,\n LOCKED thresholds R_SIZE=1.5/R_WIDTH=1.3/R_PROP=1.5, CLASS_CAP=4. Classes =\n connected components (size\u003e=2). Pure fn of SpaceReq.\n- fitness.Fitness.collapse_superposition(): per-eval COLLAPSE. Re-types each\n superposed leaf to its best in-class usage via an optimal supply-\u003edemand\n assignment (brute force \u003c=C! within cap, scipy Hungarian beyond). Objective =\n usage quality (size*width*proportion) weighted by leaf area (value-faithful,\n rate constant within class). Runs on the UNMERGED tree before all checks, so\n counts/adjacency/quality see the condensed types -\u003e NO changes needed to\n graph.py / dom.py / operators.py. Default OFF is bit-identical (250 tests pass,\n was 233).\n- Plumbing: driver.search/search_staged/_evaluate/_fitness_for take superpose\n bool (folded into conf overrides via _overrides_for); evolve.py exposes\n --superpose/--no-superpose (env HOMEMAKER_SUPERPOSE), default off.\n- tests/test_superposition.py (17): derivation (incl. service/adjacency/level\n guards + real programme-house), assignment (brute + Hungarian + rect), and\n end-to-end collapse re-typing.\n\nKEY DESIGN REALISATION: because collapse re-types at eval time, the Node never\nneeds a persisted class_id/serves field and operators need no \"retype within\nclass\" move -- the genome carries any in-class type and collapse fixes it. This\nrealises the §3 \"search and output agree by construction\" with minimal surface.\n\nOBSERVED: harbor-house derives a chained 8-code component {da1,ef1,k1,la1,m,me1,\nn,ws1} (spans 10..60 m2). Sanctioned (spec: connected-component + Hungarian\nbeyond cap); collapse self-sorts by best-fit so a 60 m2 leaf is not mis-assigned\nto a 10 m2 slot. This is the documented false-positive risk; the deferred\ninterchange:false veto hatch is the fix IF the A/B shows misgrouping hurts.\n\nREMAINING (validation RUN, not build): §7.3 A/B at equal budget superpose ON vs\nOFF on programme-house; §7.4 log relaxed-vs-collapsed gap. See follow-up issue.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-24T21:11:03Z","created_by":"Bruno Postle","updated_at":"2026-06-30T06:03:32Z","started_at":"2026-06-29T22:22:35Z","dependencies":[{"issue_id":"homemaker-py-9o5","depends_on_id":"homemaker-py-erc.3","type":"related","created_at":"2026-06-24T22:11:48Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-x3b","title":"Per-code shareable flag (SpaceReq.share) + homemaker-evolve CLI wiring","description":"Make leaf-sharing (erc.3, §13.3) safe to default-on by giving the programme author per-code control, and expose it on the real CLI (not just the experiment env var).\n\nDesign (agreed with Bruno, open to refinement — he has follow-up questions):\n- patterns.config per-space optional key 'share: N' -\u003e SpaceReq.share (int, default 1 = not shareable). N\u003e=2 means up to N rooms of this code per shared leaf.\n- Master enable stays the 'leaf_sharing' conf/CLI flag (default OFF -\u003e baseline, controls reproduce).\n- Global grain selector 'leaf_share_factor': 0 =\u003e per-code opt-in only (share a code iff it has share:N\u003e=2); F\u003e=2 =\u003e global mode (share all sized multi-instance codes at grain F) with per-code 'share' overriding (share:1 opts a code OUT). This single knob covers both the safe default-on philosophy (0 + per-code keys) and the §13.3 experiment (F=3, reproducible, no example-programme edits).\n- operators._share_rooms picks grain per code accordingly; fitness honours the explicit leaf.share (type-guarded) as today.\n- homemaker-evolve gains --leaf-sharing / --leaf-share-factor, threaded to driver.search/search_staged (already plumbed).\n- Tests: per-code grain, opt-out, default-OFF parity. NOT editing example programmes so §13.3 stays reproducible.\n\nRelates to dyh (productionise). erc.7 covers the factor/max_share sweep + erc.4 synergy.","notes":"DONE (§13.10). Per-code SpaceReq.share + has_share (programme.py). operators._share_grain resolves grain from leaf_share_factor selector: 0=per-code opt-in (share iff share:N\u003e=2), \u003e=2=global with per-code override (share:1 opts OUT, share:N sets grain). End-to-end conf injection productionised (no monkeypatch): load_config(overrides=) merged last; driver.search/innerloop.optimise/NativeEvaluator/_fitness_for thread conf_overrides={leaf_sharing:True}. CLI: homemaker-evolve --leaf-sharing/--no-leaf-sharing + --leaf-share-factor. Example programmes untouched (13.3/13.9 reproducible). Tests added: grain modes, opt-out, default-OFF parity, load_config overrides, programme parse, CLI parse. 233 pass. Smoke: harbor 37 vs 95 fails. Experiment monkeypatches updated to accept overrides=.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-24T21:03:05Z","created_by":"Bruno Postle","updated_at":"2026-06-28T21:02:48Z","started_at":"2026-06-24T21:03:45Z","closed_at":"2026-06-28T21:02:48Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-erc.7","title":"Leaf-sharing × erc.4 depth-balancing synergy + factor/max_share sweep","description":"With the missing-fail leak closed by explicit multiplicity (§13.3), revisit the erc.3↔erc.4 synergy the diagnostics predicted: depth-balanced construction lands shared leaves at their correct absolute k×target area, which should further cut size+crinkliness. Also sweep leaf_share_factor (3 won here; try 2/4) and leaf_share_max (default 4) on maple+harbor, seeds 0/1/2, staged 20k, vs the §13.3 factor-3 result (maple 86.3, harbor 50.3).","notes":"FACTOR SWEEP DONE (§13.5): factor 3 confirmed default under bal+share. maple f2=92.7 f3=82.3 f4=83.3; harbor f2=53.0 f3=40.0 f4=39.7. Factor 2 regresses both; f3/f4 tied within noise (f3 wins maple +1.0, f4 wins harbor +0.3). leaf_share_max=4 covers factor\u003c=4, no missing-fail leak (re-score OK all runs). erc.7 complete.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-24T20:51:43Z","created_by":"Bruno Postle","updated_at":"2026-06-27T09:55:56Z","started_at":"2026-06-26T07:39:54Z","closed_at":"2026-06-27T09:55:56Z","close_reason":"Closed","dependencies":[{"issue_id":"homemaker-py-erc.7","depends_on_id":"homemaker-py-erc","type":"parent-child","created_at":"2026-06-24T21:51:42Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-dyh","title":"Productionise leaf-sharing: evolve CLI flag + patterns.config key","description":"erc.3 (§13.3) proved leaf-sharing lowers the floor −37% maple / −32% harbor end-to-end, but the flag is only reachable via the LEAFSHARE env in run_staged_search.py. For real runs: (1) expose --leaf-sharing / --leaf-share-factor on homemaker-evolve (evolve.py), threading to driver.search/search_staged (already plumbed); (2) optionally read a leaf_sharing key from patterns.config so the fitness + construction stay consistent without env injection (fitness already reads conf; construction would read it in evolve). Consider whether to default it ON given the decisive win. Also: the genome.signature ignores leaf.share, so a shared vs unshared leaf of the same type/structure collide — assess if niching needs share in the signature.","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-24T20:51:41Z","created_by":"Bruno Postle","updated_at":"2026-06-24T21:03:47Z","closed_at":"2026-06-24T21:03:47Z","close_reason":"Superseded by x3b (per-code shareable flag + CLI wiring), which is the concrete implementation of dyh's 'CLI flag + patterns.config key' scope with the per-code opt-in design.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -51,6 +51,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-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-xi7","title":"9o5 validation run: A/B superpose ON vs OFF + relaxation-gap log","description":"Future RUN (not a build) for homemaker-py-9o5 type superposition. (1) §7.3 A/B at equal budget on programme-house (interchange classes {b1,b2},{t2,t3}) with --superpose vs --no-superpose; measure the COLLAPSED (final, specific) score only. PRIOR: search-easing bets 0/3 -\u003e expect null, report honestly. (2) §7.4 instrument relaxed-best vs collapsed-best divergence; a large gap diagnoses path-(a) fighting the wrong battle. (3) If harbor-house's chained 8-code class is observed to misgroup/hurt, file the deferred interchange:false veto hatch (spec §2 escape hatch). Depends on the 9o5 build (done).","status":"open","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-30T06:08:06Z","created_by":"Bruno Postle","updated_at":"2026-06-30T06:08:06Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-jrb","title":"Bakeoff: repair operator vs baseline on harbor-house","description":"Bake off the failure-directed repair operator against the current baseline on examples/harbor-house (3m.dom config). Seed from the 3M best (3m.dom) and run ~200k evals, multiple seeds. Also sweep child_budget DOWN (e.g. 80 -\u003e 40 -\u003e 20) to test the hypothesis that reallocating evals from ratio-polishing to topology repair lowers fails. Metric: final n_fails and crinkliness/connected/access counts. Reuse experiments/bakeoff_harbor.py pattern.","status":"closed","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-23T20:40:21Z","created_by":"Bruno Postle","updated_at":"2026-06-28T13:22:12Z","closed_at":"2026-06-28T13:22:12Z","close_reason":"Superseded by construction (DESIGN §13.7): 71d chain closed; interior-O dissolved the landlocked-crinkliness target the bakeoff would have measured.","dependencies":[{"issue_id":"homemaker-py-jrb","depends_on_id":"homemaker-py-71d","type":"parent-child","created_at":"2026-06-23T21:49:55Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-jrb","depends_on_id":"homemaker-py-u8x","type":"blocks","created_at":"2026-06-23T21:40:35Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-u8x","title":"mutate_repair: failure-directed topology repairs","description":"New operator mutate_repair(parent_root, fails, reqs, rng) in operators.py dispatching on failure class, targeting the leaf id named in each fail string. Priority order = ratio-invariant fails first:\n- crinkliness on L -\u003e retype a geometric neighbour of L to O (interior light well) or reassociate/swap L toward facade (attacks 13)\n- 'level N not connected' -\u003e retype a bridging leaf to C to join circulation components (attacks 2)\n- access on L -\u003e retype a neighbour to C (attacks 1)\n- too few stairs -\u003e core_divide to add aligned vertical core (attacks 1)\nReuse leaf-adjacency graph from _assign_adjacency_aware, plus reassociate/core_divide/retype. Wire into operators.mutate weighting and the driver child-generation path (driver.py:452). Depends on fails being available (parent thread task).","status":"closed","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-23T20:40:18Z","created_by":"Bruno Postle","updated_at":"2026-06-28T13:21:55Z","closed_at":"2026-06-28T13:21:55Z","close_reason":"Superseded by construction (DESIGN §13.7): interior-O (default-ON, erc.8) is 71d's named fix (interior O courtyards) and collapsed landlocked crinkliness ~13-\u003e2 of 20 in the high-budget probe. Residual now diffuse, no concentrated ratio-invariant block for a targeted repair operator. Reopen/refile if a future floor probe shows a concentrated ratio-invariant class return.","dependencies":[{"issue_id":"homemaker-py-u8x","depends_on_id":"homemaker-py-71d","type":"parent-child","created_at":"2026-06-23T21:49:53Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-u8x","depends_on_id":"homemaker-py-7u5","type":"blocks","created_at":"2026-06-23T21:40:33Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-71d","title":"Failure-directed topology-repair operator (harbor-house plateau)","description":"harbor-house plateaus at 27 fails under a 3M-eval run. Fail breakdown of the 3M best (3m.dom): 13 crinkliness, 7 size, 2 edge-too-long, 2 level-not-connected, 1 proportion, 1 access, 1 too-few-stairs.\n\nDiagnosis: ~16 of 27 fails (crinkliness 13, not-connected 2, access 1, stairs 1... actually 17 incl stairs) are INVARIANT to split ratios, but the inner loop (child_budget=80 CMA evals/child) spends essentially all eval budget on ratios. The outer comparator only keeps n_fails (driver.py:259) and operators pick targets at random, so the search reaches these discrete adjacency/daylight fails only by luck.\n\nCrinkliness root cause: a landlocked leaf (no facade edge, no adjacent uncovered O) has area_outside=0 -\u003e crink=0 -\u003e quality_uncrinkliness hits the 'if not crink: return 0.0' branch (fitness.py:339) -\u003e guaranteed fail for ALL ratios. Big rooms (cr1 80m2, da1 60m2, n 60m2) are worst. Fix is interior O courtyards / facade access = TOPOLOGY only.\n\nPlan: read the parent's structured .fails (already computed at driver.py:146, just not stored on Individual) and apply targeted, mostly-deterministic topology repairs per failure class, attacking the ratio-invariant fails the inner loop cannot touch. Reuses reassociate, core_divide, retype, and the leaf-adjacency graph.","notes":"Reparented under erc (Phase 8) as a Tier-3 search-machinery bet, LOW prior per erc's thesis ('search machinery cannot help — the floor IS the result', 0/3 wins from grade/niching/feasibility). Honest framing: this is NOT refuted by that scoreboard — those 3 losses were all selection/pruning changes; none added a TARGETED REPAIR OPERATOR, which is a new class. But do not invest here until a construction lever (erc.3/.4/ld2) moves the floor. Must follow erc's shared protocol: A/B maple-court + harbor seeds 0/1/2, 20k evals staged, control reproduces baseline (maple 136.0, harbor 74.0), verdict in DESIGN.md §13.x.","status":"closed","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-23T20:39:34Z","created_by":"Bruno Postle","updated_at":"2026-06-28T13:21:46Z","closed_at":"2026-06-28T13:21:46Z","close_reason":"Superseded by construction (DESIGN §13.7): interior-O (default-ON, erc.8) is 71d's named fix (interior O courtyards) and collapsed landlocked crinkliness ~13-\u003e2 of 20 in the high-budget probe. Residual now diffuse, no concentrated ratio-invariant block for a targeted repair operator. Reopen/refile if a future floor probe shows a concentrated ratio-invariant class return.","dependencies":[{"issue_id":"homemaker-py-71d","depends_on_id":"homemaker-py-erc","type":"parent-child","created_at":"2026-06-23T21:49:50Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -70,23 +71,23 @@ {"id":"homemaker-py-erc.6","title":"Experiment: inner-loop slack-expansion objective term","description":"Inner-loop counterpart to plot-fill construction. If Diagnostic B shows the inner loop has room to expand leaves into slack but no objective gradient to do so (the scalar rewards hitting target area but not exceeding it where slack exists), add a term/incentive so the ratio optimiser pushes leaf boundaries out to consume neighbouring slack and satisfy size, rather than parking at target.\n\nCONDITIONAL on Diagnostic B: build this only if B localizes the gap to the inner loop (room to expand, no gradient); if B shows construction targets too-small dims, prefer the plot-fill construction sibling. Must preserve the §5.4 inner-loop cliff / §4.9 lexicographic protection — the term sits where it cannot displace the fail-count ordering. A/B vs §12.2 baseline, seeds 0/1/2, 20000 evals, staged, default-OFF. Record DESIGN.md §13.6.","notes":"DEPRIORITISED by Diagnostic B (§13.2). B shows the inner loop CANNOT repair undersize: the slack is depth-driven maldistribution baked into the frozen topology, and the equal-offset ratio DOF cannot shrink a 14x leaf to feed a starved one without trading into shape fails (0.5^n cliff). Wrong DOF and wrong direction — the blocker is slicing POSITION, not a missing expansion reward. Fix belongs upstream in construction/topology (erc.4 re-scoped, erc.3). Keep as a low-priority follow-up only if a depth-balanced construction still leaves a residual size gradient the inner loop could pick up.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-22T23:16:24Z","created_by":"Bruno Postle","updated_at":"2026-06-28T13:22:22Z","closed_at":"2026-06-28T13:22:22Z","close_reason":"wont-fix (DESIGN §13.7): Diag B (§13.2) showed the inner loop cannot repair undersize (wrong DOF — slicing position, frozen-topology ratios). Superseded by depth-balanced construction (erc.4). Condition unmet.","dependencies":[{"issue_id":"homemaker-py-erc.6","depends_on_id":"homemaker-py-erc","type":"parent-child","created_at":"2026-06-23T00:16:23Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-erc.6","depends_on_id":"homemaker-py-erc.2","type":"blocks","created_at":"2026-06-23T00:16:47Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-erc.5","title":"Experiment: compactness-aware cuts (minimize leaf perimeter/area)","description":"Attacks the #1 factor, crinkliness (346) — a per-leaf perimeter/area property DISTINCT from proportion (aspect ratio). Proportion-aware seeding (leu.2) sizes splits but does not bias toward balanced, square-ish subdivision. Add a KD-tree-style 'keep both children compact' cut rule (prefer the cut orientation/position that minimises summed child perimeter/area) in construction.\n\nCONDITIONAL on Diagnostic A: if A shows per-leaf shape-fail is FLAT across densities (floor intrinsic to slicing density), better cuts at the same leaf count will not pay → this should be closed wont-fix in favour of leaf-sharing. Only build if A shows shape-fail RISES with density. A/B vs §12.2 baseline, seeds 0/1/2, 20000 evals, staged, default-OFF. Record DESIGN.md §13.5.","notes":"DEPRIORITISED by erc.1 verdict (§13.1): per-leaf shape-fail flat vs slicing density and cuts already squarest (_size_divisions_from_targets picks squarest rotation) yet still ~1.8 fails/leaf =\u003e little compactness headroom at fixed leaf count. Floor is intrinsic to leaf COUNT, not cut quality. Revisit only if leaf-sharing (erc.3) underdelivers.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-22T23:16:21Z","created_by":"Bruno Postle","updated_at":"2026-06-28T13:22:17Z","closed_at":"2026-06-28T13:22:17Z","close_reason":"wont-fix (DESIGN §13.7): Diag A (§13.1) showed the floor is intrinsic to leaf COUNT not cut quality; revisit condition was 'only if leaf-sharing underdelivers' but leaf-sharing OVER-delivered (−32…−39%, §13.3). Condition unmet.","dependencies":[{"issue_id":"homemaker-py-erc.5","depends_on_id":"homemaker-py-erc","type":"parent-child","created_at":"2026-06-23T00:16:21Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-erc.5","depends_on_id":"homemaker-py-erc.1","type":"blocks","created_at":"2026-06-23T00:16:43Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-2g5","title":"Rebuild occlusion/daylight/sun subsystem in Python (post-Phase-5, after optimisation fully native)","description":"DESIGN.md §6 port scope — a whole subsystem, not a term. quality_daylight (Leaf.pm:281-296) needs Urb::Misc::Sun + Urb::Field::Occlusion (+CIESky); quality_uncrinkliness also takes the occlusion object. Indoor spaces return 1 for daylight; cost is outdoor spaces + crinkliness. Port Sun_horizontal (262980-minute normalisation) and the occlusion wall set from Dom-\u003eWalls.","acceptance_criteria":"Daylight and crinkliness factors match Perl (float tolerance) across the corpus, including multi-storey cases","notes":"Re-scoped 2026-06-12: occlusion disabled in the Urb oracle instead of ported (see homemaker-py-gp2). Native fitness ships with simple crinkliness (illumination factor = 1, in homemaker-py-gnw). This issue is now the eventual Python occlusion rebuild, only after optimisation works entirely in Python. Restores outdoor-daylight and shaded-wall selection pressure.\nReframed 2026-06-17: orthogonal to epic homemaker-py-c4c. This is fitness FIDELITY (restoring daylight + shaded-wall selection pressure to match Perl), not search CAPABILITY — it changes what 'good' means, not the search's ability to find good. It will NOT improve final designs in the sense currently sought. Stays P4, deferred until the topology-search-quality epic lands and optimisation is fully native.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:25Z","created_by":"Bruno Postle","updated_at":"2026-06-17T19:14:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"memory","key":"ld2-13-6-interior-o-seed-diagnostic-all","value":"ld2/§13.6 interior-O seed diagnostic: ALL crinkliness fails in the constructed bal+share seed are UNDER-exposed (crink\u003c0.62, landlocked rooms with no facade + no uncovered-O neighbour) — zero over-exposed sliver fails. So the erc crinkliness residual is genuine under-daylighting, validating the interior light-well premise. Default outside_divisor=6 was too sparse (null: harbor 147-\u003e142, crinkliness even rose). odiv=3 is the seed-optimal joint setting: harbor seed fails 147-\u003e129 (-18), maple 219-\u003e206 (-14), landlocked fails drop, at cost of more leaves (harbor +4, maple +8). Because it ADDS leaves it carries the §13.4 wash-out risk; A/B to convergence pending."} +{"_type":"memory","key":"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":"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":"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":"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":"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":"strategy-decision-2026-06-12-bruno-occlusion-daylight","value":"Strategy decision 2026-06-12 (Bruno): occlusion/daylight is ORTHOGONAL to building a scalable optimiser. Disable it in Urb (env flag, homemaker-py-gp2) rather than port it; native fitness uses simple crinkliness (illumination factor = 1); rebuild occlusion in Python only after optimisation is fully native (homemaker-py-2g5, now P4). Consequence: all scores change when the flag flips — re-baseline corpus/.score, DESIGN \\$4.5 gains, gate bars at one clean boundary AFTER homemaker-py-1p0 closes; Phase-2 urb-evolve benchmark must run with the same flag."} -{"_type":"memory","key":"9o5-multi-use-leaves-is-path-a-superposition","value":"9o5 multi-use leaves is path (a) — superposition as SEARCH RELAXATION that COLLAPSES to specific usage at the end, NOT path (b) loose-fit/no-collapse. Bruno's intent: codes with SIMILAR leaf requirements form an interchangeable equivalence class; during evolution the solver doesn't commit which leaf serves which specific usage (smoother landscape, no fighting over exact leaf usage); at the end the layout is CONDENSED to specific usages by brute-forcing the in-class assignment (3 interchangeable usages over 3 leaves = 3! = 6 combinations to check, pick best). 'Derive automatically' compatibility = requirement-similarity grouping. This reverses the issue's stated 'path b preferred' note."} -{"_type":"memory","key":"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":"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":"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-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":"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-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":"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":"homemaker-py-pythonpath-set-pythonpath-home-bruno-src","value":"homemaker-layout PYTHONPATH: package installed as 'homemaker-layout' via pip install -e . so 'import homemaker_layout' works from anywhere without PYTHONPATH. For running tests use 'python -m pytest' from project root /home/bruno/src/homemaker-layout (pyproject.toml adds src/ automatically). Never try pip show homemaker — that's the old homemaker-addon conflict."} {"_type":"memory","key":"island-model-psk-14-is-a-null-priming","value":"Island model (psk, §14) is a NULL: priming a population from N converged independent elites + crossover-heavy migration does not beat best-of-N at equal total budget (maple island 124 vs control 116). The child_probe instrument shows WHY: area-matched crossover across independently-converged elites almost never synthesizes (1-3 of ~64 children beat the better parent, max drop 2-5) because the slicing encoding is non-canonical (9gp), so splices are disruptive not combinatorial. Search-machinery null #3 after graded-objective and niching/restarts; residual stays geometry/shape-bound."} -{"_type":"memory","key":"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":"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":"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-seeding-pitfall-run-search-scaled-py-s","value":"Experiment seeding pitfall: run_search_scaled.py's default PH_SEED (c964…dom) is a FINISHED programme-house design — passing it warm-starts and floors at ~3 fails, NOT a blank-slate topology search. For blank-slate runs comparable to §11.5/§11.6 baselines, seed from examples/programme-house/init.dom (a bare undivided plot; driver bootstrap auto-triggers only on bare plots). Bit the 6zy sweep — first pass used c964 and falsely showed 3-fail floor across the whole grid."} +{"_type":"memory","key":"homemaker-py-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":"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":"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":"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":"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":"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":"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":"cli-tool-style-prefer-python-m-homemaker-module","value":"CLI tool style: prefer python -m homemaker.module --parameters pattern, installable via pip install -e . with pyproject.toml entry_points. Not standalone bin/ scripts."} +{"_type":"memory","key":"experiment-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":"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":"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."} diff --git a/src/homemaker_layout/driver.py b/src/homemaker_layout/driver.py index c26a65f..27ec8a3 100644 --- a/src/homemaker_layout/driver.py +++ b/src/homemaker_layout/driver.py @@ -39,8 +39,19 @@ from . import dom, fitness, genome, innerloop, operators, programme _CHILD_INNER_KW: dict = {} +def _overrides_for(leaf_sharing: bool, superpose: bool) -> dict | None: + """Run-level conf overrides for the native evaluator (None when all off).""" + ov: dict = {} + if leaf_sharing: + ov["leaf_sharing"] = True + if superpose: + ov["superpose"] = True + return ov or None + + @functools.lru_cache(maxsize=None) -def _fitness_for(programme_dir: str, leaf_sharing: bool = False) -> "fitness.Fitness": +def _fitness_for(programme_dir: str, leaf_sharing: bool = False, + superpose: bool = False) -> "fitness.Fitness": """Cached Fitness evaluator per (programme dir, leaf_sharing) (config load is the cost). @@ -51,7 +62,7 @@ def _fitness_for(programme_dir: str, leaf_sharing: bool = False) -> "fitness.Fit inner loop instead of reading the on-disk (sharing-free) patterns.config. Cached per process — workers fork their own copy. """ - overrides = {"leaf_sharing": True} if leaf_sharing else None + overrides = _overrides_for(leaf_sharing, superpose) conf, cost = fitness.load_config(programme_dir, overrides=overrides) return fitness.Fitness(conf, cost) @@ -125,7 +136,8 @@ def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw, lineage: str, want_grade: bool = False, feasibility_max_shape_fails: int | None = None, best_n_fails: int | None = None, - leaf_sharing: bool = False) -> tuple[Individual, int]: + leaf_sharing: bool = False, + superpose: bool = False) -> tuple[Individual, int]: # §12.3 shape-feasibility pre-filter (homemaker-py-9gp.1): if even the best # achievable (proportion-aware) geometry of this topology already has at least # as many shape fails as the incumbent's TOTAL fails — and exceeds the tunable @@ -133,11 +145,11 @@ def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw, # eval instead of spending the full inner-loop budget. The best_n_fails guard # makes the proxy safe: a topology whose shape-fail floor is still below the # incumbent is never discarded. Pruned individuals are tagged and never admitted. - overrides = {"leaf_sharing": True} if leaf_sharing else None + overrides = _overrides_for(leaf_sharing, superpose) if (feasibility_max_shape_fails is not None and best_n_fails is not None): pred = operators.predicted_shape_fails( root, _reqs_for(str(programme_dir)), - _fitness_for(str(programme_dir), leaf_sharing)) + _fitness_for(str(programme_dir), leaf_sharing, superpose)) if pred > feasibility_max_shape_fails and pred >= best_n_fails: ind = Individual(root=root, fitness=0.0, n_fails=pred, ratios={}, lineage=f"pruned/{lineage}", grade=0.0, @@ -151,7 +163,8 @@ def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw, # native eval per child (~1/child_budget overhead); skipped unless requested. grade = 0.0 if want_grade: - _, _, grade = _fitness_for(str(programme_dir), leaf_sharing).score_with_grade( + _, _, grade = _fitness_for( + str(programme_dir), leaf_sharing, superpose).score_with_grade( copy.deepcopy(root)) ind = Individual(root=root, fitness=r.fitness, n_fails=r.n_fails, ratios=innerloop.ratio_map(root), lineage=lineage, @@ -199,6 +212,7 @@ def search( circ_divisor: int = 3, leaf_sharing: bool = True, leaf_share_factor: int = 3, + superpose: bool = False, depth_balanced: bool = True, interior_outside: bool = True, outside_divisor: int = 3, @@ -371,7 +385,7 @@ def search( best_nf = result.best.n_fails if result.best is not None else None full = [ (root, programme_dir, urb_root, x0, budget_, kw_, lin, use_grade, - mx, best_nf, leaf_sharing) + mx, best_nf, leaf_sharing, superpose) for root, x0, budget_, kw_, lin in tasks ] if _pool is not None: @@ -442,7 +456,8 @@ def search( x0=None, budget=seed_budget, inner_kw={}, lineage="seed", want_grade=use_grade, - leaf_sharing=leaf_sharing) + leaf_sharing=leaf_sharing, + superpose=superpose) n_evals += used admit(seed_ind, pop) @@ -551,6 +566,7 @@ def search_staged( circ_divisor: int = 3, leaf_sharing: bool = True, leaf_share_factor: int = 3, + superpose: bool = False, depth_balanced: bool = True, interior_outside: bool = True, outside_divisor: int = 3, @@ -605,6 +621,7 @@ def search_staged( circ_divisor=circ_divisor, leaf_sharing=leaf_sharing, leaf_share_factor=leaf_share_factor, + superpose=superpose, depth_balanced=depth_balanced, interior_outside=interior_outside, outside_divisor=outside_divisor) @@ -640,6 +657,7 @@ def search_staged( circ_divisor=circ_divisor, leaf_sharing=leaf_sharing, leaf_share_factor=leaf_share_factor, + superpose=superpose, depth_balanced=depth_balanced, interior_outside=interior_outside, outside_divisor=outside_divisor, @@ -684,6 +702,7 @@ def search_staged( circ_divisor=circ_divisor, leaf_sharing=leaf_sharing, leaf_share_factor=leaf_share_factor, + superpose=superpose, depth_balanced=depth_balanced, interior_outside=interior_outside, outside_divisor=outside_divisor, diff --git a/src/homemaker_layout/evolve.py b/src/homemaker_layout/evolve.py index 84c4f28..786a3c7 100644 --- a/src/homemaker_layout/evolve.py +++ b/src/homemaker_layout/evolve.py @@ -84,6 +84,12 @@ def _parse_args(argv=None) -> argparse.Namespace: "code iff its programme entry sets 'share: N>=2'); N>=2 = " "share every sized code at grain N, with a code's explicit " "'share' overriding (share:1 opts out) (default: 3)") + p.add_argument("--superpose", action=argparse.BooleanOptionalAction, + default=_env_bool("HOMEMAKER_SUPERPOSE", False), + help="type superposition (9o5): interchangeable codes (similar " + "requirements) form equivalence classes and each candidate " + "collapses every superposed leaf to its best in-class usage " + "before scoring (default: off)") p.add_argument("--output", type=Path, default=None, metavar="PATH", help="output .dom path (- for stdout)") return p.parse_args(argv) @@ -121,6 +127,7 @@ def main(argv=None) -> int: print(f"rng seed : {args.seed}", file=sys.stderr) print(f"leaf sharing : {args.leaf_sharing} (factor={args.leaf_share_factor})", file=sys.stderr) + print(f"superpose : {args.superpose}", file=sys.stderr) print(f"output : {out or 'stdout'}", file=sys.stderr, flush=True) seed_root = dom.load(str(seed_file)) @@ -140,6 +147,7 @@ def main(argv=None) -> int: n_workers=args.workers, leaf_sharing=args.leaf_sharing, leaf_share_factor=args.leaf_share_factor, + superpose=args.superpose, log=lambda m: print(m, file=sys.stderr, flush=True), ) diff --git a/src/homemaker_layout/fitness.py b/src/homemaker_layout/fitness.py index 29d9c90..a6330e1 100644 --- a/src/homemaker_layout/fitness.py +++ b/src/homemaker_layout/fitness.py @@ -208,6 +208,120 @@ class Fitness: # share_edge_cap=False still reproduces the pre-flip control arm. cap = self.conf("share_edge_cap") self._share_edge_cap = self._leaf_sharing if cap is None else bool(cap) + # 9o5 type superposition (DESIGN.md §13/homemaker-py-9o5): default OFF. + # When on, interchangeable codes (similar requirements) form equivalence + # classes; each candidate's fitness re-types (collapses) every superposed + # leaf to its best in-class usage before scoring, so search optimises the + # condensed objective directly and the relaxation gap is removed. + self._superpose = bool(self.conf("superpose")) + from .programme import CLASS_CAP as _CLASS_CAP + self._class_cap = int(self.conf("superpose_class_cap") or _CLASS_CAP) + self._interchange_classes: list | None = None # lazily derived + + # ------------------------------------------------------------------ # + # Type superposition + collapse (homemaker-py-9o5) + # ------------------------------------------------------------------ # + + def interchange_classes(self) -> list: + """Interchange equivalence classes (size>=2), derived once from the + programme and cached. Empty list when superposition has nothing to act + on, in which case the collapse is a no-op and scoring matches baseline.""" + if self._interchange_classes is None: + from . import programme as _pr + reqs = self._programme or {} + self._interchange_classes = ( + _pr.derive_interchange_classes(reqs) if reqs else [] + ) + return self._interchange_classes + + def _usage_quality(self, leaf: Node, usage: str) -> float: + """The usage-DEPENDENT part of a leaf's quality (size x width x + proportion) as if it were typed ``usage``. The remaining factors + (perpendicular, crinkliness, access) and value rate are usage-invariant + within a class, so this is the separable per-leaf collapse objective.""" + orig = leaf.type + leaf.type = usage + try: + return ( + self.quality_size(leaf) + * self.quality_width(leaf) + * self.quality_proportion(leaf) + ) + finally: + leaf.type = orig + + def _best_assignment(self, quality: list[list[float]]) -> list[tuple[int, int]]: + """Maximum-total-quality matching of ``min(rows, cols)`` leaf->slot + pairs. Brute-forces <= C! permutations when the smaller side is within + the class cap (exact and tiny); otherwise solves the equivalent + linear-sum assignment (Hungarian) — both give the optimum because the + objective is separable per leaf (§3 cost note).""" + rows = len(quality) + cols = len(quality[0]) if rows else 0 + if rows == 0 or cols == 0: + return [] + if min(rows, cols) <= self._class_cap: + import itertools + best: list[tuple[int, int]] = [] + best_score = float("-inf") + if rows <= cols: + for sel in itertools.permutations(range(cols), rows): + s = sum(quality[r][sel[r]] for r in range(rows)) + if s > best_score: + best_score = s + best = [(r, sel[r]) for r in range(rows)] + else: + for sel in itertools.permutations(range(rows), cols): + s = sum(quality[sel[c]][c] for c in range(cols)) + if s > best_score: + best_score = s + best = [(sel[c], c) for c in range(cols)] + return best + from scipy.optimize import linear_sum_assignment + import numpy as np + ri, ci = linear_sum_assignment(-np.array(quality)) + return list(zip(ri.tolist(), ci.tolist())) + + def collapse_superposition(self, root: Node) -> None: + """Re-type each superposed leaf to its best in-class usage (the per-eval + COLLAPSE, homemaker-py-9o5 §1). Runs on the UNMERGED tree before any + check, so counts/adjacency/quality downstream see the condensed types. + + Per class: SUPPLY = leaves currently typed into the class; DEMAND = the + class codes expanded by their required counts. The optimal supply->demand + matching assigns each demand slot to the leaf that fits it best; surplus + supply leaves keep their type (a genuine over-supply that scoring still + penalises), unmet demand slots stay absent (a genuine missing room).""" + classes = self.interchange_classes() + if not classes: + return + prog = self._programme or {} + by_type: dict[str, list[Node]] = {} + for lvl in dom_mod.levels(root): + for leaf in lvl.leaves(): + if leaf.type: + by_type.setdefault(leaf.type, []).append(leaf) + + for cls in classes: + supply = [lf for code in cls for lf in by_type.get(code, [])] + if not supply: + continue + slots: list[str] = [] + for code in sorted(cls): + cnt = prog[code].count if code in prog else 0 + slots.extend([code] * max(0, cnt)) + if not slots: + continue + # Weight each leaf's usage quality by its area: the condensed value is + # sum(quality * value_rate * area), and value_rate is constant within a + # class (all in-class codes are inside rooms), so area is the per-leaf + # weight that makes the matching maximise value, not just mean quality. + quality = [ + [self._usage_quality(lf, s) * geometry.area(lf) for s in slots] + for lf in supply + ] + for r, c in self._best_assignment(quality): + supply[r].type = slots[c] def conf(self, key: str): v = self._conf.get(key) @@ -1072,6 +1186,11 @@ class Fitness: programme = self._programme or {} + # 9o5 COLLAPSE: re-type superposed leaves to their best in-class usage + # before any check (no-op unless superposition is on and a class exists). + if self._superpose: + self.collapse_superposition(root) + # --- Phase 1: UNMERGED tree checks --- check_fails, missing = graph_mod.check_space_counts( root, programme, self._leaf_sharing, self._max_share) diff --git a/src/homemaker_layout/programme.py b/src/homemaker_layout/programme.py index b7a0b88..58513cd 100644 --- a/src/homemaker_layout/programme.py +++ b/src/homemaker_layout/programme.py @@ -83,6 +83,91 @@ def load_programme(path: str) -> dict[str, SpaceReq]: return _parse_spaces(conf) +# --------------------------------------------------------------------------- # +# Interchange equivalence classes (homemaker-py-9o5, type superposition) +# --------------------------------------------------------------------------- # +# +# A maximal group of codes whose leaf requirements are SIMILAR enough that one +# leaf is genuinely substitutable for any in-class usage. Derived as a pure +# function of the parsed programme (no hand-authored list on the happy path). +# Used by the superposition+collapse search relaxation: a leaf typed to any +# in-class code is left uncommitted during search and re-assigned to its best +# in-class usage at scoring time (fitness.collapse_superposition). +# +# Thresholds are LOCKED defaults (Bruno 2026-06-29); conservative on purpose — +# a missed grouping is cheap, a wrong one corrupts the relaxation. +R_SIZE = 1.5 # larger area target <= 1.5x smaller +R_WIDTH = 1.3 # clear-width targets vary less than areas; tighter band +R_PROP = 1.5 # max length/width aspect targets within 1.5x +CLASS_CAP = 4 # brute-force collapse <= C! assignments; beyond this use Hungarian + + +def _ratio(x: float, y: float) -> float: + """max/min of two positive magnitudes (inf if either is non-positive).""" + lo, hi = min(abs(x), abs(y)), max(abs(x), abs(y)) + return hi / lo if lo > 0 else float("inf") + + +def interchangeable(a: SpaceReq, b: SpaceReq) -> bool: + """True iff codes ``a`` and ``b`` satisfy the S1-S4 interchange relation + (homemaker-py-9o5 §2). Symmetric.""" + # S1 — both sized; generic circulation/outside never participate. + if not (a.has_size and b.has_size) or a.size <= 0 or b.size <= 0: + return False + if a.code[0].lower() in ("c", "o", "s") or b.code[0].lower() in ("c", "o", "s"): + return False + # S2 — requirement similarity within bounded ratios (ALL three). + if _ratio(a.size, b.size) > R_SIZE: + return False + if _ratio(a.width, b.width) > R_WIDTH: + return False + if _ratio(a.proportion, b.proportion) > R_PROP: + return False + # S3 — compatible level (equal or one None) and matching service stack. + if a.level is not None and b.level is not None and a.level != b.level: + return False + if (a.requires_below or None) != (b.requires_below or None): + return False + # S4 — no direct adjacency edge (an adjacency pair are coexisting rooms). + if b.code in a.adjacency or a.code in b.adjacency: + return False + return True + + +def derive_interchange_classes(reqs: dict[str, SpaceReq]) -> list[frozenset[str]]: + """Connected components of the interchange relation, size >= 2 + (homemaker-py-9o5 §2). Each class is a set of mutually-substitutable codes. + """ + codes = [ + c for c, r in reqs.items() + if r.has_size and r.size > 0 and c[0].lower() not in ("c", "o", "s") + ] + edges: dict[str, set[str]] = {c: set() for c in codes} + for i, a in enumerate(codes): + for b in codes[i + 1:]: + if interchangeable(reqs[a], reqs[b]): + edges[a].add(b) + edges[b].add(a) + + seen: set[str] = set() + classes: list[frozenset[str]] = [] + for c in codes: + if c in seen: + continue + comp: set[str] = set() + stack = [c] + while stack: + x = stack.pop() + if x in comp: + continue + comp.add(x) + seen.add(x) + stack.extend(edges[x] - comp) + if len(comp) >= 2: + classes.append(frozenset(comp)) + return classes + + def n_storeys_required(reqs: dict[str, SpaceReq]) -> int: """Number of storeys the programme implies, from the highest ``level:`` key. diff --git a/tests/test_superposition.py b/tests/test_superposition.py new file mode 100644 index 0000000..07ba222 --- /dev/null +++ b/tests/test_superposition.py @@ -0,0 +1,216 @@ +"""Tests for type superposition + collapse (homemaker-py-9o5). + +Covers the three layers of the feature: + 1. interchange-class derivation (programme.derive_interchange_classes) + 2. the per-eval collapse assignment (Fitness._best_assignment) + 3. end-to-end collapse re-typing on a built tree (collapse_superposition) + +plus the default-OFF guarantee. +""" + +import pytest + +from homemaker_layout import dom, geometry, programme +from homemaker_layout.dom import Node, _link_subtree +from homemaker_layout.fitness import Fitness +from homemaker_layout.programme import ( + SpaceReq, + derive_interchange_classes, + interchangeable, +) + + +def _req(code, size, width=4.0, proportion=1.5, level=None, + requires_below=None, adjacency=None, count=1): + return SpaceReq( + code=code, size=size, width=width, proportion=proportion, + level=level, requires_below=requires_below, + adjacency=list(adjacency or []), count=count, has_size=True, + has_width=True, has_proportion=True, + ) + + +# --------------------------------------------------------------------------- # +# Derivation +# --------------------------------------------------------------------------- # + +def test_similar_pair_is_grouped(): + # codes are first-letter-classed; c/o/s are generic and never participate, + # so use plain non-generic codes for the study/guest analogue + reqs = {"den": _req("den", 9.0), "guest": _req("guest", 12.0)} + assert derive_interchange_classes(reqs) == [frozenset({"den", "guest"})] + + +def test_dissimilar_size_not_grouped(): + # 60 / 10 = 6x area, far outside R_SIZE + reqs = {"hall": _req("hall", 60.0), "wc": _req("wc", 10.0)} + assert derive_interchange_classes(reqs) == [] + + +def test_width_band_is_tighter_than_size(): + # sizes within R_SIZE but widths 4.0 vs 2.5 (1.6x) exceed R_WIDTH + reqs = {"a": _req("a", 10.0, width=4.0), "b": _req("b", 12.0, width=2.5)} + assert derive_interchange_classes(reqs) == [] + + +def test_adjacency_pair_not_grouped(): + # genuinely-similar requirements, but a required adjacency means they are + # two coexisting rooms, not one interchangeable leaf (S4) + reqs = { + "x": _req("x", 10.0, adjacency=["y"]), + "y": _req("y", 11.0), + } + assert derive_interchange_classes(reqs) == [] + + +def test_service_stack_not_grouped_with_non_service(): + # a wet-stack code (requires_below) never groups with a dry room (S3) + reqs = { + "bath": _req("bath", 10.0, requires_below="bath"), + "den": _req("den", 11.0), + } + assert derive_interchange_classes(reqs) == [] + # ... but two matching-stack services do group + reqs2 = { + "bath1": _req("bath1", 10.0, requires_below="bath"), + "bath2": _req("bath2", 11.0, requires_below="bath"), + } + assert interchangeable(reqs2["bath1"], reqs2["bath2"]) + + +def test_incompatible_levels_not_grouped(): + reqs = {"a": _req("a", 10.0, level=0), "b": _req("b", 11.0, level=1)} + assert derive_interchange_classes(reqs) == [] + # one level None is still compatible + reqs2 = {"a": _req("a", 10.0, level=0), "b": _req("b", 11.0, level=None)} + assert derive_interchange_classes(reqs2) == [frozenset({"a", "b"})] + + +def test_generic_codes_never_participate(): + reqs = {"c": _req("c", 10.0), "o1": _req("o1", 11.0), "room": _req("room", 11.0)} + # c/o are circulation/outside — excluded; only one real code left -> no class + assert derive_interchange_classes(reqs) == [] + + +def test_real_programme_house(): + reqs = programme.load_programme_dir("examples/programme-house") + classes = {frozenset(c) for c in derive_interchange_classes(reqs)} + assert frozenset({"b1", "b2"}) in classes + assert frozenset({"t2", "t3"}) in classes + + +# --------------------------------------------------------------------------- # +# Assignment (collapse core) +# --------------------------------------------------------------------------- # + +def _fit_super(): + return Fitness(conf={"superpose": True}) + + +def test_best_assignment_picks_max_diagonal(): + fit = _fit_super() + # best matching is the diagonal (sum 27); any off-diagonal is worse + q = [[9, 1, 1], [1, 9, 1], [1, 1, 9]] + got = sorted(fit._best_assignment(q)) + assert got == [(0, 0), (1, 1), (2, 2)] + + +def test_best_assignment_enumerates_all_permutations(): + fit = _fit_super() + # the optimum is the anti-diagonal (10+10+10) — exercises the 3!=6 search + q = [[1, 2, 10], [2, 10, 2], [10, 2, 1]] + got = sorted(fit._best_assignment(q)) + assert got == [(0, 2), (1, 1), (2, 0)] + + +def test_best_assignment_surplus_supply(): + fit = _fit_super() + # 3 leaves, 2 demand slots -> only 2 pairs, drop the worst-fitting leaf row + q = [[10, 1], [1, 10], [0, 0]] + got = sorted(fit._best_assignment(q)) + assert got == [(0, 0), (1, 1)] + + +def test_best_assignment_surplus_demand(): + fit = _fit_super() + # 2 leaves, 3 demand slots -> 2 pairs covering the two best columns + q = [[10, 1, 0], [1, 10, 0]] + got = sorted(fit._best_assignment(q)) + assert got == [(0, 0), (1, 1)] + + +def test_best_assignment_falls_back_to_hungarian_beyond_cap(): + fit = Fitness(conf={"superpose": True, "superpose_class_cap": 1}) + q = [[9, 1, 1], [1, 9, 1], [1, 1, 9]] # min dim 3 > cap 1 -> scipy path + got = sorted(fit._best_assignment(q)) + assert got == [(0, 0), (1, 1), (2, 2)] + + +# --------------------------------------------------------------------------- # +# End-to-end collapse on a built tree +# --------------------------------------------------------------------------- # + +def _two_leaf_root(t_left: str, t_right: str, side: float = 6.0, div: float = 0.4): + geometry.clear_cache() + root = Node( + node=[[0, 0], [side, 0], [side, side], [0, side]], + rotation=0, division=[div, div], + left=Node(type=t_left), right=Node(type=t_right), + ) + _link_subtree(root, None, "") + return root + + +def _bedroom_conf(superpose=True): + return { + "superpose": superpose, + "spaces": { + "b1": {"size": [16.0, 4.0], "width": [4.0, 1.0], + "proportion": [1.5, 0.5], "count": 1}, + "b2": {"size": [12.0, 3.0], "width": [3.5, 0.8], + "proportion": [1.5, 0.5], "count": 1}, + }, + } + + +def test_collapse_relabels_to_demand_set(): + fit = Fitness(conf=_bedroom_conf()) + # both leaves typed b1; areas 14.4 (left) and 21.6 (right) + root = _two_leaf_root("b1", "b1") + left, right = root.leaves() + assert geometry.area(right) > geometry.area(left) + + fit.collapse_superposition(root) + + # the demand set {b1, b2} is now covered, not two b1's + assert sorted(lf.type for lf in root.leaves()) == ["b1", "b2"] + # the larger leaf takes the larger target (b1=16), the smaller takes b2=12 + assert right.type == "b1" + assert left.type == "b2" + + +def test_collapse_is_noop_without_a_class(): + # only one real code -> no interchange class -> collapse must not touch types + conf = {"superpose": True, + "spaces": {"b1": {"size": [16.0, 4.0], "count": 2}}} + fit = Fitness(conf=conf) + root = _two_leaf_root("b1", "b1") + fit.collapse_superposition(root) + assert [lf.type for lf in root.leaves()] == ["b1", "b1"] + + +def test_superpose_default_off(): + assert Fitness(conf=_bedroom_conf(superpose=False))._superpose is False + assert Fitness()._superpose is False + + +def test_superpose_off_does_not_relabel(): + # with the flag off, _evaluate_full must never call collapse: a two-b1 tree + # keeps both labels through scoring (proxy: collapse only fires when on) + fit = Fitness(conf=_bedroom_conf(superpose=False)) + root = _two_leaf_root("b1", "b1") + # collapse_superposition is gated by self._superpose in _evaluate_full; call + # the gate directly to document the contract + if fit._superpose: + fit.collapse_superposition(root) + assert [lf.type for lf in root.leaves()] == ["b1", "b1"]