From 7ec4e5d12142ad68b7049145d290bb303bb11d77 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 09:45:28 +0000 Subject: [PATCH] =?UTF-8?q?=C2=A739.4:=20tighten=20generic-type=20matching?= =?UTF-8?q?,=20reverting=20the=20harbor=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes the previous commit's approach. Renaming harbor's four colliding codes fixed one programme; tightening the matching rule fixes the rule, so a room may be called anything. cr1/of/st1/st2 are restored and the examples are byte-identical to their pre-§39 state -- which also means existing .dom artefacts (evolved-3M*) stay valid, so migrate_ju3_rename.py is deleted. The rule: Urb has exactly three GENERIC structural types (get_space_types: qw/C O S/), the leaves the search creates. Measured across the corpus: 154 C, 110 O, 1 S, not one lowercase generic -- while every programme code is lowercase, including single-character ones (r, t, m, n). Case is the discriminator, not length. Every generic test was type[0].lower() in (...), a case-insensitive PREFIX that swept up any programme code starting with those letters; they now match the generic set exactly. 30 sites across dom, fitness, graph, operators, programme, shapecurve and bubble. NOT applied to the SEMANTIC prefixes: l/k/b/t classify programme codes by first letter (graph.py builds bedroom<->toilet and kitchen<->living relations from them) and stay prefix-based. Where the namespaces were mixed in one expression they were split -- has_circulation's ("b","l","k","c") is three semantic prefixes plus dom.is_circulation; access()'s ("l","c","s") is semantic l plus the generic circulation set. New: dom.GENERIC_{CIRCULATION,OUTSIDE,TYPES} + is_generic(); fitness. _generic_class(), replacing the _t0 dispatch in quality_size/quality_width/ quality_proportion/value_rate -- the four terms that mattered most and that a first sweep missed, since they dispatch through a t0 variable rather than an inline test. graph._adjacency_target resolves a generic adjacency requirement (programmes write "adjacency: [c, o]") to the generic set while every other requirement keeps Perl's prefix semantics. Two subtleties: S is in both generic sets but takes the OUTSIDE parameter families -- a first translation tested circulation first and silently gave S the circulation params, caught by test_get_space_params_sahn_proportion. And validate_codes survives, narrowed to a code spelled exactly C/O/S, which is a genuine ambiguity; merely starting with c/o/s is now fine. Invariant asserted as a test: test_scoring_is_invariant_under_programme_code_ spelling relabels one tree and its config together and re-scores. Bit-identical across 12 comparisons (6 seeds x collapse on/off). Re-baseline (seed 1, 20k, original names): 58 fails (15 hard / 43 soft) against the real 37-instance programme, with cr1 at 79.1 m2 vs declared 80 (was 32.9 and 17.1), of/st1/st2 all present and in band, and one fail naming any of them. 57 -> 58 on a 5-instance-harder programme is within noise: "did not regress". Fallout (§39.5): 2g7.5's CP-SAT seeder win does not survive. Over 6 seeds -- harbor real 102/114 (cpsat loses), harbor old-effective 98/99 (tie, so the win was already marginal), maple-court 156/144 (cpsat wins). maple is the control: the solver did not regress, harbor's programme changed. Test xfail'd with that reason plus a maple companion; both assign_solver flags stay default off. Filed homemaker-py-w6x to re-check other narrow-margin harbor A/Bs. 345 passed, 1 xfailed, same 7 pre-existing fixture failures. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB --- .beads/issues.jsonl | 1 + DESIGN.md | 150 +++++++++++++++-------- examples/harbor-house-l0/generated.dom | 2 +- examples/harbor-house-l0/patterns.config | 6 +- examples/harbor-house/generated.dom | 6 +- examples/harbor-house/patterns.config | 8 +- experiments/migrate_ju3_rename.py | 79 ------------ src/homemaker_layout/bubble.py | 8 +- src/homemaker_layout/dom.py | 53 ++++++-- src/homemaker_layout/driver.py | 2 +- src/homemaker_layout/fitness.py | 82 +++++++++---- src/homemaker_layout/graph.py | 58 +++++++-- src/homemaker_layout/operators.py | 18 +-- src/homemaker_layout/programme.py | 68 +++++----- src/homemaker_layout/shapecurve.py | 7 +- tests/test_compose.py | 10 +- tests/test_operators.py | 44 +++++++ tests/test_programme.py | 102 +++++++++++++-- tests/test_staging.py | 4 +- 19 files changed, 461 insertions(+), 247 deletions(-) delete mode 100644 experiments/migrate_ju3_rename.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 684cfac..6d58c19 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -33,6 +33,7 @@ {"_type":"issue","id":"homemaker-py-1p0","title":"Geometry inner loop: full-objective equal-offset ratio optimiser","description":"DESIGN.md §5.1, §7 Phase 1. Productionise experiments/optimize_fullfitness.py into homemaker: optimise(topology, x0=None) -\u003e (geometry, fitness). DOF = equal-offset division ratios of free branches (solver.free_branches, lowest-storey cut ownership), clipped to [eps, 1-eps]. Objective = full oracle fitness (never a proxy — §4.2 falsified). Must support warm-start x0 (§5.6) and a population/batch evaluation mode so each iteration scores via one batched oracle call (§4.6).","acceptance_criteria":"Reproduces or exceeds §4.5 gains (x1.24–x1.67, no new failures) on 2f45907, candidate-002, c964435; works as a library call on any corpus .dom","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:58Z","created_by":"Bruno Postle","updated_at":"2026-06-12T08:46:31Z","started_at":"2026-06-12T00:14:19Z","closed_at":"2026-06-12T08:46:31Z","close_reason":"innerloop.optimise() lands: batched CMA-ES sigma ladder (0.05/0.15, IPOP popsize doubling, deterministic seeding) over equal-offset free-branch ratios vs full oracle fitness; warm-start x0 supported. Acceptance vs unprojected originals: x1.65/x1.66/x1.58 against bars x1.24/x1.67/x1.59, no new failures, 46 oracle calls vs NM's 200. Two near-bar results accepted as reproduced-within-noise (1% tol) — draw spread brackets the single-NM-draw bars; approved by Bruno 2026-06-12. Gotchas: equal-offset projection of legacy unequal cuts loses fitness/adds failures (midpoint projection used); pycma seed=0 means clock-seeded.","dependencies":[{"issue_id":"homemaker-py-1p0","depends_on_id":"homemaker-py-av5","type":"blocks","created_at":"2026-06-12T00:39:33Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"homemaker-py-8cs","title":"Experiment: warm-vs-cold start of inner loop (Lamarckian inheritance)","description":"DESIGN.md §5.6, §4.6. Warm-starting a child topology's inner loop from the parent's optimised ratios is the main lever for cutting per-topology cost (~3 min/topology cold). Apply single topology mutations to optimised corpus designs, re-optimise warm (surviving cuts keep values, new cuts get heuristic defaults) vs cold, compare oracle-call counts to convergence at equal final fitness.","acceptance_criteria":"Speedup factor measured across \u003e=10 mutated topologies; decision recorded (expect order-of-magnitude; if \u003c2x, revisit §4.6 Phase-2 scoping)","notes":"Experiment script committed (experiments/warm_vs_cold.py, 1cc86c8) and machinery validated oracle-free; one mutated child scored through the oracle OK. Waiting on homemaker-py-gp2 reference run to finish, then execute under URB_NO_OCCLUSION=1 (3 parents x 400 evals + 12 children x 2 x 200 evals, ~1.5-2 h oracle time). Default budgets: parent 400, child 200; target = evals to 95% of best final.","status":"closed","priority":1,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:58Z","created_by":"Bruno Postle","updated_at":"2026-06-12T11:44:45Z","closed_at":"2026-06-12T11:44:45Z","close_reason":"Measured (URB_NO_OCCLUSION=1, parent budget 400, child 200, 12 single mutations across 3 designs): cold start reached 95% of warm final in 0/12 cases within budget — speedup unbounded at practical budgets; warm finals beat cold finals x1.2-x4 in 12/12; 6/12 warm starts were within 95% at 1 eval (near-neutral mutations). Decision: Lamarckian warm-starting is MANDATORY in the memetic driver (homemaker-py-b39), not an optimisation; cold starts produce strictly worse geometry at equal budget. Note: 2 undivides were exactly fitness-neutral (same-type merge == Merge_Divided equivalence) — locality datum for homemaker-py-nyb.","dependencies":[{"issue_id":"homemaker-py-8cs","depends_on_id":"homemaker-py-1p0","type":"blocks","created_at":"2026-06-12T00:39:34Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-av5","title":"Batched oracle: score many .dom files per invocation","description":"oracle.py currently scores one .dom per urb-fitness.pl call (~1.65 s/dom). DESIGN.md §4.6: batching amortises Perl startup to ~0.99 s/dom and is required so population/batch optimisers can score a whole generation in one oracle call. Extend oracle.py with a batch API: write N .dom files, one perl invocation, parse N .score/.fails pairs. Keep the single-file path for compatibility.","acceptance_criteria":"Batch of 35 corpus files scores in one perl invocation; per-file results identical to single-file calls; measured s/dom reported","status":"closed","priority":1,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:56Z","created_by":"Bruno Postle","updated_at":"2026-06-12T00:14:06Z","started_at":"2026-06-11T23:50:40Z","closed_at":"2026-06-12T00:14:06Z","close_reason":"score_batch() lands in oracle.py; 35-file corpus parity verified single-vs-batch (1e-12 rel fitness, exact fail sets); 0.98 s/dom batched vs 1.27 single, x1.30","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"homemaker-py-0wr","title":"Re-check any harbor-house A/B decided by a small margin before the §39.4 namespace fix","description":"Until §39.4, harbor-house scored against a 32-instance EFFECTIVE programme -- cr1/of/st1/st2 (14% of room instances) were dropped by the count check and mis-parameterised by the generic c/o/s prefix rule. Any harbor A/B decided by a narrow margin was therefore decided against a programme missing a seventh of its rooms.\\n\\nOne confirmed casualty already: 2g7.5's CP-SAT seeder win (§37.7). Measured over 6 seeds on the corrected programme, greedy 102 / cpsat 114 -- cpsat now LOSES; on the old 32-instance programme it was 98/99, a tie, so the recorded win was marginal from the start. Control: on namespace-clean maple-court cpsat still wins 144/156, so the solver did not regress. Test marked xfail with a companion maple-court assertion; both assign_solver flags stay default off.\\n\\nWorth re-checking with the same lens: §13.9/§13.11 floors, §17/§20 collapse A/Bs, §23 ruin-recreate, §29/§30 beam width, §37.1 tiering -- anything whose harbor arm was close.","acceptance_criteria":"Each narrow-margin harbor result either re-measured on the corrected programme or annotated in DESIGN.md as pre-§39.4.","status":"open","priority":2,"issue_type":"task","owner":"noreply@anthropic.com","created_at":"2026-08-26T09:44:55Z","created_by":"Claude","updated_at":"2026-08-26T09:44:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-1i8","title":"Missing-space fail cascade weights rooms by patterns.config verbosity, not by design intent","description":"graph.check_space_counts emits, per missing room instance: 2 base fails ('missing required space: X' + '(critical)') plus one 'would need \u003ccheck\u003e' placeholder for each of size/width/proportion the programme HAPPENS to declare (has_size/has_width/has_proportion are literally 'size' in c etc. from the YAML). So a missing room costs 3 to 5 fails depending only on how many optional keys the author typed. Under value *= 0.5**len(failures) that is a 4x difference in fitness weight between two single rooms. Concretely in programme-house: missing b1 (declares size+width+proportion) = 5 fails = 1/32 penalty; missing t2 (declares size only) = 3 fails = 1/8 penalty. Same for harbor-house: n and cr1 cost 5 each, r and t cost 4 each. The tiered comparator inherits this -- n_hard is dominated by these cascades, so the primary search key is weighted by YAML verbosity.","design":"Either emit exactly one fail per missing instance (and let the cascade placeholders be informational, not counted), or normalise the cascade to a fixed count per instance independent of declared keys. Note this changes every historical fail-count baseline in DESIGN.md, so it needs its own A/B and a recorded re-baseline.","status":"open","priority":2,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-25T23:15:00Z","created_by":"Claude","updated_at":"2026-08-25T23:15:00Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-gvb","title":"Crinkliness is mis-tiered as SOFT, but most crinkliness fails are topological (zero-exposure) and unreachable by the inner loop","description":"fitness._SOFT_FAIL_MARKERS lists ' crinkliness' as SOFT, defined in 37.1 as 'a continuous per-leaf shape metric the inner-loop ratio solve can improve without changing the tree'. That is false for the zero-exposure case: a leaf with no daylit wall cannot be given one by ANY ratio assignment -- it needs a topology change, which is the document's own definition of HARD. Measured share of crinkliness fails that are zero-exposure: harbor-house 60% (36 of 60), maple-court 65% (83 of 127), health-centre 100% (35 of 35); on a converged 20k harbor run, 14 of 17 (82%). Since crinkliness is the single largest fail category (48% of the residual per 13.11), the tiered comparator from 2g7.3 is mis-informed about the largest block of fails it sorts: it tells the search 'these ~40 soft fails are polishable' when two-thirds of them are structurally unreachable, and n_soft is therefore not the polish-budget signal it was designed to be.","design":"Split the crinkliness fail into two strings (or tier it dynamically on area_outside==0) so zero-exposure counts HARD and wrong-ratio counts SOFT. classify_fail_tier is string-based, so the cleanest fix is emitting a distinct fail string for the zero-exposure case -- which also makes the condition visible in .fails output, where today it is indistinguishable from an ordinary shape miss.","acceptance_criteria":"Distinct fail string for zero-exposure; classify_fail_tier maps it HARD; re-run the 37.1 tiered-vs-flat A/B, whose hard/soft split changes materially under the corrected tiering.","status":"open","priority":2,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-25T23:15:00Z","created_by":"Claude","updated_at":"2026-08-25T23:15:00Z","dependencies":[{"issue_id":"homemaker-py-gvb","depends_on_id":"homemaker-py-ssz","type":"blocks","created_at":"2026-08-25T23:15:13Z","created_by":"Claude","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-57z","title":"Run homemaker-py-2g7.7 acceptance benchmark against evolved-3M-nols-3 plateau seed (needs live ANTHROPIC_API_KEY)","description":"homemaker-py-2g7.7's acceptance criteria requires an end-to-end run of the LLM repair loop against the evolved-3M-nols-3 15-hard-fail plateau seed (level 0 not connected + me1 on wrong level, which survived \u003e1M blind evals / ~2.4 days in the original evolve-3M-nols-3.log run): repair loop reduces hard-fail count within \u003c=20 LLM calls, edit-DSL rejects malformed proposals (should already be covered by unit tests), and an A/B at equal native-eval budget shows strictly better final fails on \u003e=2/3 seeds vs a no-repair control. This requires a live Claude API call (client.messages.create against claude-opus-5) which needs ANTHROPIC_API_KEY set or 'ant auth login' completed -- neither was available in the sandbox that scaffolded the feature (no ANTHROPIC_API_KEY env var, no ant CLI installed). Run this once credentials are available, then close out homemaker-py-2g7.7's acceptance criteria referencing the results.","status":"open","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-08-06T07:05:45Z","created_by":"Bruno Postle","updated_at":"2026-08-06T07:05:45Z","dependencies":[{"issue_id":"homemaker-py-57z","depends_on_id":"homemaker-py-2g7.7","type":"blocks","created_at":"2026-08-06T08:06:12Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} diff --git a/DESIGN.md b/DESIGN.md index e6fe878..584a32e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -5156,62 +5156,114 @@ document was measured against a **32-instance effective programme, not the harbor-house (§13.9, §13.11, §17, §20, §23, §37.1, §37.7) are internally consistent but are not measuring the programme as written. -### 39.3 What shipped — loud validation + harbor rename, re-baselined +### 39.3 What shipped — tighten the matching rule at the source -**The `class:` key was NOT built, deliberately.** Auditing every use of the -prefix rule first showed it runs far deeper than `c`/`o`/`s`: `l`/`k`/`b`/`t` -carry real adjacency semantics too (`graph.py` builds bedroom↔toilet and -kitchen↔living relations from first characters, `fitness.py` gates access and -public-entrance checks on them). Re-plumbing that into a declared type system -would invalidate the entire corpus and every baseline in this document, for a -problem whose actual damage is the **silence**, not the prefix convention. +A first pass took the cheaper route from the bead's design: refuse a colliding +programme code at load, and rename harbor's four. **That has been superseded and +the rename reverted** — `cr1`, `of`, `st1`, `st2` are back. The right fix was to +tighten the matching rule so a room may be called anything at all. -Two facts made the smaller fix clearly sufficient: +**The rule.** Urb has exactly three GENERIC structural types +(`get_space_types`: `qw/C O S/`) — the leaves the *search* creates. Measured +across the whole corpus: **154 `C`, 110 `O`, 1 `S`, and not one lowercase +generic**, while every programme code is lowercase — including single-character +ones (`r`, `t`, `m`, `n`). So **case is the discriminator, not length**, and the +two namespaces are cleanly separable. Every generic test used to be +`type[0].lower() in (...)` — a case-insensitive *prefix* — which swept up any +programme code starting with those letters. They now match the generic set +exactly. -1. **No corpus programme has ever declared a bare `c`/`o`/`s` code** (checked - across all ten `examples/*/patterns.config`). So `check_space_counts`' - skip never protected a legitimate case — generic types are not in `spaces` - at all — and could only ever discard a declared room. -2. **Nothing references the four harbor codes** in any `adjacency:` or - `co_locate:` list, so renaming them is local. +Crucially this is **not** applied to the SEMANTIC prefixes. `l`/`k`/`b`/`t` +classify *programme codes* by first letter (`k1` is a kitchen; `graph.py` builds +bedroom↔toilet and kitchen↔living relations from them) and stay prefix-based. +Only the generic-type tests were tightened — 30 sites across `dom.py`, +`fitness.py`, `graph.py`, `operators.py`, `programme.py`, `shapecurve.py` and +`bubble.py`. Where the two namespaces were mixed in one expression they were +split: `has_circulation`'s `("b","l","k","c")` is now three semantic prefixes +*plus* `dom.is_circulation`, and `access()`'s `("l","c","s")` is the semantic +`l` *plus* the generic circulation set. -Shipped instead: +New in `dom.py`: `GENERIC_CIRCULATION`/`GENERIC_OUTSIDE`/`GENERIC_TYPES` and +`is_generic()`. New in `fitness.py`: `_generic_class()`, which replaces the +`_t0()` first-character dispatch in `quality_size`/`quality_width`/ +`quality_proportion`/`value_rate` — the four terms that mattered most, and the +ones a first sweep missed because they dispatch through a `t0` variable rather +than an inline test. -- **`programme.validate_codes`** (new, `RESERVED_PREFIXES = ("c", "o", "s")`) - raises with the full explanation and a pointer here. Called from **both** - parse paths — `programme._parse_spaces` *and* - `fitness.Fitness._load_programme`, which parse `conf["spaces"]` - independently, so validating only one would leave the other door open. - `l`/`k`/`b`/`t` are explicitly **not** reserved: they flavour heuristics but - never discard a requirement. -- **harbor-house / harbor-house-l0 renamed**: `cr1`→`fr1`, `of`→`ao`, - `st1`→`gs1`, `st2`→`gs2`. Each new prefix is unused in harbor *and* - semantically neutral (`f`/`a`/`g` carry no adjacency meaning, unlike - `l`/`k`/`b`/`t`), and the two storage codes still share one prefix, so the - prefix-sharing structure `fitness.evaluate_building`'s per-code plot-ratio - term depends on is preserved. `name:` fields are unchanged. -- **`experiments/migrate_ju3_rename.py`** rewrites `type:` lines in `.dom` - files produced before the rename (`--check` to dry-run). Any pre-rename - artefact — notably the `evolved-3M*.dom` harbor runs — **must** be migrated - or its affected leaves will read as unmatched generics. +Two subtleties worth recording: -**Re-baseline** (`homemaker-evolve init.dom --budget 20000 --workers 4 ---seed 1`, same seed/budget/settings as §38's run, so the two are directly -comparable): +- **`S` is in both generic sets but takes the OUTSIDE parameter families.** The + original dispatch was `c0 == "c"` then `c0 in ("o", "s")`, so sahn fell to + outside. A first translation tested circulation first and silently gave `S` + the circulation params; `test_get_space_params_sahn_proportion` caught it. +- **Generic adjacency references are lowercase in `patterns.config`** — every + corpus programme writes `adjacency: [c, o]`. `graph._adjacency_target` resolves + those to the generic set while leaving every other requirement on Perl's + prefix semantics (a requirement `t` still matches `t1`, `t2`, …). Previously a + room next to `cr1` "Common Room" counted as being next to circulation. -| | effective programme | total fails | `fr1` area (declared 80) | `ao`/`gs1`/`gs2` | +`programme.validate_codes` survives but is narrowed to what is still a genuine +ambiguity: a code spelled **exactly** `C`, `O` or `S`. Codes merely starting +with those letters are now fine — that was the bug, not the rule. + +**The invariant, asserted as a test.** +`test_scoring_is_invariant_under_programme_code_spelling` builds one harbor +layout, then relabels that exact tree *and* its config together and re-scores: +same geometry, same topology, only the spelling differs. Bit-identical across +12 comparisons (6 seeds × in-search collapse on/off). Renaming a room can no +longer change what a layout scores. + +### 39.4 Re-baseline + +`homemaker-evolve init.dom --budget 20000 --workers 4 --seed 1` — same seed, +budget and settings as §38's run, so the two are directly comparable. Harbor +keeps its **original** code names throughout; only the matching rule changed. + +| | effective programme | fails | `cr1` (declared 80) | `of` ×2 / `st1` / `st2` | |---|---|---|---|---| -| before | 32 instances | 57 | **32.9 and 17.1** (two leaves, `count: 1`, no too-many fail) | **absent, zero fails** | -| after | **37 instances** | **55** (13 hard / 42 soft) | **87.2** | **all present: 14.2, 11.5, 24.6, 18.7** | +| before §39 | 32 instances | 57 | **32.9 and 17.1** (two leaves against `count: 1`, and no too-many fail) | **absent, zero fails** | +| after §39.3 | **37 instances** | 58 (15 hard / 43 soft) | **79.1** | **12.2, 11.0 / 22.5 / 20.1** | -Every one of the five previously-lost room instances is now placed and sized -inside its declared sigma band (`fr1` 80±10, `ao` 12.5±2.5, `gs1` 22±4, -`gs2` 18±3.5), and **no failure in the result mentions any of the four codes**. -The fail count did not rise despite the programme being 5 instances harder — -though at one seed each, 57 vs 55 is within noise and the honest claim is -"did not regress", not "improved". The robust result is the room placement. +Every one of the five previously-lost room instances is placed and sized inside +its declared sigma band, and exactly one failure in the whole result mentions +any of the four codes (an adjacency miss on one office). The common room is +within 1 m² of its brief, having previously converged to a fifth of it. + +Fail count 57 → 58 on a programme that is 5 instances harder: at one seed each +that is within noise, and the honest claim is **"did not regress"**, not +"improved". The robust result is the room placement. (An intermediate +measurement taken with the codes renamed rather than the rule tightened gave 55 +on the same seed — the same picture.) + +**Historical numbers.** Every harbor-house fail count recorded before §39 was +measured against the 32-instance effective programme. They remain valid relative +to each other but are not comparable to post-§39.3 numbers; treat 58 as the new +harbor reference point at this budget. + +### 39.5 Fallout: `2g7.5`'s CP-SAT seeder win does not survive the correction + +`§37.7` recorded a real, low-noise seeder-level win for `assign_solver="cpsat"` +on harbor-house, guarded by +`test_assign_cpsat_matches_or_beats_greedy_secondary_adjacency`. That test now +fails, and the cause is the corrected programme, not the solver. Measured over +6 seeds: + +| programme | greedy | cpsat | | +|---|---|---|---| +| harbor, real 37-instance | 102 | 114 | cpsat loses | +| harbor, old 32-instance effective | 98 | 99 | tie — the "win" was already marginal | +| maple-court (namespace-clean, untouched by §39) | 156 | **144** | **cpsat wins** | + +maple-court is the control: CP-SAT's advantage is intact on a programme whose +codes never collided, so nothing about the assignment solver regressed. On +harbor the four restored codes — with real adjacency requirements that were +previously dropped — change the assignment problem enough to flip an aggregate +that was a tie to begin with. The harbor test is marked `xfail` with this +reason and a companion test asserts the maple-court result; both +`assign_solver` flags remain default off, as `§37.7` already concluded for +independent reasons. + +The general lesson: **any harbor-house A/B decided by a small margin before §39 +was decided against a programme missing 14% of its rooms** and is worth +re-checking if anything depends on it. -**Historical numbers.** Every harbor-house fail count recorded before this -section was measured against the 32-instance effective programme. They remain -valid relative to each other but are not comparable to post-`ju3` numbers; -treat §39.3's 55 as the new harbor reference point. diff --git a/examples/harbor-house-l0/generated.dom b/examples/harbor-house-l0/generated.dom index 54b0ef3..5759a2b 100644 --- a/examples/harbor-house-l0/generated.dom +++ b/examples/harbor-house-l0/generated.dom @@ -85,7 +85,7 @@ r: - 0.5292445435027436 - 0.5292445435027436 l: - type: gs1 + type: st1 rotation: 0 r: type: la1 diff --git a/examples/harbor-house-l0/patterns.config b/examples/harbor-house-l0/patterns.config index a8d7c50..c213cb0 100644 --- a/examples/harbor-house-l0/patterns.config +++ b/examples/harbor-house-l0/patterns.config @@ -10,7 +10,7 @@ # Purpose: isolate per-floor CONSTRUCTION difficulty from multi-storey COUPLING. spaces: - fr1: + cr1: name: Common Room with Fireplace size: - 80.0 @@ -102,7 +102,7 @@ spaces: adjacency: - c - gs1: + st1: name: Ground Floor Storage size: - 22.0 @@ -124,7 +124,7 @@ spaces: adjacency: - c - ao: + of: name: Staff Office size: - 12.5 diff --git a/examples/harbor-house/generated.dom b/examples/harbor-house/generated.dom index 0539615..3feb5b3 100644 --- a/examples/harbor-house/generated.dom +++ b/examples/harbor-house/generated.dom @@ -41,10 +41,10 @@ l: - 0.5042565018475718 - 0.5042565018475718 l: - type: gs2 + type: st2 rotation: 0 r: - type: gs1 + type: st1 rotation: 0 r: rotation: 1 @@ -84,7 +84,7 @@ l: type: ws1 rotation: 0 r: - type: fr1 + type: cr1 rotation: 0 r: type: O diff --git a/examples/harbor-house/patterns.config b/examples/harbor-house/patterns.config index f1fcb57..260dd75 100644 --- a/examples/harbor-house/patterns.config +++ b/examples/harbor-house/patterns.config @@ -6,7 +6,7 @@ # Building programme: community living spaces spaces: # GROUND FLOOR - Main Community Areas - fr1: + cr1: name: Common Room with Fireplace size: - 80.0 @@ -148,7 +148,7 @@ spaces: co_locate: - me1 - gs1: + st1: name: Ground Floor Storage size: - 22.0 @@ -175,7 +175,7 @@ spaces: - la1 # STAFF OFFICES - Anonymous (2 total) - ao: + of: name: Staff Office size: - 12.5 @@ -216,7 +216,7 @@ spaces: count: 10 # FIRST FLOOR - Storage - gs2: + st2: name: First Floor Storage size: - 18.0 diff --git a/experiments/migrate_ju3_rename.py b/experiments/migrate_ju3_rename.py deleted file mode 100644 index 028485b..0000000 --- a/experiments/migrate_ju3_rename.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Rename harbor-house's colliding room codes in existing `.dom` files. - -`homemaker-py-ju3` (DESIGN.md §39.2) renamed four harbor-house programme codes -that collided with Urb's reserved generic type prefixes (``c`` = circulation, -``o``/``s`` = outside). Any `.dom` produced **before** that change still carries -the old leaf types, and will score differently against the renamed programme — -the old types are no longer declared, so every affected leaf reads as an -unmatched generic instead of the room it was. - -This matters for artefacts not checked in at the time, notably the -``evolved-3M*.dom`` harbor-house runs. - -Mapping (chosen so each new prefix is unused in harbor-house *and* carries no -adjacency semantics of its own — ``l``/``k``/``b``/``t`` do, ``f``/``a``/``g`` -do not — while preserving the original prefix-sharing structure, i.e. the two -storage codes still share one prefix): - - cr1 -> fr1 Common Room with Fireplace - of -> ao Staff Office - st1 -> gs1 Ground Floor Storage - st2 -> gs2 First Floor Storage - -Usage:: - - python experiments/migrate_ju3_rename.py path/to/evolved-3M.dom [...] - python experiments/migrate_ju3_rename.py --check path/to/*.dom -""" - -from __future__ import annotations - -import argparse -import re -import sys -from pathlib import Path - -RENAMES = {"cr1": "fr1", "of": "ao", "st1": "gs1", "st2": "gs2"} - - -def migrate(path: Path, check_only: bool) -> int: - """Rewrite ``type:`` lines in place; return the number of leaves changed.""" - text = path.read_text() - total = 0 - for old, new in RENAMES.items(): - # Anchored to a whole `type: ` line so a code appearing inside a - # name, comment or unrelated scalar is never touched. - text, n = re.subn(rf"^(\s*type: ){re.escape(old)}\s*$", rf"\g<1>{new}", - text, flags=re.M) - total += n - if total and not check_only: - path.write_text(text) - return total - - -def main() -> int: - ap = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - ap.add_argument("paths", nargs="+", type=Path) - ap.add_argument("--check", action="store_true", - help="report what would change without writing") - args = ap.parse_args() - - stale = 0 - for path in args.paths: - if not path.is_file(): - print(f" {path}: NOT FOUND", file=sys.stderr) - continue - n = migrate(path, args.check) - stale += bool(n) - verb = "would rename" if args.check else "renamed" - print(f" {path}: {verb} {n} leaf type(s)" - if n else f" {path}: already current") - if args.check and stale: - print(f"\n{stale} file(s) still carry pre-ju3 codes.") - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/homemaker_layout/bubble.py b/src/homemaker_layout/bubble.py index 7803330..ef34045 100644 --- a/src/homemaker_layout/bubble.py +++ b/src/homemaker_layout/bubble.py @@ -73,7 +73,7 @@ def requirement_graph(reqs: dict[str, SpaceReq]) -> nx.Graph: instances: dict[str, list[str]] = {} for code, req in reqs.items(): - if code[0].lower() in ("c", "o", "s"): + if dom.is_generic(code): continue ids = [] for i in range(req.count): @@ -83,7 +83,7 @@ def requirement_graph(reqs: dict[str, SpaceReq]) -> nx.Graph: instances[code] = ids for code, req in reqs.items(): - if code[0].lower() in ("c", "o", "s") or code not in instances: + if dom.is_generic(code) or code not in instances: continue for node_id in instances[code]: for adj_code in req.adjacency: @@ -236,7 +236,7 @@ def matched_leaves(root: Node, reqs: dict[str, SpaceReq]) -> dict[str, Node]: by_code: dict[str, list[Node]] = {} for lvl in levels(root): for leaf in lvl.leaves(): - if not leaf.type or leaf.type[0].lower() in ("c", "o", "s"): + if not leaf.type or dom.is_generic(leaf.type): continue by_code.setdefault(leaf.type, []).append(leaf) @@ -245,7 +245,7 @@ def matched_leaves(root: Node, reqs: dict[str, SpaceReq]) -> dict[str, Node]: result: dict[str, Node] = {} for code, req in reqs.items(): - if code[0].lower() in ("c", "o", "s"): + if dom.is_generic(code): continue leaves = by_code.get(code, []) for i in range(min(req.count, len(leaves))): diff --git a/src/homemaker_layout/dom.py b/src/homemaker_layout/dom.py index d2b8cc3..0e395ef 100644 --- a/src/homemaker_layout/dom.py +++ b/src/homemaker_layout/dom.py @@ -257,9 +257,41 @@ def dump(root: Node, path: str) -> None: # Structural predicates (mirrors Urb::Dom::Is_Outside etc.) # --------------------------------------------------------------------------- # +# homemaker-py-ju3 / DESIGN.md §39.4 — generic structural types vs programme codes. +# +# Urb has exactly three GENERIC structural types (``get_space_types``: +# ``qw/C O S/``) — C circulation, O outside, S sahn (an outside court that also +# serves as circulation, hence a member of both sets). These are the leaves the +# SEARCH creates; they are canonically UPPERCASE and the whole corpus obeys that +# (measured: 154 ``C``, 110 ``O``, 1 ``S``, and not one lowercase generic). +# +# Programme room codes are a different namespace and are canonically lowercase +# (``k1``, ``b1``, ``cr1``, ``of``, and single-character ones like ``r``, ``t``, +# ``m``, ``n``). CASE is therefore the discriminator, not length. +# +# The predicates below used to test ``type[0].lower() in (...)`` — a +# case-insensitive PREFIX match — which silently swept up any programme code +# beginning with c/o/s: ``cr1`` "Common Room with Fireplace" was classified as +# circulation, ``of`` "Staff Office" as outside space. Matching the generic set +# EXACTLY keeps Urb's semantics for the leaves it was written for while leaving +# programme codes alone, so a room may be called anything at all. +# +# NB this is deliberately NOT the same rule as the SEMANTIC prefixes (l/k/b/t): +# those classify PROGRAMME CODES by first letter (``k1`` is a kitchen) and must +# stay prefix-based. Only the generic-type tests are tightened. +GENERIC_CIRCULATION = ("C", "S") +GENERIC_OUTSIDE = ("O", "S") +GENERIC_TYPES = ("C", "O", "S") + + +def is_generic(type_: "str | None") -> bool: + """True for Urb's generic structural types (exactly ``C``/``O``/``S``).""" + return type_ in GENERIC_TYPES + + def is_outside(n: Node) -> bool: - """True if n's type starts with 'o' or 's' (case-insensitive).""" - return n.type is not None and n.type[0].lower() in ("o", "s") + """True if n carries a generic outside type (``O``/``S``).""" + return n.type in GENERIC_OUTSIDE def _level_root(n: Node) -> Node: @@ -351,10 +383,15 @@ def is_usable(n: Node) -> bool: def is_circulation(n: Node) -> bool: - """Usable and type 'c'/'s'; mirrors ``Urb::Dom::Is_Circulation``.""" + """Usable and carrying a generic circulation type (``C``/``S``). + + Mirrors ``Urb::Dom::Is_Circulation`` for the leaves it was written for; see + the GENERIC_* note above for why this matches the generic set exactly rather + than by first-character prefix. + """ if not is_usable(n): return False - return n.type is not None and n.type[0].lower() in ("c", "s") + return n.type in GENERIC_CIRCULATION # --------------------------------------------------------------------------- # @@ -379,12 +416,12 @@ def _merge_node(n: Node) -> None: return lt = n.left.type or "" rt = n.right.type or "" - l_os = bool(lt) and lt[0].lower() in ("o", "s") - r_os = bool(rt) and rt[0].lower() in ("o", "s") + l_os = lt in GENERIC_OUTSIDE + r_os = rt in GENERIC_OUTSIDE if not (l_os and r_os): return - l_o = bool(lt) and lt[0].lower() == "o" - r_o = bool(rt) and rt[0].lower() == "o" + l_o = lt == "O" + r_o = rt == "O" if is_supported(n) and l_o and r_o: _undivide(n, "O") elif is_supported(n): diff --git a/src/homemaker_layout/driver.py b/src/homemaker_layout/driver.py index da491a8..1105b87 100644 --- a/src/homemaker_layout/driver.py +++ b/src/homemaker_layout/driver.py @@ -611,7 +611,7 @@ def search( # A fresh seed individual (used for the initial bootstrap and for §11.5 # restart injections). Mirrors the construction order: custom seed_factory > # programme-aware construction > random divide-grown topology. - prog = {c: r for c, r in reqs.items() if c[0].lower() not in "cos"} + prog = {c: r for c, r in reqs.items() if not dom.is_generic(c)} n_target = bootstrap_n_leaves or max(len(reqs), 3) def _make_seed_task(tag: str) -> tuple: diff --git a/src/homemaker_layout/fitness.py b/src/homemaker_layout/fitness.py index cdf8c97..113705c 100644 --- a/src/homemaker_layout/fitness.py +++ b/src/homemaker_layout/fitness.py @@ -290,6 +290,24 @@ class StoreyEval: leaves: list[LeafEval] = field(default_factory=list) +def _generic_class(n: Node) -> str: + """Generic-type class of a leaf: ``"c"`` circulation, ``"o"`` outside, or + ``""`` 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) + belongs to both generic sets but takes the outside parameter families, so it + maps to ``"o"`` — exactly as ``get_space_params`` dispatches it. + """ + if n.type in dom_mod.GENERIC_OUTSIDE: + return "o" + if n.type == "C": + return "c" + 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 "" @@ -756,7 +774,7 @@ class Fitness: prog = self._programme or {} if not prog: return - room_codes = {c for c in prog if c[0].lower() not in ("c", "o", "s")} + room_codes = {c for c in prog if not dom_mod.is_generic(c)} if not room_codes: return lvls = dom_mod.levels(root) @@ -866,8 +884,7 @@ class Fitness: for lvl in lvls: for lf in lvl.leaves(): if ( - lf.type - and lf.type[0].lower() == "c" + lf.type == "C" and self._public_access(lf, root) is not None ): return set() @@ -880,7 +897,7 @@ class Fitness: if not self._public_access_outside(lf, G, root): continue nbs = list(G.neighbors(lf)) - if any(nb.type and nb.type[0].lower() == "c" for nb in nbs): + if any(nb.type == "C" for nb in nbs): continue # circulation neighbour keeps access invariant for nb in nbs: if nb.type in room_codes and nb.type[0].lower() in ("l", "k"): @@ -942,7 +959,7 @@ class Fitness: return for lvl in dom_mod.levels(root): for leaf in lvl.leaves(): - if _t0(leaf) == "s": + if leaf.type == "S": leaf.type = "O" # ------------------------------------------------------------------ # @@ -950,12 +967,18 @@ class Fitness: # ------------------------------------------------------------------ # def get_space_params(self, code: str, param: str) -> list[float]: - c0 = code[0].lower() if code else "" - if c0 == "c": + # §39.4: only the GENERIC types take the circulation/outside parameter + # families. A programme code is looked up in ``spaces`` regardless of + # what letter it happens to start with. + # NB S (sahn) is a member of BOTH generic sets, and the parameter + # families split it the outside way: the circulation branch is exactly + # C, and S takes the *_outside params (preserved from the original + # c0 == "c" / c0 in ("o", "s") dispatch). + if code == "C": v = self.conf(f"{param}_circulation") if v is not None: return v - if c0 in ("o", "s"): + if code in dom_mod.GENERIC_OUTSIDE: v = self.conf(f"{param}_outside") if v is not None: return v @@ -991,7 +1014,7 @@ class Fitness: return score def quality_proportion(self, leaf: Node) -> float: - t0 = _t0(leaf) + t0 = _generic_class(leaf) if t0 in ("o", "s"): params = self.conf("proportion_outside") elif t0 == "c": @@ -1014,7 +1037,7 @@ class Fitness: return _clipped_gaussian(aspect, params[0], params[1], "below") def quality_size(self, leaf: Node) -> float: - t0 = _t0(leaf) + t0 = _generic_class(leaf) if t0 in ("o", "s"): return 1.0 if t0 == "c": @@ -1047,7 +1070,7 @@ class Fitness: return gaussian(geometry.area(leaf), 1.0, target, sigma) def quality_width(self, leaf: Node) -> float: - t0 = _t0(leaf) + t0 = _generic_class(leaf) if ( t0 in ("o", "s") and not dom_mod.is_covered(leaf) @@ -1147,10 +1170,13 @@ class Fitness: """Useful circulation/access neighbour types; ``Urb::Dom::Access``.""" types = self.neighbour_types(leaf, G) if _t0(leaf) == "k": - return [t for t in types if t and t[0].lower() in ("l", "c", "s")] + # "l" is the SEMANTIC living-room prefix (a programme code); C/S are + # generic circulation. Two different namespaces, two different tests. + return [t for t in types + if t in dom_mod.GENERIC_CIRCULATION or t[:1].lower() == "l"] if dom_mod.is_outside(leaf) or dom_mod.is_circulation(leaf): return types - return [t for t in types if t and t[0].lower() in ("c", "s")] + return [t for t in types if t in dom_mod.GENERIC_CIRCULATION] # ------------------------------------------------------------------ # # Leaf evaluation (Leaf.pm::evaluate_leaf) @@ -1217,7 +1243,7 @@ class Fitness: # ------------------------------------------------------------------ # def value_rate(self, leaf: Node) -> float: - t0 = _t0(leaf) + t0 = _generic_class(leaf) if t0 in ("o", "s") and dom_mod.level_of(leaf) == 0: return self.conf("value_outside") if t0 in ("o", "s"): @@ -1406,13 +1432,23 @@ class Fitness: def ratio_o(self, ratios: dict[str, float]) -> float: """Outside/sahn proportion gaussian; mirrors ``ProgrammeDriven::ratio_o``.""" - proportion_o = sum(v for k, v in ratios.items() if k and k[0].lower() in ("o", "s")) + proportion_o = sum(v for k, v in ratios.items() if k in dom_mod.GENERIC_OUTSIDE) return gaussian(proportion_o, 1.0, *self.conf("ratio_outside")) def ratio_type(self, ratios: dict[str, float], code: str, ratio: float, sigma: float) -> float: """Type-class proportion gaussian; mirrors ``ProgrammeDriven::ratio_type``.""" - proportion_type = sum(v for k, v in ratios.items() if k and k[0].lower() == code[0].lower()) - proportion_non_o = 1.0 - sum(v for k, v in ratios.items() if k and k[0].lower() in ("o", "s")) + # §39.4: a generic code ("c" — the only caller) sums the GENERIC + # circulation types, not every type whose name starts with a "c". Under + # the old prefix rule a programme code like cr1 "Common Room" counted + # its whole floor area toward the building's circulation ratio. + if dom_mod.is_generic(code.upper()): + match = frozenset(dom_mod.GENERIC_CIRCULATION if code.lower() == "c" + else (code.upper(),)) + proportion_type = sum(v for k, v in ratios.items() if k in match) + else: + proportion_type = sum( + v for k, v in ratios.items() if k and k[0].lower() == code[0].lower()) + proportion_non_o = 1.0 - sum(v for k, v in ratios.items() if k in dom_mod.GENERIC_OUTSIDE) if proportion_non_o <= 0.0: proportion_non_o = 1.0 return gaussian(proportion_type / proportion_non_o, 1.0, ratio, sigma) @@ -1489,7 +1525,7 @@ class Fitness: for other in level_root.leaves(): if other is stair_leaf: continue - if not other.type or other.type[0].lower() != "c": + if other.type != "C": continue other_corners = graph_mod.stack_corners_in_use(other, graph_circ, all_lvls) if dom_mod.is_covered(other) and other_corners: @@ -1497,14 +1533,14 @@ class Fitness: if self._public_access(other, root) is not None: return None for nb in G.neighbors(other): - if nb.type and nb.type[0].lower() == "o" and self._public_access(nb, root) is not None: + if nb.type == "O" and self._public_access(nb, root) is not None: return None # If the stair itself has via-outdoor access (Entrances priority 3.5), Perl's # Entrances maps it to a leaf id, not a boundary id. Boundary_Id(edge) eq # leaf_id never matches → no entrance corners added. Return None here so # Python matches that behaviour. for nb in G.neighbors(stair_leaf): - if nb.type and nb.type[0].lower() == "o" and self._public_access(nb, root) is not None: + if nb.type == "O" and self._public_access(nb, root) is not None: return None return stair_bid @@ -1611,7 +1647,7 @@ class Fitness: if graph_circ is not None and tracking is not None and lvls is not None and root is not None: # Stair fit — ground floor circulation/covered only stair_fit = 0.0 - if level_id == 0 and leaf.type and leaf.type[0].lower() == "c" and dom_mod.is_covered(leaf): + if level_id == 0 and leaf.type == "C" and dom_mod.is_covered(leaf): all_lvls = lvls corners = graph_mod.stack_corners_in_use(leaf, graph_circ, all_lvls) n_corners = len(corners) @@ -1635,7 +1671,7 @@ class Fitness: if self._public_access_outside(leaf, G, root): tracking["has_public_access_outside"] = True if (not stair_fit - and leaf.type and leaf.type[0].lower() == "c" + and leaf.type == "C" and self._public_access(leaf, root) is not None): tracking["has_public_access_inside"] = True @@ -1683,7 +1719,7 @@ class Fitness: min_required = 0.0 for req in (self._programme or {}).values(): - if req.code and req.code[0].lower() in ("c", "o", "s"): + if dom_mod.is_generic(req.code): continue if req.size > 0: min_required += req.size * req.count diff --git a/src/homemaker_layout/graph.py b/src/homemaker_layout/graph.py index 687c7b1..e3dce17 100644 --- a/src/homemaker_layout/graph.py +++ b/src/homemaker_layout/graph.py @@ -25,7 +25,7 @@ from __future__ import annotations import networkx as nx from . import dom, geometry -from .dom import Node, levels +from .dom import Node, is_generic, levels from .programme import SpaceReq DOOR_WIDTH = 1.2 # Urb::Dom::Fitness::Base default_params door_width @@ -126,7 +126,8 @@ def has_circulation(G: nx.Graph) -> bool: continue to_remove = [ nb for nb in list(G.neighbors(v)) - if nb.type and nb.type[0].lower() in ("o", "l", "k", "t") + if (nb.type in dom.GENERIC_OUTSIDE + or (nb.type and nb.type[0].lower() in ("l", "k", "t"))) ] G.remove_edges_from((v, nb) for nb in to_remove) @@ -154,7 +155,10 @@ def has_circulation(G: nx.Graph) -> bool: # blkc nodes: keep only one outdoor neighbour per outdoor component for v in list(G.nodes()): - if not (v.type and v.type[0].lower() in ("b", "l", "k", "c")): + # 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")) + or dom.is_circulation(v)): continue out_nbs = [ nb for nb in list(G.neighbors(v)) @@ -364,7 +368,7 @@ def stack_corners_in_use( Returns [] if the stack does not span all levels above leaf, or if any level's node is not circulation type. """ - if not (leaf.type and leaf.type[0].lower() == "c"): + if leaf.type != "C": return [] stack = [leaf] + _stack_levels_above(leaf) @@ -376,7 +380,7 @@ def stack_corners_in_use( return [] # All stack nodes must be circulation - if not all(n.type and n.type[0].lower() == "c" for n in stack): + if not all(n.type == "C" for n in stack): return [] leaf_rot = _ground_rotation(leaf) @@ -409,10 +413,39 @@ def _level_index(n: Node, lvls: list[Node]) -> int: # Adjacency helpers # --------------------------------------------------------------------------- # -def _codes_match_prefix(codes: list[str], tc: str) -> bool: +def _codes_match_prefix(codes: list[str], tc) -> bool: + """Match a neighbour's codes against an adjacency target. + + ``tc`` is either a lowercase prefix string (ordinary programme requirement, + Perl's ``^target_code`` semantics — a requirement ``t`` matches ``t1``, + ``t2``, …) or, for a GENERIC requirement, the exact set of generic types it + names (see :func:`_adjacency_target`). + """ + if isinstance(tc, frozenset): + return any(c in tc for c in codes) return any(c.lower().startswith(tc) for c in codes) +def _adjacency_target(target_code: str): + """Resolve one ``adjacency:`` entry to a matcher. + + §39.4: programmes name the generic types in lowercase (``adjacency: [c, o]`` + — every corpus programme does this), and a case-insensitive PREFIX match + then let any programme code beginning with that letter satisfy the + requirement: a room next to ``cr1`` "Common Room" counted as being next to + circulation. A generic requirement now matches only the generic types it + names; every other requirement keeps Perl's prefix semantics. + """ + tc = target_code.lower() + if tc == "c": + return frozenset(dom.GENERIC_CIRCULATION) + if tc == "o": + return frozenset(("O",)) + if tc == "s": + return frozenset(("S",)) + return tc + + def has_adjacency(leaf: Node, target_code: str, G: nx.Graph, colocate_pairs=(), multi_use: bool = False) -> bool: """True if ``leaf`` (or its nearest graphed ancestor) has a neighbour whose @@ -429,7 +462,7 @@ def has_adjacency(leaf: Node, target_code: str, G: nx.Graph, node = node.parent if node is None: return False - tc = target_code.lower() + tc = _adjacency_target(target_code) for nb in G.neighbors(node): if _codes_match_prefix(leaf_codes(nb, colocate_pairs, multi_use), tc): return True @@ -453,7 +486,7 @@ def has_vertical_connection(leaf: Node, target_code: str, lvls: list[Node], if li == 0: return False below_root = lvls[li - 1] - tc = target_code.lower() + tc = _adjacency_target(target_code) return any(_codes_match_prefix(leaf_codes(bl, colocate_pairs, multi_use), tc) for bl in below_root.leaves()) @@ -527,7 +560,12 @@ def check_space_counts( missing: list[str] = [] for code, req in targets.items(): - if code[0].lower() in ("c", "o", "s"): + # §39.4: skip only Urb's GENERIC structural types. This used to test + # code[0].lower(), which silently dropped any programme code beginning + # with c/o/s from the required set -- 14% of harbor-house. Generic types + # are never declared in ``spaces`` anyway, so this is now a no-op guard + # kept for intent rather than a filter that discards real requirements. + if is_generic(code): continue leaves_of = count.get(code, []) @@ -722,7 +760,7 @@ def substrate_readiness( core_leaves = [ lf for lf in base_leaves - if lf.type and lf.type[0].lower() == "c" and geometry.area(lf) >= STAIR_MIN_AREA + if lf.type == "C" and geometry.area(lf) >= STAIR_MIN_AREA ] core_factor = 1.0 if core_leaves else 0.25 core_area = max((geometry.area(lf) for lf in core_leaves), default=0.0) diff --git a/src/homemaker_layout/operators.py b/src/homemaker_layout/operators.py index 543694d..ac6ebb6 100644 --- a/src/homemaker_layout/operators.py +++ b/src/homemaker_layout/operators.py @@ -97,9 +97,9 @@ def mutate_undivide(root: dom.Node, rng: np.random.Generator, if not cands: return _finalise(child), "undivide noop" li, n = _pick_weighted_by_storey(rng, cands, base_p) - # generic classes (circulation/outside/sahn) match case-insensitively, - # cf. Urb Is_Circulation/Is_Outside - keep = [t for t in (n.left.type, n.right.type) if t and t[0].lower() not in "cos"] + # prefer a PROGRAMME room type over a generic (circulation/outside/sahn) + # one when collapsing two children into one leaf (§39.4) + keep = [t for t in (n.left.type, n.right.type) if t and not dom.is_generic(t)] n.type = keep[0] if keep else (n.left.type or str(_pick(rng, types))) n.division = None n.left = n.right = None @@ -295,7 +295,7 @@ def mutate_level_compound_fix(root: dom.Node, rng: np.random.Generator, def _programme_codes(reqs) -> dict: """Required programme spaces only (drop generic circulation/outside/sahn).""" - return {c: r for c, r in reqs.items() if c[0].lower() not in "cos"} + return {c: r for c, r in reqs.items() if not dom.is_generic(c)} def mutate_place_missing(root: dom.Node, rng: np.random.Generator, @@ -407,7 +407,7 @@ def mutate_bridge_circulation(root: dom.Node, rng: np.random.Generator, return 0 if not n.type: return 1 - if n.type[0].lower() == "o": + if n.type in dom.GENERIC_OUTSIDE: return 0 if reqs and n.type in reqs: return 5 @@ -543,7 +543,7 @@ def mutate_deslim(root: dom.Node, rng: np.random.Generator, survivor = n.left else: survivor = max((n.left, n.right), key=_geo.area) - n.type = survivor.type if survivor.type and survivor.type[0].lower() not in "cos" else "C" + n.type = survivor.type if not dom.is_generic(survivor.type) and survivor.type else "C" n.division = None n.left = n.right = None return _finalise(child), f"deslim {li}/{n.id or 'root'} (kept {n.type})" @@ -1360,7 +1360,7 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int] base.above = None # start from the single-storey base only base_cs = [lf for lf in base.leaves() - if lf.type and lf.type[0].lower() == "c"] + if lf.type == "C"] core_path = max(base_cs, key=_geo.area).id if base_cs else None colocate_pairs = _prog.derive_colocate_pairs(reqs) if multi_use and reqs else [] @@ -1509,7 +1509,7 @@ def mutate_ruin_recreate(root: dom.Node, rng: np.random.Generator, wing_leaves = set(wing.leaves()) border_circ = sorted( {nb for lf in wing_leaves for nb in G.neighbors(lf) - if nb not in wing_leaves and nb.type and nb.type[0].lower() == "c"}, + if nb not in wing_leaves and nb.type == "C"}, key=lambda n: n.id or "") rooms = [lf.type for lf in wing.leaves() if lf.type in reqs] @@ -1737,7 +1737,7 @@ def mutate_core_undivide(root: dom.Node, rng: np.random.Generator, if node is None or not node.divided: continue keep = [t for t in (node.left.type, node.right.type) - if t and t[0].lower() not in "cos"] + if t and not dom.is_generic(t)] node.type = keep[0] if keep else (node.left.type or str(_pick(rng, types))) node.division = None node.left = node.right = None diff --git a/src/homemaker_layout/programme.py b/src/homemaker_layout/programme.py index d209d2e..ac1f36c 100644 --- a/src/homemaker_layout/programme.py +++ b/src/homemaker_layout/programme.py @@ -1,7 +1,8 @@ """Parse a ``patterns.config`` programme into per-code space requirements. -Only the ``spaces:`` section is read here. Generic codes (c/o/s) carry no -explicit targets and are left unconstrained by the solver (they absorb slack). +Only the ``spaces:`` section is read here. The generic structural types +(``C``/``O``/``S``) carry no explicit targets and are left unconstrained by the +solver (they absorb slack); they are never declared in ``spaces:``. """ from __future__ import annotations @@ -10,6 +11,8 @@ from dataclasses import dataclass, field import yaml +from .dom import GENERIC_TYPES, is_generic + # Urb::Dom::Fitness defaults for optional params (ProgrammeDriven.default_params). _DEFAULT_WIDTH = (4.0, 1.0) _DEFAULT_PROPORTION = (1.5, 0.5) @@ -59,44 +62,39 @@ def _pair(d: dict, key: str, default: tuple[float, float]) -> tuple[float, float return float(v[0]), float(v[1]) -# homemaker-py-ju3 (DESIGN.md §39.2): Urb's type system is prefix-based — a -# leaf type starting with "c" is circulation, "o"/"s" is outside — and -# programme room codes share that namespace. A code starting with one of these -# letters is silently reinterpreted as a generic type, with three unannounced -# consequences: ``graph.check_space_counts`` skips it entirely (so the room is -# never required), ``Fitness.get_space_params`` returns the generic -# ``*_circulation``/``*_outside`` parameters instead of the declared ones, and -# ``dom.is_circulation``/``is_outside`` flip (changing value rate, crinkliness -# treatment, and whether the leaf supplies daylight to neighbours). +# homemaker-py-ju3 (DESIGN.md §39.4): Urb has exactly three GENERIC structural +# types — ``C`` circulation, ``O`` outside, ``S`` sahn — which are the leaves the +# SEARCH creates, canonically uppercase. Programme room codes are a separate, +# lowercase namespace. # -# harbor-house shipped four such codes for years — ``cr1`` "Common Room with -# Fireplace" had its declared 80 m² read as circulation's 0-30 m², and 14% of -# the programme was silently optional. Refusing the code at load turns a silent -# misread into a loud one. The other prefixes with semantics (l/k/b/t) are NOT -# reserved: they only flavour adjacency heuristics and never discard a -# requirement, so programme codes may use them freely. -RESERVED_PREFIXES = ("c", "o", "s") +# These used to overlap: every generic test matched ``type[0].lower()``, a +# case-insensitive PREFIX, so a programme code beginning with c/o/s was silently +# reinterpreted as a generic type (harbor-house's ``cr1`` "Common Room with +# Fireplace" was read as circulation, and 14% of the programme became optional). +# That is fixed at the source — see the GENERIC_* note in ``dom.py`` — so a room +# code may now start with ANY letter. The only remaining collision is a code +# spelled EXACTLY like a generic type, which is a genuine ambiguity no matching +# rule can resolve. +RESERVED_CODES = ("C", "O", "S") def validate_codes(codes) -> None: - """Raise ``ValueError`` if any programme code collides with a generic type. + """Raise ``ValueError`` if a programme code IS a generic structural type. - Called from both parse paths (``_parse_spaces`` and - ``fitness.Fitness._load_programme``) so a colliding code cannot enter the - system through either door. + Only exact matches are rejected. Codes merely *starting* with c/o/s are fine + (§39.4) — that used to be the bug, not the rule. Called from both parse paths + (``_parse_spaces`` and ``fitness.Fitness._load_programme``), which read + ``conf["spaces"]`` independently. """ - bad = sorted(c for c in codes if c and c[0].lower() in RESERVED_PREFIXES) + bad = sorted(c for c in codes if c in RESERVED_CODES) if not bad: return raise ValueError( - "programme code(s) collide with Urb's reserved generic type prefixes " - f"{RESERVED_PREFIXES} (c=circulation, o/s=outside): {bad}. " - "Such a code is silently treated as a generic type: it is dropped from " - "the required-space check, its declared size/width/proportion are " - "replaced by the generic circulation/outside parameters, and it is " - "valued at the circulation/outside rate. Rename the code to start with " - "another letter (the name: field is free text and need not change). " - "See DESIGN.md §39.2 / homemaker-py-ju3." + f"programme code(s) {bad} are Urb's generic structural types " + f"{RESERVED_CODES} (C=circulation, O=outside, S=sahn). These name the " + "leaves the search itself creates, so a programme room cannot also be " + "called one. Rename the room code (the name: field is free text and " + "need not change). See DESIGN.md §39.4 / homemaker-py-ju3." ) @@ -172,7 +170,7 @@ def interchangeable(a: SpaceReq, b: SpaceReq) -> bool: # S1 — both sized; generic circulation/outside never participate. if not (a.has_size and b.has_size) or a.size <= 0 or b.size <= 0: return False - if a.code[0].lower() in ("c", "o", "s") or b.code[0].lower() in ("c", "o", "s"): + if is_generic(a.code) or is_generic(b.code): return False # S2 — requirement similarity within bounded ratios (ALL three). if _ratio(a.size, b.size) > R_SIZE: @@ -295,7 +293,7 @@ def partition_rooms_by_storey( free: list[str] = [] for code, req in reqs.items(): - if code[0].lower() in ("c", "o", "s"): + if is_generic(code): continue for _ in range(req.count): if req.level is not None and req.level < n_storeys: @@ -343,7 +341,9 @@ def write_stage1_programme( adj = spec.get("adjacency") if adj is not None: spec["adjacency"] = [ - r for r in adj if r in keep or r[0].lower() in ("c", "o", "s") + # generic adjacency references (lowercase "c"/"o"/"s" in + # patterns.config) always survive the stage-1 filter (§39.4) + r for r in adj if r in keep or r.upper() in GENERIC_TYPES ] new_spaces[code] = spec diff --git a/src/homemaker_layout/shapecurve.py b/src/homemaker_layout/shapecurve.py index 76ecd6d..c1b5e61 100644 --- a/src/homemaker_layout/shapecurve.py +++ b/src/homemaker_layout/shapecurve.py @@ -155,7 +155,12 @@ def leaf_constraints(fit, leaf: dom_mod.Node) -> LeafBounds: hard bounds instead of evaluating a Gaussian against actual geometry. Ignores leaf-sharing/co_type target adjustment (see module docstring). """ - t0 = leaf.type[0].lower() if leaf.type else "" + # §39.4: classify by the GENERIC type set, mirroring get_space_params -- + # a programme code takes its declared params whatever letter it starts with. + # S is in both generic sets but takes the outside params, exactly as + # get_space_params does -- test outside FIRST so S lands there. + t0 = ("o" if leaf.type in dom_mod.GENERIC_OUTSIDE + else "c" if leaf.type == "C" else "") # --- size -> (amin, amax) --- if t0 in ("o", "s"): diff --git a/tests/test_compose.py b/tests/test_compose.py index 471fb88..37160eb 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -38,7 +38,7 @@ GOOD_SVG = textwrap.dedent( - fr1 + cr1 k1 b1 @@ -55,7 +55,7 @@ SLOPPY_SVG = textwrap.dedent( - fr1 + cr1 k1 b1 @@ -92,14 +92,14 @@ def test_composes_synthetic_partition_and_scores(tmp_path): root = compose(boundary, storeys) leaves = root.leaves() - assert sorted(leaf.type for leaf in leaves) == ["b1", "fr1", "k1"] + assert sorted(leaf.type for leaf in leaves) == ["b1", "cr1", "k1"] # round-trips through the .dom text format out_path = tmp_path / "plan.dom" dom.dump(root, str(out_path)) reloaded = dom.load(str(out_path)) reloaded_types = sorted(leaf.type for leaf in reloaded.leaves()) - assert reloaded_types == ["b1", "fr1", "k1"] + assert reloaded_types == ["b1", "cr1", "k1"] # geometry is sane: leaf areas sum to the (wall-inset) plot area total = sum(geometry.area(leaf) for leaf in reloaded.leaves()) @@ -123,7 +123,7 @@ def test_snaps_sloppy_hand_traced_lines(tmp_path): storeys = parse_svg(str(svg_path)) root = compose(boundary, storeys, tol=0.15) - assert sorted(leaf.type for leaf in root.leaves()) == ["b1", "fr1", "k1"] + assert sorted(leaf.type for leaf in root.leaves()) == ["b1", "cr1", "k1"] # a tighter tolerance than the sketch's slop should fail to find the cuts boundary2 = dom.load(str(boundary_path)) diff --git a/tests/test_operators.py b/tests/test_operators.py index 7f3c2a3..7dc2a4c 100644 --- a/tests/test_operators.py +++ b/tests/test_operators.py @@ -513,6 +513,16 @@ def test_construction_assign_cpsat_yields_valid_seed(): canonical(root) +@pytest.mark.xfail( + reason="§39.5: this win was measured against harbor's pre-§39.4 EFFECTIVE " + "programme, which silently dropped cr1/of/st1/st2 (14% of the rooms). " + "With those restored the aggregate flips (measured greedy 102 / cpsat " + "114 over 6 seeds; it was 98/99 -- a tie -- on the 32-instance " + "programme). Not a regression in the solver: on namespace-clean " + "maple-court cpsat still wins, which the companion test asserts. " + "Both assign_solver flags remain default off (§37.7).", + strict=False, +) @pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available") def test_assign_cpsat_matches_or_beats_greedy_secondary_adjacency(): # homemaker-py-2g7.5: CP-SAT solves the same room-labelling decision the @@ -894,3 +904,37 @@ def test_mutate_bridge_circulation_prefers_free_leaf_over_required_room(): lvl0 = dom.levels(child)[0] assert lvl0.by_id("lr").type == "C" assert lvl0.by_id("rl").type == "b1" + + +@pytest.mark.skipif(not (HARBOR.parent / "maple-court").is_dir(), + reason="maple-court not available") +def test_assign_cpsat_beats_greedy_on_a_namespace_clean_programme(): + """§39.5 companion: CP-SAT's seeder-level advantage is intact on a + programme whose codes never collided with the generic type prefixes. + + This is what shows the §39.4 tightening did not regress the solver — the + harbor result above moved because harbor's programme changed (4 codes it had + been silently dropping came back), not because assignment got worse. + """ + import copy + + from homemaker_layout import fitness, programme + + maple = HARBOR.parent / "maple-court" + reqs = programme.load_programme_dir(str(maple)) + conf, cost = fitness.load_config(str(maple)) + fit = fitness.Fitness(conf, cost) + types = sorted(reqs) + ["C", "O"] + seed = dom.load(str(maple / "init.dom")) + + def secondary_fails(solver: str) -> int: + total = 0 + for trial in range(6): + root = operators.constructive_topology( + seed, reqs, np.random.default_rng(trial), types, + assign_solver=solver) + _, fails = fit.score_with_fails(copy.deepcopy(root)) + total += sum(1 for f in fails if "not adjacent to" in f) + return total + + assert secondary_fails("cpsat") < secondary_fails("greedy") diff --git a/tests/test_programme.py b/tests/test_programme.py index 0e6251a..04b8ad6 100644 --- a/tests/test_programme.py +++ b/tests/test_programme.py @@ -4,34 +4,54 @@ from pathlib import Path import pytest -from homemaker_layout import fitness, programme +from homemaker_layout import dom, fitness, programme # --------------------------------------------------------------------------- # # homemaker-py-ju3 / DESIGN.md §39.2 — reserved generic type prefixes # --------------------------------------------------------------------------- # -def test_validate_codes_accepts_non_reserved_prefixes(): - programme.validate_codes(["b1", "k1", "t3", "la1", "me1", "n", "fr1", "ao"]) +def test_validate_codes_accepts_codes_that_merely_start_with_c_o_s(): + """§39.4: the generic-type tests match C/O/S EXACTLY, so a programme code + may start with any letter. This used to raise — that was the bug, not the + rule.""" + programme.validate_codes(["cr1", "of", "st1", "st2", "b1", "k1", "la1"]) -@pytest.mark.parametrize("code", ["cr1", "of", "st1", "C", "O", "s2"]) -def test_validate_codes_rejects_reserved_prefixes(code): - """A colliding code must fail LOUDLY: silently reinterpreting it as a - generic type is the whole bug (§39.2).""" - with pytest.raises(ValueError, match="reserved generic type prefixes"): +@pytest.mark.parametrize("code", ["C", "O", "S"]) +def test_validate_codes_rejects_exact_generic_types(code): + """A code spelled exactly like a generic structural type is a genuine + ambiguity no matching rule can resolve, so it still fails loudly.""" + with pytest.raises(ValueError, match="generic structural types"): programme.validate_codes([code]) -def test_reserved_prefix_rejected_by_both_parse_paths(): +def test_exact_generic_rejected_by_both_parse_paths(): """programme._parse_spaces and fitness.Fitness._load_programme parse conf["spaces"] independently — validating only one would leave the other door open.""" - conf = {"spaces": {"cr1": {"size": [80.0, 10.0]}}} - with pytest.raises(ValueError, match="reserved generic type prefixes"): + conf = {"spaces": {"C": {"size": [80.0, 10.0]}}} + with pytest.raises(ValueError, match="generic structural types"): programme._parse_spaces(conf) - with pytest.raises(ValueError, match="reserved generic type prefixes"): + with pytest.raises(ValueError, match="generic structural types"): fitness.Fitness(conf=conf) +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}}} + 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] + assert not dom.is_generic("cr1") + assert not dom.is_circulation(dom.Node(type="cr1")) + assert not dom.is_outside(dom.Node(type="of")) + # ...while the genuine generics still classify as before + assert dom.is_circulation(dom.Node(type="C")) + assert dom.is_outside(dom.Node(type="O")) + 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.""" @@ -48,3 +68,61 @@ def test_corpus_programmes_are_namespace_clean(): for d in sorted(Path("examples").iterdir()): if (d / "patterns.config").is_file(): programme.load_programme_dir(str(d)) + + +def test_scoring_is_invariant_under_programme_code_spelling(tmp_path): + """§39.4's headline invariant: renaming a programme code must not change + what a layout scores. + + Builds one layout from harbor-house (whose codes ``cr1``/``of``/``st1``/ + ``st2`` all begin with a reserved generic letter), then relabels that exact + tree AND its config together and re-scores. Same geometry, same topology, + only the spelling differs — so any difference is the generic-type rule + leaking into the programme namespace, which is the bug this guards. + """ + import copy + import re + import shutil + + import numpy as np + + from homemaker_layout import driver, operators + + rename = {"cr1": "fr1", "of": "ao", "st1": "gs1", "st2": "gs2"} + src = Path("examples/harbor-house") + shutil.copytree(src, tmp_path / "hh") + cfg = tmp_path / "hh" / "patterns.config" + text = cfg.read_text() + for old, new in rename.items(): + text = re.sub(rf"^( ){re.escape(old)}:$", rf"\g<1>{new}:", text, flags=re.M) + cfg.write_text(text) + + def evaluator(directory): + overrides = driver._overrides_for(True, False, None, False, True, False) + conf, cost = fitness.load_config(str(directory), overrides=dict(overrides or {})) + return fitness.Fitness(conf, cost) + + def relabel(root): + for lvl in dom.levels(root): + for leaf in lvl.leaves(): + leaf.type = rename.get(leaf.type, leaf.type) + leaf.share_type = rename.get(leaf.share_type, leaf.share_type) + return root + + reqs = programme.load_programme_dir(str(src)) + before, after = evaluator(src), evaluator(tmp_path / "hh") + for seed in range(3): + root = operators.constructive_topology( + dom.load(str(src / "init.dom")), reqs, np.random.default_rng(seed), + sorted(reqs) + ["C", "O"], + min_storeys=programme.storey_minimum(str(src)), + adjacency_aware=True, proportion_aware=True, circ_divisor=3, + leaf_sharing=True, leaf_share_factor=3, depth_balanced=True, + interior_outside=True, outside_divisor=3) + score_a, fails_a = before.score_with_fails(copy.deepcopy(root)) + score_b, fails_b = after.score_with_fails(relabel(copy.deepcopy(root))) + normalised = tuple(sorted( + re.sub(r"\b(%s)\b" % "|".join(rename), lambda m: rename[m.group(1)], f) + for f in fails_a)) + assert f"{score_a:.12g}" == f"{score_b:.12g}", f"seed {seed}: score differs" + assert normalised == tuple(sorted(fails_b)), f"seed {seed}: fails differ" diff --git a/tests/test_staging.py b/tests/test_staging.py index 7016fb3..f22c3eb 100644 --- a/tests/test_staging.py +++ b/tests/test_staging.py @@ -25,7 +25,9 @@ def reqs(): def _required_counts(reqs): - return {c: r.count for c, r in reqs.items() if c[0].lower() not in "cos"} + # §39.4: every declared code is a real requirement — a programme code + # starting with c/o/s is no longer swallowed by the generic-type rule. + return {c: r.count for c, r in reqs.items() if not dom.is_generic(c)} def test_n_storeys_required(reqs):