§39.4: tighten generic-type matching, reverting the harbor rename
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
This commit is contained in:
parent
697914fac8
commit
7ec4e5d121
19 changed files with 461 additions and 247 deletions
|
|
@ -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}
|
||||
|
|
|
|||
150
DESIGN.md
150
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.
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ r:
|
|||
- 0.5292445435027436
|
||||
- 0.5292445435027436
|
||||
l:
|
||||
type: gs1
|
||||
type: st1
|
||||
rotation: 0
|
||||
r:
|
||||
type: la1
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: <code>` 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())
|
||||
|
|
@ -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))):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ GOOD_SVG = textwrap.dedent(
|
|||
<g inkscape:groupmode="layer" inkscape:label="storey-0">
|
||||
<path d="M 4,0 L 4,8"/>
|
||||
<path d="M 4,5 L 10,5"/>
|
||||
<text x="2" y="4">fr1</text>
|
||||
<text x="2" y="4">cr1</text>
|
||||
<text x="7" y="2.5">k1</text>
|
||||
<text x="7" y="6.5">b1</text>
|
||||
</g>
|
||||
|
|
@ -55,7 +55,7 @@ SLOPPY_SVG = textwrap.dedent(
|
|||
<g inkscape:groupmode="layer" inkscape:label="storey-0">
|
||||
<path d="M 4.06,-0.05 L 3.95,8.07"/>
|
||||
<path d="M 3.96,5.04 L 10.06,4.93"/>
|
||||
<text x="2" y="4">fr1</text>
|
||||
<text x="2" y="4">cr1</text>
|
||||
<text x="7" y="2.5">k1</text>
|
||||
<text x="7" y="6.5">b1</text>
|
||||
</g>
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue