diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index fbcfb36..5403bbc 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,6 +1,6 @@ {"_type":"issue","id":"homemaker-py-ju3","title":"Programme codes share a namespace with the generic c/o/s type prefixes: 14% of harbor-house is silently optional and cr1's declared targets are all discarded","description":"Urb's type system is prefix-based (a type starting with 'c' is circulation, 'o'/'s' is outside) and programme room codes live in the SAME namespace, so any code whose name happens to start with c, o or s is silently reinterpreted as a generic type. Three separate consequences, none announced anywhere in the output:\\n\\n1. graph.check_space_counts line ~530 does 'if code[0].lower() in (\"c\",\"o\",\"s\"): continue' -- the code is SKIPPED ENTIRELY. Never required, never counted, no missing fail, no too-many fail.\\n2. Fitness.get_space_params returns the generic *_circulation / *_outside params BEFORE consulting self.spaces, so declared size/width/proportion are overridden.\\n3. dom.is_circulation / is_outside become true, changing the leaf's value rate, exempting it from crinkliness, and making it supply daylight to neighbours.\\n\\nharbor-house is affected; maple-court, health-centre and programme-house are namespace-clean.\\n\\n cr1 'Common Room with Fireplace' (c): size 80.0 -\u003e 0.0/14.0, width 6.0 -\u003e 2.4, proportion 2.0 -\u003e 1.5, ALL THREE overridden; is_circulation=True so value_rate 50 not 300.\\n of 'Staff Office' x2 (o): width/proportion overridden; is_outside=True; value_rate 100.\\n st1/st2 'Storage' (s): width/proportion overridden; is_outside AND is_circulation True; value_rate 100.\\n\\n5 of 37 room instances (14%) are silently optional. MEASURED CONSEQUENCE: in a 20k-eval run the two cr1 leaves converged to 32.9 and 17.1 m2 against a declared 80 m2, and produced no too-many-spaces fail despite count:1; of/st1/st2 are absent from the result entirely with zero fails, because nothing ever asked for them. Compounds with homemaker-py-2v1: cr1 is the single largest room in the programme and is classified circulation, so the x6 value gap pays the search to shrink it.","design":"Separate the namespaces. Cleanest is an explicit per-space 'class:' key in patterns.config (inside/circulation/outside) defaulting to inside, with the prefix rule used ONLY for untyped generic leaves the search creates -- programme codes then never collide regardless of spelling. A cheaper stopgap is a load-time validation error in programme.load_programme_dir that refuses a programme code starting with c/o/s, which at least converts a silent misread into a loud one. Renaming harbor's four codes would fix that one programme but leaves the trap armed for the next author.","acceptance_criteria":"A programme declaring a code starting with c/o/s either honours its declared params and count, or fails loudly at load. harbor-house re-baselined against its real 37-instance programme, and every DESIGN.md harbor fail count re-stated or annotated as measured against the 32-instance effective programme.","status":"closed","priority":0,"issue_type":"bug","assignee":"Claude","owner":"noreply@anthropic.com","created_at":"2026-08-26T08:26:37Z","created_by":"Claude","updated_at":"2026-08-26T09:05:11Z","started_at":"2026-08-26T08:55:30Z","closed_at":"2026-08-26T09:05:11Z","close_reason":"Shipped as loud validation + harbor rename rather than the class: key (DESIGN.md §39.3). The class: key was deliberately not built: auditing the prefix rule showed l/k/b/t carry adjacency semantics too, so re-plumbing the type system would invalidate the whole corpus for a problem whose damage is the silence, not the convention. programme.validate_codes raises on c/o/s codes from both parse paths; harbor cr1/of/st1/st2 renamed to fr1/ao/gs1/gs2 (neutral unused prefixes, prefix-sharing preserved); migrate_ju3_rename.py migrates pre-rename .dom files. Re-baselined at seed 1/20k: 57 fails on the 32-instance effective programme -\u003e 55 on the real 37-instance one, with all five previously-lost room instances now placed inside their declared sigma bands and zero fails naming them.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-2v1","title":"Connectivity is under-priced ~3x against the circulation-\u003ehabitable value gap: the objective is net-positive on severing the spine","description":"Sharper root cause than homemaker-py-ssz, isolated by the 38.6 A/B (which showed none of the three crinkliness modes removes the deletion incentive). Deleting a circulation leaf merges it into its sibling, converting corridor into habitable area. value_circulation=50 vs value_inside=300, so that is a flat 6x value gain. The only counter-pressure is the 'level N not connected' fail, worth 0.5x under value *= 0.5**len(failures). Break-even needs 0.5^k \u003c 50/300, i.e. k \u003e 2.58 -- severing must cost AT LEAST 3 fails to be net-negative, and it costs 1. Net incentive to sever = 6 * 0.5 = 3.0x in favour; measured 4.06x on a well-lit (q_crink=0.736) circulation leaf, so this is NOT the zero-exposure effect and is not fixable inside quality_uncrinkliness. This is the cleanest explanation of why 'level 0 not connected' and 'level 1 not connected' are still present in evolved-3M-nols-3, the best layout found after 1.7M evals: the search is being paid 3-4x to create them.","design":"Options: (a) emit connectivity fails with a multiplicity \u003e= 3 (cheapest, but stacks with the 1i8 cascade-weighting problem and is a magic number); (b) make the connectivity penalty multiplicative and explicit rather than riding the generic 0.5^n (a dedicated building_factor term, sized from the value-rate gap so it tracks value_circulation/value_inside instead of being hard-coded); (c) revalue circulation as infrastructure -- its worth is that it makes other rooms reachable, which the current per-leaf value rate cannot express; the principled version credits circulation with the access it provides rather than its own floor area. (c) is the architecturally correct one and the biggest change. Recommend measuring (a) first purely to confirm the mechanism (does the 3x threshold flip the deletion test?), then designing (b) or (c) properly.","acceptance_criteria":"Deletion test (experiments/diag_exposure_frontage.py value + the ssz A/B harness) shows lit and buried C/O deletions are no longer rewarded; then harbor-house reaches the 15-fail floor in materially fewer than 1.7M evals AND without 'level 0/1 not connected' in the result.","status":"closed","priority":0,"issue_type":"bug","assignee":"Claude","owner":"noreply@anthropic.com","created_at":"2026-08-26T07:37:39Z","created_by":"Claude","updated_at":"2026-08-26T14:13:53Z","started_at":"2026-08-26T14:09:32Z","closed_at":"2026-08-26T14:13:53Z","close_reason":"MEASURED NULL — premise retracted (DESIGN.md §39.8). The arithmetic was right and the premise was wrong. Shipped connectivity_weight (EXPERIMENTAL, default 1.0 = flat rule, byte-identical) with connectivity_weight_for() deriving w=3 from value_inside/value_circulation rather than hard-coding it. At auto the §38.2 deletion test does not move AT ALL: 5/25 rewarded either way, median x0.26 vs x0.27 — because the connectivity fail count is UNCHANGED in every rewarded deletion (measured: 115-\u003e107 fails but 5-\u003e5 connectivity; 107-\u003e99 but 3-\u003e3; etc). Weighting a fail that never fires changes nothing. And when a deletion DOES break connectivity it is already punished: harbor 2 such deletions of 32, both punished (x0.00, x0.01); maple 5 of 32, all punished (x0.58..x0.07). Severing already costs 1-2 connectivity fails plus the cascade after them, which outweighs the x6 value gain. The x4.06 'well-lit circulation leaf' that motivated the bead was a deletion that did not change connectivity at all — it was rewarded for removing its own quality fails (§38.1's zero-value finding) and I misread it as a pricing mechanism. Flag kept default-off with the write-up per house style for a null lever.","dependency_count":0,"dependent_count":2,"comment_count":0} -{"_type":"issue","id":"homemaker-py-3z0","title":"Preserve circulation connectivity DURING the resize, instead of repairing after it","description":"Named by homemaker-py-yql / DESIGN.md §39.9. The constructive seeder picks circulation as a CONNECTED dominating set and succeeds every time; _size_divisions_from_targets then moves every wall to hit the programme's area targets and destroys it. Measured: 41 of 49 circulation-to-circulation edges lost on health-centre, surviving shared walls squeezed to 0.54-1.11 m against door_width=1.2, taking fully-connected constructed seeds to 1/20, 1/20 and 0/20 across the corpus (100% with the resize disabled).\\n\\nRepairing AFTER the fact was measured and is a net loss (§39.9): operators.repair_circulation_settled restores 100% connectivity but raises missing-room fails by 5.0-8.5 per seed, because every leaf retyped to C displaces a required room at a 3-5 fail cascade. Kept default off.\\n\\nThe fix belongs upstream: constrain the resize so a shared boundary between two circulation leaves cannot fall below door_width. That is an added constraint on an existing solve rather than a new repair pass, and it keeps the programme intact — nothing is displaced, the wall simply is not allowed to close up.\\n\\nWorth checking while there: solver.py already has min_width_generic (default 1.2) keeping generic leaves from collapsing to slivers, which is the same idea applied to leaf WIDTH rather than to a shared BOUNDARY between two specific leaves. The new constraint may belong in the same place.","acceptance_criteria":"Constructed seeds stay \u003e=90% level-connected WITH the resize enabled, without raising missing-room fails; corpus re-baselined.","status":"open","priority":1,"issue_type":"feature","owner":"noreply@anthropic.com","created_at":"2026-08-26T14:37:48Z","created_by":"Claude","updated_at":"2026-08-26T14:37:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"homemaker-py-3z0","title":"Preserve circulation connectivity DURING the resize, instead of repairing after it","description":"Named by homemaker-py-yql / DESIGN.md §39.9. The constructive seeder picks circulation as a CONNECTED dominating set and succeeds every time; _size_divisions_from_targets then moves every wall to hit the programme's area targets and destroys it. Measured: 41 of 49 circulation-to-circulation edges lost on health-centre, surviving shared walls squeezed to 0.54-1.11 m against door_width=1.2, taking fully-connected constructed seeds to 1/20, 1/20 and 0/20 across the corpus (100% with the resize disabled).\\n\\nRepairing AFTER the fact was measured and is a net loss (§39.9): operators.repair_circulation_settled restores 100% connectivity but raises missing-room fails by 5.0-8.5 per seed, because every leaf retyped to C displaces a required room at a 3-5 fail cascade. Kept default off.\\n\\nThe fix belongs upstream: constrain the resize so a shared boundary between two circulation leaves cannot fall below door_width. That is an added constraint on an existing solve rather than a new repair pass, and it keeps the programme intact — nothing is displaced, the wall simply is not allowed to close up.\\n\\nWorth checking while there: solver.py already has min_width_generic (default 1.2) keeping generic leaves from collapsing to slivers, which is the same idea applied to leaf WIDTH rather than to a shared BOUNDARY between two specific leaves. The new constraint may belong in the same place.","acceptance_criteria":"Constructed seeds stay \u003e=90% level-connected WITH the resize enabled, without raising missing-room fails; corpus re-baselined.","status":"closed","priority":1,"issue_type":"feature","assignee":"Claude","owner":"noreply@anthropic.com","created_at":"2026-08-26T14:37:48Z","created_by":"Claude","updated_at":"2026-08-26T15:10:30Z","started_at":"2026-08-26T15:02:29Z","closed_at":"2026-08-26T15:10:30Z","close_reason":"MEASURED NULL (DESIGN.md §39.10), and it reframes §39.9. Built operators._size_divisions_preserving_circulation: snapshot every cut, resize, then revert the cuts on the tree path between each circulation pair the resize broke — programme fully intact, only geometry given back. It works on connectivity (harbor 50-\u003e92%, maple 67-\u003e97%, health-centre 8-\u003e17%) but costs area accuracy: constructed-seed fails harbor 96.6-\u003e141.5, maple 141.8-\u003e175.8, size fails roughly double. Also recorded: both halves of the re-cut do damage in different proportions per programme (freezing rotation leaves harbor 71%, health-centre 8%, maple 92%), so a fix must be able to give back either; and a greedy single-cut revert stalls on the plateau where no ONE revert helps though two would, which is why targeting the broken pairs was needed.\n\nThe defence that raw constructed seeds understate the case — the resize is only a warm start, the inner loop should recover — was TESTED AND FAILS. Full search, harbor-house, 12000 evals, seed 1: OFF 43 fails (9 hard, 3 connectivity); ON 65 fails (26 hard, 4 connectivity). Worse on every axis including connectivity itself.\n\nREFRAMING: §39.9's fact stands (the resize destroys 41 of 49 circulation edges) but is NOT ACTIONABLE, because construction-time connectivity does not determine final connectivity. The search discards and rebuilds the seeder's circulation either way; constraining the seed only spends area quality the search cannot recover. Connectivity was never a construction problem, and §39.8 showed it is not an incentive problem either. Both flags stay default off; do not revisit without a new formulation (the standing bubble.py has).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-yql","title":"Why do level-not-connected fails persist in the best layout, if severing is already punished?","description":"Re-opened by homemaker-py-2v1 closing NULL (DESIGN.md §39.8). §38.2 claimed the search was PAID to sever the circulation spine; measurement refuted that — every deletion that actually breaks connectivity is already punished (x0.00 to x0.58 across harbor-house and maple-court, 0 of 7 rewarded). So the incentive is correct, yet 'level 0 not connected' and 'level 1 not connected' still survive in evolved-3M-nols-3, the best layout after 1.7M evals.\\n\\nThe evidence now points at REACHABILITY rather than incentive: connected topologies may be hard to construct and hard to hold onto under mutation, not insufficiently rewarded. Worth checking:\\n - what fraction of constructed seeds start connected at all (§39.7 measured 4/3/5 inaccessible-usable-space fails per 3 seeds, so: not many)\\n - whether a connected layout, once found, survives mutation or is quickly lost\\n - whether the constructive seeder's circulation dominating set actually guarantees connectivity, or only approximates it\\n - whether the §39.7 usage change (39% of adjacency edges now trimmed) makes connectivity materially harder to achieve, which would be a cost of that fix worth quantifying\\n\\nNOTE this is now measurable in a way it was not before §39.7: the fails actually fire on constructed seeds instead of being hidden by routes through store cupboards.","acceptance_criteria":"A measurement distinguishing 'rarely constructed' from 'constructed then lost' for level connectivity, and a named lever for whichever it is.","status":"closed","priority":1,"issue_type":"task","assignee":"Claude","owner":"noreply@anthropic.com","created_at":"2026-08-26T14:13:54Z","created_by":"Claude","updated_at":"2026-08-26T14:37:30Z","started_at":"2026-08-26T14:31:19Z","closed_at":"2026-08-26T14:37:30Z","close_reason":"ANSWERED (DESIGN.md §39.9): constructed, then lost — at construction time, in the resize. _assign_adjacency_aware picks circulation as a CONNECTED dominating set and succeeds every time; _size_divisions_from_targets then moves every wall and destroys it. Measured, 20 seeds/programme: fully-connected seeds harbor 1/20, health-centre 1/20, maple 0/20. Control with proportion_aware=False (no resize): 100% connected on all three. Mechanism confirmed on health-centre: 41 of 49 circulation-to-circulation edges destroyed by the resize, surviving shared walls squeezed to 0.54-1.11 m against door_width=1.2. Same failure mode §37.7 recorded for CP-SAT assignment, never looked for in connectivity, worth 35-95 points.\n\n§39.7 COST CHECK: zero. Identical rates under prefix-inferred vs declared usages — has_circulation never trims C-C edges, so the usage change did not make connectivity harder.\n\nREPAIR MEASURED NEGATIVE: operators.repair_circulation_settled (§37.7's alternating-minimisation fix, bridging by retyping to C after geometry settles) restores 100% connectivity on all three programmes but is a net loss — connectivity fails -0.8..-1.7 while missing-room fails +5.0..+8.5, because each retyped leaf displaces a required room at a 3-5 fail cascade. Kept default off with the write-up.\n\nNEXT LEVER NAMED: preserve the connection during the resize (constrain _size_divisions_from_targets so a shared C-C boundary cannot fall below door_width) rather than rebuild it afterwards at the programme's expense.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-sel","title":"Usage prefixes (b/t/l/k) are a second implicit namespace: 4 corpus rooms get another room's connectivity rules from their spelling","description":"§39.4 separated programme codes from the GENERIC structural types (C/O/S). It did not touch the other namespace sharing the first character: the USAGE prefixes b=bedroom, t=toilet, l=living, k=kitchen. These classify programme codes by first letter and are still prefix-based BY DESIGN (it is how Urb encodes room usage, and unlike the generic rule they never discard a requirement) -- but they are not inert.\\n\\ngraph.has_circulation deletes graph edges from them: a 'bedroom' loses its edges to living/kitchen/bedroom/toilet, a 'toilet' loses its edges to outside/living/kitchen/toilet, and b/t keep their LEAST popular circulation neighbour while l/k keep their MOST popular. fitness.access and the public-access check read them too. So a code that picks one up by accident is silently given another room's connectivity rules -- and connectivity is exactly where §38 located the residual.\\n\\nFour corpus rooms are misclassified by spelling alone:\\n la1 'Laundry Room' -\u003e living (harbor-house, harbor-house-l0, maple-court)\\n li1 'Library Corner' -\u003e living (harbor-house, maple-court)\\n br1 'Staff Room' -\u003e bedroom (health-centre)\\n tr1 'Treatment Room' -\u003e toilet (health-centre)\\n\\nMeasured on a constructed health-centre seed: tr1 (as a toilet) has its edge to the adjacent outside space O stripped from the circulation graph; br1 (as a bedroom) has its edge to t10 'Staff WC' stripped. Both feed has_circulation and therefore the 'N inaccessible usable space' / 'level N not connected' fails.\\n\\nReport it with: python experiments/audit_programme_config.py (usage-prefix section).","design":"FINAL SHAPE. usage is a plain ATTRIBUTE of the space definition, mandatory on every declared code, no prefix fallback and NO mapping table.\n\n spaces:\n ws1:\n name: Workshop Space # free text, building-specific\n usage: living # controlled, drives engine behaviour\n\nThe earlier 'usage_classes:' indirection table (binding author-coined names like craft-\u003eliving) is WITHDRAWN. It was wrong three ways: (a) an indirect name-\u003ebehaviour mapping living apart from the thing it describes is exactly the shape of the first-character prefix rule that §39 exists to remove, not relocate; (b) it is inconsistent with the schema — every other space property is a plain attribute (name/size/width/proportion/adjacency/level/requires_below/count/share/interchange/co_locate) and no key in patterns.config is an indirection table; (c) the need it served is already met, because 'programmes are building specific' is about what a room is CALLED and name: is already free text, whereas usage: records ACCESS REQUIREMENTS, which are not building-specific — a craft room and a lounge have the same ones, which is the entire reason the key exists.\n\nRULE THAT FALLS OUT: a usage value exists if and only if the engine treats it differently somewhere. Config selects among behaviours; it cannot invent them. A genuinely new access class means writing code, and at that point the value joins the controlled set for everyone. utility passes this test — not on edge-stripping (identical to bedroom today) but because it is intended to drive derive_interchange_classes; distinct behaviour in a second subsystem is a real difference, an alias is not.\n\nVocabulary: living, kitchen, bedroom, toilet, utility, none. Missing usage on a declared code = load error naming the code. Unknown value = load error.\n\nMutation-safety (the original objection): usage is CODE-level, so usage_of(leaf.type) is looked up fresh on every read exactly as size/width/adjacency already are. Never stamp it on a leaf — 51 sites assign leaf.type, and share/share_type plus the r5a resurrection bug are the precedent for why leaf-level attributes go stale.\n\nAssignments for all 52 corpus codes: experiments/usage_map_proposal.yaml (living 12, bedroom 17, utility 11, toilet 7, kitchen 2, none 3; zero unresolved).","acceptance_criteria":"A programme can declare a room's usage explicitly; the prefix rule applies only where nothing is declared; audit_programme_config reports no unintended usage classification across the corpus; re-baselined with an A/B.","notes":"ALL 52 CORPUS CODES NOW ASSIGNED (experiments/usage_map_proposal.yaml): living 12, bedroom 17, utility 11, toilet 7, kitchen 2, none 3. Zero unresolved.\n\nRulings received: li1 Library Corner + br1 Staff Room -\u003e living (same access requirements); tr1 Treatment Room -\u003e bedroom; la1 Laundry -\u003e utility; zt1 Sterilisation -\u003e utility; ph1 Pharmacy -\u003e bedroom (private room, not public, like an office); n 'Neighborhood' -\u003e bedroom (a communal sleeping area, much like a dormitory); gh1 Greenhouse -\u003e living (grows plants but a living area like a conservatory); ws1 Workshop -\u003e living CONDITIONALLY (building-specific: communal craft room vs maintenance area; both corpus programmes carrying it are communal living facilities).\n\nDESIGN DECISION 1 — utility is a DISTINCT value, not an alias for bedroom. Same edge-stripping today (identical access requirements) but separately declared, because (a) they are very different uses and the config should read truthfully, and (b) it gives programme.derive_interchange_classes (§13/§26, homemaker-py-9o5) a natural axis: bedroom-class and utility-class leaves could be interchangeable DURING search and collapse to their real use at scoring time — exactly the superposition relaxation that machinery already implements. Worth building the usage key with that hook in mind.\n\nDESIGN DECISION 2 — vocabulary controlled but NOT closed. The earlier 'closed enum, unknown value errors' recommendation is withdrawn ('programmes are building specific, so it would be nice to have a controlled vocabulary, but not entirely necessary'). Proposed: the six known names drive engine behaviour; a programme may coin its own (usage: craft) provided it binds it in the same file via usage_classes: {craft: living}. An unbound name is a load error, so naming stays free without ever letting an unrecognised usage fall through to a silent default.\n\nVERIFIED — Brand adjacency point is NOT an engine bug. fitness._evaluate_full runs check_adjacency against graph_base_pre, the UNFILTERED graph, so a declared adjacency requirement is satisfied by a neighbouring room regardless of what has_circulation strips. The stripping only denies ROUTING, which is correct. So 'a toilet adjacent to a bedroom is a positive even if there is no door' is already expressible -- but it is only scored where DECLARED, and 3 of 4 real programmes never declare it: only programme-house has t1 -\u003e adjacency [b1]. harbor-house (t vs r/n), maple-court (t/tt vs r/n) and health-centre (t9/t10 vs tr1) do not. Filed separately as homemaker-py-adj.","status":"closed","priority":1,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-26T10:06:54Z","created_by":"Claude","updated_at":"2026-08-26T13:38:20Z","closed_at":"2026-08-26T13:38:20Z","close_reason":"SHIPPED (DESIGN.md §39.7). usage: is a mandatory plain attribute of the space definition; vocabulary living/kitchen/bedroom/toilet/utility/none; missing or unknown is a load error from both parse paths. Code-level so it survives retype, never stamped on a leaf. has_circulation, fitness.access and the public-access check all key on declared usage; fitness._t0 deleted — no first-character type test remains anywhere. All 107 corpus entries migrated. MEASURED: connectivity model was ~4x too permissive; edges trimmed prefix-inferred vs declared, 3 seeds: harbor 18(9%)-\u003e79(39%), health-centre 12(8%)-\u003e59(40%), maple 53(17%)-\u003e123(39%); inaccessible fails 0-\u003e4, 2-\u003e3, 1-\u003e5. Harbor re-baseline seed 1/20k: 58 (15h/43s) -\u003e 61 (16h/45s), now reporting inaccessible-usable-space x2 plus level 0 and level 1 not connected — the count rose because the objective got honest.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-ut5","title":"Update the 2v1 acceptance target: harbor's 15-fail reference was measured pre-ju3 against the 32-instance effective programme","description":"DESIGN.md 38.7 fixes the acceptance test for homemaker-py-2v1 as 'harbor-house reaches its known 15-fail floor in materially fewer than 1.7M evals, and without level 0/1 not connected'. That 15-fail figure comes from evolved-3M-nols-3, measured before homemaker-py-ju3 against the 32-instance EFFECTIVE programme (cr1/of/st1/st2 silently dropped or mis-parameterised). Against the real 37-instance programme the number will differ, so the target as written is not measurable any more.\\n\\nNeeded: migrate evolved-3M*.dom with experiments/migrate_ju3_rename.py, rescore against the renamed programme, and restate the 2v1 acceptance figure. The 39.3 re-baseline (55 fails, seed 1, 20k evals) is the new near-term reference but is not the long-budget floor.","acceptance_criteria":"evolved-3M*.dom committed and migrated; its post-ju3 fail count recorded in DESIGN.md; the 2v1 acceptance figure restated against it.","status":"open","priority":1,"issue_type":"task","owner":"noreply@anthropic.com","created_at":"2026-08-26T09:05:23Z","created_by":"Claude","updated_at":"2026-08-26T09:05:23Z","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/DESIGN.md b/DESIGN.md index c71525b..64d5b81 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -5577,3 +5577,70 @@ constraint on an existing solve rather than a new repair pass. Filed as `min_width_generic` (default 1.2) to stop generic leaves collapsing to slivers — the same idea applied to a leaf's WIDTH rather than to a shared BOUNDARY between two specific leaves, so the new constraint may belong beside it. + +### 39.10 Preserving constructed connectivity through the resize (`homemaker-py-3z0`) — NULL, and it reframes §39.9 + +§39.9 established that `_size_divisions_from_targets` destroys the connected +circulation the seeder builds, and named the upstream fix: keep the connection +*during* the resize rather than rebuilding it after. Built and measured. **It +does not help, and the reason matters more than the lever.** + +**Both halves of the re-cut do damage, in different proportions per programme.** +The resize changes each node's ratio *and* re-picks its rotation. Freezing the +rotations and letting only the ratios move (12 seeds, % of levels connected): + +| programme | no resize | ratio only | full resize | +|---|---|---|---| +| harbor-house | 100% | 71% | 50% | +| health-centre | 100% | **8%** | 8% | +| maple-court | 100% | 92% | 67% | + +health-centre is destroyed entirely by the ratio; maple-court mostly by the +rotation. So any fix has to be able to give back either. + +**`operators._size_divisions_preserving_circulation`** snapshots every cut, +resizes, then reverts the cuts on the tree path between each +circulation-to-circulation pair the resize broke. It keeps the programme +completely intact — no retyping, no displacement, only geometry given back — +and it works on connectivity: + +| programme | connected, OFF | ON | fails OFF → ON | hard OFF → ON | +|---|---|---|---|---| +| harbor-house | 50% | **92%** | 96.6 → **141.5** | 46.0 → 65.1 | +| health-centre | 8% | 17% | 62.8 → **76.9** | 24.2 → 25.8 | +| maple-court | 67% | **97%** | 141.8 → **175.8** | 57.4 → 70.9 | + +(A first attempt reverted greedily — whichever single cut most reduced the +component count — and barely moved: it stalls on the plateau where no *one* +revert helps though two would. Targeting the specific broken pairs is what +made connectivity work.) + +Reverting a cut gives back that subtree's area accuracy, and size failures +roughly double on harbor-house (5.2 → 11.4). The obvious defence is that these +are raw constructed seeds and the inner loop has not run yet — the resize exists +to *warm-start* the ratio optimiser, so a worse warm start might cost nothing +once it converges. **Tested, and the defence fails.** Full search, harbor-house, +12 000 evals, seed 1, both arms: + +| | fails | hard | soft | connectivity | +|---|---|---|---|---| +| `preserve_circulation` OFF | **43** | **9** | 34 | **3** | +| `preserve_circulation` ON | 65 | 26 | 39 | 4 | + +Worse on every axis — including connectivity itself, the thing it was built to +fix. + +**The reframing.** §39.9's finding stands as a fact (the resize really does +destroy 41 of 49 circulation edges) but is **not actionable, because +construction-time connectivity is not what determines final connectivity**. The +search reaches 43 fails with 3 connectivity fails starting from a 50%-connected +seed; forcing the seed to 92% connected yields 65 fails and 4 connectivity +fails. The seeder's circulation topology is not the bottleneck — the search +discards and rebuilds it either way, and constraining the seed only spends area +quality the search then cannot recover. + +That also retires the framing this whole thread inherited from §38: connectivity +was never a construction problem *or* an incentive problem (§39.8). Both flags +(`repair_circulation`, `preserve_circulation`) stay default off with these +numbers recorded. **Do not revisit either without a new formulation** — the same +standing this document gives `bubble.py`. diff --git a/src/homemaker_layout/driver.py b/src/homemaker_layout/driver.py index 1105b87..79c4742 100644 --- a/src/homemaker_layout/driver.py +++ b/src/homemaker_layout/driver.py @@ -316,6 +316,7 @@ def search( shapecurve_prune: bool = False, assign_solver: str = "greedy", enable_reassign: bool = False, + preserve_circulation: bool = False, ) -> SearchResult: """Run the memetic loop from ``seed_root`` until ``budget`` oracle evaluations are consumed. Returns the best individual found; its ``root`` @@ -630,7 +631,8 @@ def search( depth_balanced=depth_balanced, interior_outside=interior_outside, outside_divisor=outside_divisor, construction_beam_width=construction_beam_width, - multi_use=multi_use, assign_solver=assign_solver) + multi_use=multi_use, assign_solver=assign_solver, + preserve_circulation=preserve_circulation) return (topo, None, child_budget, {}, f"construct/{tag}") n = int(rng.integers(max(1, n_target - 1), n_target + 2)) return (random_topology(seed_root, n, rng, types), None, child_budget, diff --git a/src/homemaker_layout/operators.py b/src/homemaker_layout/operators.py index 555a75a..90863e4 100644 --- a/src/homemaker_layout/operators.py +++ b/src/homemaker_layout/operators.py @@ -876,6 +876,129 @@ def _size_divisions_from_targets(lvl: dom.Node, reqs, fmin: float = 0.04, geometry.clear_cache() +def _circ_components(lvl: dom.Node) -> int: + """Number of connected components among this storey's circulation leaves.""" + import networkx as nx + + from . import geometry as _geo, graph as _graph + + _geo.clear_cache() + G = _geo.leaf_graph(lvl, _graph.DOOR_WIDTH) + circ = [x for x in G.nodes() if dom.is_circulation(x)] + if not circ: + return 0 + return nx.number_connected_components(G.subgraph(circ)) + + +def _circ_edges(lvl: dom.Node) -> list[tuple]: + """Circulation-to-circulation adjacencies on this storey, as leaf pairs.""" + from . import geometry as _geo, graph as _graph + + _geo.clear_cache() + G = _geo.leaf_graph(lvl, _graph.DOOR_WIDTH) + return [(a, b) for a, b in G.edges() + if dom.is_circulation(a) and dom.is_circulation(b)] + + +def _circ_edge_absent(lvl: dom.Node, a: dom.Node, b: dom.Node) -> bool: + from . import geometry as _geo, graph as _graph + + _geo.clear_cache() + G = _geo.leaf_graph(lvl, _graph.DOOR_WIDTH) + return not (G.has_node(a) and G.has_node(b) and G.has_edge(a, b)) + + +def _size_divisions_preserving_circulation(lvl: dom.Node, reqs, + max_reverts: int = 12, **kw) -> int: + """Resize toward the programme's area targets WITHOUT severing circulation. + + homemaker-py-3z0 (DESIGN.md §39.10). ``_assign_adjacency_aware`` picks + circulation as a CONNECTED dominating set, then + ``_size_divisions_from_targets`` re-cuts every node — new ratio *and* new + rotation — and the shared boundaries the dominating set relied on shrink + below ``door_width`` or vanish. Measured (§39.9): fully-connected constructed + seeds 1/20, 1/20, 0/20 across the corpus, against 100% with the resize + skipped entirely. Both halves of the re-cut do damage, in different + proportions per programme — on health-centre it is entirely the ratio, on + maple-court mostly the rotation — so a fix has to be able to give back + either. + + Rather than rebuild the connection afterwards by retyping rooms to ``C`` + (measured a net loss — it displaces required rooms at a 3-5 fail cascade + each, §39.9), this gives back the *geometry* and keeps the programme intact: + snapshot every cut, resize, then greedily revert whichever single cut most + reduces the circulation component count until the storey is connected again. + Reverting a cut costs only the area-target accuracy of that one subtree, and + the inner loop optimises ratios anyway — nothing is displaced and no label + changes. + + Returns the number of cuts reverted. + """ + from . import geometry as _geo + + nodes = [] + + def _walk(node: dom.Node) -> None: + if node.divided: + nodes.append(node) + _walk(node.left) + _walk(node.right) + + _walk(lvl) + before = {id(x): (x.rotation, list(x.division) if x.division else None) + for x in nodes} + _pre_circ_edges = _circ_edges(lvl) + + _size_divisions_from_targets(lvl, reqs, **kw) + + if _circ_components(lvl) <= 1: + _geo.clear_cache() + return 0 + + # Which circulation pairs were adjacent BEFORE the re-cut and are not now? + # Those are the connections the resize broke, and the cuts that govern each + # are exactly the ones between the two leaves — so revert those, rather than + # hunting for a single cut that happens to reduce the component count. A + # plain greedy gets stuck: often no ONE revert helps even though two would. + def _paths_between(a: dom.Node, b: dom.Node) -> list[dom.Node]: + """Divided nodes on the tree path joining two leaves (via their LCA).""" + def _chain(x: dom.Node) -> list[dom.Node]: + out = [] + while x is not None: + out.append(x) + x = x.parent + return out + ca, cb = _chain(a), _chain(b) + common = set(map(id, cb)) + lca = next((x for x in ca if id(x) in common), None) + if lca is None: + return [] + seen, out = set(), [] + for chain in (ca, cb): + for x in chain: + if x.divided and id(x) not in seen: + out.append(x) + seen.add(id(x)) + if x is lca: + break + return out + + broken = [(a, b) for a, b in _pre_circ_edges + if _circ_edge_absent(lvl, a, b)] + reverted = 0 + for a, b in broken: + if _circ_components(lvl) <= 1 or reverted >= max_reverts: + break + for node in _paths_between(a, b): + rot, div = before.get(id(node), (None, None)) + if div is None or (node.rotation == rot and node.division == div): + continue + node.rotation, node.division = rot, list(div) + reverted += 1 + _geo.clear_cache() + return reverted + + def _grow_balanced(node: dom.Node, code: str, k: int) -> None: """Turn ``node`` (a leaf) into a balanced binary subtree of ``k`` leaves, all typed ``code``. Split ratio/rotation are placeholders ([0.5,0.5], rot 0); @@ -1282,7 +1405,8 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator, construction_beam_width: int = 1, multi_use: bool = False, assign_solver: str = "greedy", - repair_circulation: bool = False) -> dom.Node: + repair_circulation: bool = False, + preserve_circulation: bool = False) -> dom.Node: """Build a seed that instantiates every required space by construction. The §11.0 diagnosis: random divide+retype chains leave required programme @@ -1387,9 +1511,10 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator, leaf_co = _leaf_colocate_from_plan(lvl, colocate_plan, reqs) if multi_use else {} leaf_extra = {lf: reqs[co].size for lf, co in leaf_co.items() if co in reqs and reqs[co].size > 0} - _size_divisions_from_targets( - lvl, reqs, leaf_mult=_leaf_mult_from_plan(lvl, share_plan), - leaf_extra=leaf_extra) + _resize = (_size_divisions_preserving_circulation if preserve_circulation + else _size_divisions_from_targets) + _resize(lvl, reqs, leaf_mult=_leaf_mult_from_plan(lvl, share_plan), + leaf_extra=leaf_extra) if adjacency_aware and assign_solver == "cpsat": _cpsat_relabel_settled(lvl, reqs) if repair_circulation: diff --git a/tests/test_operators.py b/tests/test_operators.py index 2d35194..879c363 100644 --- a/tests/test_operators.py +++ b/tests/test_operators.py @@ -999,3 +999,27 @@ def test_repair_circulation_reconnects_every_storey(): on_ok, on_tot = levels_connected(True) assert on_ok == on_tot, f"repair left {on_tot - on_ok} storeys disconnected" assert on_ok > off_ok, f"repair did not help: {off_ok}/{off_tot} -> {on_ok}/{on_tot}" + + +@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available") +def test_preserve_circulation_default_off_reproduces_prior_seeds(): + """§39.10 measured NULL, so the default must stay byte-identical.""" + from homemaker_layout import geometry, programme + + reqs = programme.load_programme_dir(str(HARBOR)) + types = sorted(reqs) + ["C", "O"] + seed = dom.load(str(HARBOR / "init.dom")) + kw = dict(min_storeys=programme.storey_minimum(str(HARBOR)), + adjacency_aware=True, proportion_aware=True, circ_divisor=3) + + def sig(**extra): + geometry.clear_cache() + root = operators.constructive_topology( + seed, reqs, np.random.default_rng(5), types, **kw, **extra) + geometry.clear_cache() + return tuple((lf.type, round(geometry.area(lf), 6)) + for lvl in dom.levels(root) for lf in lvl.leaves()) + + assert sig() == sig(preserve_circulation=False) + # ...and it does change something when enabled, or the A/B measured nothing + assert sig() != sig(preserve_circulation=True)