homemaker-layout/src/homemaker_layout/fitness.py
Bruno Postle f43de001fb rq2/§13.9: flip share_edge_cap default-ON for leaf-sharing runs
§13.8 verdict was positive and monotone-harmless, so default the share-aware
edge-too-long cap to leaf_sharing when share_edge_cap is unset — mirrors the
pll bal+share and §13.6 interior_outside default flips. Explicit
share_edge_cap=False still reproduces the pre-flip control arm.

- fitness.Fitness.__init__: cap defaults to self._leaf_sharing when the conf
  key is unset (None); explicit True/False honoured.
- run_staged_search.py: pin conf["share_edge_cap"] = share_edge in both A/B
  arms so SHAREEDGE=0 stays a clean control post-flip.
- tests: control arm now pins share_edge_cap=False; new
  test_edge_cap_defaults_on_under_leaf_sharing guards the flip.
- DESIGN.md §13.9: rebaseline §13.x floor (maple 80.3→74.0, harbor 34.7→31.0).

Non-sharing runs untouched: programme-house control re-score reproduces
bit-for-bit. 222 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:38:53 +01:00

1156 lines
46 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
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 .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
# 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))))
def load_config(directory: str | Path) -> 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."""
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 {})
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 _t0(n: Node) -> str:
"""First char of the type, lowercased ('' if untyped) — Urb's /^x/i tests."""
return n.type[0].lower() if n.type else ""
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)
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 _t0(leaf) == "s":
leaf.type = "O"
# ------------------------------------------------------------------ #
# Programme-driven parameter lookup (ProgrammeDriven.pm:29-69)
# ------------------------------------------------------------------ #
def get_space_params(self, code: str, param: str) -> list[float]:
c0 = code[0].lower() if code else ""
if c0 == "c":
v = self.conf(f"{param}_circulation")
if v is not None:
return v
if c0 in ("o", "s"):
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 = _t0(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")
aspect = geometry.aspect(leaf)
if aspect < params[0]:
return 1.0
return gaussian(aspect, 1.0, params[0], params[1])
def quality_size(self, leaf: Node) -> float:
t0 = _t0(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 self._leaf_sharing and t0 != "c" and target > 0:
# 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)
if k > 1:
target, sigma = target * k, sigma * k
return gaussian(geometry.area(leaf), 1.0, target, sigma)
def quality_width(self, leaf: Node) -> float:
t0 = _t0(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")
width = geometry.length_narrowest(leaf)
if width > params[0]:
return 1.0
return gaussian(width, 1.0, params[0], params[1])
# --- 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)
if not crink:
return 0.0
return gaussian(1 / crink, 1.0, distance, sigma)
# --- 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 _t0(leaf) == "k":
return [t for t in types if t and t[0].lower() in ("l", "c", "s")]
if dom_mod.is_outside(leaf) or dom_mod.is_circulation(leaf):
return types
return [t for t in types if t and t[0].lower() in ("c", "s")]
# ------------------------------------------------------------------ #
# 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:
t0 = _t0(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)
# ------------------------------------------------------------------ #
# Storey processing (Storey.pm::process_storey — cost/value/leaf scope)
# ------------------------------------------------------------------ #
def process_storey(self, level_root: Node, G: nx.Graph, level_id: int, fail) -> StoreyEval:
"""Per-storey cost, value and leaf evaluations on the MERGED tree.
Covers the cost/value accumulation and per-leaf checks of
``process_storey``; circulation connectivity, roof-garden, stair fit
and tracking-driven building checks are homemaker-py-hgg.
"""
groups = geometry.boundary_groups(level_root)
cost = 0.0
value = 0.0
leaves_eval: list[LeafEval] = []
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
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,
)
)
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)
return StoreyEval(cost=cost, value=value, leaves=leaves_eval)
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 and k[0].lower() in ("o", "s"))
return gaussian(proportion_o, 1.0, *self.conf("ratio_outside"))
def ratio_type(self, ratios: dict[str, float], code: str, ratio: float, sigma: float) -> float:
"""Type-class proportion gaussian; mirrors ``ProgrammeDriven::ratio_type``."""
proportion_type = sum(v for k, v in ratios.items() if k and k[0].lower() == code[0].lower())
proportion_non_o = 1.0 - sum(v for k, v in ratios.items() if k and k[0].lower() in ("o", "s"))
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 not other.type or other.type[0].lower() != "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 and nb.type[0].lower() == "o" and self._public_access(nb, root) is not None:
return None
# If the stair itself has via-outdoor access (Entrances priority 3.5), Perl's
# Entrances maps it to a leaf id, not a boundary id. Boundary_Id(edge) eq
# leaf_id never matches → no entrance corners added. Return None here so
# Python matches that behaviour.
for nb in G.neighbors(stair_leaf):
if nb.type and nb.type[0].lower() == "o" and self._public_access(nb, root) is not None:
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):
if nb.type and nb.type[0].lower() in ("l", "c", "k"):
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 and leaf.type[0].lower() == "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 and leaf.type[0].lower() == "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 req.code and req.code[0].lower() in ("c", "o", "s"):
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()
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 {}
# --- Phase 1: UNMERGED tree checks ---
check_fails, missing = graph_mod.check_space_counts(
root, programme, self._leaf_sharing, self._max_share)
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
)
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))
failures.extend(graph_mod.check_level_constraints(root, programme, missing))
failures.extend(graph_mod.check_vertical_connectivity(root, programme, missing))
# --- 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
)
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: # §11.4 outer-comparator signal only; off by default
for le in se.leaves:
grade += _leaf_grade(le.factors)
building_factor = self.evaluate_building(root, tracking)
value *= building_factor
# 0.5^n failure penalty (programme-driven mode, not 0.1^n)
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
_DW = (4.0, 1.0)
_DP = (1.5, 0.5)
spaces = conf.get("spaces") or {}
if not spaces:
self._programme_cache = None
return
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,
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),
has_size="size" in c,
has_width="width" in c,
has_proportion="proportion" in c,
)
self._programme_cache = reqs