From 03aadec1ba0d8b3912acbcb178ee811d71764941 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 13:39:41 +0000 Subject: [PATCH] =?UTF-8?q?=C2=A739.7:=20access=20requirements=20become=20?= =?UTF-8?q?a=20declared=20`usage:`=20attribute=20(homemaker-py-sel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the second namespace sharing a first character with programme codes: the usage prefixes b/t/l/k, under which a room silently inherited another room's connectivity rules from its spelling. usage is a plain, MANDATORY attribute of the space definition -- not a lookup table. An interim design proposed a top-level usage_classes: table binding author-coined names to behaviour; withdrawn, because an indirect name->behaviour mapping living apart from the thing it describes is exactly the shape of the prefix rule §39 exists to remove, it would be the only such table in a schema where every other space property is a plain attribute, and the need it served was already met -- "building specific" is about what a room is CALLED, and name: is already free text. Rule that settles it: a usage value exists iff the engine treats it differently somewhere. Config selects among behaviours; it cannot invent them. - programme.USAGES (living/kitchen/bedroom/toilet/utility/none) plus the behaviour groupings PRIVATE_USAGES / PRIVATE_STRIPS / TOILET_STRIPS / SOCIABLE_USAGES. Missing or unknown usage is a load error naming the code, from BOTH parse paths. - Code-level, never leaf-level: usage_of(leaf.type) is looked up fresh, so a retype changes the class automatically. 51 sites assign leaf.type, and share/share_type plus the r5a resurrection are the precedent for why leaf-level attributes rot. - graph.has_circulation takes the usage map and trims on declared class; fitness.access and the public-access check likewise. fitness._t0 is DELETED -- no first-character type test remains anywhere in the codebase. - utility is distinct from bedroom (same access requirements today) because it is a different use and gives derive_interchange_classes an axis to relax on. - A toilet now keeps its edge to a terminal room -- the Brand adjacency, which the old b-before-t loop ordering severed. - All 107 corpus entries migrated by experiments/migrate_usage_key.py, comments and layout preserved. MEASURED -- the connectivity model was ~4x too permissive. `none` is not neutral: nothing is trimmed, so the graph may route THROUGH the room, and 34 of 52 codes had no class (Dental Surgery, Records Room, Utilities Closet all served as corridors). Edges trimmed, prefix-inferred vs declared, 3 seeds each: harbor-house 18 (9%) -> 79 (39%) inaccessible fails 0 -> 4 health-centre 12 (8%) -> 59 (40%) inaccessible fails 2 -> 3 maple-court 53 (17%) -> 123 (39%) inaccessible fails 1 -> 5 Re-baseline (seed 1, 20k, harbor): 58 fails (15h/43s) -> 61 (16h/45s), now reporting 1-inaccessible-usable-space x2 plus level 0 and level 1 not connected. The count rose because the objective got honest -- those failures were always true of the layout and the old model could not see them. Every harbor number before this was measured against a graph crediting routes through store cupboards. Sharpens §38.2: the objective pays x60-85 to delete circulation, and until now the deleted corridors were not missed because storage stood in for them. With that substitution gone, homemaker-py-2v1 is the remaining half -- and now measurable, because the fails it should prevent actually fire. 350 passed (+5 new), same 7 pre-existing fixture failures, lint unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB --- .beads/issues.jsonl | 2 +- AGENTS.md | 9 +- CLAUDE.md | 9 +- DESIGN.md | 75 ++++++++++++++ README.md | 27 +++--- examples/harbor-house-l0/patterns.config | 10 ++ examples/harbor-house/patterns.config | 16 +++ examples/health-centre/patterns.config | 19 ++++ examples/maple-court/patterns.config | 26 +++++ examples/programme-house/patterns.config | 6 ++ examples/programme-house2/patterns.config | 6 ++ examples/y51-sweep-10/patterns.config | 6 ++ examples/y51-sweep-14/patterns.config | 6 ++ examples/y51-sweep-18/patterns.config | 6 ++ examples/y51-sweep-22/patterns.config | 6 ++ experiments/migrate_usage_key.py | 113 ++++++++++++++++++++++ src/homemaker_layout/fitness.py | 53 ++++++---- src/homemaker_layout/graph.py | 60 +++++++----- src/homemaker_layout/programme.py | 58 +++++++++++ tests/_helpers.py | 24 +++++ tests/test_collapse_global.py | 3 +- tests/test_collapse_insearch.py | 5 +- tests/test_dom_corpus.py | 4 +- tests/test_fitness.py | 11 ++- tests/test_multi_use.py | 11 ++- tests/test_programme.py | 58 +++++++++-- tests/test_superposition.py | 11 ++- 27 files changed, 548 insertions(+), 92 deletions(-) create mode 100644 experiments/migrate_usage_key.py create mode 100644 tests/_helpers.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index d96698a..960bec5 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":"open","priority":0,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-26T07:37:39Z","created_by":"Claude","updated_at":"2026-08-26T07:37:39Z","dependency_count":0,"dependent_count":2,"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":"open","priority":1,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-26T10:06:54Z","created_by":"Claude","updated_at":"2026-08-26T12:56:12Z","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} {"_type":"issue","id":"homemaker-py-hxi","title":"Buried circulation and outside space are negative-value: search is rewarded for deleting the circulation spine","description":"Direct consequence of the zero-exposure bug. Measured on a constructed harbor-house seed: deleting a buried O leaf improved the raw score 85x and removed 7 fails; deleting a buried C leaf improved it 62x and removed 6 fails. Programme rooms are held in place ONLY by the missing-space fail cascade, not by contributing value -- deleting a buried k1/da1/m costs +15 fails, so they stay, but nothing positive keeps them. Circulation and outside leaves carry no missing-space requirement, so nothing keeps them at all. Observed live: in a 20k-eval harbor-house run, undivide/core_undivide account for a large share of recorded improvements (16 occurrences in the log) -- the search is literally deleting circulation to score better. This explains three prior negative results as a single mechanism: 18 graded circulation-connectivity (a tie-break signal cannot beat a 60x scalar gradient), 21/22 bridge_circulation (the operator inserts corridor leaves the objective immediately punishes), and the 'level N not connected' hard fails surviving \u003e1M evals in the 3M run.","notes":"Depends on the zero-exposure fix; may need no separate fix if (a)/(c) there restores a value gradient for circulation. Worth re-running the 18 and 21/22 A/Bs afterwards -- both may have been measuring a broken gradient rather than a bad idea.","status":"open","priority":1,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-25T23:14:40Z","created_by":"Claude","updated_at":"2026-08-25T23:14:40Z","dependencies":[{"issue_id":"homemaker-py-hxi","depends_on_id":"homemaker-py-ssz","type":"blocks","created_at":"2026-08-25T23:15:12Z","created_by":"Claude","metadata":"{}"},{"issue_id":"homemaker-py-hxi","depends_on_id":"homemaker-py-2v1","type":"blocks","created_at":"2026-08-26T07:37:47Z","created_by":"Claude","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-ssz","title":"Zero-exposure leaves score hard quality=0: the objective assigns no value to any interior room","description":"fitness.quality_uncrinkliness returns a hard 0.0 when a leaf has no daylit wall (area_outside==0 =\u003e crink==0 =\u003e 'if not crink: return 0.0'). Because evaluate_leaf MULTIPLIES factors into quality, and process_storey accumulates 'value += quality * rate * area', such a leaf contributes EXACTLY ZERO value while still adding cost. Measured on the full default construction stack (leaf_sharing, depth_balanced, interior_outside, collapse_insearch), 3 seeds each: harbor-house 46% of interior leaves, health-centre 45%, maple-court 56% are zero-exposure. On a converged 20k-eval harbor-house run (seed 1, 57 fails) 14 of 17 crinkliness fails are zero-exposure, and ~470 m2 of the 721 m2 ground floor plate sits at zero value. This is the mathematically consistent limit of the gaussian (1/crink -\u003e inf), so it is a faithful port, not a porting bug -- but it means the objective's gradient does not describe a good building.","design":"Options, none yet chosen: (a) floor the factor at a small epsilon instead of 0 so buried leaves keep a value gradient and remain rankable; (b) make the gaussian one-sided (clip to 1.0 on the compact side) so being LESS exposed than target is not punished as hard as being over-exposed -- architecturally, a compact well-insulated room is not a defect; (c) exempt circulation/store types from the daylight requirement entirely (uncrinkliness_circulation currently uses the same [5/6, 1.1/3] as habitable rooms, so internal corridors -- completely normal architecture -- are guaranteed failures). Any change here invalidates prior fail-count baselines, so it needs its own A/B and a DESIGN.md section.","acceptance_criteria":"A/B on harbor-house + maple-court at fixed budget showing the chosen variant lowers hard-fail count without inflating soft; DESIGN.md section recording the result; prior baselines re-stated under the new objective.","status":"open","priority":1,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-25T23:14:40Z","created_by":"Claude","updated_at":"2026-08-25T23:14:40Z","dependencies":[{"issue_id":"homemaker-py-ssz","depends_on_id":"homemaker-py-2v1","type":"blocks","created_at":"2026-08-26T07:37:47Z","created_by":"Claude","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} diff --git a/AGENTS.md b/AGENTS.md index 8d5c8df..a09abfe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,10 +65,11 @@ Leaf types share a first character across three namespaces: at load. - **programme room codes** — lowercase, may start with *any* letter. The generic tests match `C`/`O`/`S` exactly, so `cr1` is a room, not circulation. -- **usage prefixes `b`/`t`/`l`/`k`** — bedroom / toilet / living / kitchen, - still matched by first letter *by design*. `graph.has_circulation` strips graph - edges from them, so a code beginning with one inherits that room's - connectivity rules whether or not intended (`homemaker-py-sel`). +- **`usage:`** — every space declares its access-requirement class + (`living`/`kitchen`/`bedroom`/`toilet`/`utility`/`none`), mandatory, no + fallback (DESIGN.md §39.7). A code's spelling decides nothing: `name:` is free + text, `usage:` drives behaviour. There is no first-character type test left + anywhere in the codebase. When adding or editing a programme, run `python experiments/audit_programme_config.py` — it reports reserved-name diff --git a/CLAUDE.md b/CLAUDE.md index 5ed52c5..d464605 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,10 +31,11 @@ Leaf types share a first character across three namespaces: at load. - **programme room codes** — lowercase, may start with *any* letter. The generic tests match `C`/`O`/`S` exactly, so `cr1` is a room, not circulation. -- **usage prefixes `b`/`t`/`l`/`k`** — bedroom / toilet / living / kitchen, - still matched by first letter *by design*. `graph.has_circulation` strips graph - edges from them, so a code beginning with one inherits that room's - connectivity rules whether or not intended (`homemaker-py-sel`). +- **`usage:`** — every space declares its access-requirement class + (`living`/`kitchen`/`bedroom`/`toilet`/`utility`/`none`), mandatory, no + fallback (DESIGN.md §39.7). A code's spelling decides nothing: `name:` is free + text, `usage:` drives behaviour. There is no first-character type test left + anywhere in the codebase. When adding or editing a programme, run `python experiments/audit_programme_config.py` — it reports reserved-name diff --git a/DESIGN.md b/DESIGN.md index 8cb2b82..0dc8979 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -5347,3 +5347,78 @@ are *currently spelled correctly* too, so it needs its own A/B and re-baseline rather than being folded in here. + +### 39.7 `usage:` — access requirements become a declared attribute (`homemaker-py-sel`) — DONE + +§39.4 separated programme codes from the generic structural types. This closes +the second namespace sharing the same first character: the **usage prefixes** +`b`/`t`/`l`/`k`, under which a room silently inherited another room's +connectivity rules from its spelling. + +**Usage is an ACCESS-REQUIREMENT class, not a room-name category** — "library +corner and staff room are living rooms as they have the same access +requirements". It is now a plain, mandatory attribute of the space definition: + +```yaml +spaces: + la1: + usage: utility # controlled, drives engine behaviour + name: Laundry Room # free text, building-specific +``` + +**Why an attribute and not a lookup table.** An interim design proposed a +top-level `usage_classes:` table binding author-coined names to behaviour. It +was withdrawn: an indirect name→behaviour mapping living apart from the thing +it describes is *exactly* the shape of the prefix rule §39 exists to remove, +it is the only such table the schema would contain (every other space property +— `name`, `size`, `adjacency`, `level`, `count`, `share`, `interchange`, +`co_locate` — is a plain attribute), and the need it served was already met: +"building specific" is about what a room is **called**, and `name:` is already +free text. The rule that settles it: **a usage value exists if and only if the +engine treats it differently somewhere.** Config selects among behaviours; it +cannot invent them. + +**Mutation safety.** Usage is keyed by CODE, so `usage_of(leaf.type)` is looked +up fresh on every read exactly as size/width/adjacency are; a retype changes the +code and the class follows. It is never stamped on a leaf — 51 sites assign +`leaf.type`, and `share`/`share_type` plus the `r5a` stale-stamp resurrection +are this project's own evidence for why leaf-level attributes rot. + +**Vocabulary** (closed): `living`, `kitchen`, `bedroom`, `toilet`, `utility`, +`none`. Missing or unknown is a load error naming the code, from **both** parse +paths. `utility` shares `bedroom`'s access requirements today but is a distinct +value — a genuinely different use, and an axis +`derive_interchange_classes` can relax on (bedroom- and utility-class leaves +interchangeable *during* search, collapsing to their real use at scoring time — +the superposition relaxation §13/§26 already implements). + +`graph.has_circulation` now takes the usage map and trims on declared class; +`fitness.access` and the public-access check likewise. **`fitness._t0` is +deleted — no first-character type test remains anywhere in the codebase.** + +**The connectivity model was ~4× too permissive.** `none` is not neutral: +nothing is trimmed, so the graph may route *through* the room. 34 of 52 corpus +codes had no class, meaning a Dental Surgery, a Records Room and a Utilities +Closet all served as corridors. Measured on constructed seeds, 3 per programme, +prefix-inferred vs declared: + +| programme | codes reclassified | edges trimmed before | after | inaccessible-space fails | +|---|---|---|---|---| +| harbor-house | 12 of 16 | 18 (9%) | **79 (39%)** | 0 → **4** | +| health-centre | 14 of 19 | 12 (8%) | **59 (40%)** | 2 → **3** | +| maple-court | 18 of 26 | 53 (17%) | **123 (39%)** | 1 → **5** | + +**Re-baseline** (`--budget 20000 --workers 4 --seed 1`, harbor-house): 58 fails +(15 hard / 43 soft) → **61 (16 hard / 45 soft)**, and the result now reports +`1 inaccessible usable space` ×2, `level 0 not connected` and `level 1 not +connected`. The count went **up because the objective got honest**: those +failures were always true of the layout, and the old model could not see them. +Every harbor number before §39.7 was measured against a connectivity graph that +credited routes through store cupboards. + +This sharpens §38.2 rather than competing with it. The objective already pays +×60–85 to delete circulation; until now the corridors it deleted were not +missed, because consulting rooms and storage stood in for them. With that +substitution gone, `homemaker-py-2v1` (connectivity priced at ×0.5 against a ×6 +circulation→habitable value gap) is the remaining half of the same problem — +and now measurable, because the fails it should be preventing actually fire. diff --git a/README.md b/README.md index eb47b55..6bf85ad 100644 --- a/README.md +++ b/README.md @@ -75,18 +75,23 @@ letter: since DESIGN.md §39.4 the generic tests match `C`/`O`/`S` exactly, so naming a room `cr1` no longer makes it circulation. (Before that fix it did — and silently dropped it from the required-space check entirely.) -**3. Usage prefixes — `b` bedroom, `t` toilet, `l` living, `k` kitchen.** -Still matched by first letter, deliberately: this is how Urb encodes room usage. -`graph.has_circulation` deletes graph edges based on them (a "bedroom" loses its -edges to living/kitchen/bedroom/toilet; a "toilet" loses its edges to -outside/living/kitchen/toilet), and the access and public-access checks read -them too. +**3. Access requirements — the `usage:` attribute.** Every space declares one +of `living`, `kitchen`, `bedroom`, `toilet`, `utility`, `none`. Mandatory, no +fallback, and a missing or unknown value is a load error. It replaced a +first-character convention (`b`/`t`/`l`/`k`) under which a room silently +inherited another room's connectivity rules from its spelling — `la1` "Laundry +Room" was trimmed as a living room (DESIGN.md §39.7). -**So a code beginning with `b`/`t`/`l`/`k` inherits that room's connectivity -rules whether or not you meant it** — `la1` "Laundry Room" is treated as a -living room, `tr1` "Treatment Room" as a toilet. This is a known wart -(`homemaker-py-sel`, DESIGN.md §39.6); an explicit `usage:` key is the planned -fix. Until then, check any new programme with: +```yaml +spaces: + la1: + usage: utility # controlled, drives engine behaviour + name: Laundry Room # free text, building-specific +``` + +A usage value exists only where the engine treats it differently, so the +vocabulary is closed: a new access class means new code, not new config. Check a +programme with: ```bash python experiments/audit_programme_config.py diff --git a/examples/harbor-house-l0/patterns.config b/examples/harbor-house-l0/patterns.config index c213cb0..d029015 100644 --- a/examples/harbor-house-l0/patterns.config +++ b/examples/harbor-house-l0/patterns.config @@ -11,6 +11,7 @@ spaces: cr1: + usage: living name: Common Room with Fireplace size: - 80.0 @@ -26,6 +27,7 @@ spaces: - o ef1: + usage: none name: Entrance Foyer size: - 15.0 @@ -37,6 +39,7 @@ spaces: - c da1: + usage: living name: Dining Area size: - 60.0 @@ -53,6 +56,7 @@ spaces: - o k1: + usage: kitchen name: Kitchen size: - 30.0 @@ -65,6 +69,7 @@ spaces: - c ws1: + usage: living name: Workshop Space size: - 40.0 @@ -80,6 +85,7 @@ spaces: - o m: + usage: living name: Meeting Room size: - 10.0 @@ -92,6 +98,7 @@ spaces: count: 3 la1: + usage: utility name: Laundry Room size: - 20.0 @@ -103,6 +110,7 @@ spaces: - c st1: + usage: utility name: Ground Floor Storage size: - 22.0 @@ -114,6 +122,7 @@ spaces: - c me1: + usage: utility name: Mechanical/Electrical Room size: - 25.0 @@ -125,6 +134,7 @@ spaces: - c of: + usage: bedroom name: Staff Office size: - 12.5 diff --git a/examples/harbor-house/patterns.config b/examples/harbor-house/patterns.config index 260dd75..b267589 100644 --- a/examples/harbor-house/patterns.config +++ b/examples/harbor-house/patterns.config @@ -7,6 +7,7 @@ spaces: # GROUND FLOOR - Main Community Areas cr1: + usage: living name: Common Room with Fireplace size: - 80.0 @@ -23,6 +24,7 @@ spaces: - o ef1: + usage: none name: Entrance Foyer size: - 15.0 @@ -40,6 +42,7 @@ spaces: - m da1: + usage: living name: Dining Area size: - 60.0 @@ -57,6 +60,7 @@ spaces: - o k1: + usage: kitchen name: Kitchen size: - 30.0 @@ -71,6 +75,7 @@ spaces: # SLEEPING AREAS - Anonymous neighborhoods (5 total, 2 ground + 3 first) n: + usage: bedroom name: Neighborhood size: - 60.0 @@ -87,6 +92,7 @@ spaces: # GROUND FLOOR - Activity Spaces ws1: + usage: living name: Workshop Space size: - 40.0 @@ -104,6 +110,7 @@ spaces: # MEETING ROOMS - Anonymous interchangeable (3 total) m: + usage: living name: Meeting Room size: - 10.0 @@ -121,6 +128,7 @@ spaces: # GROUND FLOOR - Service Areas # BATHROOMS - Ground floor (3 total) t: + usage: toilet name: Bathroom size: - 6.0 @@ -133,6 +141,7 @@ spaces: count: 6 # 3 ground + 3 first floor la1: + usage: utility name: Laundry Room size: - 20.0 @@ -149,6 +158,7 @@ spaces: - me1 st1: + usage: utility name: Ground Floor Storage size: - 22.0 @@ -161,6 +171,7 @@ spaces: - c me1: + usage: utility name: Mechanical/Electrical Room size: - 25.0 @@ -176,6 +187,7 @@ spaces: # STAFF OFFICES - Anonymous (2 total) of: + usage: bedroom name: Staff Office size: - 12.5 @@ -190,6 +202,7 @@ spaces: # FIRST FLOOR - Community Areas li1: + usage: living name: Library Corner size: - 20.0 @@ -203,6 +216,7 @@ spaces: # FIRST FLOOR - Individual Rooms (10 anonymous rooms) r: + usage: bedroom name: Individual Room size: - 10.0 @@ -217,6 +231,7 @@ spaces: # FIRST FLOOR - Storage st2: + usage: utility name: First Floor Storage size: - 18.0 @@ -229,6 +244,7 @@ spaces: - c ut1: + usage: utility name: Utilities Closet size: - 14.0 diff --git a/examples/health-centre/patterns.config b/examples/health-centre/patterns.config index 8f9617d..f7bcc70 100644 --- a/examples/health-centre/patterns.config +++ b/examples/health-centre/patterns.config @@ -24,6 +24,7 @@ spaces: # ---- PUBLIC / RECEPTION ---- rc1: + usage: none name: Reception size: - 10.0 @@ -40,6 +41,7 @@ spaces: - o wa1: + usage: none name: Waiting Room size: - 28.0 @@ -57,6 +59,7 @@ spaces: - rc1 ph1: + usage: bedroom name: Pharmacy / Dispensary size: - 12.0 @@ -75,6 +78,7 @@ spaces: # ---- CLINICAL ---- gp1: + usage: bedroom name: GP Consulting Room size: - 14.0 @@ -89,6 +93,7 @@ spaces: - c ms1: + usage: bedroom name: Minor Surgery Room size: - 20.0 @@ -107,6 +112,7 @@ spaces: - de1 de1: + usage: bedroom name: Dental Surgery size: - 18.0 @@ -124,6 +130,7 @@ spaces: - ms1 zt1: + usage: utility name: Sterilisation Room size: - 6.0 @@ -139,6 +146,7 @@ spaces: - de1 pt1: + usage: bedroom name: Physiotherapy Room size: - 24.0 @@ -153,6 +161,7 @@ spaces: - c qr1: + usage: bedroom name: Counselling Room size: - 9.0 @@ -167,6 +176,7 @@ spaces: - c tr1: + usage: bedroom name: Treatment Room size: - 16.0 @@ -182,6 +192,7 @@ spaces: # ---- ADMIN / STAFF ---- mo1: + usage: bedroom name: Manager's Office size: - 10.0 @@ -200,6 +211,7 @@ spaces: - ao1 ao1: + usage: bedroom name: Admin Office size: - 14.0 @@ -217,6 +229,7 @@ spaces: - br1 re1: + usage: utility name: Records Room size: - 7.0 @@ -234,6 +247,7 @@ spaces: - dp1 br1: + usage: living name: Staff Room size: - 16.0 @@ -250,6 +264,7 @@ spaces: - ao1 kt1: + usage: kitchen name: Staff Kitchenette size: - 8.0 @@ -266,6 +281,7 @@ spaces: # ---- SERVICE ---- me1: + usage: utility name: Plant / Mechanical Room size: - 9.0 @@ -280,6 +296,7 @@ spaces: - c dp1: + usage: utility name: General Storage size: - 8.0 @@ -296,6 +313,7 @@ spaces: - re1 t9: + usage: toilet name: Public WC size: - 4.0 @@ -311,6 +329,7 @@ spaces: count: 2 t10: + usage: toilet name: Staff WC size: - 3.0 diff --git a/examples/maple-court/patterns.config b/examples/maple-court/patterns.config index 3803c83..847c3af 100644 --- a/examples/maple-court/patterns.config +++ b/examples/maple-court/patterns.config @@ -15,6 +15,7 @@ spaces: # ---- GROUND FLOOR (level 0): community + service ---- lr1: + usage: living name: Common Living Room size: - 80.0 @@ -31,6 +32,7 @@ spaces: - o ef1: + usage: none name: Entrance Foyer size: - 15.0 @@ -43,6 +45,7 @@ spaces: - c da1: + usage: living name: Dining Area size: - 60.0 @@ -60,6 +63,7 @@ spaces: - o k1: + usage: kitchen name: Kitchen size: - 30.0 @@ -73,6 +77,7 @@ spaces: - c ws1: + usage: living name: Workshop Space size: - 40.0 @@ -89,6 +94,7 @@ spaces: - o la1: + usage: utility name: Laundry Room size: - 20.0 @@ -101,6 +107,7 @@ spaces: - c me1: + usage: utility name: Mechanical/Electrical Room size: - 25.0 @@ -113,6 +120,7 @@ spaces: - c ur1: + usage: utility name: Ground Floor Storage size: - 22.0 @@ -125,6 +133,7 @@ spaces: - c rc1: + usage: none name: Reception Office size: - 14.0 @@ -137,6 +146,7 @@ spaces: - c ph1: + usage: bedroom name: Clinic Room size: - 18.0 @@ -149,6 +159,7 @@ spaces: - c gh1: + usage: living name: Greenhouse size: - 24.0 @@ -163,6 +174,7 @@ spaces: # Meeting rooms - anonymous interchangeable (3 total, ground floor) m: + usage: living name: Meeting Room size: - 10.0 @@ -177,6 +189,7 @@ spaces: # Ground-floor bathrooms (6 total) t: + usage: toilet name: Bathroom size: - 6.0 @@ -191,6 +204,7 @@ spaces: # ---- FIRST FLOOR (level 1): residents' shared + neighborhoods ---- li1: + usage: living name: Library Corner size: - 20.0 @@ -203,6 +217,7 @@ spaces: - c lo1: + usage: living name: Lounge size: - 30.0 @@ -220,6 +235,7 @@ spaces: # Staff offices - anonymous (2 total) em: + usage: bedroom name: Staff Office size: - 12.5 @@ -234,6 +250,7 @@ spaces: # Sleeping neighborhoods - anonymous interchangeable (4 total) n: + usage: bedroom name: Neighborhood size: - 60.0 @@ -250,6 +267,7 @@ spaces: count: 4 ur2: + usage: utility name: First Floor Storage size: - 18.0 @@ -262,6 +280,7 @@ spaces: - c en1: + usage: living name: TV / Media Room size: - 22.0 @@ -275,6 +294,7 @@ spaces: # Therapy rooms - anonymous (2 total) py: + usage: bedroom name: Therapy Room size: - 16.0 @@ -290,6 +310,7 @@ spaces: # ---- SECOND FLOOR (level 2): individual rooms + amenity ---- # Individual resident rooms - anonymous (12 total) r: + usage: bedroom name: Individual Room size: - 10.0 @@ -303,6 +324,7 @@ spaces: count: 12 gy1: + usage: living name: Exercise Room size: - 28.0 @@ -319,6 +341,7 @@ spaces: - o ut1: + usage: utility name: Utilities Closet size: - 14.0 @@ -331,6 +354,7 @@ spaces: - c ur3: + usage: utility name: Second Floor Storage size: - 16.0 @@ -344,6 +368,7 @@ spaces: # Second-floor bathrooms (4 total) tt: + usage: toilet name: Upper Bathroom size: - 6.0 @@ -357,6 +382,7 @@ spaces: count: 4 q1: + usage: bedroom name: Quiet Room size: - 12.0 diff --git a/examples/programme-house/patterns.config b/examples/programme-house/patterns.config index 63d9ddf..749eabc 100644 --- a/examples/programme-house/patterns.config +++ b/examples/programme-house/patterns.config @@ -8,6 +8,7 @@ # Building programme: 2-bedroom house with ensuite spaces: b1: + usage: bedroom name: Master Bedroom size: - 16.0 @@ -21,6 +22,7 @@ spaces: adjacency: - c t1: + usage: toilet name: Ensuite Bathroom size: - 5.0 @@ -31,6 +33,7 @@ spaces: adjacency: - b1 b2: + usage: bedroom name: Second Bedroom size: - 12.0 @@ -41,6 +44,7 @@ spaces: adjacency: - c t2: + usage: toilet name: Guest Bathroom size: - 4.0 @@ -48,6 +52,7 @@ spaces: adjacency: - c l1: + usage: living name: Living/Dining/Kitchen size: - 35.0 @@ -62,6 +67,7 @@ spaces: adjacency: - c t3: + usage: toilet name: Ground Floor WC size: - 3.0 diff --git a/examples/programme-house2/patterns.config b/examples/programme-house2/patterns.config index 1bafdb1..b75a5eb 100644 --- a/examples/programme-house2/patterns.config +++ b/examples/programme-house2/patterns.config @@ -12,6 +12,7 @@ # Building programme: 2-bedroom house with ensuite spaces: b1: + usage: bedroom name: Master Bedroom size: - 16.0 @@ -25,6 +26,7 @@ spaces: adjacency: - c t1: + usage: toilet name: Ensuite Bathroom size: - 5.0 @@ -35,6 +37,7 @@ spaces: adjacency: - b1 b2: + usage: bedroom name: Second Bedroom size: - 12.0 @@ -45,6 +48,7 @@ spaces: adjacency: - c t2: + usage: toilet name: Guest Bathroom size: - 4.0 @@ -52,6 +56,7 @@ spaces: adjacency: - c l1: + usage: living name: Living/Dining/Kitchen size: - 25.0 @@ -65,6 +70,7 @@ spaces: adjacency: - c t3: + usage: toilet name: Ground Floor WC size: - 3.0 diff --git a/examples/y51-sweep-10/patterns.config b/examples/y51-sweep-10/patterns.config index 76ad85a..06cc6a0 100644 --- a/examples/y51-sweep-10/patterns.config +++ b/examples/y51-sweep-10/patterns.config @@ -5,6 +5,7 @@ # parameters (storey limits, ratios, adjacency, room-type mix) unchanged. spaces: b1: + usage: bedroom name: Master Bedroom size: - 16.0 @@ -19,6 +20,7 @@ spaces: - c count: 2 t1: + usage: toilet name: Ensuite Bathroom size: - 5.0 @@ -30,6 +32,7 @@ spaces: - b1 count: 2 b2: + usage: bedroom name: Second Bedroom size: - 12.0 @@ -41,6 +44,7 @@ spaces: - c count: 2 t2: + usage: toilet name: Guest Bathroom size: - 4.0 @@ -49,6 +53,7 @@ spaces: - c count: 2 l1: + usage: living name: Living/Dining/Kitchen size: - 35.0 @@ -63,6 +68,7 @@ spaces: adjacency: - c t3: + usage: toilet name: Ground Floor WC size: - 3.0 diff --git a/examples/y51-sweep-14/patterns.config b/examples/y51-sweep-14/patterns.config index dfdbca1..91771d7 100644 --- a/examples/y51-sweep-14/patterns.config +++ b/examples/y51-sweep-14/patterns.config @@ -5,6 +5,7 @@ # parameters (storey limits, ratios, adjacency, room-type mix) unchanged. spaces: b1: + usage: bedroom name: Master Bedroom size: - 16.0 @@ -19,6 +20,7 @@ spaces: - c count: 3 t1: + usage: toilet name: Ensuite Bathroom size: - 5.0 @@ -30,6 +32,7 @@ spaces: - b1 count: 3 b2: + usage: bedroom name: Second Bedroom size: - 12.0 @@ -41,6 +44,7 @@ spaces: - c count: 3 t2: + usage: toilet name: Guest Bathroom size: - 4.0 @@ -49,6 +53,7 @@ spaces: - c count: 3 l1: + usage: living name: Living/Dining/Kitchen size: - 35.0 @@ -63,6 +68,7 @@ spaces: adjacency: - c t3: + usage: toilet name: Ground Floor WC size: - 3.0 diff --git a/examples/y51-sweep-18/patterns.config b/examples/y51-sweep-18/patterns.config index 74d4692..a013757 100644 --- a/examples/y51-sweep-18/patterns.config +++ b/examples/y51-sweep-18/patterns.config @@ -5,6 +5,7 @@ # parameters (storey limits, ratios, adjacency, room-type mix) unchanged. spaces: b1: + usage: bedroom name: Master Bedroom size: - 16.0 @@ -19,6 +20,7 @@ spaces: - c count: 4 t1: + usage: toilet name: Ensuite Bathroom size: - 5.0 @@ -30,6 +32,7 @@ spaces: - b1 count: 4 b2: + usage: bedroom name: Second Bedroom size: - 12.0 @@ -41,6 +44,7 @@ spaces: - c count: 4 t2: + usage: toilet name: Guest Bathroom size: - 4.0 @@ -49,6 +53,7 @@ spaces: - c count: 4 l1: + usage: living name: Living/Dining/Kitchen size: - 35.0 @@ -63,6 +68,7 @@ spaces: adjacency: - c t3: + usage: toilet name: Ground Floor WC size: - 3.0 diff --git a/examples/y51-sweep-22/patterns.config b/examples/y51-sweep-22/patterns.config index b94810b..62c0efc 100644 --- a/examples/y51-sweep-22/patterns.config +++ b/examples/y51-sweep-22/patterns.config @@ -5,6 +5,7 @@ # parameters (storey limits, ratios, adjacency, room-type mix) unchanged. spaces: b1: + usage: bedroom name: Master Bedroom size: - 16.0 @@ -19,6 +20,7 @@ spaces: - c count: 5 t1: + usage: toilet name: Ensuite Bathroom size: - 5.0 @@ -30,6 +32,7 @@ spaces: - b1 count: 5 b2: + usage: bedroom name: Second Bedroom size: - 12.0 @@ -41,6 +44,7 @@ spaces: - c count: 5 t2: + usage: toilet name: Guest Bathroom size: - 4.0 @@ -49,6 +53,7 @@ spaces: - c count: 5 l1: + usage: living name: Living/Dining/Kitchen size: - 35.0 @@ -63,6 +68,7 @@ spaces: adjacency: - c t3: + usage: toilet name: Ground Floor WC size: - 3.0 diff --git a/experiments/migrate_usage_key.py b/experiments/migrate_usage_key.py new file mode 100644 index 0000000..0432c3b --- /dev/null +++ b/experiments/migrate_usage_key.py @@ -0,0 +1,113 @@ +"""Write the agreed `usage:` attribute into every corpus `patterns.config`. + +One-shot migration for `homemaker-py-sel` (DESIGN.md §39.7). Reads the reviewed +assignments in ``usage_map_proposal.yaml`` and inserts a ``usage:`` line into +each space definition, in place, preserving comments and formatting (the file +is edited as text, not round-tripped through the YAML emitter, which would strip +every comment in the corpus). + +Idempotent: a space that already declares ``usage:`` is left alone unless +``--force`` is given, in which case the existing value is rewritten. + +Usage:: + + python experiments/migrate_usage_key.py --check # dry run, report only + python experiments/migrate_usage_key.py # apply +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +import yaml + +PROPOSAL = Path(__file__).with_name("usage_map_proposal.yaml") +CORPUS = Path(__file__).resolve().parent.parent / "examples" + + +def load_assignments() -> dict[str, str]: + """code -> usage, from the reviewed proposal (its top-level keys ARE the + usage classes; every other key in the file is a comment).""" + doc = yaml.safe_load(PROPOSAL.read_text()) or {} + out: dict[str, str] = {} + for usage, codes in doc.items(): + for code in (codes or {}): + out[code] = usage + return out + + +def migrate(path: Path, assign: dict[str, str], check: bool, + force: bool) -> tuple[int, int, list[str]]: + """Insert ``usage:`` as the first property of each space definition. + + Returns ``(written, skipped, unknown_codes)``. Edits the file as text so the + corpus keeps its comments and layout. + """ + text = path.read_text() + spaces = (yaml.safe_load(text) or {}).get("spaces") or {} + space_key = re.compile(r"^ ([A-Za-z_][\w-]*):\s*$") + + written = skipped = 0 + unknown: list[str] = [] + out: list[str] = [] + + for line in text.splitlines(keepends=True): + m = space_key.match(line) + code = m.group(1) if m and m.group(1) in spaces else None + + # drop a pre-existing usage line when rewriting + if force and re.match(r"^ usage:\s", line): + continue + + out.append(line) + if code is None: + continue + usage = assign.get(code) + if usage is None: + unknown.append(code) + elif "usage" in spaces[code] and not force: + skipped += 1 + else: + out.append(f" usage: {usage}\n") + written += 1 + + if not check and written: + path.write_text("".join(out)) + return written, skipped, unknown + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--check", action="store_true", help="dry run") + ap.add_argument("--force", action="store_true", + help="rewrite a usage: that is already present") + args = ap.parse_args() + + assign = load_assignments() + print(f"{len(assign)} code -> usage assignments loaded from " + f"{PROPOSAL.name}\n") + + total_unknown: list[tuple[str, str]] = [] + for cfg in sorted(CORPUS.glob("*/patterns.config")): + written, skipped, unknown = migrate(cfg, assign, args.check, args.force) + total_unknown += [(cfg.parent.name, c) for c in unknown] + verb = "would write" if args.check else "wrote" + print(f" {cfg.parent.name:<20} {verb} {written:>3}, " + f"already present {skipped:>3}" + + (f", UNKNOWN {unknown}" if unknown else "")) + + if total_unknown: + print(f"\n{len(total_unknown)} code(s) have no assignment — " + "add them to the proposal first:", file=sys.stderr) + for d, c in total_unknown: + print(f" {d}: {c}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/homemaker_layout/fitness.py b/src/homemaker_layout/fitness.py index 3178da2..3b3f3ce 100644 --- a/src/homemaker_layout/fitness.py +++ b/src/homemaker_layout/fitness.py @@ -28,6 +28,7 @@ import yaml from . import dom as dom_mod from . import geometry +from . import programme as _programme from .dom import Node FAIL_THRESHOLD = 0.1 # Urb::Dom::Fitness::Base @@ -295,9 +296,9 @@ def _generic_class(n: Node) -> str: ``""`` for a programme room (§39.4). Replaces the old ``_t0(leaf)`` first-character dispatch in the quality terms - and value rate. ``_t0`` is still the right tool for the SEMANTIC prefixes - (``k`` kitchen, ``l`` living, …), which classify programme codes; this one - is for the generic structural types, which must match exactly. ``S`` (sahn) + and value rate. (``_t0`` itself is gone: the SEMANTIC prefixes it served — + ``k`` kitchen, ``l`` living, … — became the declared ``usage:`` key in + §39.7, so no first-character test remains anywhere.) ``S`` (sahn) belongs to both generic sets but takes the outside parameter families, so it maps to ``"o"`` — exactly as ``get_space_params`` dispatches it. """ @@ -308,11 +309,6 @@ def _generic_class(n: Node) -> str: return "" -def _t0(n: Node) -> str: - """First char of the type, lowercased ('' if untyped) — Urb's /^x/i tests.""" - return n.type[0].lower() if n.type else "" - - def _height(n: Node) -> float: """Floor-to-floor height of n's level; mirrors ``Urb::Quad::Height``.""" h = dom_mod._level_root(n).height @@ -406,6 +402,21 @@ class Fitness: self._crinkliness_floor = float( self.conf("crinkliness_floor") or 0.01) + def usages(self) -> dict[str, str]: + """``{room code: usage}`` for this programme (homemaker-py-sel). + + Keyed by CODE, never stamped on a leaf: a retype changes the code and + the usage follows automatically, exactly as size/width/adjacency do. + Generic ``C``/``O``/``S`` are absent — they are not programme rooms and + their behaviour comes from the generic type rule (§39.4). + """ + return {code: req.usage for code, req in (self._programme or {}).items()} + + def usage_of(self, leaf: Node) -> str: + """Access-requirement class of one leaf, ``""`` for a generic type.""" + req = (self._programme or {}).get(leaf.type) + return req.usage if req else "" + # ------------------------------------------------------------------ # # Type superposition + collapse (homemaker-py-9o5) # ------------------------------------------------------------------ # @@ -899,8 +910,9 @@ class Fitness: nbs = list(G.neighbors(lf)) if any(nb.type == "C" for nb in nbs): continue # circulation neighbour keeps access invariant + usages = self.usages() for nb in nbs: - if nb.type in room_codes and nb.type[0].lower() in ("l", "k"): + if usages.get(nb.type) in _programme.SOCIABLE_USAGES: pins.add(id(nb)) break return pins @@ -1169,11 +1181,12 @@ class Fitness: def access(self, leaf: Node, G: nx.Graph) -> list[str]: """Useful circulation/access neighbour types; ``Urb::Dom::Access``.""" types = self.neighbour_types(leaf, G) - if _t0(leaf) == "k": - # "l" is the SEMANTIC living-room prefix (a programme code); C/S are - # generic circulation. Two different namespaces, two different tests. + if self.usage_of(leaf) == "kitchen": + # a kitchen is served by circulation OR by a living space (§39.7) + usages = self.usages() return [t for t in types - if t in dom_mod.GENERIC_CIRCULATION or t[:1].lower() == "l"] + if t in dom_mod.GENERIC_CIRCULATION + or usages.get(t) == "living"] if dom_mod.is_outside(leaf) or dom_mod.is_circulation(leaf): return types return [t for t in types if t in dom_mod.GENERIC_CIRCULATION] @@ -1554,9 +1567,9 @@ class Fitness: if self._public_access(leaf, root) is None: return False for nb in G.neighbors(leaf): - # "l"/"k" are SEMANTIC programme-code prefixes; C is a generic - # circulation leaf. Two namespaces, two tests (§39.4). - if nb.type == "C" or (nb.type and nb.type[0].lower() in ("l", "k")): + # C is a generic circulation leaf; living/kitchen are DECLARED + # usages of programme rooms (§39.7). Two namespaces, two tests. + if nb.type == "C" or self.usage_of(nb) in _programme.SOCIABLE_USAGES: return True return False @@ -1860,7 +1873,7 @@ class Fitness: self.preprocess_building(root) _, graph_circ_pre = graph_mod.build_graphs_with_circ( - root, self.conf("door_width") or 1.2, failures.append + root, self.conf("door_width") or 1.2, failures.append, self.usages() ) graph_base_pre = graph_mod.build_graphs(root, self.conf("door_width") or 1.2) @@ -1878,7 +1891,7 @@ class Fitness: geometry.clear_cache() # mirror Perl Merge_Divided → Clean_Cache _, graph_circ = graph_mod.build_graphs_with_circ( - root, self.conf("door_width") or 1.2, failures.append + root, self.conf("door_width") or 1.2, failures.append, self.usages() ) graph_base = graph_mod.build_graphs(root, self.conf("door_width") or 1.2) @@ -1925,7 +1938,7 @@ class Fitness: def _load_programme(self, conf: dict) -> None: """Populate ``_programme_cache`` from spaces section of conf dict.""" - from .programme import SpaceReq, validate_codes + from .programme import SpaceReq, validate_codes, validate_usages _DW = (4.0, 1.0) _DP = (1.5, 0.5) spaces = conf.get("spaces") or {} @@ -1937,6 +1950,7 @@ class Fitness: # independently of programme._parse_spaces, so validating in only one # of the two would leave the other door open. validate_codes(spaces) + validate_usages(spaces) reqs: dict = {} for code, c in spaces.items(): sz = c.get("size") or [0.0, 1.0] @@ -1944,6 +1958,7 @@ class Fitness: pr = c.get("proportion") or _DP reqs[code] = SpaceReq( code=code, + usage=c["usage"], name=c.get("name", ""), size=float(sz[0]), size_sigma=float(sz[1]), diff --git a/src/homemaker_layout/graph.py b/src/homemaker_layout/graph.py index 156584a..bd85cd9 100644 --- a/src/homemaker_layout/graph.py +++ b/src/homemaker_layout/graph.py @@ -26,6 +26,7 @@ import networkx as nx from . import dom, geometry from .dom import Node, is_generic, levels +from . import programme as _pr from .programme import SpaceReq DOOR_WIDTH = 1.2 # Urb::Dom::Fitness::Base default_params door_width @@ -50,6 +51,7 @@ def build_graphs_with_circ( root: Node, door_width: float, fail, + usages: dict[str, str], ) -> tuple[list[nx.Graph], list[nx.Graph]]: """Build ``(graph_base, graph_circ)`` pairs; mirrors ``setup_storey_graphs`` in ``Base.pm:217-241``. @@ -69,7 +71,7 @@ def build_graphs_with_circ( g = geometry.leaf_graph(lvl, door_width) graph_base.append(g) gc = g.copy() - if not has_circulation(gc): + if not has_circulation(gc, usages): fail(f"{i} inaccessible usable space") graph_circ.append(gc) return graph_base, graph_circ @@ -93,9 +95,15 @@ def _avg_path_len_from(G: nx.Graph, node: Node) -> float: return 0.0 -def has_circulation(G: nx.Graph) -> bool: +def has_circulation(G: nx.Graph, usages: dict[str, str]) -> bool: """Port of ``Urb::Dom::Has_Circulation`` (modifies G in place). + ``usages`` maps room code -> access-requirement class (homemaker-py-sel, + DESIGN.md §39.7); it decides which edges are trimmed. It used to be inferred + from a leaf type's first character, so `la1` "Laundry Room" was trimmed as a + living room and `tr1` "Treatment Room" as a toilet. Codes absent from the + map (the generic ``C``/``O``/``S``) have no usage and are never trimmed. + Replicates the Perl clone quirk: isolated vertices (degree 0) are removed first since Perl's ``Graph::clone`` only copies vertices in edges. After that, removes non-usable nodes, trims bedroom/toilet cross-connections, then @@ -110,40 +118,41 @@ def has_circulation(G: nx.Graph) -> bool: non_usable = [v for v in list(G.nodes()) if not dom.is_usable(v)] G.remove_nodes_from(non_usable) - # Remove b → [lkbt] edges (bedrooms only connect to circulation/outside) + def _usage(v: Node) -> str: + return usages.get(v.type, "") + + # A TERMINAL room (bedroom/utility) is reachable from circulation or outside + # only — never a route through. Trim its edges to every other room. for v in list(G.nodes()): - if not (v.type and v.type[0].lower() == "b"): + if _usage(v) not in _pr.PRIVATE_USAGES: continue - to_remove = [ - nb for nb in list(G.neighbors(v)) - if nb.type and nb.type[0].lower() in ("l", "k", "b", "t") - ] + to_remove = [nb for nb in list(G.neighbors(v)) + if _usage(nb) in _pr.PRIVATE_STRIPS] G.remove_edges_from((v, nb) for nb in to_remove) - # Remove t → [olkt] edges (toilets only connect to bedrooms/circulation) + # A toilet keeps its edge to a terminal room (the Brand adjacency, §39.6) + # and loses outside/living/kitchen/toilet. for v in list(G.nodes()): - if not (v.type and v.type[0].lower() == "t"): + if _usage(v) != "toilet": continue - to_remove = [ - nb for nb in list(G.neighbors(v)) - if (nb.type in dom.GENERIC_OUTSIDE - or (nb.type and nb.type[0].lower() in ("l", "k", "t"))) - ] + to_remove = [nb for nb in list(G.neighbors(v)) + if nb.type in dom.GENERIC_OUTSIDE + or _usage(nb) in _pr.TOILET_STRIPS] G.remove_edges_from((v, nb) for nb in to_remove) - # btlk nodes: keep only one circulation neighbour + # Any classified room keeps only one circulation neighbour. for v in list(G.nodes()): - if not (v.type and v.type[0].lower() in ("b", "t", "l", "k")): + if _usage(v) not in _pr.PRIVATE_USAGES + ("toilet",) + _pr.SOCIABLE_USAGES: continue circ_nbs = [nb for nb in list(G.neighbors(v)) if dom.is_circulation(nb)] if len(circ_nbs) <= 1: continue circ_nbs.sort(key=lambda nb: _avg_path_len_from(G, nb)) - # b/t: keep least popular (last), remove most popular (first) - # l/k: keep most popular (first), remove least popular (last) - t0 = v.type[0].lower() + # terminal rooms and toilets keep their LEAST central circulation + # neighbour (privacy); sociable rooms keep their MOST central one. + sociable = _usage(v) in _pr.SOCIABLE_USAGES while len(circ_nbs) > 1: - if t0 in ("b", "t"): + if not sociable: G.remove_edge(v, circ_nbs.pop(0)) else: G.remove_edge(v, circ_nbs.pop()) @@ -155,9 +164,8 @@ def has_circulation(G: nx.Graph) -> bool: # blkc nodes: keep only one outdoor neighbour per outdoor component for v in list(G.nodes()): - # b/l/k are SEMANTIC programme-code prefixes; the fourth member is - # generic circulation, so it takes the generic test (§39.4). - if not ((v.type and v.type[0].lower() in ("b", "l", "k")) + # terminal rooms, sociable rooms, and generic circulation + if not (_usage(v) in _pr.PRIVATE_USAGES + _pr.SOCIABLE_USAGES or dom.is_circulation(v)): continue out_nbs = [ @@ -172,9 +180,9 @@ def has_circulation(G: nx.Graph) -> bool: component_nbs = [nb for nb in out_nbs if nb in component] if len(component_nbs) <= 1: continue - t0 = v.type[0].lower() + terminal = _usage(v) in _pr.PRIVATE_USAGES while len(component_nbs) > 1: - if t0 == "b": + if terminal: nb = component_nbs.pop(0) else: nb = component_nbs.pop() diff --git a/src/homemaker_layout/programme.py b/src/homemaker_layout/programme.py index ac1f36c..b9f01ef 100644 --- a/src/homemaker_layout/programme.py +++ b/src/homemaker_layout/programme.py @@ -22,6 +22,8 @@ _DEFAULT_PROPORTION = (1.5, 0.5) class SpaceReq: code: str name: str = "" + # homemaker-py-sel: access-requirement class; mandatory in patterns.config. + usage: str = "none" size: float = 0.0 # target floor area, m^2 size_sigma: float = 1.0 width: float = _DEFAULT_WIDTH[0] @@ -77,6 +79,32 @@ def _pair(d: dict, key: str, default: tuple[float, float]) -> tuple[float, float # rule can resolve. RESERVED_CODES = ("C", "O", "S") +# homemaker-py-sel (DESIGN.md §39.7) — the ACCESS-REQUIREMENT class of a room. +# +# Declared per space, mandatory, no fallback. This replaces the old +# first-character convention (b/t/l/k), under which a room silently inherited +# another room's connectivity rules from its spelling: `la1` "Laundry Room" was +# read as a living room, `tr1` "Treatment Room" as a toilet. +# +# The vocabulary is CONTROLLED because a value exists if and only if the engine +# treats it differently somewhere — config selects among behaviours, it cannot +# invent them. What a room is CALLED stays free text in ``name:``. +USAGES = ("living", "kitchen", "bedroom", "toilet", "utility", "none") + +# Terminal rooms: reachable from circulation or outside, never a route through. +# ``utility`` shares ``bedroom``'s access requirements today; it is a separate +# value because it is a genuinely different use and because it gives +# ``derive_interchange_classes`` an axis to relax on (§39.7 note 1). +PRIVATE_USAGES = ("bedroom", "utility") +# What a terminal room's edges are stripped down to in ``graph.has_circulation``. +PRIVATE_STRIPS = ("living", "kitchen", "bedroom", "toilet", "utility") +# A toilet keeps its edge to a terminal room (the Brand adjacency, §39.6 note 2) +# and loses the rest. +TOILET_STRIPS = ("living", "kitchen", "toilet") +# Sociable rooms keep their MOST central circulation neighbour; terminal rooms +# and toilets keep their LEAST central one. +SOCIABLE_USAGES = ("living", "kitchen") + def validate_codes(codes) -> None: """Raise ``ValueError`` if a programme code IS a generic structural type. @@ -98,9 +126,38 @@ def validate_codes(codes) -> None: ) +def validate_usages(spaces: dict) -> None: + """Every declared space must carry a known ``usage:``. + + Missing or unrecognised is a load error naming the code, never a silent + default — a forgotten key would otherwise hand the room another room's + connectivity rules, which is the bug this key exists to remove. + """ + missing = sorted(c for c, spec in spaces.items() + if not (spec or {}).get("usage")) + if missing: + raise ValueError( + f"space(s) {missing} declare no `usage:`. Every room must state its " + f"access-requirement class, one of {USAGES}. This replaced the old " + "first-character convention, under which a room inherited another " + "room's connectivity rules from its spelling. See DESIGN.md §39.7 / " + "homemaker-py-sel." + ) + unknown = sorted({(c, (spec or {})["usage"]) for c, spec in spaces.items() + if (spec or {})["usage"] not in USAGES}) + if unknown: + raise ValueError( + f"unknown usage value(s) {unknown}; expected one of {USAGES}. The " + "vocabulary is closed on purpose: a usage exists only where the " + "engine treats it differently, so a new access class means new " + "code, not new config. What the room is CALLED belongs in `name:`." + ) + + def _parse_spaces(conf: dict) -> dict[str, SpaceReq]: spaces = conf.get("spaces") or {} validate_codes(spaces) + validate_usages(spaces) out: dict[str, SpaceReq] = {} for code, c in spaces.items(): size = _pair(c, "size", (0.0, 1.0)) @@ -109,6 +166,7 @@ def _parse_spaces(conf: dict) -> dict[str, SpaceReq]: out[code] = SpaceReq( code=code, name=c.get("name", ""), + usage=c["usage"], size=size[0], size_sigma=size[1], width=width[0], diff --git a/tests/_helpers.py b/tests/_helpers.py new file mode 100644 index 0000000..2eae630 --- /dev/null +++ b/tests/_helpers.py @@ -0,0 +1,24 @@ +"""Shared helpers for the test suite.""" + +from __future__ import annotations + +# The pre-§39.7 first-character convention, kept HERE and nowhere else so that +# synthetic fixtures keep exercising what they always did while the production +# code has no prefix rule left at all (homemaker-py-sel). Tests about a specific +# usage should state it explicitly rather than rely on this. +_LEGACY_PREFIX_USAGE = {"b": "bedroom", "t": "toilet", "l": "living", "k": "kitchen"} + + +def with_usage(spaces: dict) -> dict: + """Fill in a mandatory ``usage:`` for a synthetic ``spaces`` fixture. + + ``usage`` is required in real ``patterns.config`` files, but most tests care + about size/width/collapse and not about access class. This supplies the class + the code's first letter used to imply, so those tests are unchanged by the + migration. An explicit ``usage`` in the fixture always wins. + """ + return { + code: {"usage": _LEGACY_PREFIX_USAGE.get(code[:1].lower(), "none"), + **(spec or {})} + for code, spec in spaces.items() + } diff --git a/tests/test_collapse_global.py b/tests/test_collapse_global.py index 2e1228b..410ad64 100644 --- a/tests/test_collapse_global.py +++ b/tests/test_collapse_global.py @@ -7,6 +7,7 @@ Covers the contracts of Fitness.collapse_global / collapse_finish: - no-op safety (no programme) and the keep-better wrapper """ +from _helpers import with_usage from homemaker_layout import geometry from homemaker_layout.dom import Node, _link_subtree from homemaker_layout.fitness import Fitness @@ -24,7 +25,7 @@ def _two_leaf_root(t_left: str, t_right: str, side: float = 6.0, div: float = 0. def _conf(spaces, **extra): - return {"spaces": spaces, **extra} + return {"spaces": with_usage(spaces), **extra} # --------------------------------------------------------------------------- # diff --git a/tests/test_collapse_insearch.py b/tests/test_collapse_insearch.py index 88cef0b..00bc1cc 100644 --- a/tests/test_collapse_insearch.py +++ b/tests/test_collapse_insearch.py @@ -17,6 +17,7 @@ from unittest.mock import patch import pytest +from _helpers import with_usage from homemaker_layout import dom as dom_mod from homemaker_layout.dom import Node, _link_subtree from homemaker_layout.fitness import Fitness, load_config @@ -69,7 +70,7 @@ def test_collapse_insearch_iters_knob(): def test_evaluate_full_calls_collapse_global_when_on(): fit = Fitness(conf={"collapse_insearch": True, "collapse_insearch_iters": 2, - "spaces": {"b1": {"size": [16.0, 4.0], "count": 2}}}) + "spaces": with_usage({"b1": {"size": [16.0, 4.0], "count": 2}})}) root = _two_leaf_root("b1", "b1") with patch.object(Fitness, "collapse_global", wraps=fit.collapse_global) as m: fit.score_with_fails(root) @@ -82,7 +83,7 @@ def test_evaluate_full_calls_collapse_global_when_on(): def test_evaluate_full_does_not_call_collapse_global_when_off(): - fit = Fitness(conf={"spaces": {"b1": {"size": [16.0, 4.0], "count": 2}}}) + fit = Fitness(conf={"spaces": with_usage({"b1": {"size": [16.0, 4.0], "count": 2}})}) root = _two_leaf_root("b1", "b1") with patch.object(Fitness, "collapse_global", wraps=fit.collapse_global) as m: fit.score_with_fails(root) diff --git a/tests/test_dom_corpus.py b/tests/test_dom_corpus.py index 1228f37..33f35f4 100644 --- a/tests/test_dom_corpus.py +++ b/tests/test_dom_corpus.py @@ -91,7 +91,7 @@ def _native_evaluate(src: Path): check_f, missing = graph_mod.check_space_counts(root, programme) failures.extend(check_f) fit.preprocess_building(root) - _, gcpre = graph_mod.build_graphs_with_circ(root, fit.conf("door_width") or 1.2, failures.append) + _, gcpre = graph_mod.build_graphs_with_circ(root, fit.conf("door_width") or 1.2, failures.append, fit.usages()) gbpre = graph_mod.build_graphs(root, fit.conf("door_width") or 1.2) failures.extend(graph_mod.check_adjacency(root, programme, gbpre, missing)) failures.extend(graph_mod.check_level_constraints(root, programme, missing)) @@ -99,7 +99,7 @@ def _native_evaluate(src: Path): dom.merge_divided(root) geometry.clear_cache() - _, gc = graph_mod.build_graphs_with_circ(root, fit.conf("door_width") or 1.2, failures.append) + _, gc = graph_mod.build_graphs_with_circ(root, fit.conf("door_width") or 1.2, failures.append, fit.usages()) gb = graph_mod.build_graphs(root, fit.conf("door_width") or 1.2) cost_v = fit.plot_cost(root) diff --git a/tests/test_fitness.py b/tests/test_fitness.py index 662709d..1d6800d 100644 --- a/tests/test_fitness.py +++ b/tests/test_fitness.py @@ -2,6 +2,7 @@ import pytest +from _helpers import with_usage from homemaker_layout import dom, geometry from homemaker_layout.dom import Node from homemaker_layout.fitness import ( @@ -101,7 +102,7 @@ def test_get_space_params_inside_falls_back_to_inside_defaults(): def test_get_space_params_named_space_overrides_default(): - f = Fitness(conf={"spaces": {"k1": {"size": [20.0, 4.0]}}}) + f = Fitness(conf={"spaces": with_usage({"k1": {"size": [20.0, 4.0]}})}) assert f.get_space_params("k1", "size") == [20.0, 4.0] @@ -348,14 +349,14 @@ def test_load_config_overrides_merge_last(tmp_path): from homemaker_layout.fitness import load_config (tmp_path / "patterns.config").write_text( - yaml.safe_dump({"spaces": {"b": {"size": [12.0, 1.0]}}})) + yaml.safe_dump({"spaces": with_usage({"b": {"size": [12.0, 1.0]}})})) conf, _ = load_config(tmp_path) assert "leaf_sharing" not in conf # absent on disk conf2, _ = load_config(tmp_path, overrides={"leaf_sharing": True}) assert conf2["leaf_sharing"] is True - assert conf2["spaces"]["b"] == {"size": [12.0, 1.0]} # disk content preserved + assert conf2["spaces"]["b"] == with_usage({"b": {"size": [12.0, 1.0]}})["b"] # None / empty overrides are a no-op (default-OFF parity). assert "leaf_sharing" not in load_config(tmp_path, overrides=None)[0] @@ -370,10 +371,10 @@ def test_programme_parses_per_code_share(tmp_path): from homemaker_layout.programme import load_programme p = tmp_path / "patterns.config" - p.write_text(yaml.safe_dump({"spaces": { + p.write_text(yaml.safe_dump({"spaces": with_usage({ "b": {"size": [12.0, 1.0], "share": 3}, "k": {"size": [20.0, 1.0]}, # no share key - }})) + })})) reqs = load_programme(str(p)) assert reqs["b"].share == 3 and reqs["b"].has_share is True assert reqs["k"].share == 1 and reqs["k"].has_share is False diff --git a/tests/test_multi_use.py b/tests/test_multi_use.py index c053bc3..882f653 100644 --- a/tests/test_multi_use.py +++ b/tests/test_multi_use.py @@ -15,6 +15,7 @@ from pathlib import Path import numpy as np import pytest +from _helpers import with_usage from homemaker_layout import dom, geometry, graph, operators, programme from homemaker_layout.dom import Node, _link_subtree from homemaker_layout.fitness import Fitness, gaussian @@ -117,10 +118,10 @@ def test_undeclared_pair_is_never_valid_even_if_interchangeable(): def test_co_locate_parsed_from_config(): - conf = {"spaces": { + conf = {"spaces": with_usage({ "den": {"size": [9.0, 1.0], "co_locate": ["guest"]}, "guest": {"size": [12.0, 1.0]}, - }} + })} reqs = programme._parse_spaces(conf) assert reqs["den"].co_locate == ["guest"] assert reqs["guest"].co_locate == [] @@ -255,7 +256,7 @@ def _multi_use_conf(pair=True): } if pair: spaces["x"]["co_locate"] = ["y"] - return {"multi_use": True, "spaces": spaces} + return {"multi_use": True, "spaces": with_usage(spaces)} def test_quality_size_combines_both_codes_area_additively(): @@ -341,14 +342,14 @@ def test_load_config_multi_use_override_merges_last(tmp_path): from homemaker_layout.fitness import load_config (tmp_path / "patterns.config").write_text( - yaml.safe_dump({"spaces": {"x": {"size": [10.0, 1.0]}}})) + yaml.safe_dump({"spaces": with_usage({"x": {"size": [10.0, 1.0]}})})) conf, _ = load_config(tmp_path) assert "multi_use" not in conf conf2, _ = load_config(tmp_path, overrides={"multi_use": True}) assert conf2["multi_use"] is True - assert conf2["spaces"]["x"] == {"size": [10.0, 1.0]} + assert conf2["spaces"]["x"] == with_usage({"x": {"size": [10.0, 1.0]}})["x"] # --------------------------------------------------------------------------- # diff --git a/tests/test_programme.py b/tests/test_programme.py index 04b8ad6..faa32c5 100644 --- a/tests/test_programme.py +++ b/tests/test_programme.py @@ -39,7 +39,8 @@ def test_colliding_code_is_a_full_requirement_not_a_generic(): """The §39.2 damage in one assertion: a c-prefixed code must keep its declared targets and stay in the required set.""" conf = {"spaces": {"cr1": {"size": [80.0, 10.0], "width": [6.0, 1.5], - "proportion": [2.0, 0.5], "count": 1}}} + "proportion": [2.0, 0.5], "count": 1, + "usage": "living"}}} fit = fitness.Fitness(conf=conf) assert fit.get_space_params("cr1", "size") == [80.0, 10.0] assert fit.get_space_params("cr1", "width") == [6.0, 1.5] @@ -52,14 +53,57 @@ def test_colliding_code_is_a_full_requirement_not_a_generic(): assert dom.is_outside(dom.Node(type="S")) and dom.is_circulation(dom.Node(type="S")) -def test_semantic_but_unreserved_prefixes_are_allowed(): - """l/k/b/t carry adjacency semantics but never discard a requirement, so - programme codes may use them freely — only c/o/s are reserved.""" +def test_a_codes_first_letter_no_longer_decides_anything(): + """§39.7: usage is DECLARED, so a code's spelling carries no meaning at all. + + `b1` was a bedroom purely because it started with "b"; here it declares + `utility` and that is what it is. This is the property the old prefix + convention could not offer, and the reason `la1` "Laundry Room" was being + trimmed as a living room. + """ reqs = programme._parse_spaces({"spaces": { - "l1": {"size": [20.0, 4.0]}, "k1": {"size": [12.0, 3.0]}, - "b1": {"size": [16.0, 4.0]}, "t1": {"size": [3.0, 1.0]}, + "l1": {"size": [20.0, 4.0], "usage": "utility"}, + "k1": {"size": [12.0, 3.0], "usage": "none"}, + "b1": {"size": [16.0, 4.0], "usage": "utility"}, + "zzz": {"size": [3.0, 1.0], "usage": "bedroom"}, }}) - assert sorted(reqs) == ["b1", "k1", "l1", "t1"] + assert reqs["l1"].usage == "utility" + assert reqs["k1"].usage == "none" + assert reqs["b1"].usage == "utility" + assert reqs["zzz"].usage == "bedroom" + + +def test_usage_is_mandatory_and_reported_by_code(): + with pytest.raises(ValueError, match=r"declare no .usage.*\['b1'\]|\['b1'\].*declare no"): + programme._parse_spaces({"spaces": {"b1": {"size": [16.0, 4.0]}}}) + + +def test_unknown_usage_is_rejected_not_silently_ignored(): + with pytest.raises(ValueError, match="unknown usage value"): + programme._parse_spaces( + {"spaces": {"b1": {"size": [16.0, 4.0], "usage": "craft"}}}) + + +def test_usage_is_rejected_by_both_parse_paths(): + """Fitness parses conf["spaces"] independently of programme._parse_spaces.""" + conf = {"spaces": {"b1": {"size": [16.0, 4.0]}}} + with pytest.raises(ValueError, match="declare no"): + programme._parse_spaces(conf) + with pytest.raises(ValueError, match="declare no"): + fitness.Fitness(conf=conf) + + +def test_usage_survives_a_retype_because_it_is_code_level(): + """The mutation-safety property: usage is looked up from the CODE, so a + retype changes the class automatically and nothing can go stale.""" + fit = fitness.Fitness(conf={"spaces": { + "b1": {"size": [16.0, 4.0], "usage": "bedroom"}, + "s9": {"size": [16.0, 4.0], "usage": "utility"}, + }}) + leaf = dom.Node(type="b1") + assert fit.usage_of(leaf) == "bedroom" + leaf.type = "s9" # exactly what mutate_retype does + assert fit.usage_of(leaf) == "utility" def test_corpus_programmes_are_namespace_clean(): diff --git a/tests/test_superposition.py b/tests/test_superposition.py index caf14f6..6d1b854 100644 --- a/tests/test_superposition.py +++ b/tests/test_superposition.py @@ -10,6 +10,7 @@ plus the default-OFF guarantee. import pytest +from _helpers import with_usage from homemaker_layout import dom, geometry, programme from homemaker_layout.dom import Node, _link_subtree from homemaker_layout.fitness import Fitness @@ -120,10 +121,10 @@ def test_interchange_veto_breaks_transitive_chain(): def test_interchange_veto_parsed_from_config(): - conf = {"spaces": { + conf = {"spaces": with_usage({ "den": {"size": [9.0, 1.0]}, "guest": {"size": [12.0, 1.0], "interchange": False}, - }} + })} reqs = programme._parse_spaces(conf) assert reqs["den"].interchange is True assert reqs["guest"].interchange is False @@ -202,12 +203,12 @@ def _two_leaf_root(t_left: str, t_right: str, side: float = 6.0, div: float = 0. def _bedroom_conf(superpose=True): return { "superpose": superpose, - "spaces": { + "spaces": with_usage({ "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}, - }, + }), } @@ -230,7 +231,7 @@ def test_collapse_relabels_to_demand_set(): 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}}} + "spaces": with_usage({"b1": {"size": [16.0, 4.0], "count": 2}})} fit = Fitness(conf=conf) root = _two_leaf_root("b1", "b1") fit.collapse_superposition(root)