Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
"""Native port of Urb's programme-driven fitness: leaf quality terms + cost model.
|
|
|
|
|
|
|
|
|
|
|
|
Scope (homemaker-py-gnw): per-leaf quality factors (perpendicular, proportion,
|
|
|
|
|
|
size, width, crinkliness, daylight, access), the programme-driven parameter
|
|
|
|
|
|
lookup chain (``get_space_params``), value rates, and the cost denominator
|
|
|
|
|
|
(per-leaf area costs, interior/exterior wall edge costs, boundary costs).
|
|
|
|
|
|
Storey/building checks, staircases, failure stacking and final assembly are
|
|
|
|
|
|
homemaker-py-hgg; corpus-parity validation is homemaker-py-uxz.
|
|
|
|
|
|
|
|
|
|
|
|
Source of truth: ``Urb::Dom::Fitness::{Base,Leaf,Storey,ProgrammeDriven}``.
|
|
|
|
|
|
|
|
|
|
|
|
DESCOPE (DESIGN.md §6, decision 2026-06-12): this ports *simple* crinkliness —
|
|
|
|
|
|
the CIEsky illumination factor is pinned to 1, exactly what Urb computes under
|
|
|
|
|
|
``URB_NO_OCCLUSION=1``. ``quality_daylight`` is likewise pinned to 1. Parity
|
|
|
|
|
|
targets the *flagged* oracle, never stock Urb.
|
|
|
|
|
|
|
|
|
|
|
|
Call ``dom.merge_divided(root)`` and rebuild graphs before ``process_storey``
|
|
|
|
|
|
— storey processing runs on the MERGED tree (two-phase pattern, see graph.py).
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import networkx as nx
|
|
|
|
|
|
import yaml
|
|
|
|
|
|
|
|
|
|
|
|
from . import dom as dom_mod
|
|
|
|
|
|
from . import geometry
|
§39.7: access requirements become a declared `usage:` attribute (homemaker-py-sel)
Closes the second namespace sharing a first character with programme codes: the
usage prefixes b/t/l/k, under which a room silently inherited another room's
connectivity rules from its spelling.
usage is a plain, MANDATORY attribute of the space definition -- not a lookup
table. An interim design proposed a top-level usage_classes: table binding
author-coined names to behaviour; withdrawn, because an indirect name->behaviour
mapping living apart from the thing it describes is exactly the shape of the
prefix rule §39 exists to remove, it would be the only such table in a schema
where every other space property is a plain attribute, and the need it served
was already met -- "building specific" is about what a room is CALLED, and
name: is already free text.
Rule that settles it: a usage value exists iff the engine treats it differently
somewhere. Config selects among behaviours; it cannot invent them.
- programme.USAGES (living/kitchen/bedroom/toilet/utility/none) plus the
behaviour groupings PRIVATE_USAGES / PRIVATE_STRIPS / TOILET_STRIPS /
SOCIABLE_USAGES. Missing or unknown usage is a load error naming the code,
from BOTH parse paths.
- Code-level, never leaf-level: usage_of(leaf.type) is looked up fresh, so a
retype changes the class automatically. 51 sites assign leaf.type, and
share/share_type plus the r5a resurrection are the precedent for why
leaf-level attributes rot.
- graph.has_circulation takes the usage map and trims on declared class;
fitness.access and the public-access check likewise. fitness._t0 is DELETED --
no first-character type test remains anywhere in the codebase.
- utility is distinct from bedroom (same access requirements today) because it
is a different use and gives derive_interchange_classes an axis to relax on.
- A toilet now keeps its edge to a terminal room -- the Brand adjacency, which
the old b-before-t loop ordering severed.
- All 107 corpus entries migrated by experiments/migrate_usage_key.py, comments
and layout preserved.
MEASURED -- the connectivity model was ~4x too permissive. `none` is not
neutral: nothing is trimmed, so the graph may route THROUGH the room, and 34 of
52 codes had no class (Dental Surgery, Records Room, Utilities Closet all served
as corridors). Edges trimmed, prefix-inferred vs declared, 3 seeds each:
harbor-house 18 (9%) -> 79 (39%) inaccessible fails 0 -> 4
health-centre 12 (8%) -> 59 (40%) inaccessible fails 2 -> 3
maple-court 53 (17%) -> 123 (39%) inaccessible fails 1 -> 5
Re-baseline (seed 1, 20k, harbor): 58 fails (15h/43s) -> 61 (16h/45s), now
reporting 1-inaccessible-usable-space x2 plus level 0 and level 1 not connected.
The count rose because the objective got honest -- those failures were always
true of the layout and the old model could not see them. Every harbor number
before this was measured against a graph crediting routes through store
cupboards.
Sharpens §38.2: the objective pays x60-85 to delete circulation, and until now
the deleted corridors were not missed because storage stood in for them. With
that substitution gone, homemaker-py-2v1 is the remaining half -- and now
measurable, because the fails it should prevent actually fire.
350 passed (+5 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 13:39:41 +00:00
|
|
|
|
from . import programme as _programme
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
from .dom import Node
|
|
|
|
|
|
|
|
|
|
|
|
FAIL_THRESHOLD = 0.1 # Urb::Dom::Fitness::Base
|
ssz: crinkliness is declared per space; there is no daylight attribute
Owner's ruling, and it corrects the design not just the classification: the
daylight requirement is already defined in the crinkliness. The gaussian's
compact side IS "too little exposed wall per unit floor"; its exposed side
is envelope cost. 38.9's proposed daylight: axis was redundant, and keying
it off usage: was worse than redundant.
What was actually missing: crinkliness is the only leaf quality factor with
no per-space target. size, width and proportion are all declared by the
space; crinkliness was one global number for every room in every building.
crinkliness: none -> no minimum-exposure requirement, may be buried
crinkliness: [t, s] -> this space's own target
key absent -> the global uncrinkliness target, as today
`none` clips the factor on the compact side, it does not switch it off:
over-exposure is still penalised, because a crinkly leaf costs envelope
whatever it holds. A store may be buried; a store may not be a starfish.
The mechanism is backward compatible -- an absent key resolves to the
global target, so shipping it changes no score. Behaviour changes only
where a config declares something, which keeps the objective change
visible per programme in config rather than hidden in a default.
Owner's classification: everything a person occupies wants a window, WCs
and reception/waiting/foyer included; only stores, plant, records and
laundry do not. migrate_crinkliness_key.py declared crinkliness: none on 18
corpus spaces. Crinkliness fails 271 -> 243, of which not-defects 136 (50%)
-> 108 (44%); the 28 that went are exactly the utility fails.
usage_daylight and needs_daylight are removed as mis-keyed, and
DAYLIGHT_USAGES with them -- a vocabulary value should exist only where the
engine treats it differently. The historical crinkliness_mode modes stay,
default off, so 38.6/38.8 remain reproducible.
uncrinkliness_circulation is now settable to none like any space, but its
default is left unchanged pending a ruling: corridors were not among the
groups ruled on and are 63% of the remaining phantom fails.
Lint at parity (46); tests 364 passed, same 7 pre-existing fixture failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-28 22:38:54 +00:00
|
|
|
|
_MISSING = object() # tells an absent config key from one declared null
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
|
2026-06-18 22:33:29 +01:00
|
|
|
|
# Per-leaf quality factors that emit a failure when they drop below
|
|
|
|
|
|
# FAIL_THRESHOLD (evaluate_leaf, in emission order). The graded objective
|
|
|
|
|
|
# (DESIGN.md §11.4) reads each failing factor's value as a continuous proximity
|
|
|
|
|
|
# to satisfaction — it does NOT change the scalar fitness or the fail count, only
|
|
|
|
|
|
# supplies a tie/secondary signal to the outer comparator (driver.py).
|
|
|
|
|
|
_GRADED_FACTORS = ("perpendicular", "proportion", "size", "width",
|
|
|
|
|
|
"crinkliness", "access")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _leaf_grade(factors: dict[str, float]) -> float:
|
|
|
|
|
|
"""Proximity credit for one leaf's *failing* quality factors.
|
|
|
|
|
|
|
|
|
|
|
|
Each factor below FAIL_THRESHOLD contributes ``f / FAIL_THRESHOLD`` ∈ [0, 1):
|
|
|
|
|
|
deeper failures score ~0, near-threshold failures score ~1. Summing this over
|
|
|
|
|
|
all failing factors gives a continuous proximity signal. Passing factors
|
|
|
|
|
|
contribute nothing — the signal lives entirely in the failing set — and
|
|
|
|
|
|
structural/binary fails (missing, adjacency, edge-too-long, …) contribute 0,
|
|
|
|
|
|
so the measure can never reward dropping a required room (§6 preserved).
|
|
|
|
|
|
|
|
|
|
|
|
Intended as an outer-comparator secondary key, but REJECTED as such (DESIGN.md
|
|
|
|
|
|
§11.4): within a fixed fail-tier the scalar fitness is not flat, so this added
|
|
|
|
|
|
no benefit. Kept for reproducibility / possible reuse as a diversity signal.
|
|
|
|
|
|
"""
|
|
|
|
|
|
g = 0.0
|
|
|
|
|
|
for name in _GRADED_FACTORS:
|
|
|
|
|
|
fv = factors.get(name, 1.0)
|
|
|
|
|
|
if fv < FAIL_THRESHOLD:
|
|
|
|
|
|
g += fv / FAIL_THRESHOLD
|
|
|
|
|
|
return g
|
|
|
|
|
|
|
homemaker-py-2g7.3: hard/soft fail tiering behind --use-tiers flag
Splits the flat outer-search comparator (-n_fails, fitness) into a tiered
(-n_hard, -n_soft, fitness) so search budget stops being spent polishing
SOFT shape fails (crinkliness/proportion/size/width/edge-too-long/
staircase-volume) while HARD structural fails (missing space, wrong/
required level, level/circulation/vertical connectivity, adjacency,
stairs, covered-outside, storey limits, public access) remain unfixed.
fitness.classify_fail_tier/tier_counts classify every fail string emitted
across fitness.py and graph.py, raising on anything unrecognised so new
fail sites must declare a tier. Validated against all real fail strings in
the checked-in corpus plus every fail-emission call site read from source.
driver.Individual gains n_hard/n_soft (populated from innerloop.Result.
fail_lines); search(use_tiers=...) swaps the comparator when set (default
off, so existing runs are unaffected — inner-loop 0.5^n cliff untouched).
evolve.py exposes --use-tiers / HOMEMAKER_USE_TIERS.
experiments/tier_ab_2g7_3.py runs the acceptance A/B (harbor+maple, 3
seeds, 20k evals) in the background; results pending.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSwQwpEaHFBkeVSDDWd75S
2026-08-02 16:00:39 +01:00
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
# Hard/soft fail tiering (homemaker-py-2g7.3, DESIGN.md §37)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
# HARD: the design lacks a required structural provision (a space, a level
|
|
|
|
|
|
# placement, a connectivity path, a stair, weather-tight cover) that no amount
|
|
|
|
|
|
# of ratio-only (shape) optimisation within the CURRENT topology can supply —
|
|
|
|
|
|
# fixing it needs a topology mutation (add/remove/retype/reconnect a node).
|
|
|
|
|
|
# These are graph.py's structural check_* fails plus the count/coverage fails
|
|
|
|
|
|
# fitness.py emits at the storey/building level (stairs, storey limits, public
|
|
|
|
|
|
# access, covered-outside support).
|
|
|
|
|
|
#
|
|
|
|
|
|
# SOFT: a continuous per-leaf/edge shape or quality metric — evaluate_leaf's
|
|
|
|
|
|
# perpendicular/proportion/size/width/crinkliness/access factors, wall/edge
|
|
|
|
|
|
# length caps, stair-fit volume — that the inner-loop ratio solve can, in
|
|
|
|
|
|
# principle, improve without changing the tree. "access" sits here (not with
|
|
|
|
|
|
# graph.py's structural adjacency checks) because it is computed exactly like
|
|
|
|
|
|
# proportion/crinkliness — a per-leaf continuous factor thresholded in
|
|
|
|
|
|
# evaluate_leaf — and _GRADED_FACTORS already groups it with the shape family.
|
|
|
|
|
|
#
|
|
|
|
|
|
# New fail strings MUST be added to one of these tuples — classify_fail_tier
|
|
|
|
|
|
# raises on anything unrecognised rather than silently defaulting a tier
|
|
|
|
|
|
# (homemaker-py-2g7.3 acceptance criteria).
|
|
|
|
|
|
_HARD_FAIL_MARKERS = (
|
|
|
|
|
|
"missing required space",
|
|
|
|
|
|
"too many spaces",
|
|
|
|
|
|
"would need", # missing-space cascade placeholders (size/width/proportion/
|
|
|
|
|
|
# adjacency/level/connection-below checks for an absent space)
|
|
|
|
|
|
"not adjacent to",
|
|
|
|
|
|
"on wrong level",
|
|
|
|
|
|
"not connected to", # vertical/stair connectivity to the level below
|
|
|
|
|
|
"not connected", # level circulation connectivity
|
|
|
|
|
|
"inaccessible usable space", # has_circulation disconnected a level (graph.py)
|
|
|
|
|
|
"no outside space",
|
|
|
|
|
|
"unsupported covered outside",
|
|
|
|
|
|
"covered outside above ground",
|
|
|
|
|
|
"too few stairs",
|
|
|
|
|
|
"too many stairs",
|
|
|
|
|
|
"storey limit",
|
|
|
|
|
|
"storey minimum",
|
|
|
|
|
|
"no outside public access",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
_SOFT_FAIL_MARKERS = (
|
|
|
|
|
|
" perpendicular",
|
|
|
|
|
|
" proportion",
|
|
|
|
|
|
" size",
|
|
|
|
|
|
" width",
|
|
|
|
|
|
" crinkliness",
|
|
|
|
|
|
" access",
|
|
|
|
|
|
"edge too long",
|
|
|
|
|
|
"staircase volume",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
§39.8: homemaker-py-2v1 connectivity weighting — MEASURED NULL, premise retracted
§38.2 concluded the objective is net-positive on severing a level's
circulation: merging a corridor into a habitable sibling gains x6
(value_inside/value_circulation), while "level N not connected" costs x0.5, so
break-even needs 0.5^w < 50/300, w > 2.58 -- "severing must cost at least 3
fails and costs 1". The arithmetic is right. The premise is wrong.
Shipped anyway, EXPERIMENTAL and default off (byte-identical):
fitness.connectivity_weight_for(value_inside, value_circulation) returns the
smallest weight making severing net-negative -- 3.0 at the defaults, DERIVED
from the rates rather than hard-coded so it tracks them if either is retuned.
conf["connectivity_weight"] takes 1.0 / "auto" / a number and counts each
connectivity failure as w failures in the 0.5^n penalty.
MEASUREMENT: at auto (=3) the §38.2 deletion test does not move at all -- 5/25
rewarded either way, median x0.26 vs x0.27. Reason: the connectivity fail count
is UNCHANGED in every rewarded deletion (115->107 fails but 5->5 connectivity;
107->99 but 3->3; 78->71 but 3->3). Weighting a fail that never fires changes
nothing.
And when a deletion DOES break connectivity, it is already punished. Every such
case, 4 seeds per programme: harbor-house 2 of 32 sampled deletions, both
punished (x0.00, x0.01); maple-court 5 of 32, all punished (x0.58 .. x0.07).
Severing costs 1-2 connectivity fails PLUS the cascade after them, which
already outweighs the x6 gain. The flat rule was never the problem.
Where §38.2 went wrong: the x4.06 "well-daylit circulation leaf" that motivated
the bead was a deletion that did NOT change the connectivity fail count. It was
rewarded for removing the leaf's own quality failures -- §38.1's zero-value
finding -- and I misread it as a pricing mechanism. §38.2 now carries the
retraction inline. Two lessons recorded: a plausible closed-form arithmetic is
not a measurement, and when a fix produces exactly no effect, suspect the
premise before the implementation.
Still standing from §38: §38.1 (buried leaves score zero quality and contribute
no value) and §38.3 (frontage budget) are direct measurements. §39.7 remains
the better lever on the same symptom -- it made the connectivity fails FIRE,
where this would only have made them cost more.
Re-opened as homemaker-py-yql: why level-not-connected persists in the best
layout when severing is already punished. Evidence now points at reachability,
not incentive, and it is newly measurable because §39.7 stopped store cupboards
standing in for corridors.
353 passed (+3 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 14:15:22 +00:00
|
|
|
|
# homemaker-py-2v1 (DESIGN.md §39.8) — the fails that punish severing a level's
|
|
|
|
|
|
# circulation. These are the ONLY counter-pressure against a structural x6 gain:
|
|
|
|
|
|
# deleting a circulation leaf merges it into its sibling, converting corridor
|
|
|
|
|
|
# into habitable area, and value_inside/value_circulation is 300/50.
|
|
|
|
|
|
_CONNECTIVITY_FAIL_MARKERS = ("not connected", "inaccessible usable space")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_connectivity_fail(fail: str) -> bool:
|
|
|
|
|
|
"""True for a level-connectivity failure (``level N not connected`` /
|
|
|
|
|
|
``N inaccessible usable space``)."""
|
|
|
|
|
|
return any(m in fail for m in _CONNECTIVITY_FAIL_MARKERS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def connectivity_weight_for(value_inside: float, value_circulation: float) -> float:
|
|
|
|
|
|
"""Smallest integer weight at which severing circulation is net-NEGATIVE.
|
|
|
|
|
|
|
|
|
|
|
|
Merging a circulation leaf into a habitable sibling multiplies value by
|
|
|
|
|
|
``value_inside / value_circulation`` (x6 at the defaults). One failure costs
|
|
|
|
|
|
x0.5. So the penalty only outweighs the gain once
|
|
|
|
|
|
``0.5**w < value_circulation / value_inside``, i.e.
|
|
|
|
|
|
``w > log(vc/vi) / log(0.5)`` — 2.58 at the defaults, hence 3.
|
|
|
|
|
|
|
|
|
|
|
|
Derived from the value rates rather than hard-coded, so the two stay in step
|
|
|
|
|
|
if either rate is ever retuned.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import math
|
|
|
|
|
|
if value_inside <= 0 or value_circulation <= 0:
|
|
|
|
|
|
return 1.0
|
|
|
|
|
|
ratio = value_circulation / value_inside
|
|
|
|
|
|
if ratio >= 1.0: # circulation already worth as much
|
|
|
|
|
|
return 1.0
|
|
|
|
|
|
return float(math.ceil(math.log(ratio) / math.log(0.5)))
|
|
|
|
|
|
|
|
|
|
|
|
|
homemaker-py-2g7.3: hard/soft fail tiering behind --use-tiers flag
Splits the flat outer-search comparator (-n_fails, fitness) into a tiered
(-n_hard, -n_soft, fitness) so search budget stops being spent polishing
SOFT shape fails (crinkliness/proportion/size/width/edge-too-long/
staircase-volume) while HARD structural fails (missing space, wrong/
required level, level/circulation/vertical connectivity, adjacency,
stairs, covered-outside, storey limits, public access) remain unfixed.
fitness.classify_fail_tier/tier_counts classify every fail string emitted
across fitness.py and graph.py, raising on anything unrecognised so new
fail sites must declare a tier. Validated against all real fail strings in
the checked-in corpus plus every fail-emission call site read from source.
driver.Individual gains n_hard/n_soft (populated from innerloop.Result.
fail_lines); search(use_tiers=...) swaps the comparator when set (default
off, so existing runs are unaffected — inner-loop 0.5^n cliff untouched).
evolve.py exposes --use-tiers / HOMEMAKER_USE_TIERS.
experiments/tier_ab_2g7_3.py runs the acceptance A/B (harbor+maple, 3
seeds, 20k evals) in the background; results pending.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSwQwpEaHFBkeVSDDWd75S
2026-08-02 16:00:39 +01:00
|
|
|
|
def classify_fail_tier(fail: str) -> str:
|
|
|
|
|
|
"""Return ``"hard"`` or ``"soft"`` for one failure string.
|
|
|
|
|
|
|
|
|
|
|
|
Checks ``_HARD_FAIL_MARKERS`` before ``_SOFT_FAIL_MARKERS`` so cascade
|
|
|
|
|
|
placeholders like "missing k1: would need size check" (a missing-space
|
|
|
|
|
|
consequence, HARD) aren't caught by the generic " size" SOFT marker.
|
|
|
|
|
|
Raises ``ValueError`` for a fail string matching neither list.
|
|
|
|
|
|
"""
|
|
|
|
|
|
for marker in _HARD_FAIL_MARKERS:
|
|
|
|
|
|
if marker in fail:
|
|
|
|
|
|
return "hard"
|
|
|
|
|
|
for marker in _SOFT_FAIL_MARKERS:
|
|
|
|
|
|
if marker in fail:
|
|
|
|
|
|
return "soft"
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"unclassified fail string (add a tier marker in fitness.py): {fail!r}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def tier_counts(fails) -> tuple[int, int]:
|
|
|
|
|
|
"""Return ``(n_hard, n_soft)`` for an iterable of failure strings."""
|
|
|
|
|
|
n_hard = n_soft = 0
|
|
|
|
|
|
for f in fails:
|
|
|
|
|
|
if classify_fail_tier(f) == "hard":
|
|
|
|
|
|
n_hard += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
n_soft += 1
|
|
|
|
|
|
return n_hard, n_soft
|
|
|
|
|
|
|
|
|
|
|
|
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
# Urb::Dom::Fitness::Base $CONF — keep values byte-identical to the Perl
|
|
|
|
|
|
# expressions (5.0/6 etc. evaluate to the same IEEE doubles in both languages).
|
|
|
|
|
|
CONF_DEFAULTS: dict = {
|
|
|
|
|
|
"value_inside": 300.0,
|
|
|
|
|
|
"value_circulation": 50.0,
|
|
|
|
|
|
"value_outside": 100.0,
|
|
|
|
|
|
"value_supported": 300.0,
|
|
|
|
|
|
"storey_limit": 4,
|
|
|
|
|
|
"storey_minimum": 2,
|
|
|
|
|
|
"latitude": 53.3814,
|
|
|
|
|
|
"door_width": 1.2,
|
|
|
|
|
|
"plot_ratio": [2.00, 0.50],
|
|
|
|
|
|
"ratio_outside": [0.33, 0.15],
|
|
|
|
|
|
"ratio_circulation": [0.00, 0.20],
|
|
|
|
|
|
"uncrinkliness": [5.0 / 6, 1.1 / 3],
|
|
|
|
|
|
"uncrinkliness_circulation": [5.0 / 6, 1.1 / 3],
|
|
|
|
|
|
"size_circulation": [0.0, 14.0],
|
|
|
|
|
|
"size_inside": [16.0, 3.5],
|
|
|
|
|
|
"proportion_outside": [1.5, 50],
|
|
|
|
|
|
"proportion_circulation": [1.5, 0.5],
|
|
|
|
|
|
"proportion_inside": [1.5, 0.5],
|
|
|
|
|
|
"width_outside": [3.0, 0.3],
|
|
|
|
|
|
"width_circulation": [2.4, 0.2],
|
|
|
|
|
|
"width_inside": [4.0, 1.0],
|
|
|
|
|
|
"perpendicular_inside": 0.3,
|
|
|
|
|
|
"perpendicular_outside": 10.0,
|
|
|
|
|
|
"allow_sahn_circulation": 0,
|
|
|
|
|
|
"force_roof_garden": 1,
|
|
|
|
|
|
"evaluate_room_types": 1,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Urb::Dom::Fitness::Base $COST
|
|
|
|
|
|
COST_DEFAULTS: dict = {
|
|
|
|
|
|
"plot": 10.0,
|
|
|
|
|
|
"outside_covered_supported": 210.0,
|
|
|
|
|
|
"outside_covered": 110.0,
|
|
|
|
|
|
"outside_supported": 110.0,
|
|
|
|
|
|
"outside": 10.0,
|
|
|
|
|
|
"inside": 200.0,
|
|
|
|
|
|
"interior_wall": 200.0 / 3,
|
|
|
|
|
|
"exterior_wall": 100.0,
|
|
|
|
|
|
"boundary": 50.0 / 3,
|
|
|
|
|
|
"boundary_wall": 400.0 / 3,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# ProgrammeDriven::default_params ultimate fallbacks
|
|
|
|
|
|
_PARAM_FALLBACKS = {
|
|
|
|
|
|
"size": [16.0, 3.5],
|
|
|
|
|
|
"width": [4.0, 1.0],
|
|
|
|
|
|
"proportion": [1.5, 0.5],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_E = 2.718281828 # Urb::Math::gaussian uses this truncated e, not math.e
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def gaussian(x: float, a: float, b: float, c: float) -> float:
|
|
|
|
|
|
"""Bit-faithful port of ``Urb::Math::gaussian`` (note the truncated e)."""
|
|
|
|
|
|
return a * (_E ** (0 - ((x - b) ** 2 / (2 * c * c))))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 10:20:49 +01:00
|
|
|
|
def _gaussian_product(target_a: float, sigma_a: float,
|
|
|
|
|
|
target_b: float, sigma_b: float) -> tuple[float, float]:
|
|
|
|
|
|
"""Precision-weighted combination of two Gaussian (target, sigma) pairs
|
|
|
|
|
|
(homemaker-py-1s3): the product of two Gaussian curves evaluated at the
|
|
|
|
|
|
same point is itself proportional to a Gaussian with precisions (1/sigma^2)
|
|
|
|
|
|
ADDING and target the precision-weighted average — always an INTERMEDIATE
|
|
|
|
|
|
target (never simply the stricter of the two) with a NARROWER spread than
|
|
|
|
|
|
either input, unlike a naive max-target/min-sigma combination.
|
|
|
|
|
|
|
|
|
|
|
|
Used by ``quality_width``/``quality_proportion`` to combine a co-located
|
|
|
|
|
|
leaf's two codes' shape targets. A/B-measured (DESIGN.md §33) as the best
|
|
|
|
|
|
of three tried: beats both the naive max-target/min-sigma hack (health-
|
|
|
|
|
|
centre +24.5% worse) and the max-of-two MIXTURE combination below
|
|
|
|
|
|
(health-centre +20.4% worse) — this precision-weighted single compromise
|
|
|
|
|
|
peak was the only one to improve both example programmes."""
|
|
|
|
|
|
prec_a, prec_b = 1.0 / (sigma_a * sigma_a), 1.0 / (sigma_b * sigma_b)
|
|
|
|
|
|
prec_c = prec_a + prec_b
|
|
|
|
|
|
target_c = (target_a * prec_a + target_b * prec_b) / prec_c
|
|
|
|
|
|
return target_c, (1.0 / prec_c) ** 0.5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _clipped_gaussian(x: float, target: float, sigma: float, good_side: str) -> float:
|
|
|
|
|
|
"""The 'flat 1.0 once the target is met, gaussian decay short of it' shape
|
|
|
|
|
|
both ``quality_width`` (wider than target is good) and
|
|
|
|
|
|
``quality_proportion`` (squarer/lower aspect than target is good) use.
|
|
|
|
|
|
|
|
|
|
|
|
Also the building block of a MIXTURE alternative to ``_gaussian_product``
|
|
|
|
|
|
that was tried and measured worse (homemaker-py-1s3, DESIGN.md §33):
|
|
|
|
|
|
evaluate this once per served code and combine with ``max()`` instead of
|
|
|
|
|
|
computing one combined (target, sigma) — the leaf scores well if the
|
|
|
|
|
|
realised geometry ends up close to EITHER code's target rather than one
|
|
|
|
|
|
narrow compromise peak, echoing the per-leaf usage collapse §26 path (a)
|
|
|
|
|
|
uses at the whole-leaf-type level. Appealing in principle (no forced
|
|
|
|
|
|
compromise) but empirically worse on the tightly-packed health-centre
|
|
|
|
|
|
programme (+20.4%, vs -13.9% for the precision-weighted product currently
|
|
|
|
|
|
used) — plausibly because ``max()`` lets a leaf score 1.0 by satisfying
|
|
|
|
|
|
only the WEAKER of two codes' targets, under-constraining the search."""
|
|
|
|
|
|
if (good_side == "above" and x > target) or (good_side == "below" and x < target):
|
|
|
|
|
|
return 1.0
|
|
|
|
|
|
return gaussian(x, 1.0, target, sigma)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-28 22:04:35 +01:00
|
|
|
|
def load_config(directory: str | Path,
|
|
|
|
|
|
overrides: dict | None = None) -> tuple[dict, dict]:
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
"""Load (patterns, costs) config for a corpus directory, mirroring
|
|
|
|
|
|
``urb-fitness.pl``: project-level ``../<name>.config`` first, then the
|
2026-06-28 22:04:35 +01:00
|
|
|
|
local file's keys override it.
|
|
|
|
|
|
|
|
|
|
|
|
``overrides`` (homemaker-py-x3b) are merged into the patterns conf last, so a
|
|
|
|
|
|
caller can switch on run-level knobs (e.g. ``leaf_sharing``) without editing
|
|
|
|
|
|
any ``patterns.config`` on disk — keeping the §13.3 example programmes
|
|
|
|
|
|
reproducible while the CLI/driver drives sharing programmatically."""
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
directory = Path(directory)
|
|
|
|
|
|
conf: dict = {}
|
|
|
|
|
|
cost: dict = {}
|
|
|
|
|
|
for target, name in ((conf, "patterns.config"), (cost, "costs.config")):
|
|
|
|
|
|
for p in (directory.parent / name, directory / name):
|
|
|
|
|
|
if p.is_file():
|
|
|
|
|
|
with open(p) as fh:
|
|
|
|
|
|
target.update(yaml.safe_load(fh) or {})
|
2026-06-28 22:04:35 +01:00
|
|
|
|
if overrides:
|
|
|
|
|
|
conf.update(overrides)
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
return conf, cost
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class LeafEval:
|
|
|
|
|
|
level: int
|
|
|
|
|
|
id: str
|
|
|
|
|
|
type: str
|
|
|
|
|
|
area: float
|
|
|
|
|
|
rate: float
|
|
|
|
|
|
quality: float
|
|
|
|
|
|
factors: dict[str, float] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class StoreyEval:
|
|
|
|
|
|
cost: float
|
|
|
|
|
|
value: float
|
|
|
|
|
|
leaves: list[LeafEval] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
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
|
§39.7: access requirements become a declared `usage:` attribute (homemaker-py-sel)
Closes the second namespace sharing a first character with programme codes: the
usage prefixes b/t/l/k, under which a room silently inherited another room's
connectivity rules from its spelling.
usage is a plain, MANDATORY attribute of the space definition -- not a lookup
table. An interim design proposed a top-level usage_classes: table binding
author-coined names to behaviour; withdrawn, because an indirect name->behaviour
mapping living apart from the thing it describes is exactly the shape of the
prefix rule §39 exists to remove, it would be the only such table in a schema
where every other space property is a plain attribute, and the need it served
was already met -- "building specific" is about what a room is CALLED, and
name: is already free text.
Rule that settles it: a usage value exists iff the engine treats it differently
somewhere. Config selects among behaviours; it cannot invent them.
- programme.USAGES (living/kitchen/bedroom/toilet/utility/none) plus the
behaviour groupings PRIVATE_USAGES / PRIVATE_STRIPS / TOILET_STRIPS /
SOCIABLE_USAGES. Missing or unknown usage is a load error naming the code,
from BOTH parse paths.
- Code-level, never leaf-level: usage_of(leaf.type) is looked up fresh, so a
retype changes the class automatically. 51 sites assign leaf.type, and
share/share_type plus the r5a resurrection are the precedent for why
leaf-level attributes rot.
- graph.has_circulation takes the usage map and trims on declared class;
fitness.access and the public-access check likewise. fitness._t0 is DELETED --
no first-character type test remains anywhere in the codebase.
- utility is distinct from bedroom (same access requirements today) because it
is a different use and gives derive_interchange_classes an axis to relax on.
- A toilet now keeps its edge to a terminal room -- the Brand adjacency, which
the old b-before-t loop ordering severed.
- All 107 corpus entries migrated by experiments/migrate_usage_key.py, comments
and layout preserved.
MEASURED -- the connectivity model was ~4x too permissive. `none` is not
neutral: nothing is trimmed, so the graph may route THROUGH the room, and 34 of
52 codes had no class (Dental Surgery, Records Room, Utilities Closet all served
as corridors). Edges trimmed, prefix-inferred vs declared, 3 seeds each:
harbor-house 18 (9%) -> 79 (39%) inaccessible fails 0 -> 4
health-centre 12 (8%) -> 59 (40%) inaccessible fails 2 -> 3
maple-court 53 (17%) -> 123 (39%) inaccessible fails 1 -> 5
Re-baseline (seed 1, 20k, harbor): 58 fails (15h/43s) -> 61 (16h/45s), now
reporting 1-inaccessible-usable-space x2 plus level 0 and level 1 not connected.
The count rose because the objective got honest -- those failures were always
true of the layout and the old model could not see them. Every harbor number
before this was measured against a graph crediting routes through store
cupboards.
Sharpens §38.2: the objective pays x60-85 to delete circulation, and until now
the deleted corridors were not missed because storage stood in for them. With
that substitution gone, homemaker-py-2v1 is the remaining half -- and now
measurable, because the fails it should prevent actually fire.
350 passed (+5 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 13:39:41 +00:00
|
|
|
|
and value rate. (``_t0`` itself is gone: the SEMANTIC prefixes it served —
|
|
|
|
|
|
``k`` kitchen, ``l`` living, … — became the declared ``usage:`` key in
|
|
|
|
|
|
§39.7, so no first-character test remains anywhere.) ``S`` (sahn)
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
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 ""
|
|
|
|
|
|
|
|
|
|
|
|
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
def _height(n: Node) -> float:
|
|
|
|
|
|
"""Floor-to-floor height of n's level; mirrors ``Urb::Quad::Height``."""
|
|
|
|
|
|
h = dom_mod._level_root(n).height
|
|
|
|
|
|
return h if h is not None else 3.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _perimeter(n: Node) -> dict:
|
|
|
|
|
|
"""Perimeter dict from the lowest level root (``Urb::Quad::Perimeter``)."""
|
|
|
|
|
|
lr = dom_mod._level_root(n)
|
|
|
|
|
|
while lr.below is not None:
|
|
|
|
|
|
lr = lr.below
|
|
|
|
|
|
return lr.perimeter or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Fitness:
|
|
|
|
|
|
"""Programme-driven leaf quality + cost evaluation.
|
|
|
|
|
|
|
|
|
|
|
|
``conf`` is the parsed patterns.config mapping (including ``spaces``);
|
|
|
|
|
|
``cost`` the costs.config mapping. Lookup falls back to the Base.pm
|
|
|
|
|
|
defaults, as ``Urb::Dom::Fitness::Base::Conf/Cost`` do.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, conf: dict | None = None, cost: dict | None = None):
|
|
|
|
|
|
self._conf = conf or {}
|
|
|
|
|
|
self._cost = cost or {}
|
|
|
|
|
|
self.spaces: dict = self._conf.get("spaces") or {}
|
2026-06-13 20:55:25 +01:00
|
|
|
|
self._programme_cache: dict | None = None
|
|
|
|
|
|
self._load_programme(self._conf)
|
2026-06-24 08:30:26 +01:00
|
|
|
|
# erc.3 leaf-sharing (DESIGN.md §13.3): default OFF. When on, a leaf sized
|
|
|
|
|
|
# to k×target counts as k same-code rooms (count check + size centring).
|
|
|
|
|
|
self._leaf_sharing = bool(self.conf("leaf_sharing"))
|
|
|
|
|
|
self._max_share = int(self.conf("leaf_share_max") or 4)
|
2026-06-28 21:38:53 +01:00
|
|
|
|
# erc.hph §13.7/§13.8: scale the edge-too-long cap by a shared leaf's
|
|
|
|
|
|
# share k so an aggregate (k-room) leaf is not penalised for long walls —
|
|
|
|
|
|
# the §13.3 leak on a different measure. The §13.8 A/B verdict was
|
|
|
|
|
|
# positive and monotone-harmless, so the default is ON for leaf-sharing
|
|
|
|
|
|
# runs (mirrors the pll/interior_outside default flips). An explicit
|
|
|
|
|
|
# share_edge_cap=False still reproduces the pre-flip control arm.
|
|
|
|
|
|
cap = self.conf("share_edge_cap")
|
|
|
|
|
|
self._share_edge_cap = self._leaf_sharing if cap is None else bool(cap)
|
9o5: type superposition + per-eval collapse (multi-use leaves)
Interchangeable codes (similar size/width/proportion, compatible level/stack,
no adjacency edge) form equivalence classes derived from the programme. With
--superpose (default off), each fitness eval COLLAPSES every superposed leaf to
its best in-class usage via an optimal supply->demand assignment (brute force
<=C! within cap C=4, scipy Hungarian beyond), then scores the condensed types.
Because collapse re-types on the unmerged tree before all checks, counts /
adjacency / quality are unchanged downstream -- no Node field, no graph/operator
changes -- and default OFF is bit-identical.
- programme.py: derive_interchange_classes + interchangeable (S1-S4, locked
thresholds R_SIZE=1.5/R_WIDTH=1.3/R_PROP=1.5, CLASS_CAP=4)
- fitness.py: collapse_superposition, _best_assignment, _usage_quality;
superpose/superpose_class_cap conf knobs; collapse hooked into _evaluate_full
- driver.py/evolve.py: superpose flag plumbed beside leaf_sharing; --superpose
- tests/test_superposition.py: 17 tests (derivation, assignment, end-to-end)
Closes homemaker-py-9o5 (build); validation A/B is homemaker-py-xi7.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:08:46 +01:00
|
|
|
|
# 9o5 type superposition (DESIGN.md §13/homemaker-py-9o5): default OFF.
|
|
|
|
|
|
# When on, interchangeable codes (similar requirements) form equivalence
|
|
|
|
|
|
# classes; each candidate's fitness re-types (collapses) every superposed
|
|
|
|
|
|
# leaf to its best in-class usage before scoring, so search optimises the
|
|
|
|
|
|
# condensed objective directly and the relaxation gap is removed.
|
|
|
|
|
|
self._superpose = bool(self.conf("superpose"))
|
2026-07-18 18:44:24 +01:00
|
|
|
|
# homemaker-py-qi6 graded circulation-connectivity signal (DESIGN.md §18):
|
|
|
|
|
|
# default OFF. When on, the graded proximity scalar (want_grade) is the
|
|
|
|
|
|
# per-level largest-circ-component fraction instead of the §11.4 leaf
|
|
|
|
|
|
# quality-proximity — a secondary comparator key giving the outer search
|
|
|
|
|
|
# a gradient the binary "level N not connected" fail lacks. Leaves the
|
|
|
|
|
|
# scalar fitness and fail count untouched, exactly like §11.4.
|
|
|
|
|
|
self._conn_grade = bool(self.conf("conn_grade"))
|
9o5: type superposition + per-eval collapse (multi-use leaves)
Interchangeable codes (similar size/width/proportion, compatible level/stack,
no adjacency edge) form equivalence classes derived from the programme. With
--superpose (default off), each fitness eval COLLAPSES every superposed leaf to
its best in-class usage via an optimal supply->demand assignment (brute force
<=C! within cap C=4, scipy Hungarian beyond), then scores the condensed types.
Because collapse re-types on the unmerged tree before all checks, counts /
adjacency / quality are unchanged downstream -- no Node field, no graph/operator
changes -- and default OFF is bit-identical.
- programme.py: derive_interchange_classes + interchangeable (S1-S4, locked
thresholds R_SIZE=1.5/R_WIDTH=1.3/R_PROP=1.5, CLASS_CAP=4)
- fitness.py: collapse_superposition, _best_assignment, _usage_quality;
superpose/superpose_class_cap conf knobs; collapse hooked into _evaluate_full
- driver.py/evolve.py: superpose flag plumbed beside leaf_sharing; --superpose
- tests/test_superposition.py: 17 tests (derivation, assignment, end-to-end)
Closes homemaker-py-9o5 (build); validation A/B is homemaker-py-xi7.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:08:46 +01:00
|
|
|
|
from .programme import CLASS_CAP as _CLASS_CAP
|
|
|
|
|
|
self._class_cap = int(self.conf("superpose_class_cap") or _CLASS_CAP)
|
|
|
|
|
|
self._interchange_classes: list | None = None # lazily derived
|
2026-07-19 20:35:18 +01:00
|
|
|
|
# homemaker-py-qpk: IN-SEARCH global collapse. Default OFF. When on,
|
|
|
|
|
|
# collapse_global (the 94g finish-time cell<->room relabel) runs INSIDE
|
|
|
|
|
|
# _evaluate_full every eval instead of once at the end, so search
|
|
|
|
|
|
# optimises the collapsed objective directly (mirrors the 9o5 per-eval
|
|
|
|
|
|
# collapse above, at GLOBAL scope). Carries the 9o5/xi7 landscape-
|
|
|
|
|
|
# flattening risk amplified to the whole building; gated behind its own
|
|
|
|
|
|
# A/B (DESIGN.md §17 follow-on, homemaker-py-qpk) — do not default on
|
|
|
|
|
|
# without a positive result.
|
|
|
|
|
|
self._collapse_insearch = bool(self.conf("collapse_insearch"))
|
|
|
|
|
|
adj = self.conf("collapse_insearch_adjacency")
|
|
|
|
|
|
self._collapse_insearch_adjacency = True if adj is None else bool(adj)
|
|
|
|
|
|
# Fewer Jacobi passes than the finish-time default (6): a per-eval cost,
|
|
|
|
|
|
# not a one-shot polish — profile before raising.
|
|
|
|
|
|
self._collapse_insearch_iters = int(self.conf("collapse_insearch_iters") or 3)
|
2026-07-31 00:16:12 +01:00
|
|
|
|
# homemaker-py-1s3 §26 path b: multi-use leaves as a PERMANENT design
|
|
|
|
|
|
# goal (no per-eval collapse, unlike superpose above). Default OFF.
|
|
|
|
|
|
# When on, a leaf carrying a live, valid co_type counts toward BOTH
|
|
|
|
|
|
# codes' requirements simultaneously (graph.leaf_codes), and its size
|
|
|
|
|
|
# target combines both codes' area (quality_size).
|
|
|
|
|
|
self._multi_use = bool(self.conf("multi_use"))
|
|
|
|
|
|
self._colocate_pairs: list | None = None # lazily derived
|
§38.2 refinement: connectivity is under-priced ~3x, not just a crinkliness bug
Follow-up measurement corrects the first draft of §38 in two ways.
1. Harbor-house's floor is 15 fails (evolved-3M-nols-3, 1.7M evals), not the
30-40 I quoted from §13.11's 20k-budget runs. Frontage deficit predicts the
COST of solving, not impossibility: ~150x budget gap between a
frontage-short and a frontage-surplus programme. Table corrected.
2. Zero-exposure is only half the mechanism, and not the dominant half.
Splitting the deletion test by lit vs buried shows a WELL-DAYLIT corridor
(q_crink=0.736) is still worth x4.06 to delete. Cause: value_circulation=50
vs value_inside=300, so merging corridor into room is a flat x6 gain, while
'level N not connected' costs only x0.5. Break-even needs 0.5^k < 50/300,
i.e. k > 2.58 -- severing must cost at least 3 fails and costs 1. Net x3.0
predicted, x4.06 measured. The objective is net-positive on severing the
spine even when the circulation is perfectly lit, which explains why both
'level N not connected' fails survive in the best layout after 1.7M evals.
Adds fitness.quality_uncrinkliness crinkliness_mode (EXPERIMENTAL, default
"urb" = stock hard 0.0, byte-identical: 336 passed vs 331 before, same 7
pre-existing fixture failures). A/B harness ab_crinkliness_mode_ssz.py shows
none of the three modes removes the incentive, and the lit column is 3/8 under
every mode including stock -- clean isolation of the two mechanisms.
Filed homemaker-py-2v1 (P0) for the pricing fix; ssz/hxi now depend on it.
Acceptance test recorded up front: harbor must reach 15 fails in materially
fewer than 1.7M evals AND without either not-connected fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 07:40:37 +00:00
|
|
|
|
# homemaker-py-ssz (DESIGN.md §38.1): how quality_uncrinkliness treats a
|
|
|
|
|
|
# leaf with no daylit wall. "urb" (default) = stock hard 0.0, byte-
|
|
|
|
|
|
# identical to every prior run. "floor"/"compact_ok"/"exempt_circulation"
|
|
|
|
|
|
# are the three candidate repairs — see quality_uncrinkliness.
|
§39.8: homemaker-py-2v1 connectivity weighting — MEASURED NULL, premise retracted
§38.2 concluded the objective is net-positive on severing a level's
circulation: merging a corridor into a habitable sibling gains x6
(value_inside/value_circulation), while "level N not connected" costs x0.5, so
break-even needs 0.5^w < 50/300, w > 2.58 -- "severing must cost at least 3
fails and costs 1". The arithmetic is right. The premise is wrong.
Shipped anyway, EXPERIMENTAL and default off (byte-identical):
fitness.connectivity_weight_for(value_inside, value_circulation) returns the
smallest weight making severing net-negative -- 3.0 at the defaults, DERIVED
from the rates rather than hard-coded so it tracks them if either is retuned.
conf["connectivity_weight"] takes 1.0 / "auto" / a number and counts each
connectivity failure as w failures in the 0.5^n penalty.
MEASUREMENT: at auto (=3) the §38.2 deletion test does not move at all -- 5/25
rewarded either way, median x0.26 vs x0.27. Reason: the connectivity fail count
is UNCHANGED in every rewarded deletion (115->107 fails but 5->5 connectivity;
107->99 but 3->3; 78->71 but 3->3). Weighting a fail that never fires changes
nothing.
And when a deletion DOES break connectivity, it is already punished. Every such
case, 4 seeds per programme: harbor-house 2 of 32 sampled deletions, both
punished (x0.00, x0.01); maple-court 5 of 32, all punished (x0.58 .. x0.07).
Severing costs 1-2 connectivity fails PLUS the cascade after them, which
already outweighs the x6 gain. The flat rule was never the problem.
Where §38.2 went wrong: the x4.06 "well-daylit circulation leaf" that motivated
the bead was a deletion that did NOT change the connectivity fail count. It was
rewarded for removing the leaf's own quality failures -- §38.1's zero-value
finding -- and I misread it as a pricing mechanism. §38.2 now carries the
retraction inline. Two lessons recorded: a plausible closed-form arithmetic is
not a measurement, and when a fix produces exactly no effect, suspect the
premise before the implementation.
Still standing from §38: §38.1 (buried leaves score zero quality and contribute
no value) and §38.3 (frontage budget) are direct measurements. §39.7 remains
the better lever on the same symptom -- it made the connectivity fails FIRE,
where this would only have made them cost more.
Re-opened as homemaker-py-yql: why level-not-connected persists in the best
layout when severing is already punished. Evidence now points at reachability,
not incentive, and it is newly measurable because §39.7 stopped store cupboards
standing in for corridors.
353 passed (+3 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 14:15:22 +00:00
|
|
|
|
# homemaker-py-2v1 (§39.8), EXPERIMENTAL: 1.0 (default) is the flat rule,
|
|
|
|
|
|
# byte-identical to every prior run. "auto" derives the smallest weight
|
|
|
|
|
|
# that makes severing circulation net-negative; a number sets it explicitly.
|
|
|
|
|
|
cw = self.conf("connectivity_weight")
|
|
|
|
|
|
if cw is None:
|
|
|
|
|
|
self._connectivity_weight = 1.0
|
|
|
|
|
|
elif isinstance(cw, str) and cw.lower() == "auto":
|
|
|
|
|
|
self._connectivity_weight = connectivity_weight_for(
|
|
|
|
|
|
float(self.conf("value_inside")),
|
|
|
|
|
|
float(self.conf("value_circulation")))
|
|
|
|
|
|
else:
|
|
|
|
|
|
self._connectivity_weight = float(cw)
|
§38.2 refinement: connectivity is under-priced ~3x, not just a crinkliness bug
Follow-up measurement corrects the first draft of §38 in two ways.
1. Harbor-house's floor is 15 fails (evolved-3M-nols-3, 1.7M evals), not the
30-40 I quoted from §13.11's 20k-budget runs. Frontage deficit predicts the
COST of solving, not impossibility: ~150x budget gap between a
frontage-short and a frontage-surplus programme. Table corrected.
2. Zero-exposure is only half the mechanism, and not the dominant half.
Splitting the deletion test by lit vs buried shows a WELL-DAYLIT corridor
(q_crink=0.736) is still worth x4.06 to delete. Cause: value_circulation=50
vs value_inside=300, so merging corridor into room is a flat x6 gain, while
'level N not connected' costs only x0.5. Break-even needs 0.5^k < 50/300,
i.e. k > 2.58 -- severing must cost at least 3 fails and costs 1. Net x3.0
predicted, x4.06 measured. The objective is net-positive on severing the
spine even when the circulation is perfectly lit, which explains why both
'level N not connected' fails survive in the best layout after 1.7M evals.
Adds fitness.quality_uncrinkliness crinkliness_mode (EXPERIMENTAL, default
"urb" = stock hard 0.0, byte-identical: 336 passed vs 331 before, same 7
pre-existing fixture failures). A/B harness ab_crinkliness_mode_ssz.py shows
none of the three modes removes the incentive, and the lit column is 3/8 under
every mode including stock -- clean isolation of the two mechanisms.
Filed homemaker-py-2v1 (P0) for the pricing fix; ssz/hxi now depend on it.
Acceptance test recorded up front: harbor must reach 15 fails in materially
fewer than 1.7M evals AND without either not-connected fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 07:40:37 +00:00
|
|
|
|
self._crinkliness_mode = str(self.conf("crinkliness_mode") or "urb")
|
|
|
|
|
|
if self._crinkliness_mode not in (
|
ssz: crinkliness is declared per space; there is no daylight attribute
Owner's ruling, and it corrects the design not just the classification: the
daylight requirement is already defined in the crinkliness. The gaussian's
compact side IS "too little exposed wall per unit floor"; its exposed side
is envelope cost. 38.9's proposed daylight: axis was redundant, and keying
it off usage: was worse than redundant.
What was actually missing: crinkliness is the only leaf quality factor with
no per-space target. size, width and proportion are all declared by the
space; crinkliness was one global number for every room in every building.
crinkliness: none -> no minimum-exposure requirement, may be buried
crinkliness: [t, s] -> this space's own target
key absent -> the global uncrinkliness target, as today
`none` clips the factor on the compact side, it does not switch it off:
over-exposure is still penalised, because a crinkly leaf costs envelope
whatever it holds. A store may be buried; a store may not be a starfish.
The mechanism is backward compatible -- an absent key resolves to the
global target, so shipping it changes no score. Behaviour changes only
where a config declares something, which keeps the objective change
visible per programme in config rather than hidden in a default.
Owner's classification: everything a person occupies wants a window, WCs
and reception/waiting/foyer included; only stores, plant, records and
laundry do not. migrate_crinkliness_key.py declared crinkliness: none on 18
corpus spaces. Crinkliness fails 271 -> 243, of which not-defects 136 (50%)
-> 108 (44%); the 28 that went are exactly the utility fails.
usage_daylight and needs_daylight are removed as mis-keyed, and
DAYLIGHT_USAGES with them -- a vocabulary value should exist only where the
engine treats it differently. The historical crinkliness_mode modes stay,
default off, so 38.6/38.8 remain reproducible.
uncrinkliness_circulation is now settable to none like any space, but its
default is left unchanged pending a ruling: corridors were not among the
groups ruled on and are 63% of the remaining phantom fails.
Lint at parity (46); tests 364 passed, same 7 pre-existing fixture failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-28 22:38:54 +00:00
|
|
|
|
"urb", "floor", "compact_ok", "exempt_circulation"):
|
§38.2 refinement: connectivity is under-priced ~3x, not just a crinkliness bug
Follow-up measurement corrects the first draft of §38 in two ways.
1. Harbor-house's floor is 15 fails (evolved-3M-nols-3, 1.7M evals), not the
30-40 I quoted from §13.11's 20k-budget runs. Frontage deficit predicts the
COST of solving, not impossibility: ~150x budget gap between a
frontage-short and a frontage-surplus programme. Table corrected.
2. Zero-exposure is only half the mechanism, and not the dominant half.
Splitting the deletion test by lit vs buried shows a WELL-DAYLIT corridor
(q_crink=0.736) is still worth x4.06 to delete. Cause: value_circulation=50
vs value_inside=300, so merging corridor into room is a flat x6 gain, while
'level N not connected' costs only x0.5. Break-even needs 0.5^k < 50/300,
i.e. k > 2.58 -- severing must cost at least 3 fails and costs 1. Net x3.0
predicted, x4.06 measured. The objective is net-positive on severing the
spine even when the circulation is perfectly lit, which explains why both
'level N not connected' fails survive in the best layout after 1.7M evals.
Adds fitness.quality_uncrinkliness crinkliness_mode (EXPERIMENTAL, default
"urb" = stock hard 0.0, byte-identical: 336 passed vs 331 before, same 7
pre-existing fixture failures). A/B harness ab_crinkliness_mode_ssz.py shows
none of the three modes removes the incentive, and the lit column is 3/8 under
every mode including stock -- clean isolation of the two mechanisms.
Filed homemaker-py-2v1 (P0) for the pricing fix; ssz/hxi now depend on it.
Acceptance test recorded up front: harbor must reach 15 fails in materially
fewer than 1.7M evals AND without either not-connected fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 07:40:37 +00:00
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"unknown crinkliness_mode: {self._crinkliness_mode!r}")
|
|
|
|
|
|
# The floored value stays BELOW FAIL_THRESHOLD, so a buried leaf still
|
|
|
|
|
|
# emits its crinkliness failure and the fail count is unchanged — only
|
|
|
|
|
|
# the value gradient is restored. Raising this above FAIL_THRESHOLD
|
|
|
|
|
|
# would silently delete a whole fail category.
|
|
|
|
|
|
self._crinkliness_floor = float(
|
|
|
|
|
|
self.conf("crinkliness_floor") or 0.01)
|
9o5: type superposition + per-eval collapse (multi-use leaves)
Interchangeable codes (similar size/width/proportion, compatible level/stack,
no adjacency edge) form equivalence classes derived from the programme. With
--superpose (default off), each fitness eval COLLAPSES every superposed leaf to
its best in-class usage via an optimal supply->demand assignment (brute force
<=C! within cap C=4, scipy Hungarian beyond), then scores the condensed types.
Because collapse re-types on the unmerged tree before all checks, counts /
adjacency / quality are unchanged downstream -- no Node field, no graph/operator
changes -- and default OFF is bit-identical.
- programme.py: derive_interchange_classes + interchangeable (S1-S4, locked
thresholds R_SIZE=1.5/R_WIDTH=1.3/R_PROP=1.5, CLASS_CAP=4)
- fitness.py: collapse_superposition, _best_assignment, _usage_quality;
superpose/superpose_class_cap conf knobs; collapse hooked into _evaluate_full
- driver.py/evolve.py: superpose flag plumbed beside leaf_sharing; --superpose
- tests/test_superposition.py: 17 tests (derivation, assignment, end-to-end)
Closes homemaker-py-9o5 (build); validation A/B is homemaker-py-xi7.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:08:46 +01:00
|
|
|
|
|
§39.7: access requirements become a declared `usage:` attribute (homemaker-py-sel)
Closes the second namespace sharing a first character with programme codes: the
usage prefixes b/t/l/k, under which a room silently inherited another room's
connectivity rules from its spelling.
usage is a plain, MANDATORY attribute of the space definition -- not a lookup
table. An interim design proposed a top-level usage_classes: table binding
author-coined names to behaviour; withdrawn, because an indirect name->behaviour
mapping living apart from the thing it describes is exactly the shape of the
prefix rule §39 exists to remove, it would be the only such table in a schema
where every other space property is a plain attribute, and the need it served
was already met -- "building specific" is about what a room is CALLED, and
name: is already free text.
Rule that settles it: a usage value exists iff the engine treats it differently
somewhere. Config selects among behaviours; it cannot invent them.
- programme.USAGES (living/kitchen/bedroom/toilet/utility/none) plus the
behaviour groupings PRIVATE_USAGES / PRIVATE_STRIPS / TOILET_STRIPS /
SOCIABLE_USAGES. Missing or unknown usage is a load error naming the code,
from BOTH parse paths.
- Code-level, never leaf-level: usage_of(leaf.type) is looked up fresh, so a
retype changes the class automatically. 51 sites assign leaf.type, and
share/share_type plus the r5a resurrection are the precedent for why
leaf-level attributes rot.
- graph.has_circulation takes the usage map and trims on declared class;
fitness.access and the public-access check likewise. fitness._t0 is DELETED --
no first-character type test remains anywhere in the codebase.
- utility is distinct from bedroom (same access requirements today) because it
is a different use and gives derive_interchange_classes an axis to relax on.
- A toilet now keeps its edge to a terminal room -- the Brand adjacency, which
the old b-before-t loop ordering severed.
- All 107 corpus entries migrated by experiments/migrate_usage_key.py, comments
and layout preserved.
MEASURED -- the connectivity model was ~4x too permissive. `none` is not
neutral: nothing is trimmed, so the graph may route THROUGH the room, and 34 of
52 codes had no class (Dental Surgery, Records Room, Utilities Closet all served
as corridors). Edges trimmed, prefix-inferred vs declared, 3 seeds each:
harbor-house 18 (9%) -> 79 (39%) inaccessible fails 0 -> 4
health-centre 12 (8%) -> 59 (40%) inaccessible fails 2 -> 3
maple-court 53 (17%) -> 123 (39%) inaccessible fails 1 -> 5
Re-baseline (seed 1, 20k, harbor): 58 fails (15h/43s) -> 61 (16h/45s), now
reporting 1-inaccessible-usable-space x2 plus level 0 and level 1 not connected.
The count rose because the objective got honest -- those failures were always
true of the layout and the old model could not see them. Every harbor number
before this was measured against a graph crediting routes through store
cupboards.
Sharpens §38.2: the objective pays x60-85 to delete circulation, and until now
the deleted corridors were not missed because storage stood in for them. With
that substitution gone, homemaker-py-2v1 is the remaining half -- and now
measurable, because the fails it should prevent actually fire.
350 passed (+5 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 13:39:41 +00:00
|
|
|
|
def usages(self) -> dict[str, str]:
|
|
|
|
|
|
"""``{room code: usage}`` for this programme (homemaker-py-sel).
|
|
|
|
|
|
|
|
|
|
|
|
Keyed by CODE, never stamped on a leaf: a retype changes the code and
|
|
|
|
|
|
the usage follows automatically, exactly as size/width/adjacency do.
|
|
|
|
|
|
Generic ``C``/``O``/``S`` are absent — they are not programme rooms and
|
|
|
|
|
|
their behaviour comes from the generic type rule (§39.4).
|
|
|
|
|
|
"""
|
|
|
|
|
|
return {code: req.usage for code, req in (self._programme or {}).items()}
|
|
|
|
|
|
|
|
|
|
|
|
def usage_of(self, leaf: Node) -> str:
|
|
|
|
|
|
"""Access-requirement class of one leaf, ``""`` for a generic type."""
|
|
|
|
|
|
req = (self._programme or {}).get(leaf.type)
|
|
|
|
|
|
return req.usage if req else ""
|
|
|
|
|
|
|
9o5: type superposition + per-eval collapse (multi-use leaves)
Interchangeable codes (similar size/width/proportion, compatible level/stack,
no adjacency edge) form equivalence classes derived from the programme. With
--superpose (default off), each fitness eval COLLAPSES every superposed leaf to
its best in-class usage via an optimal supply->demand assignment (brute force
<=C! within cap C=4, scipy Hungarian beyond), then scores the condensed types.
Because collapse re-types on the unmerged tree before all checks, counts /
adjacency / quality are unchanged downstream -- no Node field, no graph/operator
changes -- and default OFF is bit-identical.
- programme.py: derive_interchange_classes + interchangeable (S1-S4, locked
thresholds R_SIZE=1.5/R_WIDTH=1.3/R_PROP=1.5, CLASS_CAP=4)
- fitness.py: collapse_superposition, _best_assignment, _usage_quality;
superpose/superpose_class_cap conf knobs; collapse hooked into _evaluate_full
- driver.py/evolve.py: superpose flag plumbed beside leaf_sharing; --superpose
- tests/test_superposition.py: 17 tests (derivation, assignment, end-to-end)
Closes homemaker-py-9o5 (build); validation A/B is homemaker-py-xi7.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:08:46 +01:00
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# Type superposition + collapse (homemaker-py-9o5)
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
|
|
|
|
|
|
def interchange_classes(self) -> list:
|
|
|
|
|
|
"""Interchange equivalence classes (size>=2), derived once from the
|
|
|
|
|
|
programme and cached. Empty list when superposition has nothing to act
|
|
|
|
|
|
on, in which case the collapse is a no-op and scoring matches baseline."""
|
|
|
|
|
|
if self._interchange_classes is None:
|
|
|
|
|
|
from . import programme as _pr
|
|
|
|
|
|
reqs = self._programme or {}
|
|
|
|
|
|
self._interchange_classes = (
|
|
|
|
|
|
_pr.derive_interchange_classes(reqs) if reqs else []
|
|
|
|
|
|
)
|
|
|
|
|
|
return self._interchange_classes
|
|
|
|
|
|
|
2026-07-31 00:16:12 +01:00
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# Multi-use leaves (homemaker-py-1s3, §26 path b)
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
|
|
|
|
|
|
def colocate_pairs(self) -> list:
|
|
|
|
|
|
"""Valid co-location pairs, derived once from the programme and
|
|
|
|
|
|
cached. Empty list when nothing is declared, in which case
|
|
|
|
|
|
``multi_use`` is a no-op and scoring matches baseline."""
|
|
|
|
|
|
if self._colocate_pairs is None:
|
|
|
|
|
|
from . import programme as _pr
|
|
|
|
|
|
reqs = self._programme or {}
|
|
|
|
|
|
self._colocate_pairs = (
|
|
|
|
|
|
_pr.derive_colocate_pairs(reqs) if reqs else []
|
|
|
|
|
|
)
|
|
|
|
|
|
return self._colocate_pairs
|
|
|
|
|
|
|
|
|
|
|
|
def _leaf_co_type(self, leaf: Node) -> "str | None":
|
|
|
|
|
|
"""The leaf's live, valid co_type under ``multi_use``, else ``None``."""
|
|
|
|
|
|
if not self._multi_use or not leaf.co_type or not leaf.type:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if frozenset((leaf.type, leaf.co_type)) in self.colocate_pairs():
|
|
|
|
|
|
return leaf.co_type
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
9o5: type superposition + per-eval collapse (multi-use leaves)
Interchangeable codes (similar size/width/proportion, compatible level/stack,
no adjacency edge) form equivalence classes derived from the programme. With
--superpose (default off), each fitness eval COLLAPSES every superposed leaf to
its best in-class usage via an optimal supply->demand assignment (brute force
<=C! within cap C=4, scipy Hungarian beyond), then scores the condensed types.
Because collapse re-types on the unmerged tree before all checks, counts /
adjacency / quality are unchanged downstream -- no Node field, no graph/operator
changes -- and default OFF is bit-identical.
- programme.py: derive_interchange_classes + interchangeable (S1-S4, locked
thresholds R_SIZE=1.5/R_WIDTH=1.3/R_PROP=1.5, CLASS_CAP=4)
- fitness.py: collapse_superposition, _best_assignment, _usage_quality;
superpose/superpose_class_cap conf knobs; collapse hooked into _evaluate_full
- driver.py/evolve.py: superpose flag plumbed beside leaf_sharing; --superpose
- tests/test_superposition.py: 17 tests (derivation, assignment, end-to-end)
Closes homemaker-py-9o5 (build); validation A/B is homemaker-py-xi7.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:08:46 +01:00
|
|
|
|
def _usage_quality(self, leaf: Node, usage: str) -> float:
|
|
|
|
|
|
"""The usage-DEPENDENT part of a leaf's quality (size x width x
|
|
|
|
|
|
proportion) as if it were typed ``usage``. The remaining factors
|
|
|
|
|
|
(perpendicular, crinkliness, access) and value rate are usage-invariant
|
|
|
|
|
|
within a class, so this is the separable per-leaf collapse objective."""
|
|
|
|
|
|
orig = leaf.type
|
2026-08-01 21:08:32 +01:00
|
|
|
|
orig_share_type = leaf.share_type
|
9o5: type superposition + per-eval collapse (multi-use leaves)
Interchangeable codes (similar size/width/proportion, compatible level/stack,
no adjacency edge) form equivalence classes derived from the programme. With
--superpose (default off), each fitness eval COLLAPSES every superposed leaf to
its best in-class usage via an optimal supply->demand assignment (brute force
<=C! within cap C=4, scipy Hungarian beyond), then scores the condensed types.
Because collapse re-types on the unmerged tree before all checks, counts /
adjacency / quality are unchanged downstream -- no Node field, no graph/operator
changes -- and default OFF is bit-identical.
- programme.py: derive_interchange_classes + interchangeable (S1-S4, locked
thresholds R_SIZE=1.5/R_WIDTH=1.3/R_PROP=1.5, CLASS_CAP=4)
- fitness.py: collapse_superposition, _best_assignment, _usage_quality;
superpose/superpose_class_cap conf knobs; collapse hooked into _evaluate_full
- driver.py/evolve.py: superpose flag plumbed beside leaf_sharing; --superpose
- tests/test_superposition.py: 17 tests (derivation, assignment, end-to-end)
Closes homemaker-py-9o5 (build); validation A/B is homemaker-py-xi7.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:08:46 +01:00
|
|
|
|
leaf.type = usage
|
2026-08-01 21:08:32 +01:00
|
|
|
|
if usage != orig:
|
|
|
|
|
|
# homemaker-py-iio: a stale share (share>1, share_type left over
|
|
|
|
|
|
# from a code this leaf no longer holds) must not spuriously
|
|
|
|
|
|
# reactivate just because THIS hypothetical usage probe happens to
|
|
|
|
|
|
# match the old share_type -- graph.leaf_share reads leaf.type,
|
|
|
|
|
|
# which we have just overridden, so it would otherwise compare the
|
|
|
|
|
|
# stale share_type against the candidate usage instead of the
|
|
|
|
|
|
# leaf's real committed type. Only the leaf's OWN current type
|
|
|
|
|
|
# (usage == orig) may legitimately carry a live share.
|
|
|
|
|
|
leaf.share_type = None
|
9o5: type superposition + per-eval collapse (multi-use leaves)
Interchangeable codes (similar size/width/proportion, compatible level/stack,
no adjacency edge) form equivalence classes derived from the programme. With
--superpose (default off), each fitness eval COLLAPSES every superposed leaf to
its best in-class usage via an optimal supply->demand assignment (brute force
<=C! within cap C=4, scipy Hungarian beyond), then scores the condensed types.
Because collapse re-types on the unmerged tree before all checks, counts /
adjacency / quality are unchanged downstream -- no Node field, no graph/operator
changes -- and default OFF is bit-identical.
- programme.py: derive_interchange_classes + interchangeable (S1-S4, locked
thresholds R_SIZE=1.5/R_WIDTH=1.3/R_PROP=1.5, CLASS_CAP=4)
- fitness.py: collapse_superposition, _best_assignment, _usage_quality;
superpose/superpose_class_cap conf knobs; collapse hooked into _evaluate_full
- driver.py/evolve.py: superpose flag plumbed beside leaf_sharing; --superpose
- tests/test_superposition.py: 17 tests (derivation, assignment, end-to-end)
Closes homemaker-py-9o5 (build); validation A/B is homemaker-py-xi7.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:08:46 +01:00
|
|
|
|
try:
|
|
|
|
|
|
return (
|
|
|
|
|
|
self.quality_size(leaf)
|
|
|
|
|
|
* self.quality_width(leaf)
|
|
|
|
|
|
* self.quality_proportion(leaf)
|
|
|
|
|
|
)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
leaf.type = orig
|
2026-08-01 21:08:32 +01:00
|
|
|
|
leaf.share_type = orig_share_type
|
9o5: type superposition + per-eval collapse (multi-use leaves)
Interchangeable codes (similar size/width/proportion, compatible level/stack,
no adjacency edge) form equivalence classes derived from the programme. With
--superpose (default off), each fitness eval COLLAPSES every superposed leaf to
its best in-class usage via an optimal supply->demand assignment (brute force
<=C! within cap C=4, scipy Hungarian beyond), then scores the condensed types.
Because collapse re-types on the unmerged tree before all checks, counts /
adjacency / quality are unchanged downstream -- no Node field, no graph/operator
changes -- and default OFF is bit-identical.
- programme.py: derive_interchange_classes + interchangeable (S1-S4, locked
thresholds R_SIZE=1.5/R_WIDTH=1.3/R_PROP=1.5, CLASS_CAP=4)
- fitness.py: collapse_superposition, _best_assignment, _usage_quality;
superpose/superpose_class_cap conf knobs; collapse hooked into _evaluate_full
- driver.py/evolve.py: superpose flag plumbed beside leaf_sharing; --superpose
- tests/test_superposition.py: 17 tests (derivation, assignment, end-to-end)
Closes homemaker-py-9o5 (build); validation A/B is homemaker-py-xi7.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:08:46 +01:00
|
|
|
|
|
|
|
|
|
|
def _best_assignment(self, quality: list[list[float]]) -> list[tuple[int, int]]:
|
|
|
|
|
|
"""Maximum-total-quality matching of ``min(rows, cols)`` leaf->slot
|
|
|
|
|
|
pairs. Brute-forces <= C! permutations when the smaller side is within
|
|
|
|
|
|
the class cap (exact and tiny); otherwise solves the equivalent
|
|
|
|
|
|
linear-sum assignment (Hungarian) — both give the optimum because the
|
|
|
|
|
|
objective is separable per leaf (§3 cost note)."""
|
|
|
|
|
|
rows = len(quality)
|
|
|
|
|
|
cols = len(quality[0]) if rows else 0
|
|
|
|
|
|
if rows == 0 or cols == 0:
|
|
|
|
|
|
return []
|
|
|
|
|
|
if min(rows, cols) <= self._class_cap:
|
|
|
|
|
|
import itertools
|
|
|
|
|
|
best: list[tuple[int, int]] = []
|
|
|
|
|
|
best_score = float("-inf")
|
|
|
|
|
|
if rows <= cols:
|
|
|
|
|
|
for sel in itertools.permutations(range(cols), rows):
|
|
|
|
|
|
s = sum(quality[r][sel[r]] for r in range(rows))
|
|
|
|
|
|
if s > best_score:
|
|
|
|
|
|
best_score = s
|
|
|
|
|
|
best = [(r, sel[r]) for r in range(rows)]
|
|
|
|
|
|
else:
|
|
|
|
|
|
for sel in itertools.permutations(range(rows), cols):
|
|
|
|
|
|
s = sum(quality[sel[c]][c] for c in range(cols))
|
|
|
|
|
|
if s > best_score:
|
|
|
|
|
|
best_score = s
|
|
|
|
|
|
best = [(sel[c], c) for c in range(cols)]
|
|
|
|
|
|
return best
|
|
|
|
|
|
from scipy.optimize import linear_sum_assignment
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
ri, ci = linear_sum_assignment(-np.array(quality))
|
|
|
|
|
|
return list(zip(ri.tolist(), ci.tolist()))
|
|
|
|
|
|
|
|
|
|
|
|
def collapse_superposition(self, root: Node) -> None:
|
|
|
|
|
|
"""Re-type each superposed leaf to its best in-class usage (the per-eval
|
|
|
|
|
|
COLLAPSE, homemaker-py-9o5 §1). Runs on the UNMERGED tree before any
|
|
|
|
|
|
check, so counts/adjacency/quality downstream see the condensed types.
|
|
|
|
|
|
|
|
|
|
|
|
Per class: SUPPLY = leaves currently typed into the class; DEMAND = the
|
|
|
|
|
|
class codes expanded by their required counts. The optimal supply->demand
|
|
|
|
|
|
matching assigns each demand slot to the leaf that fits it best; surplus
|
|
|
|
|
|
supply leaves keep their type (a genuine over-supply that scoring still
|
|
|
|
|
|
penalises), unmet demand slots stay absent (a genuine missing room)."""
|
|
|
|
|
|
classes = self.interchange_classes()
|
|
|
|
|
|
if not classes:
|
|
|
|
|
|
return
|
|
|
|
|
|
prog = self._programme or {}
|
|
|
|
|
|
by_type: dict[str, list[Node]] = {}
|
|
|
|
|
|
for lvl in dom_mod.levels(root):
|
|
|
|
|
|
for leaf in lvl.leaves():
|
|
|
|
|
|
if leaf.type:
|
|
|
|
|
|
by_type.setdefault(leaf.type, []).append(leaf)
|
|
|
|
|
|
|
|
|
|
|
|
for cls in classes:
|
|
|
|
|
|
supply = [lf for code in cls for lf in by_type.get(code, [])]
|
|
|
|
|
|
if not supply:
|
|
|
|
|
|
continue
|
|
|
|
|
|
slots: list[str] = []
|
|
|
|
|
|
for code in sorted(cls):
|
|
|
|
|
|
cnt = prog[code].count if code in prog else 0
|
|
|
|
|
|
slots.extend([code] * max(0, cnt))
|
|
|
|
|
|
if not slots:
|
|
|
|
|
|
continue
|
|
|
|
|
|
# Weight each leaf's usage quality by its area: the condensed value is
|
|
|
|
|
|
# sum(quality * value_rate * area), and value_rate is constant within a
|
|
|
|
|
|
# class (all in-class codes are inside rooms), so area is the per-leaf
|
|
|
|
|
|
# weight that makes the matching maximise value, not just mean quality.
|
|
|
|
|
|
quality = [
|
|
|
|
|
|
[self._usage_quality(lf, s) * geometry.area(lf) for s in slots]
|
|
|
|
|
|
for lf in supply
|
|
|
|
|
|
]
|
|
|
|
|
|
for r, c in self._best_assignment(quality):
|
|
|
|
|
|
supply[r].type = slots[c]
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
|
2026-07-18 01:18:36 +01:00
|
|
|
|
# Forbidden-pairing penalty for the global collapse cost matrix: large and
|
|
|
|
|
|
# finite (Hungarian cannot take -inf) yet far below any real value, so the
|
|
|
|
|
|
# optimal matching never uses a level-mismatched pair unless it is forced.
|
|
|
|
|
|
_COLLAPSE_FORBID = -1e12
|
2026-07-18 08:37:02 +01:00
|
|
|
|
# Weight of one avoided fail (a satisfied adjacency or a passing
|
|
|
|
|
|
# size/width/proportion factor) in the collapse objective — far above the
|
|
|
|
|
|
# continuous quality span (~max area) so fail count dominates and raw
|
|
|
|
|
|
# quality only breaks ties; far below the forbid penalty so level holds.
|
|
|
|
|
|
_COLLAPSE_FAIL_W = 1e6
|
2026-07-18 01:18:36 +01:00
|
|
|
|
|
2026-07-26 20:59:07 +01:00
|
|
|
|
def _collapse_value(
|
|
|
|
|
|
self,
|
|
|
|
|
|
lf: Node,
|
|
|
|
|
|
code: str,
|
|
|
|
|
|
lvl: int,
|
|
|
|
|
|
prog: dict,
|
|
|
|
|
|
objective: str,
|
|
|
|
|
|
forbid: float,
|
|
|
|
|
|
fail_w: float,
|
|
|
|
|
|
) -> float:
|
|
|
|
|
|
"""Base (non-adjacency) collapse value of relabelling ``lf`` (on storey
|
|
|
|
|
|
``lvl``) to ``code``: the ``_COLLAPSE_FORBID`` penalty on a level
|
|
|
|
|
|
mismatch, else quality_size*width*proportion*area, plus ``fail_w`` per
|
|
|
|
|
|
passing factor under the ``"threshold"`` objective. Shared by the
|
|
|
|
|
|
collapse_global assignment matrix and the 2-opt polish below so both
|
|
|
|
|
|
score a (leaf, code) pair identically."""
|
|
|
|
|
|
req = prog[code]
|
|
|
|
|
|
if req.level is not None and req.level != lvl:
|
|
|
|
|
|
return forbid
|
|
|
|
|
|
orig = lf.type
|
2026-08-01 21:08:32 +01:00
|
|
|
|
orig_share_type = lf.share_type
|
2026-07-26 20:59:07 +01:00
|
|
|
|
lf.type = code
|
2026-08-01 21:08:32 +01:00
|
|
|
|
if code != orig:
|
|
|
|
|
|
# homemaker-py-iio: see _usage_quality -- a stale share/share_type
|
|
|
|
|
|
# left over from a code this leaf no longer holds must not
|
|
|
|
|
|
# spuriously reactivate just because this hypothetical candidate
|
|
|
|
|
|
# code happens to match it (graph.leaf_share reads leaf.type,
|
|
|
|
|
|
# which is overridden to the candidate here). Only the leaf's own
|
|
|
|
|
|
# current type (code == orig) may legitimately carry a live share.
|
|
|
|
|
|
lf.share_type = None
|
2026-07-26 20:59:07 +01:00
|
|
|
|
try:
|
|
|
|
|
|
qs = self.quality_size(lf)
|
|
|
|
|
|
qw = self.quality_width(lf)
|
|
|
|
|
|
qp = self.quality_proportion(lf)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
lf.type = orig
|
2026-08-01 21:08:32 +01:00
|
|
|
|
lf.share_type = orig_share_type
|
2026-07-26 20:59:07 +01:00
|
|
|
|
val = qs * qw * qp * geometry.area(lf)
|
|
|
|
|
|
if objective == "threshold":
|
|
|
|
|
|
passes = (
|
|
|
|
|
|
(qs >= FAIL_THRESHOLD) + (qw >= FAIL_THRESHOLD) + (qp >= FAIL_THRESHOLD)
|
|
|
|
|
|
)
|
|
|
|
|
|
val += fail_w * passes
|
|
|
|
|
|
return val
|
|
|
|
|
|
|
|
|
|
|
|
def _two_opt_adjacency_polish(
|
|
|
|
|
|
self,
|
|
|
|
|
|
supply: list[Node],
|
|
|
|
|
|
levels_of: list[int],
|
|
|
|
|
|
graphs: list,
|
|
|
|
|
|
code_adj: dict[str, list[str]],
|
|
|
|
|
|
prog: dict,
|
|
|
|
|
|
objective: str,
|
|
|
|
|
|
forbid: float,
|
|
|
|
|
|
fail_w: float,
|
|
|
|
|
|
max_passes: int = 20,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""homemaker-py-9wi: a local-search pass beyond collapse_global's Jacobi
|
|
|
|
|
|
adjacency relaxation. Jacobi re-solves a LINEAR assignment each round
|
|
|
|
|
|
holding neighbours' labels fixed from the previous round -- exact per
|
|
|
|
|
|
round, but the true objective is quadratic (a satisfied adjacency
|
|
|
|
|
|
depends on a PAIR of labels), so synchronous Jacobi can plateau short
|
|
|
|
|
|
of the joint optimum. This adds 2-opt: for every same-level pair of
|
|
|
|
|
|
supply leaves, try swapping their CURRENT labels and keep the swap
|
|
|
|
|
|
only if it strictly increases the total reward (own quality/threshold
|
|
|
|
|
|
value + fail_w per satisfied adjacency) summed over the two leaves and
|
|
|
|
|
|
every leaf adjacent to either -- the only cells a label swap between
|
|
|
|
|
|
i and j can change. Repeats to a fixpoint (or ``max_passes``).
|
|
|
|
|
|
|
|
|
|
|
|
Same-level-only pairing keeps the hard level constraint for free: both
|
|
|
|
|
|
codes already matched their own leaf's level before the swap, and the
|
|
|
|
|
|
two leaves share a level, so the swap is valid on both sides. A swap
|
|
|
|
|
|
is applied only when it is a STRICT improvement, so this can only
|
|
|
|
|
|
reduce, never increase, the fail count -- monotone by construction,
|
|
|
|
|
|
like the Hungarian solve it refines."""
|
|
|
|
|
|
from . import graph as graph_mod
|
|
|
|
|
|
|
|
|
|
|
|
idx_of_leaf = {id(lf): i for i, lf in enumerate(supply)}
|
|
|
|
|
|
|
|
|
|
|
|
def reward(idx: int) -> float:
|
|
|
|
|
|
lf = supply[idx]
|
|
|
|
|
|
code = lf.type
|
|
|
|
|
|
val = self._collapse_value(
|
|
|
|
|
|
lf, code, levels_of[idx], prog, objective, forbid, fail_w
|
|
|
|
|
|
)
|
|
|
|
|
|
if val <= forbid:
|
|
|
|
|
|
return val
|
|
|
|
|
|
G = graphs[levels_of[idx]]
|
|
|
|
|
|
sat = sum(1 for ac in code_adj.get(code, ()) if graph_mod.has_adjacency(lf, ac, G))
|
|
|
|
|
|
return val + fail_w * sat
|
|
|
|
|
|
|
|
|
|
|
|
def affected(i: int, j: int) -> set[int]:
|
|
|
|
|
|
aff = {i, j}
|
|
|
|
|
|
for k in (i, j):
|
|
|
|
|
|
lf = supply[k]
|
|
|
|
|
|
G = graphs[levels_of[k]]
|
|
|
|
|
|
if G.has_node(lf):
|
|
|
|
|
|
for nb in G.neighbors(lf):
|
|
|
|
|
|
nidx = idx_of_leaf.get(id(nb))
|
|
|
|
|
|
if nidx is not None:
|
|
|
|
|
|
aff.add(nidx)
|
|
|
|
|
|
return aff
|
|
|
|
|
|
|
|
|
|
|
|
by_level: dict[int, list[int]] = {}
|
|
|
|
|
|
for idx, lvl in enumerate(levels_of):
|
|
|
|
|
|
by_level.setdefault(lvl, []).append(idx)
|
|
|
|
|
|
|
|
|
|
|
|
changed = True
|
|
|
|
|
|
passes = 0
|
|
|
|
|
|
while changed and passes < max_passes:
|
|
|
|
|
|
changed = False
|
|
|
|
|
|
passes += 1
|
|
|
|
|
|
for idxs in by_level.values():
|
|
|
|
|
|
for a in range(len(idxs)):
|
|
|
|
|
|
for b in range(a + 1, len(idxs)):
|
|
|
|
|
|
i, j = idxs[a], idxs[b]
|
|
|
|
|
|
ci, cj = supply[i].type, supply[j].type
|
|
|
|
|
|
if ci == cj:
|
|
|
|
|
|
continue
|
|
|
|
|
|
aff = affected(i, j)
|
|
|
|
|
|
before = sum(reward(k) for k in aff)
|
|
|
|
|
|
supply[i].type, supply[j].type = cj, ci
|
|
|
|
|
|
after = sum(reward(k) for k in aff)
|
|
|
|
|
|
if after > before + 1e-9:
|
|
|
|
|
|
changed = True
|
|
|
|
|
|
else:
|
|
|
|
|
|
supply[i].type, supply[j].type = ci, cj
|
|
|
|
|
|
|
2026-07-18 01:18:36 +01:00
|
|
|
|
def collapse_global(
|
2026-07-18 08:37:02 +01:00
|
|
|
|
self,
|
|
|
|
|
|
root: Node,
|
|
|
|
|
|
adjacency: bool = True,
|
|
|
|
|
|
objective: str = "threshold",
|
94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
|
preserve_public_access: bool = True,
|
2026-07-18 08:37:02 +01:00
|
|
|
|
iters: int = 6,
|
2026-07-26 20:59:07 +01:00
|
|
|
|
local_search: bool = False,
|
|
|
|
|
|
local_search_passes: int = 20,
|
2026-07-18 01:18:36 +01:00
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Finish-time GLOBAL cell->room collapse (homemaker-py-94g): relabel
|
|
|
|
|
|
every inside-room leaf across the whole building to the required room it
|
|
|
|
|
|
fits best, via one optimal assignment over the full leaf set — the 9o5
|
|
|
|
|
|
per-class collapse generalised to N inside leaves <-> M required rooms.
|
|
|
|
|
|
|
|
|
|
|
|
SUPPLY = leaves whose type is an assignable programme room code; DEMAND =
|
|
|
|
|
|
every such code expanded by its required count, tagged with its required
|
|
|
|
|
|
level. Assignable codes EXCLUDE any starting c/o/s: check_space_counts
|
|
|
|
|
|
(graph.py) skips those as circulation/outside/sahn — including room codes
|
|
|
|
|
|
that collide with the convention (cr1, st1, st2) — so those leaves form
|
|
|
|
|
|
the circulation/structure skeleton and must not be relabelled. Surplus
|
|
|
|
|
|
leaves keep their type (genuine over-supply); unmet demand stays absent
|
|
|
|
|
|
(genuine missing room).
|
|
|
|
|
|
|
|
|
|
|
|
HARD LEVEL constraint: a leaf may only take a room whose required level
|
|
|
|
|
|
matches its storey (a -1e12 forbid penalty), so the collapse never adds a
|
|
|
|
|
|
wrong-level fail. ADJACENCY (when ``adjacency``): the objective adds a
|
|
|
|
|
|
bonus for each of a code's required adjacencies satisfied at a leaf given
|
|
|
|
|
|
the CURRENT labelling. Because geometry is fixed at finish time, each
|
|
|
|
|
|
leaf's graph neighbours are fixed and only labels move, so the problem is
|
|
|
|
|
|
a labelling relaxation: warm-started from the evolved labels, each pass is
|
|
|
|
|
|
a linear assignment over quality + adjacency-bonus computed from the
|
|
|
|
|
|
previous pass, iterated to a fixpoint (Jacobi/WFC-style). Maximising
|
2026-07-18 08:37:02 +01:00
|
|
|
|
satisfied adjacencies minimises adjacency fails.
|
|
|
|
|
|
|
|
|
|
|
|
OBJECTIVE selects the per-leaf base value: ``"quality"`` maximises the
|
|
|
|
|
|
separable continuous fit sum(usage_quality * area) collapse_superposition
|
|
|
|
|
|
uses; ``"threshold"`` maximises the COUNT of size/width/proportion factors
|
|
|
|
|
|
that PASS (>= FAIL_THRESHOLD), with continuous fit only as a tiebreak.
|
|
|
|
|
|
Continuous quality can trade one leaf just over threshold for another just
|
|
|
|
|
|
under (a fail SHUFFLE); the threshold objective optimises the fail count
|
|
|
|
|
|
directly. Under both, a satisfied adjacency and a passing factor carry the
|
|
|
|
|
|
same weight (_COLLAPSE_FAIL_W = one avoided fail), so the collapse
|
|
|
|
|
|
minimises (adjacency + size/width/proportion) fails jointly.
|
2026-07-18 01:18:36 +01:00
|
|
|
|
|
2026-07-26 23:33:40 +01:00
|
|
|
|
LOCAL_SEARCH (homemaker-py-9wi, default False HERE): after the Jacobi
|
|
|
|
|
|
loop above reaches its fixpoint, run a 2-opt polish
|
|
|
|
|
|
(_two_opt_adjacency_polish) that tries swapping the labels of every
|
|
|
|
|
|
same-level pair of supply leaves and keeps a swap only if it strictly
|
|
|
|
|
|
improves the total reward. Jacobi re-solves a LINEAR assignment each
|
|
|
|
|
|
round holding neighbours' labels fixed from the previous round, so it
|
|
|
|
|
|
can plateau short of the true quadratic-assignment optimum (a satisfied
|
|
|
|
|
|
adjacency depends on a PAIR of labels, not one); 2-opt reaches past that
|
|
|
|
|
|
plateau. Monotone by construction (only strictly-improving swaps are
|
|
|
|
|
|
kept) and cheap (<1s on the largest file) as a ONE-SHOT finish-time
|
|
|
|
|
|
polish — but this method is also called on the UNMERGED tree every
|
|
|
|
|
|
fitness eval when collapse_insearch (qpk) is on, so the method-level
|
|
|
|
|
|
default stays False to keep that hot path cheap. A 46-file A/B sweep
|
|
|
|
|
|
across harbor-house (12) and programme-house (34) found 0 regressions
|
|
|
|
|
|
and 2 improvements (evolved-anneal-3M.dom 21->19, a82f07068e4408fdd0d5e
|
|
|
|
|
|
3dc469a8dee.dom 3->2 fails) for the finish-time, one-shot use, so
|
|
|
|
|
|
homemaker-py-cdl turned it on by default there: homemaker-collapse
|
|
|
|
|
|
--local-search and homemaker-evolve --collapse-local-search both
|
|
|
|
|
|
default True and pass it through explicitly.
|
2026-07-26 20:59:07 +01:00
|
|
|
|
|
94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
|
PRESERVE_PUBLIC_ACCESS pins the room leaf that solely provides the
|
|
|
|
|
|
building's street access (an l/k neighbour of a public outside leaf, with
|
|
|
|
|
|
no circulation fallback) so the collapse cannot drop the building-level
|
|
|
|
|
|
"no outside public access" check — the one recurring regression the
|
|
|
|
|
|
per-leaf objective cannot see (it is existential and building-scoped).
|
|
|
|
|
|
|
2026-07-18 01:18:36 +01:00
|
|
|
|
One-shot finish-time pass on a committed layout, not a per-eval re-type."""
|
94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
|
from collections import Counter
|
|
|
|
|
|
from . import graph as graph_mod
|
|
|
|
|
|
|
2026-08-02 10:52:40 +01:00
|
|
|
|
# Defensive (homemaker-py-cvw): geometry._cache is id()-keyed and only
|
|
|
|
|
|
# safe when cold or exclusively populated by this tree; a stale entry
|
|
|
|
|
|
# from a gc'd tree at a recycled address could otherwise alias in.
|
|
|
|
|
|
geometry.clear_cache()
|
|
|
|
|
|
|
2026-08-02 10:45:12 +01:00
|
|
|
|
# homemaker-py-r5a: drop any stale share/share_type BEFORE this pass
|
|
|
|
|
|
# relabels anything, so a leaf relabelled back to the code its stale
|
|
|
|
|
|
# stamp names cannot resurrect a multiplicity credit (see
|
|
|
|
|
|
# dom.canonicalize_shares).
|
|
|
|
|
|
dom_mod.canonicalize_shares(root)
|
|
|
|
|
|
|
2026-07-18 01:18:36 +01:00
|
|
|
|
prog = self._programme or {}
|
|
|
|
|
|
if not prog:
|
|
|
|
|
|
return
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
room_codes = {c for c in prog if not dom_mod.is_generic(c)}
|
2026-07-18 01:18:36 +01:00
|
|
|
|
if not room_codes:
|
|
|
|
|
|
return
|
|
|
|
|
|
lvls = dom_mod.levels(root)
|
94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
|
graphs = (
|
|
|
|
|
|
graph_mod.build_graphs(root, self.conf("door_width") or 1.2)
|
|
|
|
|
|
if (adjacency or preserve_public_access)
|
|
|
|
|
|
else None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
pinned = (
|
|
|
|
|
|
self._public_access_pins(root, graphs, lvls, room_codes)
|
|
|
|
|
|
if (preserve_public_access and graphs is not None)
|
|
|
|
|
|
else set()
|
|
|
|
|
|
)
|
|
|
|
|
|
supply = [
|
|
|
|
|
|
lf
|
|
|
|
|
|
for lvl in lvls
|
|
|
|
|
|
for lf in lvl.leaves()
|
|
|
|
|
|
if lf.type in room_codes and id(lf) not in pinned
|
|
|
|
|
|
]
|
2026-07-18 01:18:36 +01:00
|
|
|
|
if not supply:
|
|
|
|
|
|
return
|
94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
|
# Demand = room-code counts, minus one slot per pinned leaf (its instance
|
|
|
|
|
|
# is already met by the pin, so it must not be demanded of another leaf).
|
|
|
|
|
|
slot_counts = Counter({c: max(0, prog[c].count) for c in room_codes})
|
|
|
|
|
|
if pinned:
|
|
|
|
|
|
for lvl in lvls:
|
|
|
|
|
|
for lf in lvl.leaves():
|
|
|
|
|
|
if id(lf) in pinned and slot_counts.get(lf.type, 0) > 0:
|
|
|
|
|
|
slot_counts[lf.type] -= 1
|
|
|
|
|
|
slots = [c for c in sorted(slot_counts) for _ in range(slot_counts[c])]
|
2026-07-18 01:18:36 +01:00
|
|
|
|
if not slots:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
forbid = self._COLLAPSE_FORBID
|
2026-07-18 08:37:02 +01:00
|
|
|
|
fail_w = self._COLLAPSE_FAIL_W
|
2026-07-18 01:18:36 +01:00
|
|
|
|
levels_of = [dom_mod.level_of(lf) for lf in supply]
|
2026-07-18 08:37:02 +01:00
|
|
|
|
# Base per-cell value: forbid on level mismatch, else the separable fit.
|
|
|
|
|
|
# In "threshold" mode add fail_w per passing size/width/proportion factor
|
|
|
|
|
|
# so the matching maximises passes first, continuous fit only as tiebreak.
|
2026-07-26 20:59:07 +01:00
|
|
|
|
base: list[list[float]] = [
|
|
|
|
|
|
[
|
|
|
|
|
|
self._collapse_value(lf, code, levels_of[i], prog, objective, forbid, fail_w)
|
|
|
|
|
|
for code in slots
|
|
|
|
|
|
]
|
|
|
|
|
|
for i, lf in enumerate(supply)
|
|
|
|
|
|
]
|
2026-07-18 01:18:36 +01:00
|
|
|
|
|
|
|
|
|
|
if not adjacency:
|
|
|
|
|
|
for r, c in self._best_assignment(base):
|
|
|
|
|
|
if base[r][c] > forbid:
|
|
|
|
|
|
supply[r].type = slots[c]
|
|
|
|
|
|
return
|
|
|
|
|
|
|
94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
|
# Adjacency relaxation on the pre-merge base graph (built above, fixed
|
2026-07-18 08:37:02 +01:00
|
|
|
|
# geometry). A satisfied adjacency is worth fail_w — one avoided fail,
|
|
|
|
|
|
# the same unit as a passing factor — so both are minimised jointly.
|
2026-07-18 01:18:36 +01:00
|
|
|
|
code_adj = {code: prog[code].adjacency for code in set(slots)}
|
|
|
|
|
|
|
|
|
|
|
|
prev_labels: list[str | None] = None # type: ignore[assignment]
|
|
|
|
|
|
for _ in range(max(1, iters)):
|
|
|
|
|
|
quality = [list(row) for row in base]
|
|
|
|
|
|
for i, lf in enumerate(supply):
|
|
|
|
|
|
G = graphs[levels_of[i]]
|
|
|
|
|
|
for j, code in enumerate(slots):
|
|
|
|
|
|
if quality[i][j] <= forbid:
|
|
|
|
|
|
continue
|
|
|
|
|
|
sat = sum(
|
|
|
|
|
|
1
|
|
|
|
|
|
for ac in code_adj[code]
|
|
|
|
|
|
if graph_mod.has_adjacency(lf, ac, G)
|
|
|
|
|
|
)
|
2026-07-18 08:37:02 +01:00
|
|
|
|
quality[i][j] += fail_w * sat
|
2026-07-18 01:18:36 +01:00
|
|
|
|
assign = self._best_assignment(quality)
|
|
|
|
|
|
new_labels: list[str | None] = [lf.type for lf in supply]
|
|
|
|
|
|
for r, c in assign:
|
|
|
|
|
|
if quality[r][c] > forbid:
|
|
|
|
|
|
new_labels[r] = slots[c]
|
|
|
|
|
|
# Apply synchronously so the next pass reads the updated neighbours.
|
|
|
|
|
|
for lf, lab in zip(supply, new_labels):
|
|
|
|
|
|
lf.type = lab
|
|
|
|
|
|
if new_labels == prev_labels:
|
|
|
|
|
|
break
|
|
|
|
|
|
prev_labels = new_labels
|
|
|
|
|
|
|
2026-07-26 20:59:07 +01:00
|
|
|
|
if local_search:
|
|
|
|
|
|
self._two_opt_adjacency_polish(
|
|
|
|
|
|
supply,
|
|
|
|
|
|
levels_of,
|
|
|
|
|
|
graphs,
|
|
|
|
|
|
code_adj,
|
|
|
|
|
|
prog,
|
|
|
|
|
|
objective,
|
|
|
|
|
|
forbid,
|
|
|
|
|
|
fail_w,
|
|
|
|
|
|
max_passes=local_search_passes,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
|
def _public_access_pins(
|
|
|
|
|
|
self, root: Node, graphs: list, lvls: list, room_codes: set
|
|
|
|
|
|
) -> set[int]:
|
|
|
|
|
|
"""id()s of room leaves to hold fixed so the building keeps street access
|
|
|
|
|
|
across a collapse. If a ground circulation leaf already gives public
|
|
|
|
|
|
access it is invariant (circulation is never relabelled) — return empty.
|
|
|
|
|
|
Otherwise, for each outside leaf that provides public access solely via an
|
|
|
|
|
|
l/k ROOM neighbour (no circulation fallback), pin one such neighbour."""
|
|
|
|
|
|
for lvl in lvls:
|
|
|
|
|
|
for lf in lvl.leaves():
|
|
|
|
|
|
if (
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
lf.type == "C"
|
94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
|
and self._public_access(lf, root) is not None
|
|
|
|
|
|
):
|
|
|
|
|
|
return set()
|
|
|
|
|
|
pins: set[int] = set()
|
|
|
|
|
|
for li, lvl in enumerate(lvls):
|
|
|
|
|
|
G = graphs[li]
|
|
|
|
|
|
for lf in lvl.leaves():
|
|
|
|
|
|
if not G.has_node(lf):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if not self._public_access_outside(lf, G, root):
|
|
|
|
|
|
continue
|
|
|
|
|
|
nbs = list(G.neighbors(lf))
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
if any(nb.type == "C" for nb in nbs):
|
94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
|
continue # circulation neighbour keeps access invariant
|
§39.7: access requirements become a declared `usage:` attribute (homemaker-py-sel)
Closes the second namespace sharing a first character with programme codes: the
usage prefixes b/t/l/k, under which a room silently inherited another room's
connectivity rules from its spelling.
usage is a plain, MANDATORY attribute of the space definition -- not a lookup
table. An interim design proposed a top-level usage_classes: table binding
author-coined names to behaviour; withdrawn, because an indirect name->behaviour
mapping living apart from the thing it describes is exactly the shape of the
prefix rule §39 exists to remove, it would be the only such table in a schema
where every other space property is a plain attribute, and the need it served
was already met -- "building specific" is about what a room is CALLED, and
name: is already free text.
Rule that settles it: a usage value exists iff the engine treats it differently
somewhere. Config selects among behaviours; it cannot invent them.
- programme.USAGES (living/kitchen/bedroom/toilet/utility/none) plus the
behaviour groupings PRIVATE_USAGES / PRIVATE_STRIPS / TOILET_STRIPS /
SOCIABLE_USAGES. Missing or unknown usage is a load error naming the code,
from BOTH parse paths.
- Code-level, never leaf-level: usage_of(leaf.type) is looked up fresh, so a
retype changes the class automatically. 51 sites assign leaf.type, and
share/share_type plus the r5a resurrection are the precedent for why
leaf-level attributes rot.
- graph.has_circulation takes the usage map and trims on declared class;
fitness.access and the public-access check likewise. fitness._t0 is DELETED --
no first-character type test remains anywhere in the codebase.
- utility is distinct from bedroom (same access requirements today) because it
is a different use and gives derive_interchange_classes an axis to relax on.
- A toilet now keeps its edge to a terminal room -- the Brand adjacency, which
the old b-before-t loop ordering severed.
- All 107 corpus entries migrated by experiments/migrate_usage_key.py, comments
and layout preserved.
MEASURED -- the connectivity model was ~4x too permissive. `none` is not
neutral: nothing is trimmed, so the graph may route THROUGH the room, and 34 of
52 codes had no class (Dental Surgery, Records Room, Utilities Closet all served
as corridors). Edges trimmed, prefix-inferred vs declared, 3 seeds each:
harbor-house 18 (9%) -> 79 (39%) inaccessible fails 0 -> 4
health-centre 12 (8%) -> 59 (40%) inaccessible fails 2 -> 3
maple-court 53 (17%) -> 123 (39%) inaccessible fails 1 -> 5
Re-baseline (seed 1, 20k, harbor): 58 fails (15h/43s) -> 61 (16h/45s), now
reporting 1-inaccessible-usable-space x2 plus level 0 and level 1 not connected.
The count rose because the objective got honest -- those failures were always
true of the layout and the old model could not see them. Every harbor number
before this was measured against a graph crediting routes through store
cupboards.
Sharpens §38.2: the objective pays x60-85 to delete circulation, and until now
the deleted corridors were not missed because storage stood in for them. With
that substitution gone, homemaker-py-2v1 is the remaining half -- and now
measurable, because the fails it should prevent actually fire.
350 passed (+5 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 13:39:41 +00:00
|
|
|
|
usages = self.usages()
|
94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
|
for nb in nbs:
|
§39.7: access requirements become a declared `usage:` attribute (homemaker-py-sel)
Closes the second namespace sharing a first character with programme codes: the
usage prefixes b/t/l/k, under which a room silently inherited another room's
connectivity rules from its spelling.
usage is a plain, MANDATORY attribute of the space definition -- not a lookup
table. An interim design proposed a top-level usage_classes: table binding
author-coined names to behaviour; withdrawn, because an indirect name->behaviour
mapping living apart from the thing it describes is exactly the shape of the
prefix rule §39 exists to remove, it would be the only such table in a schema
where every other space property is a plain attribute, and the need it served
was already met -- "building specific" is about what a room is CALLED, and
name: is already free text.
Rule that settles it: a usage value exists iff the engine treats it differently
somewhere. Config selects among behaviours; it cannot invent them.
- programme.USAGES (living/kitchen/bedroom/toilet/utility/none) plus the
behaviour groupings PRIVATE_USAGES / PRIVATE_STRIPS / TOILET_STRIPS /
SOCIABLE_USAGES. Missing or unknown usage is a load error naming the code,
from BOTH parse paths.
- Code-level, never leaf-level: usage_of(leaf.type) is looked up fresh, so a
retype changes the class automatically. 51 sites assign leaf.type, and
share/share_type plus the r5a resurrection are the precedent for why
leaf-level attributes rot.
- graph.has_circulation takes the usage map and trims on declared class;
fitness.access and the public-access check likewise. fitness._t0 is DELETED --
no first-character type test remains anywhere in the codebase.
- utility is distinct from bedroom (same access requirements today) because it
is a different use and gives derive_interchange_classes an axis to relax on.
- A toilet now keeps its edge to a terminal room -- the Brand adjacency, which
the old b-before-t loop ordering severed.
- All 107 corpus entries migrated by experiments/migrate_usage_key.py, comments
and layout preserved.
MEASURED -- the connectivity model was ~4x too permissive. `none` is not
neutral: nothing is trimmed, so the graph may route THROUGH the room, and 34 of
52 codes had no class (Dental Surgery, Records Room, Utilities Closet all served
as corridors). Edges trimmed, prefix-inferred vs declared, 3 seeds each:
harbor-house 18 (9%) -> 79 (39%) inaccessible fails 0 -> 4
health-centre 12 (8%) -> 59 (40%) inaccessible fails 2 -> 3
maple-court 53 (17%) -> 123 (39%) inaccessible fails 1 -> 5
Re-baseline (seed 1, 20k, harbor): 58 fails (15h/43s) -> 61 (16h/45s), now
reporting 1-inaccessible-usable-space x2 plus level 0 and level 1 not connected.
The count rose because the objective got honest -- those failures were always
true of the layout and the old model could not see them. Every harbor number
before this was measured against a graph crediting routes through store
cupboards.
Sharpens §38.2: the objective pays x60-85 to delete circulation, and until now
the deleted corridors were not missed because storage stood in for them. With
that substitution gone, homemaker-py-2v1 is the remaining half -- and now
measurable, because the fails it should prevent actually fire.
350 passed (+5 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 13:39:41 +00:00
|
|
|
|
if usages.get(nb.type) in _programme.SOCIABLE_USAGES:
|
94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
|
pins.add(id(nb))
|
|
|
|
|
|
break
|
|
|
|
|
|
return pins
|
|
|
|
|
|
|
|
|
|
|
|
def collapse_finish(self, root: Node, **kw) -> tuple[Node, int, int, bool]:
|
|
|
|
|
|
"""Keep-better finish-time collapse: apply :meth:`collapse_global` to a
|
|
|
|
|
|
copy and return it only if it does not INCREASE the fail count, else the
|
|
|
|
|
|
original — a strictly monotone polish (safety belt; collapse_global is
|
|
|
|
|
|
already monotone on the harbor-house set but not proven so in general).
|
|
|
|
|
|
|
|
|
|
|
|
Returns ``(tree, base_fails, collapsed_fails, applied)``. Both the input
|
|
|
|
|
|
and returned trees are UNMERGED — scoring is done on throwaway deepcopies
|
2026-08-05 07:52:44 +01:00
|
|
|
|
because ``score_with_fails`` merges the tree in place.
|
|
|
|
|
|
|
|
|
|
|
|
homemaker-py-sd3: ``base_fails``/``cand_fails`` are measured with
|
|
|
|
|
|
``collapse_insearch`` forced off, regardless of how ``self`` was
|
|
|
|
|
|
configured. The guard's job is to protect the CANONICAL fail count of
|
|
|
|
|
|
the written ``.dom`` — what ``homemaker-fitness`` reports on disk with
|
|
|
|
|
|
no in-search override — not this run's in-search objective. Scoring
|
|
|
|
|
|
with ``collapse_insearch`` on made the guard vacuous: ``score_with_fails``
|
|
|
|
|
|
re-applies its own ``collapse_global`` pass before counting fails, so
|
|
|
|
|
|
``base_fails`` already reflected an auto-collapsed tree and came out
|
|
|
|
|
|
equal to ``cand_fails`` regardless of what this method's own explicit
|
|
|
|
|
|
collapse actually did."""
|
94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
|
import copy
|
|
|
|
|
|
|
2026-08-05 07:52:44 +01:00
|
|
|
|
saved_insearch = self._collapse_insearch
|
|
|
|
|
|
self._collapse_insearch = False
|
|
|
|
|
|
try:
|
|
|
|
|
|
base_fails = len(self.score_with_fails(copy.deepcopy(root))[1])
|
|
|
|
|
|
cand = copy.deepcopy(root)
|
|
|
|
|
|
self.collapse_global(cand, **kw)
|
|
|
|
|
|
cand_fails = len(self.score_with_fails(copy.deepcopy(cand))[1])
|
|
|
|
|
|
finally:
|
|
|
|
|
|
self._collapse_insearch = saved_insearch
|
94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's
only street access is an l/k ROOM neighbour of a public outside leaf (no
circulation fallback — an existential building-level check the per-leaf
objective can't see), that leaf is pinned (kept, its demand slot decremented)
so the collapse can't drop "no outside public access". Best layout 15→13
becomes 15→12 with zero new fails; sweep total 172→171, still monotone.
collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper,
scores on throwaway copies (score_with_fails merges in place), returns the
collapse only if fails don't increase.
Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical
re-score); evolve.py runs it after the sharing polish behind --collapse/
--no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies
it to an existing .dom, writing <stem>.collapsed.dom.
tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s
exclusion, no-op safety, keep-better/unmerged. 267 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-18 10:29:44 +01:00
|
|
|
|
if cand_fails <= base_fails:
|
|
|
|
|
|
return cand, base_fails, cand_fails, True
|
|
|
|
|
|
return copy.deepcopy(root), base_fails, cand_fails, False
|
|
|
|
|
|
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
def conf(self, key: str):
|
|
|
|
|
|
v = self._conf.get(key)
|
|
|
|
|
|
if v is not None:
|
|
|
|
|
|
return v
|
|
|
|
|
|
return CONF_DEFAULTS.get(key)
|
|
|
|
|
|
|
|
|
|
|
|
def cost(self, key: str) -> float:
|
|
|
|
|
|
v = self._cost.get(key)
|
|
|
|
|
|
if v is not None:
|
|
|
|
|
|
return v
|
|
|
|
|
|
return COST_DEFAULTS.get(key, 0.0)
|
|
|
|
|
|
|
|
|
|
|
|
def preprocess_building(self, root: Node) -> None:
|
|
|
|
|
|
"""Sahn-to-Outside type conversion (``Building.pm::preprocess_building``).
|
|
|
|
|
|
Run BEFORE graph build and merge_divided — it changes merge outcomes."""
|
|
|
|
|
|
if self.conf("allow_sahn_circulation"):
|
|
|
|
|
|
return
|
|
|
|
|
|
for lvl in dom_mod.levels(root):
|
|
|
|
|
|
for leaf in lvl.leaves():
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
if leaf.type == "S":
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
leaf.type = "O"
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# Programme-driven parameter lookup (ProgrammeDriven.pm:29-69)
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
|
|
|
|
|
|
def get_space_params(self, code: str, param: str) -> list[float]:
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
# §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":
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
v = self.conf(f"{param}_circulation")
|
|
|
|
|
|
if v is not None:
|
|
|
|
|
|
return v
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
if code in dom_mod.GENERIC_OUTSIDE:
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
v = self.conf(f"{param}_outside")
|
|
|
|
|
|
if v is not None:
|
|
|
|
|
|
return v
|
|
|
|
|
|
sp = self.spaces.get(code) # exact-key match, as in Perl
|
|
|
|
|
|
if sp is not None and param in sp:
|
|
|
|
|
|
return sp[param]
|
2026-06-13 22:23:03 +01:00
|
|
|
|
if param == "width" and sp is not None:
|
|
|
|
|
|
# Derive a sane width from size and proportion rather than
|
|
|
|
|
|
# falling back to width_inside [4.0, 1.0], which is impossible
|
|
|
|
|
|
# for small programme spaces (e.g. a 3 m² WC).
|
|
|
|
|
|
size = sp.get("size") or self.conf("size_inside") or _PARAM_FALLBACKS["size"]
|
|
|
|
|
|
proportion = sp.get("proportion") or self.conf("proportion_inside") or _PARAM_FALLBACKS["proportion"]
|
|
|
|
|
|
target = (size[0] / proportion[0]) ** 0.5
|
|
|
|
|
|
sigma = max(0.1, target * size[1] / (2.0 * size[0]))
|
|
|
|
|
|
return [target, sigma]
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
v = self.conf(f"{param}_inside")
|
|
|
|
|
|
if v is not None:
|
|
|
|
|
|
return v
|
|
|
|
|
|
return _PARAM_FALLBACKS.get(param)
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# Quality terms (Leaf.pm)
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
|
|
|
|
|
|
def quality_perpendicular(self, leaf: Node) -> float:
|
|
|
|
|
|
sigma = self.conf(
|
|
|
|
|
|
"perpendicular_outside" if dom_mod.is_outside(leaf) else "perpendicular_inside"
|
|
|
|
|
|
)
|
|
|
|
|
|
score = 1.0
|
|
|
|
|
|
for i in range(4):
|
|
|
|
|
|
# 1.570796: Urb::Dom::Perpendicular hard-codes this, not pi/2
|
|
|
|
|
|
score *= gaussian(geometry.angle(leaf, i), 1.0, 1.570796, sigma)
|
|
|
|
|
|
return score
|
|
|
|
|
|
|
|
|
|
|
|
def quality_proportion(self, leaf: Node) -> float:
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
t0 = _generic_class(leaf)
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
if t0 in ("o", "s"):
|
|
|
|
|
|
params = self.conf("proportion_outside")
|
|
|
|
|
|
elif t0 == "c":
|
|
|
|
|
|
params = self.conf("proportion_circulation")
|
|
|
|
|
|
else:
|
|
|
|
|
|
params = self.get_space_params(leaf.type, "proportion")
|
2026-07-31 00:16:12 +01:00
|
|
|
|
co_type = self._leaf_co_type(leaf)
|
|
|
|
|
|
if co_type:
|
2026-07-31 10:20:49 +01:00
|
|
|
|
# 1s3: A/B-measured (DESIGN.md §33) — the precision-weighted
|
|
|
|
|
|
# combination (one intermediate, narrower target) beat both
|
|
|
|
|
|
# the naive max/min hack AND the max-of-two mixture on the
|
|
|
|
|
|
# health-centre programme; the mixture's permissiveness (any
|
|
|
|
|
|
# width/aspect satisfying the WEAKER of the two codes scores
|
|
|
|
|
|
# 1.0) under-constrains the search on tightly-packed
|
|
|
|
|
|
# programmes even though it is the more appealing model.
|
2026-07-31 00:16:12 +01:00
|
|
|
|
co_params = self.get_space_params(co_type, "proportion")
|
2026-07-31 10:20:49 +01:00
|
|
|
|
params = _gaussian_product(params[0], params[1],
|
|
|
|
|
|
co_params[0], co_params[1])
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
aspect = geometry.aspect(leaf)
|
2026-07-31 10:20:49 +01:00
|
|
|
|
return _clipped_gaussian(aspect, params[0], params[1], "below")
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
|
|
|
|
|
|
def quality_size(self, leaf: Node) -> float:
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
t0 = _generic_class(leaf)
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
if t0 in ("o", "s"):
|
|
|
|
|
|
return 1.0
|
|
|
|
|
|
if t0 == "c":
|
|
|
|
|
|
params = self.conf("size_circulation")
|
|
|
|
|
|
else:
|
|
|
|
|
|
params = self.get_space_params(leaf.type, "size")
|
2026-06-24 08:30:26 +01:00
|
|
|
|
target, sigma = params[0], params[1]
|
2026-07-31 00:16:12 +01:00
|
|
|
|
if t0 != "c" and target > 0:
|
|
|
|
|
|
k = 1
|
|
|
|
|
|
if self._leaf_sharing:
|
|
|
|
|
|
# erc.3: a shared leaf holds k same-code rooms; centre the
|
|
|
|
|
|
# Gaussian on k×target (k = leaf's explicit, type-guarded
|
|
|
|
|
|
# share) and scale sigma by k so the *fractional* size
|
|
|
|
|
|
# tolerance is preserved. An undersize shared leaf now lands
|
|
|
|
|
|
# a (light) size fail here instead of a (heavy) missing fail
|
|
|
|
|
|
# in the count check — the §13.3 leak fix.
|
|
|
|
|
|
from . import graph as _graph
|
|
|
|
|
|
k = _graph.leaf_share(leaf, self._max_share)
|
|
|
|
|
|
co_type = None if k > 1 else self._leaf_co_type(leaf)
|
2026-06-24 08:30:26 +01:00
|
|
|
|
if k > 1:
|
|
|
|
|
|
target, sigma = target * k, sigma * k
|
2026-07-31 00:16:12 +01:00
|
|
|
|
elif co_type:
|
|
|
|
|
|
# 1s3 §26 path b: a fused leaf's floor area serves BOTH codes'
|
|
|
|
|
|
# requirements at once — additive, the same operation as
|
|
|
|
|
|
# leaf-sharing's k×target sum (k identical terms), here with
|
|
|
|
|
|
# 2 different terms. A leaf never carries both a live share>1
|
|
|
|
|
|
# and a live co_type (construction never stamps both).
|
|
|
|
|
|
co_params = self.get_space_params(co_type, "size")
|
|
|
|
|
|
target, sigma = target + co_params[0], sigma + co_params[1]
|
2026-06-24 08:30:26 +01:00
|
|
|
|
return gaussian(geometry.area(leaf), 1.0, target, sigma)
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
|
|
|
|
|
|
def quality_width(self, leaf: Node) -> float:
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
t0 = _generic_class(leaf)
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
if (
|
|
|
|
|
|
t0 in ("o", "s")
|
|
|
|
|
|
and not dom_mod.is_covered(leaf)
|
|
|
|
|
|
and not dom_mod.is_supported(leaf)
|
|
|
|
|
|
and dom_mod.level_of(leaf)
|
|
|
|
|
|
):
|
|
|
|
|
|
return 1.0
|
|
|
|
|
|
if t0 in ("o", "s"):
|
|
|
|
|
|
params = self.conf("width_outside")
|
|
|
|
|
|
elif t0 == "c":
|
|
|
|
|
|
params = self.conf("width_circulation")
|
|
|
|
|
|
else:
|
|
|
|
|
|
params = self.get_space_params(leaf.type, "width")
|
2026-07-31 00:16:12 +01:00
|
|
|
|
co_type = self._leaf_co_type(leaf)
|
|
|
|
|
|
if co_type:
|
2026-07-31 10:20:49 +01:00
|
|
|
|
# 1s3: precision-weighted, same reasoning as quality_proportion
|
|
|
|
|
|
# above.
|
2026-07-31 00:16:12 +01:00
|
|
|
|
co_params = self.get_space_params(co_type, "width")
|
2026-07-31 10:20:49 +01:00
|
|
|
|
params = _gaussian_product(params[0], params[1],
|
|
|
|
|
|
co_params[0], co_params[1])
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
width = geometry.length_narrowest(leaf)
|
2026-07-31 10:20:49 +01:00
|
|
|
|
return _clipped_gaussian(width, params[0], params[1], "above")
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
|
|
|
|
|
|
# --- simple crinkliness (URB_NO_OCCLUSION: illumination factor = 1) --- #
|
|
|
|
|
|
|
|
|
|
|
|
def area_outside(self, leaf: Node, G: nx.Graph, groups: dict) -> float:
|
|
|
|
|
|
"""Illuminated external wall area; ``Urb::Dom::Area_Outside`` with the
|
|
|
|
|
|
CIEsky illumination factor pinned to 1 (simple crinkliness)."""
|
|
|
|
|
|
length = 0.0
|
|
|
|
|
|
for nb in G.neighbors(leaf):
|
|
|
|
|
|
if not dom_mod.is_outside(nb) or dom_mod.is_covered(nb):
|
|
|
|
|
|
continue
|
|
|
|
|
|
# Faithful loop over all internal boundaries: Overlap() is > 0
|
|
|
|
|
|
# only on a boundary both quads actually share an edge of.
|
|
|
|
|
|
for contributors in groups.values():
|
|
|
|
|
|
if geometry.boundary_pair_overlap(contributors, leaf, nb) > 0:
|
|
|
|
|
|
length += G[leaf][nb]["width"]
|
|
|
|
|
|
perimeter = _perimeter(leaf)
|
|
|
|
|
|
for e in range(4):
|
|
|
|
|
|
bid = geometry.boundary_id(leaf, e)
|
|
|
|
|
|
if bid not in geometry._EXTERNAL:
|
|
|
|
|
|
continue
|
|
|
|
|
|
ptype = (perimeter.get(bid) or "").lower()
|
|
|
|
|
|
if ptype in ("private", "fortified"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
length += geometry.edge_length(leaf, e)
|
|
|
|
|
|
return length * _height(leaf)
|
|
|
|
|
|
|
|
|
|
|
|
def crinkliness(self, leaf: Node, G: nx.Graph, groups: dict) -> float:
|
|
|
|
|
|
area = geometry.area(leaf)
|
|
|
|
|
|
if not area:
|
|
|
|
|
|
return 9999999999
|
|
|
|
|
|
return self.area_outside(leaf, G, groups) / area
|
|
|
|
|
|
|
ssz: crinkliness is declared per space; there is no daylight attribute
Owner's ruling, and it corrects the design not just the classification: the
daylight requirement is already defined in the crinkliness. The gaussian's
compact side IS "too little exposed wall per unit floor"; its exposed side
is envelope cost. 38.9's proposed daylight: axis was redundant, and keying
it off usage: was worse than redundant.
What was actually missing: crinkliness is the only leaf quality factor with
no per-space target. size, width and proportion are all declared by the
space; crinkliness was one global number for every room in every building.
crinkliness: none -> no minimum-exposure requirement, may be buried
crinkliness: [t, s] -> this space's own target
key absent -> the global uncrinkliness target, as today
`none` clips the factor on the compact side, it does not switch it off:
over-exposure is still penalised, because a crinkly leaf costs envelope
whatever it holds. A store may be buried; a store may not be a starfish.
The mechanism is backward compatible -- an absent key resolves to the
global target, so shipping it changes no score. Behaviour changes only
where a config declares something, which keeps the objective change
visible per programme in config rather than hidden in a default.
Owner's classification: everything a person occupies wants a window, WCs
and reception/waiting/foyer included; only stores, plant, records and
laundry do not. migrate_crinkliness_key.py declared crinkliness: none on 18
corpus spaces. Crinkliness fails 271 -> 243, of which not-defects 136 (50%)
-> 108 (44%); the 28 that went are exactly the utility fails.
usage_daylight and needs_daylight are removed as mis-keyed, and
DAYLIGHT_USAGES with them -- a vocabulary value should exist only where the
engine treats it differently. The historical crinkliness_mode modes stay,
default off, so 38.6/38.8 remain reproducible.
uncrinkliness_circulation is now settable to none like any space, but its
default is left unchanged pending a ruling: corridors were not among the
groups ruled on and are 63% of the remaining phantom fails.
Lint at parity (46); tests 364 passed, same 7 pre-existing fixture failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-28 22:38:54 +00:00
|
|
|
|
def crinkliness_params(self, leaf: Node) -> "tuple[float, float] | None":
|
|
|
|
|
|
"""``(target, sigma)`` for this leaf's crinkliness, or ``None`` when the
|
|
|
|
|
|
space declares no minimum-exposure requirement (homemaker-py-ssz).
|
|
|
|
|
|
|
|
|
|
|
|
The COMPACT side of the crinkliness gaussian is the daylight
|
|
|
|
|
|
requirement -- too little exposed wall per unit floor -- so a space
|
|
|
|
|
|
declaring `crinkliness: none` is declaring that it does not need a
|
|
|
|
|
|
window. There is no separate daylight attribute; see DESIGN.md §38.10.
|
|
|
|
|
|
|
|
|
|
|
|
Resolution order, mirroring how size/width/proportion resolve:
|
|
|
|
|
|
a programme space's own declaration wins; a generic circulation leaf
|
|
|
|
|
|
takes `uncrinkliness_circulation`; everything else takes the global
|
|
|
|
|
|
`uncrinkliness`.
|
|
|
|
|
|
"""
|
|
|
|
|
|
req = (self._programme or {}).get(leaf.type)
|
|
|
|
|
|
if req is not None and req.has_crinkliness:
|
|
|
|
|
|
if req.crinkliness is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return req.crinkliness, req.crinkliness_sigma
|
|
|
|
|
|
key = ("uncrinkliness_circulation" if dom_mod.is_circulation(leaf)
|
|
|
|
|
|
else "uncrinkliness")
|
|
|
|
|
|
# An explicit null/`none` in the config means "no minimum-exposure
|
|
|
|
|
|
# requirement". `conf()` cannot express that -- it collapses None to the
|
|
|
|
|
|
# default -- so the raw dict is read here, the same way `_optional_pair`
|
|
|
|
|
|
# tells an absent per-space key from a declared-empty one.
|
|
|
|
|
|
raw = self._conf.get(key, _MISSING)
|
|
|
|
|
|
if raw is None or (isinstance(raw, str) and raw.strip().lower() == "none"):
|
|
|
|
|
|
return None
|
|
|
|
|
|
params = self.conf(key)
|
|
|
|
|
|
if params is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return params[0], params[1]
|
|
|
|
|
|
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
def quality_uncrinkliness(self, leaf: Node, G: nx.Graph, groups: dict) -> float:
|
|
|
|
|
|
if dom_mod.is_outside(leaf) and not dom_mod.is_covered(leaf):
|
|
|
|
|
|
return 1.0
|
ssz: crinkliness is declared per space; there is no daylight attribute
Owner's ruling, and it corrects the design not just the classification: the
daylight requirement is already defined in the crinkliness. The gaussian's
compact side IS "too little exposed wall per unit floor"; its exposed side
is envelope cost. 38.9's proposed daylight: axis was redundant, and keying
it off usage: was worse than redundant.
What was actually missing: crinkliness is the only leaf quality factor with
no per-space target. size, width and proportion are all declared by the
space; crinkliness was one global number for every room in every building.
crinkliness: none -> no minimum-exposure requirement, may be buried
crinkliness: [t, s] -> this space's own target
key absent -> the global uncrinkliness target, as today
`none` clips the factor on the compact side, it does not switch it off:
over-exposure is still penalised, because a crinkly leaf costs envelope
whatever it holds. A store may be buried; a store may not be a starfish.
The mechanism is backward compatible -- an absent key resolves to the
global target, so shipping it changes no score. Behaviour changes only
where a config declares something, which keeps the objective change
visible per programme in config rather than hidden in a default.
Owner's classification: everything a person occupies wants a window, WCs
and reception/waiting/foyer included; only stores, plant, records and
laundry do not. migrate_crinkliness_key.py declared crinkliness: none on 18
corpus spaces. Crinkliness fails 271 -> 243, of which not-defects 136 (50%)
-> 108 (44%); the 28 that went are exactly the utility fails.
usage_daylight and needs_daylight are removed as mis-keyed, and
DAYLIGHT_USAGES with them -- a vocabulary value should exist only where the
engine treats it differently. The historical crinkliness_mode modes stay,
default off, so 38.6/38.8 remain reproducible.
uncrinkliness_circulation is now settable to none like any space, but its
default is left unchanged pending a ruling: corridors were not among the
groups ruled on and are 63% of the remaining phantom fails.
Lint at parity (46); tests 364 passed, same 7 pre-existing fixture failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-28 22:38:54 +00:00
|
|
|
|
params = self.crinkliness_params(leaf)
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
crink = self.crinkliness(leaf, G, groups)
|
§38.2 refinement: connectivity is under-priced ~3x, not just a crinkliness bug
Follow-up measurement corrects the first draft of §38 in two ways.
1. Harbor-house's floor is 15 fails (evolved-3M-nols-3, 1.7M evals), not the
30-40 I quoted from §13.11's 20k-budget runs. Frontage deficit predicts the
COST of solving, not impossibility: ~150x budget gap between a
frontage-short and a frontage-surplus programme. Table corrected.
2. Zero-exposure is only half the mechanism, and not the dominant half.
Splitting the deletion test by lit vs buried shows a WELL-DAYLIT corridor
(q_crink=0.736) is still worth x4.06 to delete. Cause: value_circulation=50
vs value_inside=300, so merging corridor into room is a flat x6 gain, while
'level N not connected' costs only x0.5. Break-even needs 0.5^k < 50/300,
i.e. k > 2.58 -- severing must cost at least 3 fails and costs 1. Net x3.0
predicted, x4.06 measured. The objective is net-positive on severing the
spine even when the circulation is perfectly lit, which explains why both
'level N not connected' fails survive in the best layout after 1.7M evals.
Adds fitness.quality_uncrinkliness crinkliness_mode (EXPERIMENTAL, default
"urb" = stock hard 0.0, byte-identical: 336 passed vs 331 before, same 7
pre-existing fixture failures). A/B harness ab_crinkliness_mode_ssz.py shows
none of the three modes removes the incentive, and the lit column is 3/8 under
every mode including stock -- clean isolation of the two mechanisms.
Filed homemaker-py-2v1 (P0) for the pricing fix; ssz/hxi now depend on it.
Acceptance test recorded up front: harbor must reach 15 fails in materially
fewer than 1.7M evals AND without either not-connected fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 07:40:37 +00:00
|
|
|
|
|
ssz: crinkliness is declared per space; there is no daylight attribute
Owner's ruling, and it corrects the design not just the classification: the
daylight requirement is already defined in the crinkliness. The gaussian's
compact side IS "too little exposed wall per unit floor"; its exposed side
is envelope cost. 38.9's proposed daylight: axis was redundant, and keying
it off usage: was worse than redundant.
What was actually missing: crinkliness is the only leaf quality factor with
no per-space target. size, width and proportion are all declared by the
space; crinkliness was one global number for every room in every building.
crinkliness: none -> no minimum-exposure requirement, may be buried
crinkliness: [t, s] -> this space's own target
key absent -> the global uncrinkliness target, as today
`none` clips the factor on the compact side, it does not switch it off:
over-exposure is still penalised, because a crinkly leaf costs envelope
whatever it holds. A store may be buried; a store may not be a starfish.
The mechanism is backward compatible -- an absent key resolves to the
global target, so shipping it changes no score. Behaviour changes only
where a config declares something, which keeps the objective change
visible per programme in config rather than hidden in a default.
Owner's classification: everything a person occupies wants a window, WCs
and reception/waiting/foyer included; only stores, plant, records and
laundry do not. migrate_crinkliness_key.py declared crinkliness: none on 18
corpus spaces. Crinkliness fails 271 -> 243, of which not-defects 136 (50%)
-> 108 (44%); the 28 that went are exactly the utility fails.
usage_daylight and needs_daylight are removed as mis-keyed, and
DAYLIGHT_USAGES with them -- a vocabulary value should exist only where the
engine treats it differently. The historical crinkliness_mode modes stay,
default off, so 38.6/38.8 remain reproducible.
uncrinkliness_circulation is now settable to none like any space, but its
default is left unchanged pending a ruling: corridors were not among the
groups ruled on and are 63% of the remaining phantom fails.
Lint at parity (46); tests 364 passed, same 7 pre-existing fixture failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-28 22:38:54 +00:00
|
|
|
|
if params is None:
|
|
|
|
|
|
# No minimum-exposure requirement: this space does not need a
|
|
|
|
|
|
# window, so being buried -- the fully compact limit -- is not a
|
|
|
|
|
|
# defect. Over-exposure still is: a crinkly leaf costs envelope
|
|
|
|
|
|
# whatever it holds. So the factor is CLIPPED on the compact side,
|
|
|
|
|
|
# never switched off, and the over-exposed side keeps the global
|
|
|
|
|
|
# bound.
|
|
|
|
|
|
if not crink:
|
|
|
|
|
|
return 1.0
|
|
|
|
|
|
distance, sigma = self.conf("uncrinkliness")
|
|
|
|
|
|
if 1 / crink > distance:
|
|
|
|
|
|
return 1.0
|
|
|
|
|
|
return gaussian(1 / crink, 1.0, distance, sigma)
|
|
|
|
|
|
|
|
|
|
|
|
distance, sigma = params
|
|
|
|
|
|
|
|
|
|
|
|
# homemaker-py-ssz (DESIGN.md §38.1), EXPERIMENTAL, all default OFF --
|
|
|
|
|
|
# `crinkliness_mode="urb"` reproduces the stock behaviour exactly. These
|
|
|
|
|
|
# were the first attempt at the problem and are SUPERSEDED by the
|
|
|
|
|
|
# declared per-space target above (§38.10); they are kept only so the
|
|
|
|
|
|
# §38.6/§38.8 measurements remain reproducible. Do not build on them.
|
§38.2 refinement: connectivity is under-priced ~3x, not just a crinkliness bug
Follow-up measurement corrects the first draft of §38 in two ways.
1. Harbor-house's floor is 15 fails (evolved-3M-nols-3, 1.7M evals), not the
30-40 I quoted from §13.11's 20k-budget runs. Frontage deficit predicts the
COST of solving, not impossibility: ~150x budget gap between a
frontage-short and a frontage-surplus programme. Table corrected.
2. Zero-exposure is only half the mechanism, and not the dominant half.
Splitting the deletion test by lit vs buried shows a WELL-DAYLIT corridor
(q_crink=0.736) is still worth x4.06 to delete. Cause: value_circulation=50
vs value_inside=300, so merging corridor into room is a flat x6 gain, while
'level N not connected' costs only x0.5. Break-even needs 0.5^k < 50/300,
i.e. k > 2.58 -- severing must cost at least 3 fails and costs 1. Net x3.0
predicted, x4.06 measured. The objective is net-positive on severing the
spine even when the circulation is perfectly lit, which explains why both
'level N not connected' fails survive in the best layout after 1.7M evals.
Adds fitness.quality_uncrinkliness crinkliness_mode (EXPERIMENTAL, default
"urb" = stock hard 0.0, byte-identical: 336 passed vs 331 before, same 7
pre-existing fixture failures). A/B harness ab_crinkliness_mode_ssz.py shows
none of the three modes removes the incentive, and the lit column is 3/8 under
every mode including stock -- clean isolation of the two mechanisms.
Filed homemaker-py-2v1 (P0) for the pricing fix; ssz/hxi now depend on it.
Acceptance test recorded up front: harbor must reach 15 fails in materially
fewer than 1.7M evals AND without either not-connected fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 07:40:37 +00:00
|
|
|
|
mode = self._crinkliness_mode
|
|
|
|
|
|
if mode == "exempt_circulation" and dom_mod.is_circulation(leaf):
|
|
|
|
|
|
return 1.0
|
ssz: crinkliness is declared per space; there is no daylight attribute
Owner's ruling, and it corrects the design not just the classification: the
daylight requirement is already defined in the crinkliness. The gaussian's
compact side IS "too little exposed wall per unit floor"; its exposed side
is envelope cost. 38.9's proposed daylight: axis was redundant, and keying
it off usage: was worse than redundant.
What was actually missing: crinkliness is the only leaf quality factor with
no per-space target. size, width and proportion are all declared by the
space; crinkliness was one global number for every room in every building.
crinkliness: none -> no minimum-exposure requirement, may be buried
crinkliness: [t, s] -> this space's own target
key absent -> the global uncrinkliness target, as today
`none` clips the factor on the compact side, it does not switch it off:
over-exposure is still penalised, because a crinkly leaf costs envelope
whatever it holds. A store may be buried; a store may not be a starfish.
The mechanism is backward compatible -- an absent key resolves to the
global target, so shipping it changes no score. Behaviour changes only
where a config declares something, which keeps the objective change
visible per programme in config rather than hidden in a default.
Owner's classification: everything a person occupies wants a window, WCs
and reception/waiting/foyer included; only stores, plant, records and
laundry do not. migrate_crinkliness_key.py declared crinkliness: none on 18
corpus spaces. Crinkliness fails 271 -> 243, of which not-defects 136 (50%)
-> 108 (44%); the 28 that went are exactly the utility fails.
usage_daylight and needs_daylight are removed as mis-keyed, and
DAYLIGHT_USAGES with them -- a vocabulary value should exist only where the
engine treats it differently. The historical crinkliness_mode modes stay,
default off, so 38.6/38.8 remain reproducible.
uncrinkliness_circulation is now settable to none like any space, but its
default is left unchanged pending a ruling: corridors were not among the
groups ruled on and are 63% of the remaining phantom fails.
Lint at parity (46); tests 364 passed, same 7 pre-existing fixture failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-28 22:38:54 +00:00
|
|
|
|
one_sided = mode == "compact_ok"
|
ssz: daylight is required of rooms that do not need it
DESIGN.md 38.6 concluded the three crinkliness modes were inert against the
circulation-deletion incentive. Two things were wrong with that measurement.
Its premise, 38.2, is retracted. And its script selected leaves with the
pre-39.4 prefix rule `type[:1].upper() in ("C","O")`, which sweeps every
programme room starting with c or o -- cr1, of1 -- in as circulation.
The simpler problem is that none of the three modes ever touched the leaves
ssz is about. quality_uncrinkliness reaches `if not crink` before any mode
logic that matters, so for a zero-exposure leaf: floor returns 0.01 (one
percent of a unit quality, multiplied into a product and weighed against a
whole leaf's cost -- inert); compact_ok is self-contradictory, announcing
that compact is not a defect and then returning the floor for the most
compact case of all; exempt_circulation reaches at most a third of them.
Measured: 0% / 0% / 0% / 21-33% of buried leaves rescued.
What the buried leaves are, now that 39.7 gives every space a usage: two
thirds of them are spaces that architecturally do not want a window --
stores, WCs, plant, corridors, covered courtyards -- scored identically
with a windowless bedroom. harbor 22/33, maple 33/46, health 9/18.
- crinkliness_mode="usage_daylight": daylight required of the uses a
person occupies (programme.DAYLIGHT_USAGES) and nothing else. Elsewhere
the factor is clipped on the compact side only, so being buried stops
being a defect while over-exposure still costs -- a crinkly leaf costs
envelope whatever it is used for. A windowless bedroom stays the hard
zero it is under stock: 11/11, 13/13, 9/9 still failing.
- compact_ok repaired to score the buried limit as compact, the behaviour
its name always claimed. It now rescues 100% including bedrooms, and is
kept as the upper-bound control, not a candidate.
- ab_ssz_search.py: the fixed-budget search A/B ssz's acceptance criteria
actually asks for. Every arm is optimised under its own objective and
re-scored under stock urb, because the permissive modes return 1.0
where stock fails and would otherwise win by deleting a fail category.
- ab_crinkliness_mode_ssz.py: prefix rule fixed, retracted premise
flagged in its docstring.
- 38.7's remaining claims from the retracted 38.2/38.3 corrected.
Default is unchanged ("urb"), byte-identical to all prior runs. Lint at
parity (46 pre-existing); tests 366 passed, 10 new, same 7 pre-existing
fixture failures (homemaker-py-bdf).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 16:45:10 +00:00
|
|
|
|
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
if not crink:
|
ssz: daylight is required of rooms that do not need it
DESIGN.md 38.6 concluded the three crinkliness modes were inert against the
circulation-deletion incentive. Two things were wrong with that measurement.
Its premise, 38.2, is retracted. And its script selected leaves with the
pre-39.4 prefix rule `type[:1].upper() in ("C","O")`, which sweeps every
programme room starting with c or o -- cr1, of1 -- in as circulation.
The simpler problem is that none of the three modes ever touched the leaves
ssz is about. quality_uncrinkliness reaches `if not crink` before any mode
logic that matters, so for a zero-exposure leaf: floor returns 0.01 (one
percent of a unit quality, multiplied into a product and weighed against a
whole leaf's cost -- inert); compact_ok is self-contradictory, announcing
that compact is not a defect and then returning the floor for the most
compact case of all; exempt_circulation reaches at most a third of them.
Measured: 0% / 0% / 0% / 21-33% of buried leaves rescued.
What the buried leaves are, now that 39.7 gives every space a usage: two
thirds of them are spaces that architecturally do not want a window --
stores, WCs, plant, corridors, covered courtyards -- scored identically
with a windowless bedroom. harbor 22/33, maple 33/46, health 9/18.
- crinkliness_mode="usage_daylight": daylight required of the uses a
person occupies (programme.DAYLIGHT_USAGES) and nothing else. Elsewhere
the factor is clipped on the compact side only, so being buried stops
being a defect while over-exposure still costs -- a crinkly leaf costs
envelope whatever it is used for. A windowless bedroom stays the hard
zero it is under stock: 11/11, 13/13, 9/9 still failing.
- compact_ok repaired to score the buried limit as compact, the behaviour
its name always claimed. It now rescues 100% including bedrooms, and is
kept as the upper-bound control, not a candidate.
- ab_ssz_search.py: the fixed-budget search A/B ssz's acceptance criteria
actually asks for. Every arm is optimised under its own objective and
re-scored under stock urb, because the permissive modes return 1.0
where stock fails and would otherwise win by deleting a fail category.
- ab_crinkliness_mode_ssz.py: prefix rule fixed, retracted premise
flagged in its docstring.
- 38.7's remaining claims from the retracted 38.2/38.3 corrected.
Default is unchanged ("urb"), byte-identical to all prior runs. Lint at
parity (46 pre-existing); tests 366 passed, 10 new, same 7 pre-existing
fixture failures (homemaker-py-bdf).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 16:45:10 +00:00
|
|
|
|
if one_sided:
|
|
|
|
|
|
return 1.0
|
|
|
|
|
|
return self._crinkliness_floor if mode == "floor" else 0.0
|
|
|
|
|
|
|
§38.2 refinement: connectivity is under-priced ~3x, not just a crinkliness bug
Follow-up measurement corrects the first draft of §38 in two ways.
1. Harbor-house's floor is 15 fails (evolved-3M-nols-3, 1.7M evals), not the
30-40 I quoted from §13.11's 20k-budget runs. Frontage deficit predicts the
COST of solving, not impossibility: ~150x budget gap between a
frontage-short and a frontage-surplus programme. Table corrected.
2. Zero-exposure is only half the mechanism, and not the dominant half.
Splitting the deletion test by lit vs buried shows a WELL-DAYLIT corridor
(q_crink=0.736) is still worth x4.06 to delete. Cause: value_circulation=50
vs value_inside=300, so merging corridor into room is a flat x6 gain, while
'level N not connected' costs only x0.5. Break-even needs 0.5^k < 50/300,
i.e. k > 2.58 -- severing must cost at least 3 fails and costs 1. Net x3.0
predicted, x4.06 measured. The objective is net-positive on severing the
spine even when the circulation is perfectly lit, which explains why both
'level N not connected' fails survive in the best layout after 1.7M evals.
Adds fitness.quality_uncrinkliness crinkliness_mode (EXPERIMENTAL, default
"urb" = stock hard 0.0, byte-identical: 336 passed vs 331 before, same 7
pre-existing fixture failures). A/B harness ab_crinkliness_mode_ssz.py shows
none of the three modes removes the incentive, and the lit column is 3/8 under
every mode including stock -- clean isolation of the two mechanisms.
Filed homemaker-py-2v1 (P0) for the pricing fix; ssz/hxi now depend on it.
Acceptance test recorded up front: harbor must reach 15 fails in materially
fewer than 1.7M evals AND without either not-connected fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 07:40:37 +00:00
|
|
|
|
q = gaussian(1 / crink, 1.0, distance, sigma)
|
ssz: daylight is required of rooms that do not need it
DESIGN.md 38.6 concluded the three crinkliness modes were inert against the
circulation-deletion incentive. Two things were wrong with that measurement.
Its premise, 38.2, is retracted. And its script selected leaves with the
pre-39.4 prefix rule `type[:1].upper() in ("C","O")`, which sweeps every
programme room starting with c or o -- cr1, of1 -- in as circulation.
The simpler problem is that none of the three modes ever touched the leaves
ssz is about. quality_uncrinkliness reaches `if not crink` before any mode
logic that matters, so for a zero-exposure leaf: floor returns 0.01 (one
percent of a unit quality, multiplied into a product and weighed against a
whole leaf's cost -- inert); compact_ok is self-contradictory, announcing
that compact is not a defect and then returning the floor for the most
compact case of all; exempt_circulation reaches at most a third of them.
Measured: 0% / 0% / 0% / 21-33% of buried leaves rescued.
What the buried leaves are, now that 39.7 gives every space a usage: two
thirds of them are spaces that architecturally do not want a window --
stores, WCs, plant, corridors, covered courtyards -- scored identically
with a windowless bedroom. harbor 22/33, maple 33/46, health 9/18.
- crinkliness_mode="usage_daylight": daylight required of the uses a
person occupies (programme.DAYLIGHT_USAGES) and nothing else. Elsewhere
the factor is clipped on the compact side only, so being buried stops
being a defect while over-exposure still costs -- a crinkly leaf costs
envelope whatever it is used for. A windowless bedroom stays the hard
zero it is under stock: 11/11, 13/13, 9/9 still failing.
- compact_ok repaired to score the buried limit as compact, the behaviour
its name always claimed. It now rescues 100% including bedrooms, and is
kept as the upper-bound control, not a candidate.
- ab_ssz_search.py: the fixed-budget search A/B ssz's acceptance criteria
actually asks for. Every arm is optimised under its own objective and
re-scored under stock urb, because the permissive modes return 1.0
where stock fails and would otherwise win by deleting a fail category.
- ab_crinkliness_mode_ssz.py: prefix rule fixed, retracted premise
flagged in its docstring.
- 38.7's remaining claims from the retracted 38.2/38.3 corrected.
Default is unchanged ("urb"), byte-identical to all prior runs. Lint at
parity (46 pre-existing); tests 366 passed, 10 new, same 7 pre-existing
fixture failures (homemaker-py-bdf).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 16:45:10 +00:00
|
|
|
|
if one_sided and 1 / crink > distance:
|
§38.2 refinement: connectivity is under-priced ~3x, not just a crinkliness bug
Follow-up measurement corrects the first draft of §38 in two ways.
1. Harbor-house's floor is 15 fails (evolved-3M-nols-3, 1.7M evals), not the
30-40 I quoted from §13.11's 20k-budget runs. Frontage deficit predicts the
COST of solving, not impossibility: ~150x budget gap between a
frontage-short and a frontage-surplus programme. Table corrected.
2. Zero-exposure is only half the mechanism, and not the dominant half.
Splitting the deletion test by lit vs buried shows a WELL-DAYLIT corridor
(q_crink=0.736) is still worth x4.06 to delete. Cause: value_circulation=50
vs value_inside=300, so merging corridor into room is a flat x6 gain, while
'level N not connected' costs only x0.5. Break-even needs 0.5^k < 50/300,
i.e. k > 2.58 -- severing must cost at least 3 fails and costs 1. Net x3.0
predicted, x4.06 measured. The objective is net-positive on severing the
spine even when the circulation is perfectly lit, which explains why both
'level N not connected' fails survive in the best layout after 1.7M evals.
Adds fitness.quality_uncrinkliness crinkliness_mode (EXPERIMENTAL, default
"urb" = stock hard 0.0, byte-identical: 336 passed vs 331 before, same 7
pre-existing fixture failures). A/B harness ab_crinkliness_mode_ssz.py shows
none of the three modes removes the incentive, and the lit column is 3/8 under
every mode including stock -- clean isolation of the two mechanisms.
Filed homemaker-py-2v1 (P0) for the pricing fix; ssz/hxi now depend on it.
Acceptance test recorded up front: harbor must reach 15 fails in materially
fewer than 1.7M evals AND without either not-connected fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 07:40:37 +00:00
|
|
|
|
return 1.0
|
|
|
|
|
|
return max(q, self._crinkliness_floor) if mode in ("floor", "compact_ok") else q
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
|
|
|
|
|
|
# --- access --- #
|
|
|
|
|
|
|
|
|
|
|
|
def neighbour_types(self, leaf: Node, G: nx.Graph) -> list[str]:
|
|
|
|
|
|
return sorted(nb.type or "" for nb in G.neighbors(leaf) if dom_mod.is_usable(nb))
|
|
|
|
|
|
|
|
|
|
|
|
def access(self, leaf: Node, G: nx.Graph) -> list[str]:
|
|
|
|
|
|
"""Useful circulation/access neighbour types; ``Urb::Dom::Access``."""
|
|
|
|
|
|
types = self.neighbour_types(leaf, G)
|
§39.7: access requirements become a declared `usage:` attribute (homemaker-py-sel)
Closes the second namespace sharing a first character with programme codes: the
usage prefixes b/t/l/k, under which a room silently inherited another room's
connectivity rules from its spelling.
usage is a plain, MANDATORY attribute of the space definition -- not a lookup
table. An interim design proposed a top-level usage_classes: table binding
author-coined names to behaviour; withdrawn, because an indirect name->behaviour
mapping living apart from the thing it describes is exactly the shape of the
prefix rule §39 exists to remove, it would be the only such table in a schema
where every other space property is a plain attribute, and the need it served
was already met -- "building specific" is about what a room is CALLED, and
name: is already free text.
Rule that settles it: a usage value exists iff the engine treats it differently
somewhere. Config selects among behaviours; it cannot invent them.
- programme.USAGES (living/kitchen/bedroom/toilet/utility/none) plus the
behaviour groupings PRIVATE_USAGES / PRIVATE_STRIPS / TOILET_STRIPS /
SOCIABLE_USAGES. Missing or unknown usage is a load error naming the code,
from BOTH parse paths.
- Code-level, never leaf-level: usage_of(leaf.type) is looked up fresh, so a
retype changes the class automatically. 51 sites assign leaf.type, and
share/share_type plus the r5a resurrection are the precedent for why
leaf-level attributes rot.
- graph.has_circulation takes the usage map and trims on declared class;
fitness.access and the public-access check likewise. fitness._t0 is DELETED --
no first-character type test remains anywhere in the codebase.
- utility is distinct from bedroom (same access requirements today) because it
is a different use and gives derive_interchange_classes an axis to relax on.
- A toilet now keeps its edge to a terminal room -- the Brand adjacency, which
the old b-before-t loop ordering severed.
- All 107 corpus entries migrated by experiments/migrate_usage_key.py, comments
and layout preserved.
MEASURED -- the connectivity model was ~4x too permissive. `none` is not
neutral: nothing is trimmed, so the graph may route THROUGH the room, and 34 of
52 codes had no class (Dental Surgery, Records Room, Utilities Closet all served
as corridors). Edges trimmed, prefix-inferred vs declared, 3 seeds each:
harbor-house 18 (9%) -> 79 (39%) inaccessible fails 0 -> 4
health-centre 12 (8%) -> 59 (40%) inaccessible fails 2 -> 3
maple-court 53 (17%) -> 123 (39%) inaccessible fails 1 -> 5
Re-baseline (seed 1, 20k, harbor): 58 fails (15h/43s) -> 61 (16h/45s), now
reporting 1-inaccessible-usable-space x2 plus level 0 and level 1 not connected.
The count rose because the objective got honest -- those failures were always
true of the layout and the old model could not see them. Every harbor number
before this was measured against a graph crediting routes through store
cupboards.
Sharpens §38.2: the objective pays x60-85 to delete circulation, and until now
the deleted corridors were not missed because storage stood in for them. With
that substitution gone, homemaker-py-2v1 is the remaining half -- and now
measurable, because the fails it should prevent actually fire.
350 passed (+5 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 13:39:41 +00:00
|
|
|
|
if self.usage_of(leaf) == "kitchen":
|
|
|
|
|
|
# a kitchen is served by circulation OR by a living space (§39.7)
|
|
|
|
|
|
usages = self.usages()
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
return [t for t in types
|
§39.7: access requirements become a declared `usage:` attribute (homemaker-py-sel)
Closes the second namespace sharing a first character with programme codes: the
usage prefixes b/t/l/k, under which a room silently inherited another room's
connectivity rules from its spelling.
usage is a plain, MANDATORY attribute of the space definition -- not a lookup
table. An interim design proposed a top-level usage_classes: table binding
author-coined names to behaviour; withdrawn, because an indirect name->behaviour
mapping living apart from the thing it describes is exactly the shape of the
prefix rule §39 exists to remove, it would be the only such table in a schema
where every other space property is a plain attribute, and the need it served
was already met -- "building specific" is about what a room is CALLED, and
name: is already free text.
Rule that settles it: a usage value exists iff the engine treats it differently
somewhere. Config selects among behaviours; it cannot invent them.
- programme.USAGES (living/kitchen/bedroom/toilet/utility/none) plus the
behaviour groupings PRIVATE_USAGES / PRIVATE_STRIPS / TOILET_STRIPS /
SOCIABLE_USAGES. Missing or unknown usage is a load error naming the code,
from BOTH parse paths.
- Code-level, never leaf-level: usage_of(leaf.type) is looked up fresh, so a
retype changes the class automatically. 51 sites assign leaf.type, and
share/share_type plus the r5a resurrection are the precedent for why
leaf-level attributes rot.
- graph.has_circulation takes the usage map and trims on declared class;
fitness.access and the public-access check likewise. fitness._t0 is DELETED --
no first-character type test remains anywhere in the codebase.
- utility is distinct from bedroom (same access requirements today) because it
is a different use and gives derive_interchange_classes an axis to relax on.
- A toilet now keeps its edge to a terminal room -- the Brand adjacency, which
the old b-before-t loop ordering severed.
- All 107 corpus entries migrated by experiments/migrate_usage_key.py, comments
and layout preserved.
MEASURED -- the connectivity model was ~4x too permissive. `none` is not
neutral: nothing is trimmed, so the graph may route THROUGH the room, and 34 of
52 codes had no class (Dental Surgery, Records Room, Utilities Closet all served
as corridors). Edges trimmed, prefix-inferred vs declared, 3 seeds each:
harbor-house 18 (9%) -> 79 (39%) inaccessible fails 0 -> 4
health-centre 12 (8%) -> 59 (40%) inaccessible fails 2 -> 3
maple-court 53 (17%) -> 123 (39%) inaccessible fails 1 -> 5
Re-baseline (seed 1, 20k, harbor): 58 fails (15h/43s) -> 61 (16h/45s), now
reporting 1-inaccessible-usable-space x2 plus level 0 and level 1 not connected.
The count rose because the objective got honest -- those failures were always
true of the layout and the old model could not see them. Every harbor number
before this was measured against a graph crediting routes through store
cupboards.
Sharpens §38.2: the objective pays x60-85 to delete circulation, and until now
the deleted corridors were not missed because storage stood in for them. With
that substitution gone, homemaker-py-2v1 is the remaining half -- and now
measurable, because the fails it should prevent actually fire.
350 passed (+5 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 13:39:41 +00:00
|
|
|
|
if t in dom_mod.GENERIC_CIRCULATION
|
|
|
|
|
|
or usages.get(t) == "living"]
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
if dom_mod.is_outside(leaf) or dom_mod.is_circulation(leaf):
|
|
|
|
|
|
return types
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
return [t for t in types if t in dom_mod.GENERIC_CIRCULATION]
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# Leaf evaluation (Leaf.pm::evaluate_leaf)
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
|
|
|
|
|
|
def evaluate_leaf(
|
|
|
|
|
|
self, leaf: Node, G: nx.Graph, level_id: int, groups: dict, fail
|
|
|
|
|
|
) -> tuple[float, dict[str, float]]:
|
|
|
|
|
|
"""Return (quality, per-factor dict); appends failures via ``fail``.
|
|
|
|
|
|
|
|
|
|
|
|
Factor order and fail strings mirror ``evaluate_leaf`` exactly.
|
|
|
|
|
|
"""
|
|
|
|
|
|
lid = leaf.id
|
|
|
|
|
|
factors: dict[str, float] = {}
|
|
|
|
|
|
quality = 1.0
|
|
|
|
|
|
|
|
|
|
|
|
f = self.quality_perpendicular(leaf)
|
|
|
|
|
|
if f < FAIL_THRESHOLD:
|
|
|
|
|
|
fail(f"{level_id}/{lid} perpendicular")
|
|
|
|
|
|
factors["perpendicular"] = f
|
|
|
|
|
|
quality *= f
|
|
|
|
|
|
|
|
|
|
|
|
f = self.quality_proportion(leaf)
|
|
|
|
|
|
if f < FAIL_THRESHOLD:
|
|
|
|
|
|
fail(f"{level_id}/{lid} proportion")
|
|
|
|
|
|
factors["proportion"] = f
|
|
|
|
|
|
quality *= f
|
|
|
|
|
|
|
|
|
|
|
|
f = self.quality_size(leaf)
|
|
|
|
|
|
if f < FAIL_THRESHOLD:
|
|
|
|
|
|
fail(f"{level_id}/{lid} size")
|
|
|
|
|
|
factors["size"] = f
|
|
|
|
|
|
quality *= f
|
|
|
|
|
|
|
|
|
|
|
|
f = self.quality_width(leaf)
|
|
|
|
|
|
if f < FAIL_THRESHOLD:
|
|
|
|
|
|
fail(f"{level_id}/{lid} width")
|
|
|
|
|
|
factors["width"] = f
|
|
|
|
|
|
quality *= f
|
|
|
|
|
|
|
|
|
|
|
|
f = self.quality_uncrinkliness(leaf, G, groups)
|
|
|
|
|
|
if f < FAIL_THRESHOLD:
|
|
|
|
|
|
fail(f"{level_id}/{lid} crinkliness")
|
|
|
|
|
|
factors["crinkliness"] = f
|
|
|
|
|
|
quality *= f
|
|
|
|
|
|
|
|
|
|
|
|
# Daylight pinned to 1 — URB_NO_OCCLUSION semantics (DESIGN.md §6).
|
|
|
|
|
|
factors["daylight"] = 1.0
|
|
|
|
|
|
|
|
|
|
|
|
if len(self.access(leaf, G)) > 0:
|
|
|
|
|
|
f = 1.0
|
|
|
|
|
|
elif not dom_mod.level_of(leaf) and dom_mod.is_outside(leaf):
|
|
|
|
|
|
f = 1.0
|
|
|
|
|
|
else:
|
|
|
|
|
|
f = 0.01
|
|
|
|
|
|
fail(f"{level_id}/{lid} access")
|
|
|
|
|
|
factors["access"] = f
|
|
|
|
|
|
quality *= f
|
|
|
|
|
|
|
|
|
|
|
|
return quality, factors
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# Value rates and costs (Leaf.pm:146-251, Storey.pm:122-147)
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
|
|
|
|
|
|
def value_rate(self, leaf: Node) -> float:
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
t0 = _generic_class(leaf)
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
if t0 in ("o", "s") and dom_mod.level_of(leaf) == 0:
|
|
|
|
|
|
return self.conf("value_outside")
|
|
|
|
|
|
if t0 in ("o", "s"):
|
|
|
|
|
|
return self.conf("value_supported")
|
|
|
|
|
|
if t0 == "c":
|
|
|
|
|
|
return self.conf("value_circulation")
|
|
|
|
|
|
return self.conf("value_inside")
|
|
|
|
|
|
|
|
|
|
|
|
def leaf_cost(self, leaf: Node) -> float:
|
|
|
|
|
|
if dom_mod.is_outside(leaf):
|
|
|
|
|
|
covered = dom_mod.is_covered(leaf)
|
|
|
|
|
|
supported = dom_mod.is_supported(leaf)
|
|
|
|
|
|
if covered and supported:
|
|
|
|
|
|
rate = self.cost("outside_covered_supported")
|
|
|
|
|
|
elif covered:
|
|
|
|
|
|
rate = self.cost("outside_covered")
|
|
|
|
|
|
elif supported:
|
|
|
|
|
|
rate = self.cost("outside_supported")
|
|
|
|
|
|
else:
|
|
|
|
|
|
rate = self.cost("outside")
|
|
|
|
|
|
else:
|
|
|
|
|
|
rate = self.cost("inside")
|
|
|
|
|
|
return rate * geometry.area(leaf)
|
|
|
|
|
|
|
hph/§13.8: share-aware edge-too-long cap — shared leaves no longer penalised for aggregate wall length
§13.7 flagged edge-too-long as harbor's top fail class. Dissection showed the
bulk are a leaf-sharing REPRESENTATION ARTIFACT: a share=k leaf aggregates k
same-code rooms, so its walls run ~k× the flat 8 m cap purely for being big —
the same §13.3 leak (size/missing relaxed for shared leaves) on the wall measure,
since edge_cost/outside_edge_cost ignored leaf.share.
Fix: Fitness._edge_cap(*leaves) scales the 8 m cap by the largest type-guarded
leaf_share among adjoining leaves, mirroring quality_size's k×target; non-shared
leaves keep the flat cap so genuine narrow/oversize pathologies stay flagged.
Gated behind a share_edge_cap config knob (SHAREEDGE env), default OFF so the
§13.x controls reproduce.
A/B (full Phase-8 stack, staged, 20k evals, seeds 0/1/2): control reproduces
§13.7 (maple 80.3 exact, harbor 34.7≈34.0); share-aware arm maple 80.3→74.0
(−7.9%), harbor 34.7→31.0 (−10.6%), zero regressions across 6 seeds. Positive
and monotone-harmless (only ever removes a false-positive fail). Verdict:
recommend default-ON; follow-up issue flips the default + rebaselines the floor.
Tests: 6 new unit tests for _edge_cap (221 pass).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JygRv4n2dcyDQqMiDRe7TN
2026-06-28 21:24:51 +01:00
|
|
|
|
def _edge_cap(self, *leaves: Node) -> float:
|
|
|
|
|
|
"""Wall-length cap before 'edge too long' fires (erc.hph/§13.7).
|
|
|
|
|
|
|
|
|
|
|
|
Default flat 8 m, as Urb. A shared leaf (share=k, type-guarded) holds k
|
|
|
|
|
|
same-code rooms, so its walls run ~k× longer purely as a leaf-sharing
|
|
|
|
|
|
representation artifact — the same leak §13.3 closed for size. Scale the
|
|
|
|
|
|
cap by the largest share among the adjoining leaves, mirroring
|
|
|
|
|
|
quality_size's k×target. Non-shared leaves keep the flat cap, so genuine
|
|
|
|
|
|
narrow/oversize pathologies stay flagged."""
|
|
|
|
|
|
cap = 8.0
|
|
|
|
|
|
if self._leaf_sharing and self._share_edge_cap:
|
|
|
|
|
|
from . import graph as _graph
|
|
|
|
|
|
k = max(_graph.leaf_share(leaf, self._max_share) for leaf in leaves)
|
|
|
|
|
|
if k > 1:
|
|
|
|
|
|
cap *= k
|
|
|
|
|
|
return cap
|
|
|
|
|
|
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
def edge_cost(self, G: nx.Graph, a: Node, b: Node, fail) -> float:
|
|
|
|
|
|
"""Interior/exterior wall cost for one graph edge
|
|
|
|
|
|
(``Storey.pm::calculate_edge_cost``)."""
|
|
|
|
|
|
height = _height(a)
|
|
|
|
|
|
a_out, b_out = dom_mod.is_outside(a), dom_mod.is_outside(b)
|
|
|
|
|
|
if a_out and b_out:
|
|
|
|
|
|
rate = 0.0
|
|
|
|
|
|
elif not a_out and not b_out:
|
|
|
|
|
|
rate = self.cost("interior_wall")
|
|
|
|
|
|
else:
|
|
|
|
|
|
rate = self.cost("exterior_wall")
|
|
|
|
|
|
width = G[a][b]["width"]
|
hph/§13.8: share-aware edge-too-long cap — shared leaves no longer penalised for aggregate wall length
§13.7 flagged edge-too-long as harbor's top fail class. Dissection showed the
bulk are a leaf-sharing REPRESENTATION ARTIFACT: a share=k leaf aggregates k
same-code rooms, so its walls run ~k× the flat 8 m cap purely for being big —
the same §13.3 leak (size/missing relaxed for shared leaves) on the wall measure,
since edge_cost/outside_edge_cost ignored leaf.share.
Fix: Fitness._edge_cap(*leaves) scales the 8 m cap by the largest type-guarded
leaf_share among adjoining leaves, mirroring quality_size's k×target; non-shared
leaves keep the flat cap so genuine narrow/oversize pathologies stay flagged.
Gated behind a share_edge_cap config knob (SHAREEDGE env), default OFF so the
§13.x controls reproduce.
A/B (full Phase-8 stack, staged, 20k evals, seeds 0/1/2): control reproduces
§13.7 (maple 80.3 exact, harbor 34.7≈34.0); share-aware arm maple 80.3→74.0
(−7.9%), harbor 34.7→31.0 (−10.6%), zero regressions across 6 seeds. Positive
and monotone-harmless (only ever removes a false-positive fail). Verdict:
recommend default-ON; follow-up issue flips the default + rebaselines the floor.
Tests: 6 new unit tests for _edge_cap (221 pass).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JygRv4n2dcyDQqMiDRe7TN
2026-06-28 21:24:51 +01:00
|
|
|
|
if width > self._edge_cap(a, b) and rate > 0.0:
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
fail(f"{dom_mod.level_of(a)}/{a.id} {b.id} edge too long")
|
|
|
|
|
|
return rate * width * height
|
|
|
|
|
|
|
|
|
|
|
|
def outside_edge_cost(self, leaf: Node, fail) -> float:
|
|
|
|
|
|
"""Plot-boundary cost for a leaf's external edges
|
|
|
|
|
|
(``Leaf.pm::calculate_outside_edge_cost``)."""
|
|
|
|
|
|
rate = self.cost("boundary") if dom_mod.is_outside(leaf) else self.cost("boundary_wall")
|
hph/§13.8: share-aware edge-too-long cap — shared leaves no longer penalised for aggregate wall length
§13.7 flagged edge-too-long as harbor's top fail class. Dissection showed the
bulk are a leaf-sharing REPRESENTATION ARTIFACT: a share=k leaf aggregates k
same-code rooms, so its walls run ~k× the flat 8 m cap purely for being big —
the same §13.3 leak (size/missing relaxed for shared leaves) on the wall measure,
since edge_cost/outside_edge_cost ignored leaf.share.
Fix: Fitness._edge_cap(*leaves) scales the 8 m cap by the largest type-guarded
leaf_share among adjoining leaves, mirroring quality_size's k×target; non-shared
leaves keep the flat cap so genuine narrow/oversize pathologies stay flagged.
Gated behind a share_edge_cap config knob (SHAREEDGE env), default OFF so the
§13.x controls reproduce.
A/B (full Phase-8 stack, staged, 20k evals, seeds 0/1/2): control reproduces
§13.7 (maple 80.3 exact, harbor 34.7≈34.0); share-aware arm maple 80.3→74.0
(−7.9%), harbor 34.7→31.0 (−10.6%), zero regressions across 6 seeds. Positive
and monotone-harmless (only ever removes a false-positive fail). Verdict:
recommend default-ON; follow-up issue flips the default + rebaselines the floor.
Tests: 6 new unit tests for _edge_cap (221 pass).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JygRv4n2dcyDQqMiDRe7TN
2026-06-28 21:24:51 +01:00
|
|
|
|
cap = self._edge_cap(leaf)
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
length = 0.0
|
|
|
|
|
|
for e in range(4):
|
|
|
|
|
|
if geometry.boundary_id(leaf, e) not in geometry._EXTERNAL:
|
|
|
|
|
|
continue
|
|
|
|
|
|
edge_len = geometry.edge_length(leaf, e)
|
|
|
|
|
|
length += edge_len
|
|
|
|
|
|
if dom_mod.is_outside(leaf):
|
|
|
|
|
|
continue
|
hph/§13.8: share-aware edge-too-long cap — shared leaves no longer penalised for aggregate wall length
§13.7 flagged edge-too-long as harbor's top fail class. Dissection showed the
bulk are a leaf-sharing REPRESENTATION ARTIFACT: a share=k leaf aggregates k
same-code rooms, so its walls run ~k× the flat 8 m cap purely for being big —
the same §13.3 leak (size/missing relaxed for shared leaves) on the wall measure,
since edge_cost/outside_edge_cost ignored leaf.share.
Fix: Fitness._edge_cap(*leaves) scales the 8 m cap by the largest type-guarded
leaf_share among adjoining leaves, mirroring quality_size's k×target; non-shared
leaves keep the flat cap so genuine narrow/oversize pathologies stay flagged.
Gated behind a share_edge_cap config knob (SHAREEDGE env), default OFF so the
§13.x controls reproduce.
A/B (full Phase-8 stack, staged, 20k evals, seeds 0/1/2): control reproduces
§13.7 (maple 80.3 exact, harbor 34.7≈34.0); share-aware arm maple 80.3→74.0
(−7.9%), harbor 34.7→31.0 (−10.6%), zero regressions across 6 seeds. Positive
and monotone-harmless (only ever removes a false-positive fail). Verdict:
recommend default-ON; follow-up issue flips the default + rebaselines the floor.
Tests: 6 new unit tests for _edge_cap (221 pass).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JygRv4n2dcyDQqMiDRe7TN
2026-06-28 21:24:51 +01:00
|
|
|
|
if edge_len > cap:
|
Native fitness: leaf quality terms + cost model (homemaker-py-gnw)
Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python. Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).
Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 07:59:21 +01:00
|
|
|
|
fail(f"{dom_mod.level_of(leaf)}/{leaf.id} outside edge too long")
|
|
|
|
|
|
return rate * length * _height(leaf)
|
|
|
|
|
|
|
|
|
|
|
|
def plot_cost(self, root: Node) -> float:
|
|
|
|
|
|
"""The 'initial cost' term: plot rate x lowest-root area."""
|
|
|
|
|
|
return self.cost("plot") * geometry.area(root)
|
2026-06-13 20:55:25 +01:00
|
|
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
|
|
|
|
# Stair geometry (Urb::Misc::Stairs + Urb::Dom::Stair_Fit)
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _risers_number(height: float, max_riser: float) -> int:
|
|
|
|
|
|
"""Number of risers; mirrors ``risers_number`` in ``Urb::Misc::Stairs``."""
|
|
|
|
|
|
n = height / max_riser
|
|
|
|
|
|
return n if int(n) == n else 1 + int(n)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _ideal_going(riser: float) -> float:
|
|
|
|
|
|
"""Ideal going in metres; mirrors ``ideal_going`` in ``Urb::Misc::Stairs``."""
|
|
|
|
|
|
going = 0.625 - 2 * riser
|
|
|
|
|
|
if going < 0.22:
|
|
|
|
|
|
return 0.22
|
|
|
|
|
|
if int(going * 200) == going * 200:
|
|
|
|
|
|
return going
|
|
|
|
|
|
return 0.005 + int(going * 200) / 200
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _three_turn(risers: int, going_a: int) -> int:
|
|
|
|
|
|
r = int((risers + 1) / 2) - 5 - int(going_a)
|
|
|
|
|
|
return max(0, r)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _two_turn(risers: int, going_a: int) -> int:
|
|
|
|
|
|
if risers % 2 == 1:
|
|
|
|
|
|
r = int(risers / 2) - 3 - int(going_a / 2)
|
|
|
|
|
|
else:
|
|
|
|
|
|
r = int(risers / 2) - 3 - int((going_a + 1) / 2)
|
|
|
|
|
|
return max(0, r)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _one_turn(risers: int, going_a: int) -> int:
|
|
|
|
|
|
r = risers - 4 - int(going_a)
|
|
|
|
|
|
return max(0, r)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _zero_turn(risers: int, going_a: int) -> int:
|
|
|
|
|
|
if going_a + 2 > risers:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
return risers - 1
|
|
|
|
|
|
|
|
|
|
|
|
def _stair_fit(self, leaf: Node, corners: list[int]) -> float:
|
|
|
|
|
|
"""Stair fit score for one circulation leaf; mirrors ``Urb::Dom::Stair_Fit``."""
|
|
|
|
|
|
root = dom_mod._level_root(leaf)
|
|
|
|
|
|
while root.below is not None:
|
|
|
|
|
|
root = root.below
|
|
|
|
|
|
max_riser = getattr(root, "stair_riser", None) or 0.21
|
|
|
|
|
|
width = getattr(root, "stair_width", None) or 1.25
|
|
|
|
|
|
|
|
|
|
|
|
height = _height(leaf)
|
|
|
|
|
|
risers = self._risers_number(height, max_riser)
|
|
|
|
|
|
going = self._ideal_going(height / risers)
|
|
|
|
|
|
base = geometry.edge_length(leaf, corners[0])
|
|
|
|
|
|
length = geometry.edge_length(leaf, corners[0] + 1)
|
|
|
|
|
|
|
|
|
|
|
|
going_a = int((base - 2 * width) / going)
|
|
|
|
|
|
n = len(corners)
|
|
|
|
|
|
if n == 1:
|
|
|
|
|
|
going_b = self._three_turn(risers, going_a)
|
|
|
|
|
|
elif n == 2:
|
|
|
|
|
|
going_b = self._two_turn(risers, going_a)
|
|
|
|
|
|
elif n == 3:
|
|
|
|
|
|
going_b = self._one_turn(risers, going_a)
|
|
|
|
|
|
else:
|
|
|
|
|
|
going_b = self._zero_turn(risers, going_a)
|
|
|
|
|
|
|
|
|
|
|
|
return length / (width * 2 + going * going_b)
|
|
|
|
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
|
|
|
|
# Building-level ratio helpers (Dom.pm:Ratios/Areas/Area_Internal)
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _areas(root: Node) -> tuple[float, dict[str, float]]:
|
|
|
|
|
|
"""Total usable area and per-type area dict; mirrors ``Urb::Dom::Areas``."""
|
|
|
|
|
|
area_all = 0.0
|
|
|
|
|
|
areas: dict[str, float] = {}
|
|
|
|
|
|
for lvl in dom_mod.levels(root):
|
|
|
|
|
|
for leaf in lvl.leaves():
|
|
|
|
|
|
if not dom_mod.is_usable(leaf):
|
|
|
|
|
|
continue
|
|
|
|
|
|
a = geometry.area(leaf)
|
|
|
|
|
|
area_all += a
|
|
|
|
|
|
t = leaf.type or ""
|
|
|
|
|
|
areas[t] = areas.get(t, 0.0) + a
|
|
|
|
|
|
return area_all, areas
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _area_internal(root: Node) -> float:
|
|
|
|
|
|
"""Non-outside usable area; mirrors ``Urb::Dom::Area_Internal``."""
|
|
|
|
|
|
total = 0.0
|
|
|
|
|
|
for lvl in dom_mod.levels(root):
|
|
|
|
|
|
for leaf in lvl.leaves():
|
|
|
|
|
|
if dom_mod.is_outside(leaf):
|
|
|
|
|
|
continue
|
|
|
|
|
|
total += geometry.area(leaf)
|
|
|
|
|
|
return total
|
|
|
|
|
|
|
|
|
|
|
|
def _ratios(self, root: Node) -> dict[str, float]:
|
|
|
|
|
|
"""Per-type proportions; mirrors ``Urb::Dom::Ratios``."""
|
|
|
|
|
|
area_all, areas = self._areas(root)
|
|
|
|
|
|
if area_all == 0.0:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
return {t: a / area_all for t, a in areas.items()}
|
|
|
|
|
|
|
|
|
|
|
|
def ratio_o(self, ratios: dict[str, float]) -> float:
|
|
|
|
|
|
"""Outside/sahn proportion gaussian; mirrors ``ProgrammeDriven::ratio_o``."""
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
proportion_o = sum(v for k, v in ratios.items() if k in dom_mod.GENERIC_OUTSIDE)
|
2026-06-13 20:55:25 +01:00
|
|
|
|
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``."""
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
# §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)
|
2026-06-13 20:55:25 +01:00
|
|
|
|
if proportion_non_o <= 0.0:
|
|
|
|
|
|
proportion_non_o = 1.0
|
|
|
|
|
|
return gaussian(proportion_type / proportion_non_o, 1.0, ratio, sigma)
|
|
|
|
|
|
|
|
|
|
|
|
def quality_staircase_volume(self, *stair_fits: float) -> float:
|
|
|
|
|
|
"""Best-stair gaussian; mirrors ``ProgrammeDriven::quality_staircase_volume``."""
|
|
|
|
|
|
factor = 0.09
|
|
|
|
|
|
for sf in stair_fits:
|
|
|
|
|
|
if sf < 1:
|
|
|
|
|
|
f2 = gaussian(sf, 1.2, 1.0, 0.1)
|
|
|
|
|
|
else:
|
|
|
|
|
|
f2 = gaussian(sf, 1.2, 1.0, 0.5)
|
|
|
|
|
|
if f2 > factor:
|
|
|
|
|
|
factor = f2
|
|
|
|
|
|
return factor
|
|
|
|
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
|
|
|
|
# Public access / boundary length helpers
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _access_external(leaf: Node) -> list[str]:
|
|
|
|
|
|
"""External boundary ids ('a'-'d') for each edge of leaf."""
|
|
|
|
|
|
_EXT = frozenset("abcd")
|
|
|
|
|
|
result = []
|
|
|
|
|
|
for edge in range(4):
|
|
|
|
|
|
bid = geometry.boundary_id(leaf, edge)
|
|
|
|
|
|
if bid in _EXT:
|
|
|
|
|
|
result.append(bid)
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _perimeter_type(root: Node, bid: str) -> str:
|
|
|
|
|
|
"""Type string from root perimeter dict ('' if not set)."""
|
|
|
|
|
|
p = root.perimeter
|
|
|
|
|
|
if p is None:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
return p.get(bid) or ""
|
|
|
|
|
|
|
|
|
|
|
|
def _public_access(self, leaf: Node, root: Node) -> str | None:
|
|
|
|
|
|
"""Return external boundary id if leaf has public street access; mirrors
|
|
|
|
|
|
``Urb::Dom::Public_Access``. Returns None if no public access."""
|
|
|
|
|
|
if dom_mod.level_of(leaf) != 0:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if leaf.divided:
|
|
|
|
|
|
return None
|
|
|
|
|
|
for bid in self._access_external(leaf):
|
|
|
|
|
|
if self._perimeter_type(root, bid).lower() != "private":
|
|
|
|
|
|
return bid
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _entrance_bid_for_stair(
|
|
|
|
|
|
self,
|
|
|
|
|
|
stair_leaf: Node,
|
|
|
|
|
|
level_root: Node,
|
|
|
|
|
|
G: nx.Graph,
|
|
|
|
|
|
graph_circ: list,
|
|
|
|
|
|
all_lvls: list,
|
|
|
|
|
|
root: Node,
|
|
|
|
|
|
) -> str | None:
|
|
|
|
|
|
"""Return boundary id if stair_leaf is the building entrance; else None.
|
|
|
|
|
|
|
|
|
|
|
|
Mirrors the stair-entrance selection in Urb::Dom::Entrances: a stair C
|
|
|
|
|
|
leaf wins (priority 3) only when no non-stair C leaf has a higher-priority
|
|
|
|
|
|
entrance (priority 4 direct, 4.5 via outdoor). Via-outdoor stair entries
|
|
|
|
|
|
(priority 3.5) map to a leaf id, not a boundary, so they never produce
|
|
|
|
|
|
entrance corners in Perl either.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from . import graph as graph_mod
|
|
|
|
|
|
|
|
|
|
|
|
stair_bid = self._public_access(stair_leaf, root)
|
|
|
|
|
|
if stair_bid is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
for other in level_root.leaves():
|
|
|
|
|
|
if other is stair_leaf:
|
|
|
|
|
|
continue
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
if other.type != "C":
|
2026-06-13 20:55:25 +01:00
|
|
|
|
continue
|
|
|
|
|
|
other_corners = graph_mod.stack_corners_in_use(other, graph_circ, all_lvls)
|
|
|
|
|
|
if dom_mod.is_covered(other) and other_corners:
|
|
|
|
|
|
continue # also a stair — same priority, skip
|
|
|
|
|
|
if self._public_access(other, root) is not None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
for nb in G.neighbors(other):
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
if nb.type == "O" and self._public_access(nb, root) is not None:
|
2026-06-13 20:55:25 +01:00
|
|
|
|
return None
|
2026-06-13 21:44:42 +01:00
|
|
|
|
# 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):
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
if nb.type == "O" and self._public_access(nb, root) is not None:
|
2026-06-13 21:44:42 +01:00
|
|
|
|
return None
|
2026-06-13 20:55:25 +01:00
|
|
|
|
return stair_bid
|
|
|
|
|
|
|
|
|
|
|
|
def _public_access_outside(self, leaf: Node, G: nx.Graph, root: Node) -> bool:
|
|
|
|
|
|
"""True if leaf is an outside street-edge node with an lck neighbour;
|
|
|
|
|
|
mirrors ``Urb::Dom::Public_Access_Outside``."""
|
|
|
|
|
|
if leaf.divided:
|
|
|
|
|
|
return False
|
|
|
|
|
|
if not dom_mod.is_outside(leaf):
|
|
|
|
|
|
return False
|
|
|
|
|
|
if self._public_access(leaf, root) is None:
|
|
|
|
|
|
return False
|
|
|
|
|
|
for nb in G.neighbors(leaf):
|
§39.7: access requirements become a declared `usage:` attribute (homemaker-py-sel)
Closes the second namespace sharing a first character with programme codes: the
usage prefixes b/t/l/k, under which a room silently inherited another room's
connectivity rules from its spelling.
usage is a plain, MANDATORY attribute of the space definition -- not a lookup
table. An interim design proposed a top-level usage_classes: table binding
author-coined names to behaviour; withdrawn, because an indirect name->behaviour
mapping living apart from the thing it describes is exactly the shape of the
prefix rule §39 exists to remove, it would be the only such table in a schema
where every other space property is a plain attribute, and the need it served
was already met -- "building specific" is about what a room is CALLED, and
name: is already free text.
Rule that settles it: a usage value exists iff the engine treats it differently
somewhere. Config selects among behaviours; it cannot invent them.
- programme.USAGES (living/kitchen/bedroom/toilet/utility/none) plus the
behaviour groupings PRIVATE_USAGES / PRIVATE_STRIPS / TOILET_STRIPS /
SOCIABLE_USAGES. Missing or unknown usage is a load error naming the code,
from BOTH parse paths.
- Code-level, never leaf-level: usage_of(leaf.type) is looked up fresh, so a
retype changes the class automatically. 51 sites assign leaf.type, and
share/share_type plus the r5a resurrection are the precedent for why
leaf-level attributes rot.
- graph.has_circulation takes the usage map and trims on declared class;
fitness.access and the public-access check likewise. fitness._t0 is DELETED --
no first-character type test remains anywhere in the codebase.
- utility is distinct from bedroom (same access requirements today) because it
is a different use and gives derive_interchange_classes an axis to relax on.
- A toilet now keeps its edge to a terminal room -- the Brand adjacency, which
the old b-before-t loop ordering severed.
- All 107 corpus entries migrated by experiments/migrate_usage_key.py, comments
and layout preserved.
MEASURED -- the connectivity model was ~4x too permissive. `none` is not
neutral: nothing is trimmed, so the graph may route THROUGH the room, and 34 of
52 codes had no class (Dental Surgery, Records Room, Utilities Closet all served
as corridors). Edges trimmed, prefix-inferred vs declared, 3 seeds each:
harbor-house 18 (9%) -> 79 (39%) inaccessible fails 0 -> 4
health-centre 12 (8%) -> 59 (40%) inaccessible fails 2 -> 3
maple-court 53 (17%) -> 123 (39%) inaccessible fails 1 -> 5
Re-baseline (seed 1, 20k, harbor): 58 fails (15h/43s) -> 61 (16h/45s), now
reporting 1-inaccessible-usable-space x2 plus level 0 and level 1 not connected.
The count rose because the objective got honest -- those failures were always
true of the layout and the old model could not see them. Every harbor number
before this was measured against a graph crediting routes through store
cupboards.
Sharpens §38.2: the objective pays x60-85 to delete circulation, and until now
the deleted corridors were not missed because storage stood in for them. With
that substitution gone, homemaker-py-2v1 is the remaining half -- and now
measurable, because the fails it should prevent actually fire.
350 passed (+5 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 13:39:41 +00:00
|
|
|
|
# C is a generic circulation leaf; living/kitchen are DECLARED
|
|
|
|
|
|
# usages of programme rooms (§39.7). Two namespaces, two tests.
|
|
|
|
|
|
if nb.type == "C" or self.usage_of(nb) in _programme.SOCIABLE_USAGES:
|
2026-06-13 20:55:25 +01:00
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
def _public_length(self, leaf: Node, root: Node) -> float:
|
|
|
|
|
|
"""Non-private external boundary metres; mirrors ``Urb::Dom::Public_Length``."""
|
|
|
|
|
|
if dom_mod.level_of(leaf) != 0:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
total = 0.0
|
|
|
|
|
|
for edge in range(4):
|
|
|
|
|
|
bid = geometry.boundary_id(leaf, edge)
|
|
|
|
|
|
if bid not in frozenset("abcd"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if self._perimeter_type(root, bid).lower() == "private":
|
|
|
|
|
|
continue
|
|
|
|
|
|
total += geometry.edge_length(leaf, edge)
|
|
|
|
|
|
return total
|
|
|
|
|
|
|
|
|
|
|
|
def _private_length(self, leaf: Node, root: Node) -> float:
|
|
|
|
|
|
"""Private external boundary metres; mirrors ``Urb::Dom::Private_Length``."""
|
|
|
|
|
|
if dom_mod.level_of(leaf) != 0:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
total = 0.0
|
|
|
|
|
|
for edge in range(4):
|
|
|
|
|
|
bid = geometry.boundary_id(leaf, edge)
|
|
|
|
|
|
if bid not in frozenset("abcd"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if self._perimeter_type(root, bid).lower() != "private":
|
|
|
|
|
|
continue
|
|
|
|
|
|
total += geometry.edge_length(leaf, edge)
|
|
|
|
|
|
return total
|
|
|
|
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
|
|
|
|
# Extended process_storey (adds circ, stair, tracking)
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
|
|
|
|
|
|
|
|
|
|
def process_storey(
|
|
|
|
|
|
self,
|
|
|
|
|
|
level_root: Node,
|
|
|
|
|
|
G: nx.Graph,
|
|
|
|
|
|
level_id: int,
|
|
|
|
|
|
fail,
|
|
|
|
|
|
graph_circ: list[nx.Graph] | None = None,
|
|
|
|
|
|
tracking: dict | None = None,
|
|
|
|
|
|
lvls: list[Node] | None = None,
|
|
|
|
|
|
root: Node | None = None,
|
|
|
|
|
|
) -> StoreyEval:
|
|
|
|
|
|
"""Per-storey cost, value and leaf evaluations on the MERGED tree.
|
|
|
|
|
|
|
|
|
|
|
|
Optional ``graph_circ``, ``tracking``, ``lvls``, ``root`` activate the
|
|
|
|
|
|
homemaker-py-hgg storey checks (stair fit, circulation connectivity,
|
|
|
|
|
|
roof-garden, public-access tracking). When omitted the method behaves
|
|
|
|
|
|
as in homemaker-py-gnw (leaf quality + costs only).
|
|
|
|
|
|
"""
|
|
|
|
|
|
from . import graph as graph_mod
|
|
|
|
|
|
|
|
|
|
|
|
groups = geometry.boundary_groups(level_root)
|
|
|
|
|
|
cost = 0.0
|
|
|
|
|
|
value = 0.0
|
|
|
|
|
|
leaves_eval: list[LeafEval] = []
|
|
|
|
|
|
has_outdoor_space = False
|
|
|
|
|
|
|
|
|
|
|
|
for leaf in level_root.leaves():
|
|
|
|
|
|
if dom_mod.is_outside(leaf) and dom_mod.is_covered(leaf) and level_id:
|
|
|
|
|
|
if not dom_mod.is_supported(leaf):
|
|
|
|
|
|
fail(f"{level_id}/{leaf.id} unsupported covered outside")
|
|
|
|
|
|
fail(f"{level_id}/{leaf.id} covered outside above ground")
|
|
|
|
|
|
|
|
|
|
|
|
cost += self.leaf_cost(leaf)
|
|
|
|
|
|
if not dom_mod.is_usable(leaf):
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if dom_mod.is_outside(leaf):
|
|
|
|
|
|
has_outdoor_space = True
|
|
|
|
|
|
|
|
|
|
|
|
quality, factors = self.evaluate_leaf(leaf, G, level_id, groups, fail)
|
|
|
|
|
|
rate = self.value_rate(leaf)
|
|
|
|
|
|
value += quality * rate * geometry.area(leaf)
|
|
|
|
|
|
leaves_eval.append(
|
|
|
|
|
|
LeafEval(
|
|
|
|
|
|
level=level_id,
|
|
|
|
|
|
id=leaf.id,
|
|
|
|
|
|
type=leaf.type or "",
|
|
|
|
|
|
area=geometry.area(leaf),
|
|
|
|
|
|
rate=rate,
|
|
|
|
|
|
quality=quality,
|
|
|
|
|
|
factors=factors,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
if level_id == 0 and leaf.type == "C" and dom_mod.is_covered(leaf):
|
2026-06-13 20:55:25 +01:00
|
|
|
|
all_lvls = lvls
|
|
|
|
|
|
corners = graph_mod.stack_corners_in_use(leaf, graph_circ, all_lvls)
|
|
|
|
|
|
n_corners = len(corners)
|
|
|
|
|
|
if n_corners:
|
|
|
|
|
|
# Mirror Perl check_stair_fit: add entrance door corners so
|
|
|
|
|
|
# the stair loses the corner it shares with the entrance.
|
|
|
|
|
|
entrance_bid = self._entrance_bid_for_stair(
|
|
|
|
|
|
leaf, level_root, G, graph_circ, all_lvls, root
|
|
|
|
|
|
)
|
|
|
|
|
|
if entrance_bid is not None:
|
|
|
|
|
|
for edge in range(4):
|
|
|
|
|
|
if geometry.boundary_id(leaf, edge) == entrance_bid:
|
|
|
|
|
|
for ec in (edge, edge + 1):
|
|
|
|
|
|
if ec not in corners:
|
|
|
|
|
|
corners = corners + [ec]
|
|
|
|
|
|
stair_fit = self._stair_fit(leaf, corners)
|
|
|
|
|
|
tracking["stair_fit"].append(stair_fit)
|
|
|
|
|
|
|
|
|
|
|
|
# Public access tracking
|
|
|
|
|
|
if root is not None:
|
|
|
|
|
|
if self._public_access_outside(leaf, G, root):
|
|
|
|
|
|
tracking["has_public_access_outside"] = True
|
|
|
|
|
|
if (not stair_fit
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
and leaf.type == "C"
|
2026-06-13 20:55:25 +01:00
|
|
|
|
and self._public_access(leaf, root) is not None):
|
|
|
|
|
|
tracking["has_public_access_inside"] = True
|
|
|
|
|
|
|
|
|
|
|
|
pub = self._public_length(leaf, root)
|
|
|
|
|
|
tracking["public_length_all"] = tracking.get("public_length_all", 0.0) + pub
|
|
|
|
|
|
if dom_mod.is_outside(leaf):
|
|
|
|
|
|
tracking["public_length_outside"] = tracking.get("public_length_outside", 0.0) + pub
|
|
|
|
|
|
priv = self._private_length(leaf, root)
|
|
|
|
|
|
tracking["private_length_all"] = tracking.get("private_length_all", 0.0) + priv
|
|
|
|
|
|
if dom_mod.is_outside(leaf):
|
|
|
|
|
|
tracking["private_length_outside"] = tracking.get("private_length_outside", 0.0) + priv
|
|
|
|
|
|
|
|
|
|
|
|
for a, b in G.edges():
|
|
|
|
|
|
cost += self.edge_cost(G, a, b, fail)
|
|
|
|
|
|
for leaf in level_root.leaves():
|
|
|
|
|
|
cost += self.outside_edge_cost(leaf, fail)
|
|
|
|
|
|
|
|
|
|
|
|
if graph_circ is not None:
|
|
|
|
|
|
# Connected_Circulation check on a copy of the circ graph
|
|
|
|
|
|
gc_copy = graph_circ[level_id].copy() if level_id < len(graph_circ) else nx.Graph()
|
|
|
|
|
|
if not graph_mod.connected_circulation(gc_copy):
|
|
|
|
|
|
fail(f"level {level_id} not connected")
|
|
|
|
|
|
|
|
|
|
|
|
conf_fg = self.conf("force_roof_garden")
|
|
|
|
|
|
if conf_fg and not has_outdoor_space:
|
|
|
|
|
|
fail(f"level {level_id} no outside space")
|
|
|
|
|
|
|
|
|
|
|
|
return StoreyEval(cost=cost, value=value, leaves=leaves_eval)
|
|
|
|
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
|
|
|
|
# Building-level evaluation
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
|
|
|
|
|
|
|
|
|
|
def evaluate_building(self, root: Node, tracking: dict) -> float:
|
|
|
|
|
|
"""Building factor; mirrors ``evaluate_building_program_driven``."""
|
|
|
|
|
|
from . import graph as graph_mod
|
|
|
|
|
|
|
|
|
|
|
|
ratios = self._ratios(root)
|
|
|
|
|
|
|
|
|
|
|
|
factor = 1.0
|
|
|
|
|
|
factor *= self.ratio_o(ratios)
|
|
|
|
|
|
|
|
|
|
|
|
circ_ratio = self.conf("ratio_circulation")
|
|
|
|
|
|
factor *= self.ratio_type(ratios, "c", circ_ratio[0], circ_ratio[1])
|
|
|
|
|
|
|
|
|
|
|
|
min_required = 0.0
|
|
|
|
|
|
for req in (self._programme or {}).values():
|
§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
2026-08-26 09:45:28 +00:00
|
|
|
|
if dom_mod.is_generic(req.code):
|
2026-06-13 20:55:25 +01:00
|
|
|
|
continue
|
|
|
|
|
|
if req.size > 0:
|
|
|
|
|
|
min_required += req.size * req.count
|
|
|
|
|
|
min_required *= 1.2
|
|
|
|
|
|
actual_internal = self._area_internal(root)
|
|
|
|
|
|
if actual_internal < min_required and min_required > 0:
|
|
|
|
|
|
f2 = gaussian(actual_internal, 1.0, min_required, min_required * 0.15)
|
|
|
|
|
|
factor *= f2
|
|
|
|
|
|
|
|
|
|
|
|
# Public/private ratios (optional config)
|
|
|
|
|
|
pub_all = tracking.get("public_length_all", 0.0)
|
|
|
|
|
|
pub_ratio = tracking.get("public_length_outside", 0.0) / pub_all if pub_all else 0.0
|
|
|
|
|
|
conf_po = self.conf("ratio_public_outside")
|
|
|
|
|
|
if conf_po and isinstance(conf_po, list):
|
|
|
|
|
|
factor *= gaussian(pub_ratio, 1.0, conf_po[0], conf_po[1])
|
|
|
|
|
|
|
|
|
|
|
|
priv_all = tracking.get("private_length_all", 0.0)
|
|
|
|
|
|
priv_ratio = tracking.get("private_length_outside", 0.0) / priv_all if priv_all else 0.0
|
|
|
|
|
|
conf_pr = self.conf("ratio_private_outside")
|
|
|
|
|
|
if conf_pr and isinstance(conf_pr, list):
|
|
|
|
|
|
factor *= gaussian(priv_ratio, 1.0, conf_pr[0], conf_pr[1])
|
|
|
|
|
|
|
|
|
|
|
|
# Staircase volume (multi-level only)
|
|
|
|
|
|
lvls = dom_mod.levels(root)
|
|
|
|
|
|
if len(lvls) > 1:
|
|
|
|
|
|
sf_factor = self.quality_staircase_volume(*tracking.get("stair_fit", []))
|
|
|
|
|
|
if sf_factor < FAIL_THRESHOLD:
|
|
|
|
|
|
tracking["_failures"].append("staircase volume")
|
|
|
|
|
|
factor *= sf_factor
|
|
|
|
|
|
|
|
|
|
|
|
stair_min = self.conf("staircase_min") or 1
|
|
|
|
|
|
stair_max = self.conf("staircase_max") or 1
|
|
|
|
|
|
stair_count = len(tracking.get("stair_fit", []))
|
|
|
|
|
|
|
|
|
|
|
|
if stair_count < stair_min:
|
|
|
|
|
|
tracking["_failures"].append(
|
|
|
|
|
|
f"too few stairs ({stair_count}, min {stair_min})"
|
|
|
|
|
|
)
|
|
|
|
|
|
if stair_count > stair_max:
|
|
|
|
|
|
tracking["_failures"].append(
|
|
|
|
|
|
f"too many stairs ({stair_count}, max {stair_max})"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Storey limit / minimum
|
|
|
|
|
|
n_storeys = len(lvls)
|
|
|
|
|
|
storey_limit = self.conf("storey_limit") or 4
|
|
|
|
|
|
storey_min = self.conf("storey_minimum") or 2
|
|
|
|
|
|
if n_storeys - 1 >= storey_limit:
|
|
|
|
|
|
tracking["_failures"].append("storey limit")
|
|
|
|
|
|
if n_storeys < storey_min:
|
|
|
|
|
|
tracking["_failures"].append("storey minimum")
|
|
|
|
|
|
|
|
|
|
|
|
# Public access
|
|
|
|
|
|
if not (tracking.get("has_public_access_outside") or tracking.get("has_public_access_inside")):
|
|
|
|
|
|
tracking["_failures"].append("no outside public access")
|
|
|
|
|
|
|
|
|
|
|
|
return factor
|
|
|
|
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
|
|
|
|
# Full pipeline
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
|
|
|
|
|
|
|
|
|
|
def evaluate(self, root: Node) -> float:
|
|
|
|
|
|
"""Full programme-driven fitness; mirrors ``ProgrammeDriven::_apply``.
|
|
|
|
|
|
|
|
|
|
|
|
Returns ``value / cost`` (the final score as in Urb).
|
|
|
|
|
|
"""
|
2026-06-18 22:33:29 +01:00
|
|
|
|
score, _, _ = self._evaluate_full(root)
|
2026-06-13 21:44:42 +01:00
|
|
|
|
return score
|
|
|
|
|
|
|
|
|
|
|
|
def score_with_fails(self, root: Node) -> tuple[float, tuple[str, ...]]:
|
|
|
|
|
|
"""Same as ``evaluate`` but also returns the sorted failure strings."""
|
2026-06-18 22:33:29 +01:00
|
|
|
|
score, fails, _ = self._evaluate_full(root)
|
|
|
|
|
|
return score, fails
|
|
|
|
|
|
|
|
|
|
|
|
def score_with_grade(
|
|
|
|
|
|
self, root: Node
|
|
|
|
|
|
) -> tuple[float, tuple[str, ...], float]:
|
|
|
|
|
|
"""``score_with_fails`` plus the graded proximity scalar (§11.4).
|
|
|
|
|
|
|
|
|
|
|
|
The grade is a continuous secondary signal for the outer comparator only;
|
|
|
|
|
|
it leaves ``score`` and the fail count untouched (and so the inner-loop
|
|
|
|
|
|
0.5^n cliff protection, §5.4, intact).
|
|
|
|
|
|
"""
|
|
|
|
|
|
return self._evaluate_full(root, want_grade=True)
|
2026-06-13 21:44:42 +01:00
|
|
|
|
|
2026-06-18 22:33:29 +01:00
|
|
|
|
def _evaluate_full(
|
|
|
|
|
|
self, root: Node, want_grade: bool = False
|
|
|
|
|
|
) -> tuple[float, tuple[str, ...], float]:
|
2026-06-13 20:55:25 +01:00
|
|
|
|
from . import graph as graph_mod
|
|
|
|
|
|
|
|
|
|
|
|
geometry.clear_cache()
|
2026-08-02 10:45:12 +01:00
|
|
|
|
# homemaker-py-r5a: canonicalise stale share stamps before any
|
|
|
|
|
|
# relabelling pass (collapse_superposition/collapse_global) or read
|
|
|
|
|
|
# can resurrect one -- see dom.canonicalize_shares.
|
|
|
|
|
|
dom_mod.canonicalize_shares(root)
|
2026-06-13 20:55:25 +01:00
|
|
|
|
|
|
|
|
|
|
failures: list[str] = []
|
|
|
|
|
|
tracking: dict = {
|
|
|
|
|
|
"has_public_access_outside": False,
|
|
|
|
|
|
"has_public_access_inside": False,
|
|
|
|
|
|
"public_length_all": 0.0,
|
|
|
|
|
|
"public_length_outside": 0.0,
|
|
|
|
|
|
"private_length_all": 0.0,
|
|
|
|
|
|
"private_length_outside": 0.0,
|
|
|
|
|
|
"stair_fit": [],
|
|
|
|
|
|
"_failures": failures,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
programme = self._programme or {}
|
|
|
|
|
|
|
9o5: type superposition + per-eval collapse (multi-use leaves)
Interchangeable codes (similar size/width/proportion, compatible level/stack,
no adjacency edge) form equivalence classes derived from the programme. With
--superpose (default off), each fitness eval COLLAPSES every superposed leaf to
its best in-class usage via an optimal supply->demand assignment (brute force
<=C! within cap C=4, scipy Hungarian beyond), then scores the condensed types.
Because collapse re-types on the unmerged tree before all checks, counts /
adjacency / quality are unchanged downstream -- no Node field, no graph/operator
changes -- and default OFF is bit-identical.
- programme.py: derive_interchange_classes + interchangeable (S1-S4, locked
thresholds R_SIZE=1.5/R_WIDTH=1.3/R_PROP=1.5, CLASS_CAP=4)
- fitness.py: collapse_superposition, _best_assignment, _usage_quality;
superpose/superpose_class_cap conf knobs; collapse hooked into _evaluate_full
- driver.py/evolve.py: superpose flag plumbed beside leaf_sharing; --superpose
- tests/test_superposition.py: 17 tests (derivation, assignment, end-to-end)
Closes homemaker-py-9o5 (build); validation A/B is homemaker-py-xi7.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:08:46 +01:00
|
|
|
|
# 9o5 COLLAPSE: re-type superposed leaves to their best in-class usage
|
|
|
|
|
|
# before any check (no-op unless superposition is on and a class exists).
|
|
|
|
|
|
if self._superpose:
|
|
|
|
|
|
self.collapse_superposition(root)
|
|
|
|
|
|
|
2026-07-19 20:35:18 +01:00
|
|
|
|
# homemaker-py-qpk: IN-SEARCH global collapse (DESIGN.md §17 follow-on).
|
|
|
|
|
|
# Runs before any check, same as collapse_superposition above, so counts/
|
|
|
|
|
|
# adjacency/quality downstream see the collapsed (relabelled) types. Uses
|
|
|
|
|
|
# its own graph build (fixed geometry, only labels move) — safe to call
|
|
|
|
|
|
# on the unmerged tree, exactly as collapse_global's finish-time use does.
|
|
|
|
|
|
if self._collapse_insearch:
|
|
|
|
|
|
self.collapse_global(
|
|
|
|
|
|
root,
|
|
|
|
|
|
adjacency=self._collapse_insearch_adjacency,
|
|
|
|
|
|
objective="threshold",
|
|
|
|
|
|
preserve_public_access=True,
|
|
|
|
|
|
iters=self._collapse_insearch_iters,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-13 20:55:25 +01:00
|
|
|
|
# --- Phase 1: UNMERGED tree checks ---
|
2026-06-24 08:30:26 +01:00
|
|
|
|
check_fails, missing = graph_mod.check_space_counts(
|
2026-07-31 00:16:12 +01:00
|
|
|
|
root, programme, self._leaf_sharing, self._max_share,
|
|
|
|
|
|
self._multi_use, self.colocate_pairs())
|
2026-06-13 20:55:25 +01:00
|
|
|
|
failures.extend(check_fails)
|
|
|
|
|
|
|
|
|
|
|
|
self.preprocess_building(root)
|
|
|
|
|
|
_, graph_circ_pre = graph_mod.build_graphs_with_circ(
|
§39.7: access requirements become a declared `usage:` attribute (homemaker-py-sel)
Closes the second namespace sharing a first character with programme codes: the
usage prefixes b/t/l/k, under which a room silently inherited another room's
connectivity rules from its spelling.
usage is a plain, MANDATORY attribute of the space definition -- not a lookup
table. An interim design proposed a top-level usage_classes: table binding
author-coined names to behaviour; withdrawn, because an indirect name->behaviour
mapping living apart from the thing it describes is exactly the shape of the
prefix rule §39 exists to remove, it would be the only such table in a schema
where every other space property is a plain attribute, and the need it served
was already met -- "building specific" is about what a room is CALLED, and
name: is already free text.
Rule that settles it: a usage value exists iff the engine treats it differently
somewhere. Config selects among behaviours; it cannot invent them.
- programme.USAGES (living/kitchen/bedroom/toilet/utility/none) plus the
behaviour groupings PRIVATE_USAGES / PRIVATE_STRIPS / TOILET_STRIPS /
SOCIABLE_USAGES. Missing or unknown usage is a load error naming the code,
from BOTH parse paths.
- Code-level, never leaf-level: usage_of(leaf.type) is looked up fresh, so a
retype changes the class automatically. 51 sites assign leaf.type, and
share/share_type plus the r5a resurrection are the precedent for why
leaf-level attributes rot.
- graph.has_circulation takes the usage map and trims on declared class;
fitness.access and the public-access check likewise. fitness._t0 is DELETED --
no first-character type test remains anywhere in the codebase.
- utility is distinct from bedroom (same access requirements today) because it
is a different use and gives derive_interchange_classes an axis to relax on.
- A toilet now keeps its edge to a terminal room -- the Brand adjacency, which
the old b-before-t loop ordering severed.
- All 107 corpus entries migrated by experiments/migrate_usage_key.py, comments
and layout preserved.
MEASURED -- the connectivity model was ~4x too permissive. `none` is not
neutral: nothing is trimmed, so the graph may route THROUGH the room, and 34 of
52 codes had no class (Dental Surgery, Records Room, Utilities Closet all served
as corridors). Edges trimmed, prefix-inferred vs declared, 3 seeds each:
harbor-house 18 (9%) -> 79 (39%) inaccessible fails 0 -> 4
health-centre 12 (8%) -> 59 (40%) inaccessible fails 2 -> 3
maple-court 53 (17%) -> 123 (39%) inaccessible fails 1 -> 5
Re-baseline (seed 1, 20k, harbor): 58 fails (15h/43s) -> 61 (16h/45s), now
reporting 1-inaccessible-usable-space x2 plus level 0 and level 1 not connected.
The count rose because the objective got honest -- those failures were always
true of the layout and the old model could not see them. Every harbor number
before this was measured against a graph crediting routes through store
cupboards.
Sharpens §38.2: the objective pays x60-85 to delete circulation, and until now
the deleted corridors were not missed because storage stood in for them. With
that substitution gone, homemaker-py-2v1 is the remaining half -- and now
measurable, because the fails it should prevent actually fire.
350 passed (+5 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 13:39:41 +00:00
|
|
|
|
root, self.conf("door_width") or 1.2, failures.append, self.usages()
|
2026-06-13 20:55:25 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
graph_base_pre = graph_mod.build_graphs(root, self.conf("door_width") or 1.2)
|
|
|
|
|
|
|
2026-07-31 00:16:12 +01:00
|
|
|
|
failures.extend(graph_mod.check_adjacency(
|
|
|
|
|
|
root, programme, graph_base_pre, missing,
|
|
|
|
|
|
self._multi_use, self.colocate_pairs()))
|
|
|
|
|
|
failures.extend(graph_mod.check_level_constraints(
|
|
|
|
|
|
root, programme, missing, self._multi_use, self.colocate_pairs()))
|
|
|
|
|
|
failures.extend(graph_mod.check_vertical_connectivity(
|
|
|
|
|
|
root, programme, missing, self._multi_use, self.colocate_pairs()))
|
2026-06-13 20:55:25 +01:00
|
|
|
|
|
|
|
|
|
|
# --- Phase 2: MERGED tree ---
|
|
|
|
|
|
dom_mod.merge_divided(root)
|
|
|
|
|
|
geometry.clear_cache() # mirror Perl Merge_Divided → Clean_Cache
|
|
|
|
|
|
|
|
|
|
|
|
_, graph_circ = graph_mod.build_graphs_with_circ(
|
§39.7: access requirements become a declared `usage:` attribute (homemaker-py-sel)
Closes the second namespace sharing a first character with programme codes: the
usage prefixes b/t/l/k, under which a room silently inherited another room's
connectivity rules from its spelling.
usage is a plain, MANDATORY attribute of the space definition -- not a lookup
table. An interim design proposed a top-level usage_classes: table binding
author-coined names to behaviour; withdrawn, because an indirect name->behaviour
mapping living apart from the thing it describes is exactly the shape of the
prefix rule §39 exists to remove, it would be the only such table in a schema
where every other space property is a plain attribute, and the need it served
was already met -- "building specific" is about what a room is CALLED, and
name: is already free text.
Rule that settles it: a usage value exists iff the engine treats it differently
somewhere. Config selects among behaviours; it cannot invent them.
- programme.USAGES (living/kitchen/bedroom/toilet/utility/none) plus the
behaviour groupings PRIVATE_USAGES / PRIVATE_STRIPS / TOILET_STRIPS /
SOCIABLE_USAGES. Missing or unknown usage is a load error naming the code,
from BOTH parse paths.
- Code-level, never leaf-level: usage_of(leaf.type) is looked up fresh, so a
retype changes the class automatically. 51 sites assign leaf.type, and
share/share_type plus the r5a resurrection are the precedent for why
leaf-level attributes rot.
- graph.has_circulation takes the usage map and trims on declared class;
fitness.access and the public-access check likewise. fitness._t0 is DELETED --
no first-character type test remains anywhere in the codebase.
- utility is distinct from bedroom (same access requirements today) because it
is a different use and gives derive_interchange_classes an axis to relax on.
- A toilet now keeps its edge to a terminal room -- the Brand adjacency, which
the old b-before-t loop ordering severed.
- All 107 corpus entries migrated by experiments/migrate_usage_key.py, comments
and layout preserved.
MEASURED -- the connectivity model was ~4x too permissive. `none` is not
neutral: nothing is trimmed, so the graph may route THROUGH the room, and 34 of
52 codes had no class (Dental Surgery, Records Room, Utilities Closet all served
as corridors). Edges trimmed, prefix-inferred vs declared, 3 seeds each:
harbor-house 18 (9%) -> 79 (39%) inaccessible fails 0 -> 4
health-centre 12 (8%) -> 59 (40%) inaccessible fails 2 -> 3
maple-court 53 (17%) -> 123 (39%) inaccessible fails 1 -> 5
Re-baseline (seed 1, 20k, harbor): 58 fails (15h/43s) -> 61 (16h/45s), now
reporting 1-inaccessible-usable-space x2 plus level 0 and level 1 not connected.
The count rose because the objective got honest -- those failures were always
true of the layout and the old model could not see them. Every harbor number
before this was measured against a graph crediting routes through store
cupboards.
Sharpens §38.2: the objective pays x60-85 to delete circulation, and until now
the deleted corridors were not missed because storage stood in for them. With
that substitution gone, homemaker-py-2v1 is the remaining half -- and now
measurable, because the fails it should prevent actually fire.
350 passed (+5 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 13:39:41 +00:00
|
|
|
|
root, self.conf("door_width") or 1.2, failures.append, self.usages()
|
2026-06-13 20:55:25 +01:00
|
|
|
|
)
|
|
|
|
|
|
graph_base = graph_mod.build_graphs(root, self.conf("door_width") or 1.2)
|
|
|
|
|
|
|
|
|
|
|
|
cost = self.plot_cost(root)
|
|
|
|
|
|
value = 0.0
|
2026-06-18 22:33:29 +01:00
|
|
|
|
grade = 0.0
|
2026-06-13 20:55:25 +01:00
|
|
|
|
lvls = dom_mod.levels(root)
|
|
|
|
|
|
|
|
|
|
|
|
for li, lvl in enumerate(lvls):
|
|
|
|
|
|
se = self.process_storey(
|
|
|
|
|
|
lvl, graph_base[li], li, failures.append,
|
|
|
|
|
|
graph_circ=graph_circ,
|
|
|
|
|
|
tracking=tracking,
|
|
|
|
|
|
lvls=lvls,
|
|
|
|
|
|
root=root,
|
|
|
|
|
|
)
|
|
|
|
|
|
cost += se.cost
|
|
|
|
|
|
value += se.value
|
2026-07-18 18:44:24 +01:00
|
|
|
|
if want_grade and not self._conn_grade: # §11.4 signal; off by default
|
2026-06-18 22:33:29 +01:00
|
|
|
|
for le in se.leaves:
|
|
|
|
|
|
grade += _leaf_grade(le.factors)
|
2026-06-13 20:55:25 +01:00
|
|
|
|
|
2026-07-18 18:44:24 +01:00
|
|
|
|
# §18 (homemaker-py-qi6): repurpose the grade channel for the graded
|
|
|
|
|
|
# circulation-connectivity signal — sum of per-level largest-circ-component
|
|
|
|
|
|
# fractions, higher when circulation is closer to a single connected spine.
|
|
|
|
|
|
# Secondary comparator key only; score and fail count are untouched.
|
|
|
|
|
|
if want_grade and self._conn_grade:
|
|
|
|
|
|
for gc in graph_circ:
|
|
|
|
|
|
grade += graph_mod.circulation_connectivity(gc)
|
|
|
|
|
|
|
2026-06-13 20:55:25 +01:00
|
|
|
|
building_factor = self.evaluate_building(root, tracking)
|
|
|
|
|
|
value *= building_factor
|
|
|
|
|
|
|
§39.8: homemaker-py-2v1 connectivity weighting — MEASURED NULL, premise retracted
§38.2 concluded the objective is net-positive on severing a level's
circulation: merging a corridor into a habitable sibling gains x6
(value_inside/value_circulation), while "level N not connected" costs x0.5, so
break-even needs 0.5^w < 50/300, w > 2.58 -- "severing must cost at least 3
fails and costs 1". The arithmetic is right. The premise is wrong.
Shipped anyway, EXPERIMENTAL and default off (byte-identical):
fitness.connectivity_weight_for(value_inside, value_circulation) returns the
smallest weight making severing net-negative -- 3.0 at the defaults, DERIVED
from the rates rather than hard-coded so it tracks them if either is retuned.
conf["connectivity_weight"] takes 1.0 / "auto" / a number and counts each
connectivity failure as w failures in the 0.5^n penalty.
MEASUREMENT: at auto (=3) the §38.2 deletion test does not move at all -- 5/25
rewarded either way, median x0.26 vs x0.27. Reason: the connectivity fail count
is UNCHANGED in every rewarded deletion (115->107 fails but 5->5 connectivity;
107->99 but 3->3; 78->71 but 3->3). Weighting a fail that never fires changes
nothing.
And when a deletion DOES break connectivity, it is already punished. Every such
case, 4 seeds per programme: harbor-house 2 of 32 sampled deletions, both
punished (x0.00, x0.01); maple-court 5 of 32, all punished (x0.58 .. x0.07).
Severing costs 1-2 connectivity fails PLUS the cascade after them, which
already outweighs the x6 gain. The flat rule was never the problem.
Where §38.2 went wrong: the x4.06 "well-daylit circulation leaf" that motivated
the bead was a deletion that did NOT change the connectivity fail count. It was
rewarded for removing the leaf's own quality failures -- §38.1's zero-value
finding -- and I misread it as a pricing mechanism. §38.2 now carries the
retraction inline. Two lessons recorded: a plausible closed-form arithmetic is
not a measurement, and when a fix produces exactly no effect, suspect the
premise before the implementation.
Still standing from §38: §38.1 (buried leaves score zero quality and contribute
no value) and §38.3 (frontage budget) are direct measurements. §39.7 remains
the better lever on the same symptom -- it made the connectivity fails FIRE,
where this would only have made them cost more.
Re-opened as homemaker-py-yql: why level-not-connected persists in the best
layout when severing is already punished. Evidence now points at reachability,
not incentive, and it is newly measurable because §39.7 stopped store cupboards
standing in for corridors.
353 passed (+3 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 14:15:22 +00:00
|
|
|
|
# 0.5^n failure penalty (programme-driven mode, not 0.1^n).
|
|
|
|
|
|
#
|
|
|
|
|
|
# homemaker-py-2v1: connectivity failures may carry EXTRA weight. Under
|
|
|
|
|
|
# the flat rule every failure costs x0.5, but severing a level's
|
|
|
|
|
|
# circulation *gains* value_inside/value_circulation = x6 (the corridor
|
|
|
|
|
|
# becomes habitable area when it merges into its sibling), so the
|
|
|
|
|
|
# objective was net-positive on destroying the spine — measured x4.06 on
|
|
|
|
|
|
# a well-daylit circulation leaf. ``connectivity_weight`` counts each
|
|
|
|
|
|
# connectivity fail as w failures; ``"auto"`` derives the smallest w that
|
|
|
|
|
|
# makes severing net-negative from the value rates themselves.
|
|
|
|
|
|
w = self._connectivity_weight
|
|
|
|
|
|
if w != 1.0:
|
|
|
|
|
|
n_conn = sum(1 for f in failures if is_connectivity_fail(f))
|
|
|
|
|
|
n_other = len(failures) - n_conn
|
|
|
|
|
|
value *= 0.5 ** (n_other + w * n_conn)
|
|
|
|
|
|
else:
|
|
|
|
|
|
value *= 0.5 ** len(failures)
|
2026-06-13 20:55:25 +01:00
|
|
|
|
|
2026-06-13 21:44:42 +01:00
|
|
|
|
score = value / cost if cost != 0.0 else 0.0
|
2026-06-18 22:33:29 +01:00
|
|
|
|
return score, tuple(sorted(failures)), grade
|
2026-06-13 20:55:25 +01:00
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def _programme(self) -> dict | None:
|
|
|
|
|
|
"""Programme requirements parsed from config, or None."""
|
|
|
|
|
|
return self._programme_cache
|
|
|
|
|
|
|
|
|
|
|
|
def _load_programme(self, conf: dict) -> None:
|
|
|
|
|
|
"""Populate ``_programme_cache`` from spaces section of conf dict."""
|
§39.7: access requirements become a declared `usage:` attribute (homemaker-py-sel)
Closes the second namespace sharing a first character with programme codes: the
usage prefixes b/t/l/k, under which a room silently inherited another room's
connectivity rules from its spelling.
usage is a plain, MANDATORY attribute of the space definition -- not a lookup
table. An interim design proposed a top-level usage_classes: table binding
author-coined names to behaviour; withdrawn, because an indirect name->behaviour
mapping living apart from the thing it describes is exactly the shape of the
prefix rule §39 exists to remove, it would be the only such table in a schema
where every other space property is a plain attribute, and the need it served
was already met -- "building specific" is about what a room is CALLED, and
name: is already free text.
Rule that settles it: a usage value exists iff the engine treats it differently
somewhere. Config selects among behaviours; it cannot invent them.
- programme.USAGES (living/kitchen/bedroom/toilet/utility/none) plus the
behaviour groupings PRIVATE_USAGES / PRIVATE_STRIPS / TOILET_STRIPS /
SOCIABLE_USAGES. Missing or unknown usage is a load error naming the code,
from BOTH parse paths.
- Code-level, never leaf-level: usage_of(leaf.type) is looked up fresh, so a
retype changes the class automatically. 51 sites assign leaf.type, and
share/share_type plus the r5a resurrection are the precedent for why
leaf-level attributes rot.
- graph.has_circulation takes the usage map and trims on declared class;
fitness.access and the public-access check likewise. fitness._t0 is DELETED --
no first-character type test remains anywhere in the codebase.
- utility is distinct from bedroom (same access requirements today) because it
is a different use and gives derive_interchange_classes an axis to relax on.
- A toilet now keeps its edge to a terminal room -- the Brand adjacency, which
the old b-before-t loop ordering severed.
- All 107 corpus entries migrated by experiments/migrate_usage_key.py, comments
and layout preserved.
MEASURED -- the connectivity model was ~4x too permissive. `none` is not
neutral: nothing is trimmed, so the graph may route THROUGH the room, and 34 of
52 codes had no class (Dental Surgery, Records Room, Utilities Closet all served
as corridors). Edges trimmed, prefix-inferred vs declared, 3 seeds each:
harbor-house 18 (9%) -> 79 (39%) inaccessible fails 0 -> 4
health-centre 12 (8%) -> 59 (40%) inaccessible fails 2 -> 3
maple-court 53 (17%) -> 123 (39%) inaccessible fails 1 -> 5
Re-baseline (seed 1, 20k, harbor): 58 fails (15h/43s) -> 61 (16h/45s), now
reporting 1-inaccessible-usable-space x2 plus level 0 and level 1 not connected.
The count rose because the objective got honest -- those failures were always
true of the layout and the old model could not see them. Every harbor number
before this was measured against a graph crediting routes through store
cupboards.
Sharpens §38.2: the objective pays x60-85 to delete circulation, and until now
the deleted corridors were not missed because storage stood in for them. With
that substitution gone, homemaker-py-2v1 is the remaining half -- and now
measurable, because the fails it should prevent actually fire.
350 passed (+5 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 13:39:41 +00:00
|
|
|
|
from .programme import SpaceReq, validate_codes, validate_usages
|
2026-06-13 20:55:25 +01:00
|
|
|
|
_DW = (4.0, 1.0)
|
|
|
|
|
|
_DP = (1.5, 0.5)
|
|
|
|
|
|
spaces = conf.get("spaces") or {}
|
|
|
|
|
|
if not spaces:
|
|
|
|
|
|
self._programme_cache = None
|
|
|
|
|
|
return
|
ju3: reject programme codes colliding with the c/o/s generic type prefixes
Closes homemaker-py-ju3. DESIGN.md §39.3.
The class: key from the bead's design was deliberately NOT built. Auditing
every use of the prefix rule first showed it runs deeper than c/o/s -- l/k/b/t
carry real adjacency semantics (graph.py builds bedroom<->toilet and
kitchen<->living relations from first characters) -- so re-plumbing the type
system would invalidate the whole corpus and every baseline, for a problem
whose damage is the silence, not the convention. Two findings made the smaller
fix sufficient: no corpus programme has ever declared a bare c/o/s code, so
check_space_counts' skip only ever discarded declared rooms; and nothing
references harbor's four codes in any adjacency or co_locate list.
- programme.validate_codes raises on a reserved-prefix code, with the full
explanation. Called from BOTH parse paths (programme._parse_spaces and
fitness.Fitness._load_programme parse conf["spaces"] independently, so
validating one would leave the other door open). l/k/b/t stay unreserved.
- harbor-house and harbor-house-l0 renamed: cr1->fr1, of->ao, st1->gs1,
st2->gs2. New prefixes are unused in harbor and semantically neutral, and
the two storage codes still share a prefix, preserving the structure
evaluate_building's per-code plot-ratio term depends on. name: unchanged.
- experiments/migrate_ju3_rename.py migrates .dom files written before the
rename (--check dry-runs). Pre-rename artefacts, notably evolved-3M*.dom,
must be migrated or their leaves read as unmatched generics.
- test_collapse_global's c/o/s exclusion test now uses a generic C leaf, which
is what the exclusion is actually for; it previously relied on a programme
code colliding, which is no longer possible.
Re-baseline (seed 1, 20k evals, same settings as §38's run): 57 fails against
the 32-instance effective programme -> 55 against the real 37-instance one,
with all five previously-lost room instances now placed inside their declared
sigma bands (fr1 87.2 vs declared 80, was 32.9/17.1; ao/gs1/gs2 were absent
entirely) and no failure naming any of the four codes. At one seed each,
57 vs 55 is within noise -- the robust result is the room placement, not the
count. Historical harbor numbers are not comparable to post-ju3 ones; filed
homemaker-py-t3s to restate 2v1's acceptance figure once evolved-3M is
migrated.
346 passed (+10 new), same 7 pre-existing fixture failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 09:06:08 +00:00
|
|
|
|
# homemaker-py-ju3 (DESIGN.md §39.2): reject codes colliding with the
|
|
|
|
|
|
# generic c/o/s type prefixes here too — Fitness parses conf["spaces"]
|
|
|
|
|
|
# independently of programme._parse_spaces, so validating in only one
|
|
|
|
|
|
# of the two would leave the other door open.
|
|
|
|
|
|
validate_codes(spaces)
|
§39.7: access requirements become a declared `usage:` attribute (homemaker-py-sel)
Closes the second namespace sharing a first character with programme codes: the
usage prefixes b/t/l/k, under which a room silently inherited another room's
connectivity rules from its spelling.
usage is a plain, MANDATORY attribute of the space definition -- not a lookup
table. An interim design proposed a top-level usage_classes: table binding
author-coined names to behaviour; withdrawn, because an indirect name->behaviour
mapping living apart from the thing it describes is exactly the shape of the
prefix rule §39 exists to remove, it would be the only such table in a schema
where every other space property is a plain attribute, and the need it served
was already met -- "building specific" is about what a room is CALLED, and
name: is already free text.
Rule that settles it: a usage value exists iff the engine treats it differently
somewhere. Config selects among behaviours; it cannot invent them.
- programme.USAGES (living/kitchen/bedroom/toilet/utility/none) plus the
behaviour groupings PRIVATE_USAGES / PRIVATE_STRIPS / TOILET_STRIPS /
SOCIABLE_USAGES. Missing or unknown usage is a load error naming the code,
from BOTH parse paths.
- Code-level, never leaf-level: usage_of(leaf.type) is looked up fresh, so a
retype changes the class automatically. 51 sites assign leaf.type, and
share/share_type plus the r5a resurrection are the precedent for why
leaf-level attributes rot.
- graph.has_circulation takes the usage map and trims on declared class;
fitness.access and the public-access check likewise. fitness._t0 is DELETED --
no first-character type test remains anywhere in the codebase.
- utility is distinct from bedroom (same access requirements today) because it
is a different use and gives derive_interchange_classes an axis to relax on.
- A toilet now keeps its edge to a terminal room -- the Brand adjacency, which
the old b-before-t loop ordering severed.
- All 107 corpus entries migrated by experiments/migrate_usage_key.py, comments
and layout preserved.
MEASURED -- the connectivity model was ~4x too permissive. `none` is not
neutral: nothing is trimmed, so the graph may route THROUGH the room, and 34 of
52 codes had no class (Dental Surgery, Records Room, Utilities Closet all served
as corridors). Edges trimmed, prefix-inferred vs declared, 3 seeds each:
harbor-house 18 (9%) -> 79 (39%) inaccessible fails 0 -> 4
health-centre 12 (8%) -> 59 (40%) inaccessible fails 2 -> 3
maple-court 53 (17%) -> 123 (39%) inaccessible fails 1 -> 5
Re-baseline (seed 1, 20k, harbor): 58 fails (15h/43s) -> 61 (16h/45s), now
reporting 1-inaccessible-usable-space x2 plus level 0 and level 1 not connected.
The count rose because the objective got honest -- those failures were always
true of the layout and the old model could not see them. Every harbor number
before this was measured against a graph crediting routes through store
cupboards.
Sharpens §38.2: the objective pays x60-85 to delete circulation, and until now
the deleted corridors were not missed because storage stood in for them. With
that substitution gone, homemaker-py-2v1 is the remaining half -- and now
measurable, because the fails it should prevent actually fire.
350 passed (+5 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 13:39:41 +00:00
|
|
|
|
validate_usages(spaces)
|
2026-06-13 20:55:25 +01:00
|
|
|
|
reqs: dict = {}
|
|
|
|
|
|
for code, c in spaces.items():
|
|
|
|
|
|
sz = c.get("size") or [0.0, 1.0]
|
|
|
|
|
|
w = c.get("width") or _DW
|
|
|
|
|
|
pr = c.get("proportion") or _DP
|
ssz: crinkliness is declared per space; there is no daylight attribute
Owner's ruling, and it corrects the design not just the classification: the
daylight requirement is already defined in the crinkliness. The gaussian's
compact side IS "too little exposed wall per unit floor"; its exposed side
is envelope cost. 38.9's proposed daylight: axis was redundant, and keying
it off usage: was worse than redundant.
What was actually missing: crinkliness is the only leaf quality factor with
no per-space target. size, width and proportion are all declared by the
space; crinkliness was one global number for every room in every building.
crinkliness: none -> no minimum-exposure requirement, may be buried
crinkliness: [t, s] -> this space's own target
key absent -> the global uncrinkliness target, as today
`none` clips the factor on the compact side, it does not switch it off:
over-exposure is still penalised, because a crinkly leaf costs envelope
whatever it holds. A store may be buried; a store may not be a starfish.
The mechanism is backward compatible -- an absent key resolves to the
global target, so shipping it changes no score. Behaviour changes only
where a config declares something, which keeps the objective change
visible per programme in config rather than hidden in a default.
Owner's classification: everything a person occupies wants a window, WCs
and reception/waiting/foyer included; only stores, plant, records and
laundry do not. migrate_crinkliness_key.py declared crinkliness: none on 18
corpus spaces. Crinkliness fails 271 -> 243, of which not-defects 136 (50%)
-> 108 (44%); the 28 that went are exactly the utility fails.
usage_daylight and needs_daylight are removed as mis-keyed, and
DAYLIGHT_USAGES with them -- a vocabulary value should exist only where the
engine treats it differently. The historical crinkliness_mode modes stay,
default off, so 38.6/38.8 remain reproducible.
uncrinkliness_circulation is now settable to none like any space, but its
default is left unchanged pending a ruling: corridors were not among the
groups ruled on and are 63% of the remaining phantom fails.
Lint at parity (46); tests 364 passed, same 7 pre-existing fixture failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-28 22:38:54 +00:00
|
|
|
|
_crink = _programme._optional_pair(c, "crinkliness")
|
2026-06-13 20:55:25 +01:00
|
|
|
|
reqs[code] = SpaceReq(
|
|
|
|
|
|
code=code,
|
§39.7: access requirements become a declared `usage:` attribute (homemaker-py-sel)
Closes the second namespace sharing a first character with programme codes: the
usage prefixes b/t/l/k, under which a room silently inherited another room's
connectivity rules from its spelling.
usage is a plain, MANDATORY attribute of the space definition -- not a lookup
table. An interim design proposed a top-level usage_classes: table binding
author-coined names to behaviour; withdrawn, because an indirect name->behaviour
mapping living apart from the thing it describes is exactly the shape of the
prefix rule §39 exists to remove, it would be the only such table in a schema
where every other space property is a plain attribute, and the need it served
was already met -- "building specific" is about what a room is CALLED, and
name: is already free text.
Rule that settles it: a usage value exists iff the engine treats it differently
somewhere. Config selects among behaviours; it cannot invent them.
- programme.USAGES (living/kitchen/bedroom/toilet/utility/none) plus the
behaviour groupings PRIVATE_USAGES / PRIVATE_STRIPS / TOILET_STRIPS /
SOCIABLE_USAGES. Missing or unknown usage is a load error naming the code,
from BOTH parse paths.
- Code-level, never leaf-level: usage_of(leaf.type) is looked up fresh, so a
retype changes the class automatically. 51 sites assign leaf.type, and
share/share_type plus the r5a resurrection are the precedent for why
leaf-level attributes rot.
- graph.has_circulation takes the usage map and trims on declared class;
fitness.access and the public-access check likewise. fitness._t0 is DELETED --
no first-character type test remains anywhere in the codebase.
- utility is distinct from bedroom (same access requirements today) because it
is a different use and gives derive_interchange_classes an axis to relax on.
- A toilet now keeps its edge to a terminal room -- the Brand adjacency, which
the old b-before-t loop ordering severed.
- All 107 corpus entries migrated by experiments/migrate_usage_key.py, comments
and layout preserved.
MEASURED -- the connectivity model was ~4x too permissive. `none` is not
neutral: nothing is trimmed, so the graph may route THROUGH the room, and 34 of
52 codes had no class (Dental Surgery, Records Room, Utilities Closet all served
as corridors). Edges trimmed, prefix-inferred vs declared, 3 seeds each:
harbor-house 18 (9%) -> 79 (39%) inaccessible fails 0 -> 4
health-centre 12 (8%) -> 59 (40%) inaccessible fails 2 -> 3
maple-court 53 (17%) -> 123 (39%) inaccessible fails 1 -> 5
Re-baseline (seed 1, 20k, harbor): 58 fails (15h/43s) -> 61 (16h/45s), now
reporting 1-inaccessible-usable-space x2 plus level 0 and level 1 not connected.
The count rose because the objective got honest -- those failures were always
true of the layout and the old model could not see them. Every harbor number
before this was measured against a graph crediting routes through store
cupboards.
Sharpens §38.2: the objective pays x60-85 to delete circulation, and until now
the deleted corridors were not missed because storage stood in for them. With
that substitution gone, homemaker-py-2v1 is the remaining half -- and now
measurable, because the fails it should prevent actually fire.
350 passed (+5 new), same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 13:39:41 +00:00
|
|
|
|
usage=c["usage"],
|
2026-06-13 20:55:25 +01:00
|
|
|
|
name=c.get("name", ""),
|
|
|
|
|
|
size=float(sz[0]),
|
|
|
|
|
|
size_sigma=float(sz[1]),
|
|
|
|
|
|
width=float(w[0]),
|
|
|
|
|
|
width_sigma=float(w[1]),
|
|
|
|
|
|
proportion=float(pr[0]),
|
|
|
|
|
|
proportion_sigma=float(pr[1]),
|
|
|
|
|
|
adjacency=list(c.get("adjacency") or []),
|
|
|
|
|
|
level=c.get("level"),
|
|
|
|
|
|
requires_below=c.get("requires_below"),
|
|
|
|
|
|
count=int(c.get("count") or 1),
|
2026-07-31 00:16:12 +01:00
|
|
|
|
co_locate=list(c.get("co_locate") or []),
|
2026-06-13 20:55:25 +01:00
|
|
|
|
has_size="size" in c,
|
|
|
|
|
|
has_width="width" in c,
|
|
|
|
|
|
has_proportion="proportion" in c,
|
ssz: crinkliness is declared per space; there is no daylight attribute
Owner's ruling, and it corrects the design not just the classification: the
daylight requirement is already defined in the crinkliness. The gaussian's
compact side IS "too little exposed wall per unit floor"; its exposed side
is envelope cost. 38.9's proposed daylight: axis was redundant, and keying
it off usage: was worse than redundant.
What was actually missing: crinkliness is the only leaf quality factor with
no per-space target. size, width and proportion are all declared by the
space; crinkliness was one global number for every room in every building.
crinkliness: none -> no minimum-exposure requirement, may be buried
crinkliness: [t, s] -> this space's own target
key absent -> the global uncrinkliness target, as today
`none` clips the factor on the compact side, it does not switch it off:
over-exposure is still penalised, because a crinkly leaf costs envelope
whatever it holds. A store may be buried; a store may not be a starfish.
The mechanism is backward compatible -- an absent key resolves to the
global target, so shipping it changes no score. Behaviour changes only
where a config declares something, which keeps the objective change
visible per programme in config rather than hidden in a default.
Owner's classification: everything a person occupies wants a window, WCs
and reception/waiting/foyer included; only stores, plant, records and
laundry do not. migrate_crinkliness_key.py declared crinkliness: none on 18
corpus spaces. Crinkliness fails 271 -> 243, of which not-defects 136 (50%)
-> 108 (44%); the 28 that went are exactly the utility fails.
usage_daylight and needs_daylight are removed as mis-keyed, and
DAYLIGHT_USAGES with them -- a vocabulary value should exist only where the
engine treats it differently. The historical crinkliness_mode modes stay,
default off, so 38.6/38.8 remain reproducible.
uncrinkliness_circulation is now settable to none like any space, but its
default is left unchanged pending a ruling: corridors were not among the
groups ruled on and are 63% of the remaining phantom fails.
Lint at parity (46); tests 364 passed, same 7 pre-existing fixture failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-28 22:38:54 +00:00
|
|
|
|
has_crinkliness="crinkliness" in c,
|
|
|
|
|
|
crinkliness=_crink[0],
|
|
|
|
|
|
crinkliness_sigma=_crink[1],
|
2026-06-13 20:55:25 +01:00
|
|
|
|
)
|
|
|
|
|
|
self._programme_cache = reqs
|