homemaker-layout/src/homemaker_layout/fitness.py
Claude 109849e438
A terrace is no longer worth more per m2 than a real internal room
Owner's ruling. Measured over the twelve baseline layouts as realised value per
m2 (rate x quality, not the rate alone):

  as shipped before this   room  67.0   terrace 294.6   violates, 4.39x
  value_supported=100 only room  67.0   terrace  98.2   violates, 1.46x
  geometric mean only      room 132.9   terrace 296.8   violates, 2.23x
  both                     room 132.9   terrace  98.9   satisfies

The 4.4x is roughly 2.2x aggregation and 2.0x rate, so neither half alone is
enough. That is why 39.18's geometric-mean aggregation moves from default-OFF
to default-ON here rather than waiting on its own A/B: it is not an optional
improvement, it is half of a ruling.

value_supported 300 -> 100, and set to value_outside rather than to a number
that makes the inequality come out -- back-solving from the corpus's measured
mean room quality would rot the moment either changed. Outdoor space is worth
the same to an occupant whatever level it sits on; the real difference between
a ground garden and a roof terrace is what it takes to BUILD, and cost already
says that (outside 10.0 vs outside_supported 110.0). Value describes worth,
cost describes structure, and the level belongs in the second.

Changed in CONF_DEFAULTS and the four corpus patterns.config files, which all
declared 300.0 explicitly. NOT changed in harbor-house-l0 (a shape-curve test
fixture) or y51-sweep-* (historical fixtures that exist to reproduce past
measurements) -- repricing those would destroy what they are for.

Neither change can move a fail, structurally rather than luckily: value rates
never enter fail emission, and evaluate_leaf emits each fail from its factor
before anything is combined. Verified corpus-wide: identical fail sets, scores
+11% to +169% (and -5% once, on a layout that is mostly terrace).

tests/test_terrace_value_ruling.py pins the ruling as an invariant of the
objective, and asserts that reverting the aggregation breaks it again, so
neither half can be quietly dropped.

The 500k cold-start baseline (39.12) is superseded -- this changes what "good"
means. The layouts stay valid and their fail counts are unchanged, but a fresh
corpus run is needed before any new number is compared with them.

Still untouched: circulation returns 0.07 per unit cost against a room's 0.66,
by far the worst thing a building can contain. That is homemaker-py-hxi.

Refs homemaker-py-ecx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-09-05 19:25:02 +00:00

2267 lines
102 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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
import functools
import math
from dataclasses import dataclass, field
from pathlib import Path
import networkx as nx
import yaml
from . import dom as dom_mod
from . import geometry
from . import programme as _programme
from .dom import Node
FAIL_THRESHOLD = 0.1 # Urb::Dom::Fitness::Base
_MISSING = object() # tells an absent config key from one declared null
# 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",
)
# 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.
"""
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,
# homemaker-py-ecx (DESIGN.md §39.19). Was 300.0 -- the same rate as
# `value_inside`, which made a roof terrace worth as much per square metre
# as a real internal room, and (after quality, which barely touches an
# outside leaf) 4.4x as much in practice. The owner's ruling is that a
# terrace must not be worth more per area than a room.
#
# Set to `value_outside`, not to some number that makes an inequality come
# out: outdoor space is worth the same to an occupant whatever level it sits
# on, and the real difference between a ground garden and a roof terrace is
# what it takes to BUILD -- which `cost` already says, `outside` 10.0
# against `outside_supported` 110.0. Value describes worth, cost describes
# structure; the level belongs in the second.
"value_supported": 100.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],
# PROVENANCE (DESIGN.md §39.16). This is Christopher Alexander, A Pattern
# Language 159, "Light on Two Sides of Every Room" -- not an arbitrary
# constant. The factor is evaluated at `1/crink = A/(L*h)`: floor area per
# metre of ILLUMINATED wall, over storey height. It is NOT room depth, and
# reading it as depth has caused two published errors -- it equals depth
# only for a room lit on one side.
#
# 5/6 x h = 2.5 m at h=3 is therefore 2.5 m of room depth PER WINDOW WALL:
# a shallow room (a corridor along an outside wall) may be lit on one side,
# a deeper one needs a corner or windows on opposite walls. At the fail
# edge (+2.146 sigma) the allowance is 4.86 m per wall -- so 4.86 m
# single-aspect, or 9.72 m between two opposite windows.
#
# Independently corroborated twice: the gaussian's own FAIL_THRESHOLD
# crossing and §38.3's frontage bound `L >= A/(1.6202*h)`, derived
# separately, agree at 1.6202. Of the numbers in this table it is the
# best-supported, and the last that should be retuned.
"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))))
@functools.lru_cache(maxsize=None)
def _crink_at_fail_threshold(distance: float, sigma: float) -> float:
"""The crinkliness at which the stock gaussian crosses ``FAIL_THRESHOLD``
on the COMPACT (too-little-exposure) side (homemaker-py-9gj).
``quality_uncrinkliness`` evaluates the gaussian at ``x = 1/crink``, so
solving ``gaussian(x, 1, distance, sigma) == FAIL_THRESHOLD`` for the root
above ``distance`` and inverting gives the crinkliness below which a leaf
fails. Uses ``_E`` rather than ``math.e`` so the crossing agrees with the
truncated constant the gaussian itself is evaluated with.
"""
d = math.sqrt(2.0 * sigma * sigma
* math.log(1.0 / FAIL_THRESHOLD) / math.log(_E))
return 1.0 / (distance + d)
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)
def _generic_class(n: Node) -> str:
"""Generic-type class of a leaf: ``"c"`` circulation, ``"o"`` outside, or
``""`` for a programme room (§39.4).
Replaces the old ``_t0(leaf)`` first-character dispatch in the quality terms
and value rate. (``_t0`` itself is gone: the SEMANTIC prefixes it served —
``k`` kitchen, ``l`` living, … — became the declared ``usage:`` key in
§39.7, so no first-character test remains anywhere.) ``S`` (sahn)
belongs to both generic sets but takes the outside parameter families, so it
maps to ``"o"`` — exactly as ``get_space_params`` dispatches it.
"""
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
# 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.
# 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)
self._crinkliness_mode = str(self.conf("crinkliness_mode") or "urb")
if self._crinkliness_mode not in (
"urb", "floor", "compact_ok", "exempt_circulation"):
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)
# homemaker-py-9gj (DESIGN.md §39.13): how the FAILING compact tail of
# the crinkliness gaussian is scaled. "gaussian" (default) is stock,
# byte-identical to every prior run. "ramp" replaces the tail — and
# only the tail, only below FAIL_THRESHOLD, only on the compact side —
# with a straight line in crinkliness, because the stock exponent grows
# like 1/crink^2 and underflows the whole tail to a numerically
# indistinguishable zero. Nothing at or above FAIL_THRESHOLD moves, so
# the fail set is byte-identical either way and no calibration changes.
self._crinkliness_tail = str(self.conf("crinkliness_tail") or "gaussian")
if self._crinkliness_tail not in ("gaussian", "ramp"):
raise ValueError(
f"unknown crinkliness_tail: {self._crinkliness_tail!r}")
# homemaker-py-9gj (DESIGN.md §39.14): what the factor rewards ABOVE the
# fail threshold, orthogonal to `crinkliness_tail` below it.
# "gaussian" (default) is stock: two-sided, so a room with MORE daylit
# wall than target is penalised for it. "daylight" clips that side to
# 1.0 -- daylight is a sufficiency requirement, and the envelope a
# well-lit room costs is already charged by `exterior_wall` and
# `boundary_wall` in the cost model, so penalising it again in value
# bills the same wall twice.
# homemaker-py-ecx (DESIGN.md §39.18/§39.19): how a leaf's quality
# factors are combined. "geometric_mean" is now the DEFAULT and
# "product" is the old stock behaviour, kept for comparison.
# The geometric mean divides out
# how many questions the leaf was ASKED, because quality is a product
# and an outside leaf is exempt from size, crinkliness and access while
# a room is judged on all three -- so exemption alone buys a higher
# quality, and quality multiplies the value rate. Fails are emitted per
# factor inside evaluate_leaf, before any combining, so the fail set
# cannot move either way.
#
# Made default ON with the §39.19 value_supported change, because the
# owner's ruling -- a terrace must not be worth more per square metre
# than a real internal room -- needs BOTH. Measured on the corpus:
# stock 4.39x, rate alone 1.46x, aggregation alone 2.23x, together
# 98.9 against 132.9 per m2, which is the ruling satisfied.
self._quality_aggregate = str(
self.conf("quality_aggregate") or "geometric_mean")
if self._quality_aggregate not in ("product", "geometric_mean"):
raise ValueError(
f"unknown quality_aggregate: {self._quality_aggregate!r}")
self._crinkliness_shape = str(self.conf("crinkliness_shape") or "gaussian")
if self._crinkliness_shape not in ("gaussian", "daylight"):
raise ValueError(
f"unknown crinkliness_shape: {self._crinkliness_shape!r}")
if self._crinkliness_shape == "daylight" and self._crinkliness_mode != "urb":
raise ValueError(
"crinkliness_shape='daylight' is incompatible with "
f"crinkliness_mode={self._crinkliness_mode!r} (§38.1's modes are "
"superseded; use one or the other, not both)")
if self._crinkliness_tail == "ramp" and self._crinkliness_mode != "urb":
# Both rewrite the same tail; composing them would give a shape
# neither was measured under.
raise ValueError(
"crinkliness_tail='ramp' is incompatible with "
f"crinkliness_mode={self._crinkliness_mode!r} (§38.1's modes are "
"superseded; use one or the other, not both)")
def usages(self) -> dict[str, str]:
"""``{room code: usage}`` for this programme (homemaker-py-sel).
Keyed by CODE, never stamped on a leaf: a retype changes the code and
the usage follows automatically, exactly as size/width/adjacency do.
Generic ``C``/``O``/``S`` are absent — they are not programme rooms and
their behaviour comes from the generic type rule (§39.4).
"""
return {code: req.usage for code, req in (self._programme or {}).items()}
def usage_of(self, leaf: Node) -> str:
"""Access-requirement class of one leaf, ``""`` for a generic type."""
req = (self._programme or {}).get(leaf.type)
return req.usage if req else ""
# ------------------------------------------------------------------ #
# Type superposition + collapse (homemaker-py-9o5)
# ------------------------------------------------------------------ #
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
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 (
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))
if any(nb.type == "C" for nb in nbs):
continue # circulation neighbour keeps access invariant
usages = self.usages()
for nb in nbs:
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():
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: only the GENERIC types take the circulation/outside parameter
# families. A programme code is looked up in ``spaces`` regardless of
# what letter it happens to start with.
# NB S (sahn) is a member of BOTH generic sets, and the parameter
# families split it the outside way: the circulation branch is exactly
# C, and S takes the *_outside params (preserved from the original
# c0 == "c" / c0 in ("o", "s") dispatch).
if code == "C":
v = self.conf(f"{param}_circulation")
if v is not None:
return v
if 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:
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:
# 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")
params = _gaussian_product(params[0], params[1],
co_params[0], co_params[1])
aspect = geometry.aspect(leaf)
return _clipped_gaussian(aspect, params[0], params[1], "below")
def quality_size(self, leaf: Node) -> float:
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:
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:
# 1s3: precision-weighted, same reasoning as quality_proportion
# above.
co_params = self.get_space_params(co_type, "width")
params = _gaussian_product(params[0], params[1],
co_params[0], co_params[1])
width = geometry.length_narrowest(leaf)
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 crinkliness_params(self, leaf: Node) -> "tuple[float, float] | None":
"""``(target, sigma)`` for this leaf's crinkliness, or ``None`` when the
space declares no minimum-exposure requirement (homemaker-py-ssz).
The COMPACT side of the crinkliness gaussian is the daylight
requirement -- too little exposed wall per unit floor -- so a space
declaring `crinkliness: none` is declaring that it does not need a
window. There is no separate daylight attribute; see DESIGN.md §38.10.
Resolution order, mirroring how size/width/proportion resolve:
a programme space's own declaration wins; a generic circulation leaf
takes `uncrinkliness_circulation`; everything else takes the global
`uncrinkliness`.
"""
req = (self._programme or {}).get(leaf.type)
if req is not None and req.has_crinkliness:
if req.crinkliness is None:
return None
return req.crinkliness, req.crinkliness_sigma
key = ("uncrinkliness_circulation" if dom_mod.is_circulation(leaf)
else "uncrinkliness")
# An explicit null/`none` in the config means "no minimum-exposure
# requirement". `conf()` cannot express that -- it collapses None to the
# default -- so the raw dict is read here, the same way `_optional_pair`
# tells an absent per-space key from a declared-empty one.
raw = self._conf.get(key, _MISSING)
if raw is None or (isinstance(raw, str) and raw.strip().lower() == "none"):
return None
params = self.conf(key)
if params is None:
return None
return params[0], params[1]
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
params = self.crinkliness_params(leaf)
crink = self.crinkliness(leaf, G, groups)
if params is None:
# No minimum-exposure requirement: this space does not need a
# window, so being buried -- the fully compact limit -- is not a
# defect. Over-exposure still is: a crinkly leaf costs envelope
# whatever it holds. So the factor is CLIPPED on the compact side,
# never switched off, and the over-exposed side keeps the global
# bound.
#
# ...unless `crinkliness_shape="daylight"`, under which the
# over-exposed side is not this factor's business at all (the cost
# model charges that wall). A space with no daylight requirement
# then has nothing left to be judged on. (§39.14)
if self._crinkliness_shape == "daylight":
return 1.0
if not crink:
return 1.0
distance, sigma = self.conf("uncrinkliness")
if 1 / crink > distance:
return 1.0
return gaussian(1 / crink, 1.0, distance, sigma)
distance, sigma = params
# homemaker-py-ssz (DESIGN.md §38.1), EXPERIMENTAL, all default OFF --
# `crinkliness_mode="urb"` reproduces the stock behaviour exactly. These
# were the first attempt at the problem and are SUPERSEDED by the
# declared per-space target above (§38.10); they are kept only so the
# §38.6/§38.8 measurements remain reproducible. Do not build on them.
mode = self._crinkliness_mode
if mode == "exempt_circulation" and dom_mod.is_circulation(leaf):
return 1.0
one_sided = mode == "compact_ok"
if not crink:
if one_sided:
return 1.0
return self._crinkliness_floor if mode == "floor" else 0.0
if self._crinkliness_shape == "daylight" and 1 / crink <= distance:
# homemaker-py-9gj (DESIGN.md §39.14, corrected by §39.16).
# `1/crink` is floor area per metre of illuminated wall over storey
# height -- room depth PER WINDOW WALL, not room depth. The target
# is Alexander 159, which states a MINIMUM (light on at least two
# sides), and a two-sided gaussian turns a minimum into a target:
# 68% of the leaves in this clipped region are lit on two or more
# sides and are being docked for satisfying the pattern well. Nor
# should this factor price the surplus envelope -- `edge_cost` and
# `outside_edge_cost` already bill that wall.
# Clipping here (rather than at the fail threshold) keeps the
# factor CONTINUOUS: the graded approach to the daylight limit
# survives, and no 10x cliff is introduced at the very boundary
# the fail multiplier already steps on. Note this is the OPPOSITE
# side from §38.1's `compact_ok`, which forgives being buried.
return 1.0
q = gaussian(1 / crink, 1.0, distance, sigma)
if one_sided and 1 / crink > distance:
return 1.0
if mode in ("floor", "compact_ok"):
return max(q, self._crinkliness_floor)
if (self._crinkliness_tail == "ramp"
and q < FAIL_THRESHOLD and 1 / crink > distance):
# homemaker-py-9gj (DESIGN.md §39.13). The stock exponent is
# (1/crink - distance)^2 / 2sigma^2, so it grows without bound as
# exposure falls: measured over the corpus baseline, the failing
# compact tail spans crink 0.12..0.59 and q 1e-300..1e-1, every
# value of which is numerically zero beside a passing leaf's ~1.
# The search therefore cannot rank two layouts that differ only in
# how exposed their under-lit rooms are. Replacing the tail with a
# straight line in crink keeps the ordering, makes it
# representable, is continuous at the threshold, and still sends a
# fully buried leaf (crink == 0, handled above) to exactly 0.
return FAIL_THRESHOLD * crink / _crink_at_fail_threshold(distance, sigma)
return 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)
if self.usage_of(leaf) == "kitchen":
# a kitchen is served by circulation OR by a living space (§39.7)
usages = self.usages()
return [t for t in types
if t in dom_mod.GENERIC_CIRCULATION
or usages.get(t) == "living"]
if dom_mod.is_outside(leaf) or dom_mod.is_circulation(leaf):
return types
return [t for t in types if t in dom_mod.GENERIC_CIRCULATION]
# ------------------------------------------------------------------ #
# 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
if self._quality_aggregate == "geometric_mean":
quality = self._aggregate_geometric(leaf, factors)
return quality, factors
def factor_is_asked(self, name: str, leaf: Node) -> bool:
"""Is this factor a real question for this leaf, or an exemption?
Exempt factors return exactly 1.0 from their `quality_*` method, which
is indistinguishable from a leaf that was asked and answered perfectly
-- so the two cases have to be told apart here.
`tests/test_fitness_aggregate.py` asserts the invariant this duplication
rests on: whenever this returns False, the factor really is 1.0.
"""
if name == "daylight":
return False # pinned to 1.0, URB_NO_OCCLUSION §6
if name == "size":
return _generic_class(leaf) not in ("o", "s")
if name == "crinkliness":
if dom_mod.is_outside(leaf) and not dom_mod.is_covered(leaf):
return False # uncovered outside is lit by definition
if self.crinkliness_params(leaf) is None:
# no minimum-exposure requirement; under "daylight" nothing is
# left to ask, under "gaussian" the over-exposed side still bites
return self._crinkliness_shape != "daylight"
return True
if name == "access":
return not (not dom_mod.level_of(leaf) and dom_mod.is_outside(leaf))
return True
def _aggregate_geometric(self, leaf: Node, factors: dict[str, float]) -> float:
"""Geometric mean over the factors this leaf was actually asked.
Computed in log space so six factors near zero cannot underflow the
product before the root is taken. A zero factor stays zero -- a fully
buried leaf is worth nothing under either aggregation.
"""
asked = [v for name, v in factors.items()
if self.factor_is_asked(name, leaf)]
if not asked:
return 1.0
if any(v <= 0.0 for v in asked):
return 0.0
return math.exp(sum(math.log(v) for v in asked) / len(asked))
# ------------------------------------------------------------------ #
# Value rates and costs (Leaf.pm:146-251, Storey.pm:122-147)
# ------------------------------------------------------------------ #
def value_rate(self, leaf: Node) -> float:
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``."""
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: 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
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):
if nb.type == "O" and self._public_access(nb, root) is not None:
return None
# If the stair itself has via-outdoor access (Entrances priority 3.5), Perl's
# Entrances maps it to a leaf id, not a boundary id. Boundary_Id(edge) eq
# leaf_id never matches → no entrance corners added. Return None here so
# Python matches that behaviour.
for nb in G.neighbors(stair_leaf):
if nb.type == "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):
# 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
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
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():
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(
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(
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
# 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."""
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
# 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)
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
_crink = _programme._optional_pair(c, "crinkliness")
reqs[code] = SpaceReq(
code=code,
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,
has_crinkliness="crinkliness" in c,
crinkliness=_crink[0],
crinkliness_sigma=_crink[1],
)
self._programme_cache = reqs