homemaker-layout/src/homemaker_layout/fitness.py

2069 lines
91 KiB
Python
Raw Normal View History

"""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
from .dom import Node
FAIL_THRESHOLD = 0.1 # Urb::Dom::Fitness::Base
# 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
# --------------------------------------------------------------------------- #
# 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)))
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
# 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))))
homemaker-py-1s3: land precision-weighted shape combination for multi-use leaves Follow-up to the previous commit: user flagged that quality_width/ quality_proportion's "stricter of both" (max target, min sigma) combination for a fused leaf's two codes was an ad hoc hack. Tried two more principled alternatives and A/B'd all three against the harbor-house/health-centre example programmes (20k evals x 3 seeds each): 1. stricter-of-both (original) -> health-centre +24.5% worse 2. precision-weighted Gaussian product -> health-centre -13.9% better 3. mixture (max of two Gaussians) -> health-centre +20.4% worse Landed #2 (fitness._gaussian_product): combining two Gaussian evidence sources about the same quantity via precision-weighting gives an intermediate target with a narrower spread, unlike the naive max/min hack. #3's building block (_clipped_gaussian) is kept, documented, and unit-tested as a recorded negative alternative -- somewhat counterintuitively, the more philosophically appealing "let the leaf collapse toward whichever code fits" mixture model was empirically worse, because max() lets a leaf score 1.0 by satisfying only the weaker of the two codes' targets. multi_use stays default OFF -- the precision-weighted result improves both example programmes on average but isn't the clean sweep needed for a default flip (harbor-house loses 1/3 seeds). DESIGN.md §33 rewritten with the full three-way comparison. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8agJBT2ZpmF3ErW7wi2wY
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)
def load_config(directory: str | Path,
overrides: dict | None = None) -> tuple[dict, dict]:
"""Load (patterns, costs) config for a corpus directory, mirroring
``urb-fitness.pl``: project-level ``../<name>.config`` first, then the
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."""
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 {})
if overrides:
conf.update(overrides)
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 ""
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 {}
self._programme_cache: dict | None = None
self._load_programme(self._conf)
# 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)
# 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 (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"))
# 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"))
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
# 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)
# 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: 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
"urb", "floor", "compact_ok", "exempt_circulation",
"usage_daylight"):
§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)
§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 ""
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
def needs_daylight(self, leaf: Node) -> bool:
"""Does this leaf's declared usage want a window? (homemaker-py-ssz)
True only for uses a person occupies (``programme.DAYLIGHT_USAGES``).
A generic ``C``/``O``/``S`` leaf has no programme entry and so is False,
which is the intended reading: a corridor or a covered courtyard is not
failing when it has no daylit wall.
"""
return self.usage_of(leaf) in _programme.DAYLIGHT_USAGES
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
# ------------------------------------------------------------------ #
# 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
# ------------------------------------------------------------------ #
# 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
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
orig_share_type = leaf.share_type
leaf.type = usage
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
try:
return (
self.quality_size(leaf)
* self.quality_width(leaf)
* self.quality_proportion(leaf)
)
finally:
leaf.type = orig
leaf.share_type = orig_share_type
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]
# 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
# 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
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
orig_share_type = lf.share_type
lf.type = code
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
try:
qs = self.quality_size(lf)
qw = self.quality_width(lf)
qp = self.quality_proportion(lf)
finally:
lf.type = orig
lf.share_type = orig_share_type
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
def collapse_global(
self,
root: Node,
adjacency: bool = True,
objective: str = "threshold",
preserve_public_access: bool = True,
iters: int = 6,
local_search: bool = False,
local_search_passes: int = 20,
) -> 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
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.
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.
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).
One-shot finish-time pass on a committed layout, not a per-eval re-type."""
from collections import Counter
from . import graph as graph_mod
# 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()
# 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)
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)}
if not room_codes:
return
lvls = dom_mod.levels(root)
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
]
if not supply:
return
# 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])]
if not slots:
return
forbid = self._COLLAPSE_FORBID
fail_w = self._COLLAPSE_FAIL_W
levels_of = [dom_mod.level_of(lf) for lf in supply]
# 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.
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)
]
if not adjacency:
for r, c in self._best_assignment(base):
if base[r][c] > forbid:
supply[r].type = slots[c]
return
# Adjacency relaxation on the pre-merge base graph (built above, fixed
# geometry). A satisfied adjacency is worth fail_w — one avoided fail,
# the same unit as a passing factor — so both are minimised jointly.
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)
)
quality[i][j] += fail_w * sat
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
if local_search:
self._two_opt_adjacency_polish(
supply,
levels_of,
graphs,
code_adj,
prog,
objective,
forbid,
fail_w,
max_passes=local_search_passes,
)
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"
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):
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()
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:
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
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."""
import copy
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
if cand_fails <= base_fails:
return cand, base_fails, cand_fails, True
return copy.deepcopy(root), base_fails, cand_fails, False
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":
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":
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:
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]
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]
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)
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")
co_type = self._leaf_co_type(leaf)
if co_type:
homemaker-py-1s3: land precision-weighted shape combination for multi-use leaves Follow-up to the previous commit: user flagged that quality_width/ quality_proportion's "stricter of both" (max target, min sigma) combination for a fused leaf's two codes was an ad hoc hack. Tried two more principled alternatives and A/B'd all three against the harbor-house/health-centre example programmes (20k evals x 3 seeds each): 1. stricter-of-both (original) -> health-centre +24.5% worse 2. precision-weighted Gaussian product -> health-centre -13.9% better 3. mixture (max of two Gaussians) -> health-centre +20.4% worse Landed #2 (fitness._gaussian_product): combining two Gaussian evidence sources about the same quantity via precision-weighting gives an intermediate target with a narrower spread, unlike the naive max/min hack. #3's building block (_clipped_gaussian) is kept, documented, and unit-tested as a recorded negative alternative -- somewhat counterintuitively, the more philosophically appealing "let the leaf collapse toward whichever code fits" mixture model was empirically worse, because max() lets a leaf score 1.0 by satisfying only the weaker of the two codes' targets. multi_use stays default OFF -- the precision-weighted result improves both example programmes on average but isn't the clean sweep needed for a default flip (harbor-house loses 1/3 seeds). DESIGN.md §33 rewritten with the full three-way comparison. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8agJBT2ZpmF3ErW7wi2wY
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.
co_params = self.get_space_params(co_type, "proportion")
homemaker-py-1s3: land precision-weighted shape combination for multi-use leaves Follow-up to the previous commit: user flagged that quality_width/ quality_proportion's "stricter of both" (max target, min sigma) combination for a fused leaf's two codes was an ad hoc hack. Tried two more principled alternatives and A/B'd all three against the harbor-house/health-centre example programmes (20k evals x 3 seeds each): 1. stricter-of-both (original) -> health-centre +24.5% worse 2. precision-weighted Gaussian product -> health-centre -13.9% better 3. mixture (max of two Gaussians) -> health-centre +20.4% worse Landed #2 (fitness._gaussian_product): combining two Gaussian evidence sources about the same quantity via precision-weighting gives an intermediate target with a narrower spread, unlike the naive max/min hack. #3's building block (_clipped_gaussian) is kept, documented, and unit-tested as a recorded negative alternative -- somewhat counterintuitively, the more philosophically appealing "let the leaf collapse toward whichever code fits" mixture model was empirically worse, because max() lets a leaf score 1.0 by satisfying only the weaker of the two codes' targets. multi_use stays default OFF -- the precision-weighted result improves both example programmes on average but isn't the clean sweep needed for a default flip (harbor-house loses 1/3 seeds). DESIGN.md §33 rewritten with the full three-way comparison. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8agJBT2ZpmF3ErW7wi2wY
2026-07-31 10:20:49 +01:00
params = _gaussian_product(params[0], params[1],
co_params[0], co_params[1])
aspect = geometry.aspect(leaf)
homemaker-py-1s3: land precision-weighted shape combination for multi-use leaves Follow-up to the previous commit: user flagged that quality_width/ quality_proportion's "stricter of both" (max target, min sigma) combination for a fused leaf's two codes was an ad hoc hack. Tried two more principled alternatives and A/B'd all three against the harbor-house/health-centre example programmes (20k evals x 3 seeds each): 1. stricter-of-both (original) -> health-centre +24.5% worse 2. precision-weighted Gaussian product -> health-centre -13.9% better 3. mixture (max of two Gaussians) -> health-centre +20.4% worse Landed #2 (fitness._gaussian_product): combining two Gaussian evidence sources about the same quantity via precision-weighting gives an intermediate target with a narrower spread, unlike the naive max/min hack. #3's building block (_clipped_gaussian) is kept, documented, and unit-tested as a recorded negative alternative -- somewhat counterintuitively, the more philosophically appealing "let the leaf collapse toward whichever code fits" mixture model was empirically worse, because max() lets a leaf score 1.0 by satisfying only the weaker of the two codes' targets. multi_use stays default OFF -- the precision-weighted result improves both example programmes on average but isn't the clean sweep needed for a default flip (harbor-house loses 1/3 seeds). DESIGN.md §33 rewritten with the full three-way comparison. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8agJBT2ZpmF3ErW7wi2wY
2026-07-31 10:20:49 +01:00
return _clipped_gaussian(aspect, params[0], params[1], "below")
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)
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")
target, sigma = params[0], params[1]
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)
if k > 1:
target, sigma = target * k, sigma * k
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]
return gaussian(geometry.area(leaf), 1.0, target, sigma)
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)
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")
co_type = self._leaf_co_type(leaf)
if co_type:
homemaker-py-1s3: land precision-weighted shape combination for multi-use leaves Follow-up to the previous commit: user flagged that quality_width/ quality_proportion's "stricter of both" (max target, min sigma) combination for a fused leaf's two codes was an ad hoc hack. Tried two more principled alternatives and A/B'd all three against the harbor-house/health-centre example programmes (20k evals x 3 seeds each): 1. stricter-of-both (original) -> health-centre +24.5% worse 2. precision-weighted Gaussian product -> health-centre -13.9% better 3. mixture (max of two Gaussians) -> health-centre +20.4% worse Landed #2 (fitness._gaussian_product): combining two Gaussian evidence sources about the same quantity via precision-weighting gives an intermediate target with a narrower spread, unlike the naive max/min hack. #3's building block (_clipped_gaussian) is kept, documented, and unit-tested as a recorded negative alternative -- somewhat counterintuitively, the more philosophically appealing "let the leaf collapse toward whichever code fits" mixture model was empirically worse, because max() lets a leaf score 1.0 by satisfying only the weaker of the two codes' targets. multi_use stays default OFF -- the precision-weighted result improves both example programmes on average but isn't the clean sweep needed for a default flip (harbor-house loses 1/3 seeds). DESIGN.md §33 rewritten with the full three-way comparison. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8agJBT2ZpmF3ErW7wi2wY
2026-07-31 10:20:49 +01:00
# 1s3: precision-weighted, same reasoning as quality_proportion
# above.
co_params = self.get_space_params(co_type, "width")
homemaker-py-1s3: land precision-weighted shape combination for multi-use leaves Follow-up to the previous commit: user flagged that quality_width/ quality_proportion's "stricter of both" (max target, min sigma) combination for a fused leaf's two codes was an ad hoc hack. Tried two more principled alternatives and A/B'd all three against the harbor-house/health-centre example programmes (20k evals x 3 seeds each): 1. stricter-of-both (original) -> health-centre +24.5% worse 2. precision-weighted Gaussian product -> health-centre -13.9% better 3. mixture (max of two Gaussians) -> health-centre +20.4% worse Landed #2 (fitness._gaussian_product): combining two Gaussian evidence sources about the same quantity via precision-weighting gives an intermediate target with a narrower spread, unlike the naive max/min hack. #3's building block (_clipped_gaussian) is kept, documented, and unit-tested as a recorded negative alternative -- somewhat counterintuitively, the more philosophically appealing "let the leaf collapse toward whichever code fits" mixture model was empirically worse, because max() lets a leaf score 1.0 by satisfying only the weaker of the two codes' targets. multi_use stays default OFF -- the precision-weighted result improves both example programmes on average but isn't the clean sweep needed for a default flip (harbor-house loses 1/3 seeds). DESIGN.md §33 rewritten with the full three-way comparison. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8agJBT2ZpmF3ErW7wi2wY
2026-07-31 10:20:49 +01:00
params = _gaussian_product(params[0], params[1],
co_params[0], co_params[1])
width = geometry.length_narrowest(leaf)
homemaker-py-1s3: land precision-weighted shape combination for multi-use leaves Follow-up to the previous commit: user flagged that quality_width/ quality_proportion's "stricter of both" (max target, min sigma) combination for a fused leaf's two codes was an ad hoc hack. Tried two more principled alternatives and A/B'd all three against the harbor-house/health-centre example programmes (20k evals x 3 seeds each): 1. stricter-of-both (original) -> health-centre +24.5% worse 2. precision-weighted Gaussian product -> health-centre -13.9% better 3. mixture (max of two Gaussians) -> health-centre +20.4% worse Landed #2 (fitness._gaussian_product): combining two Gaussian evidence sources about the same quantity via precision-weighting gives an intermediate target with a narrower spread, unlike the naive max/min hack. #3's building block (_clipped_gaussian) is kept, documented, and unit-tested as a recorded negative alternative -- somewhat counterintuitively, the more philosophically appealing "let the leaf collapse toward whichever code fits" mixture model was empirically worse, because max() lets a leaf score 1.0 by satisfying only the weaker of the two codes' targets. multi_use stays default OFF -- the precision-weighted result improves both example programmes on average but isn't the clean sweep needed for a default flip (harbor-house loses 1/3 seeds). DESIGN.md §33 rewritten with the full three-way comparison. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8agJBT2ZpmF3ErW7wi2wY
2026-07-31 10:20:49 +01:00
return _clipped_gaussian(width, params[0], params[1], "above")
# --- 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
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
key = "uncrinkliness_circulation" if dom_mod.is_circulation(leaf) else "uncrinkliness"
distance, sigma = self.conf(key)
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
# homemaker-py-ssz (DESIGN.md §38.1), EXPERIMENTAL, all default OFF —
# `crinkliness_mode="urb"` reproduces the stock behaviour exactly.
#
# Stock Urb returns a hard 0.0 for a leaf with no daylit wall. That is
# the correct limit of the formula (1/crink -> inf, gaussian -> 0), but
# because evaluate_leaf MULTIPLIES factors into quality and
# process_storey accumulates `value += quality * rate * area`, such a
# leaf contributes EXACTLY ZERO value while still costing — so the
# objective cannot rank buried rooms at all, and buried circulation/
# outside leaves (which no missing-space cascade pins) are pure
# liabilities worth ~x60-x85 to delete. Measured: 45-56% of interior
# leaves are in this state. These modes restore a gradient there.
mode = self._crinkliness_mode
if mode == "exempt_circulation" and dom_mod.is_circulation(leaf):
# (c) internal corridors are ordinary architecture; stop requiring
# every circulation leaf to reach daylight.
return 1.0
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
# (b)/(d) one-sided: being MORE compact than target is not a defect the
# way over-exposure is. Over-exposure still is one -- a crinkly leaf
# costs envelope whatever it is used for -- so this clips the compact
# side only, it does not switch the factor off.
#
# `compact_ok` applies that to every leaf; `usage_daylight` applies it
# only where nobody is sitting -- a store, a toilet, plant, a corridor,
# a covered courtyard -- and leaves habitable rooms on stock behaviour,
# so a windowless bedroom is still the hard failure it should be.
one_sided = mode == "compact_ok" or (
mode == "usage_daylight" and not self.needs_daylight(leaf))
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
# Zero exposure IS the compact limit (1/crink -> inf), so a
# one-sided factor has to score it 1.0. Reaching here and returning
# the floor instead was the flaw in the first `compact_ok`: it
# announced that compact is not a defect and then punished the most
# compact case of all hardest (§38.8).
if one_sided:
return 1.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
# (a) floor: keep buried leaves rankable by their other factors
# instead of collapsing the whole quality product to zero.
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
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
# --- 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"]
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]
# ------------------------------------------------------------------ #
# 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)
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)
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
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"]
if width > self._edge_cap(a, b) and rate > 0.0:
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")
cap = self._edge_cap(leaf)
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
if edge_len > cap:
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)
# ----------------------------------------------------------------------- #
# 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)
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)
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":
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:
return None
# If the stair itself has via-outdoor access (Entrances priority 3.5), Perl's
# Entrances maps it to a leaf id, not a boundary id. Boundary_Id(edge) eq
# leaf_id never matches → no entrance corners added. Return None here so
# Python matches that behaviour.
for nb in G.neighbors(stair_leaf):
§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:
return None
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:
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):
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"
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):
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).
"""
score, _, _ = self._evaluate_full(root)
return score
def score_with_fails(self, root: Node) -> tuple[float, tuple[str, ...]]:
"""Same as ``evaluate`` but also returns the sorted failure strings."""
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)
def _evaluate_full(
self, root: Node, want_grade: bool = False
) -> tuple[float, tuple[str, ...], float]:
from . import graph as graph_mod
geometry.clear_cache()
# 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)
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 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)
# 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,
)
# --- Phase 1: UNMERGED tree checks ---
check_fails, missing = graph_mod.check_space_counts(
root, programme, self._leaf_sharing, self._max_share,
self._multi_use, self.colocate_pairs())
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()
)
graph_base_pre = graph_mod.build_graphs(root, self.conf("door_width") or 1.2)
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()))
# --- 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()
)
graph_base = graph_mod.build_graphs(root, self.conf("door_width") or 1.2)
cost = self.plot_cost(root)
value = 0.0
grade = 0.0
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
if want_grade and not self._conn_grade: # §11.4 signal; off by default
for le in se.leaves:
grade += _leaf_grade(le.factors)
# §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)
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)
score = value / cost if cost != 0.0 else 0.0
return score, tuple(sorted(failures)), grade
@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
_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)
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
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"],
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),
co_locate=list(c.get("co_locate") or []),
has_size="size" in c,
has_width="width" in c,
has_proportion="proportion" in c,
)
self._programme_cache = reqs