Replace the area-derived share recovery with explicit, type-guarded per-leaf multiplicity: construction stamps leaf.share=k and leaf.share_type=code; the fitness (graph.leaf_share) honours k only while leaf.type==share_type, so any retype/undivide auto-invalidates a stale share — no operator resets, and a small leaf cannot retype its way into covering rooms it does not provide. Two Node fields survive the whole search via deepcopy (genome.decode is unused in the hot path); .dom emits `share` only on a live shared leaf. This closes the §13.3 missing-fail leak: floor probe missing 17–44 → 0, and the achievable floor drops −39% harbor (120.3→73.3) / −32% maple (194.7→133.0) with no re-emergence as size fails. Flag threaded through driver.search/search_staged → constructive_topology / lift_base_to_storeys, exposed via LEAFSHARE/LEAFSHAREFAC in run_staged_search.py (injects the objective into inner-loop + final-score fitness so both A/B arms share one programme dir). run_leafshare_ab.sh runs the staged 20k A/B. Smoke-tested end-to-end (harbor, factor 3, re-score OK). 214 tests pass; default-OFF reproduces baseline. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
372 lines
12 KiB
Python
372 lines
12 KiB
Python
"""Read/write Urb ``.dom`` YAML into a ``Node`` tree.
|
|
|
|
A ``.dom`` file is a YAML serialisation of a binary slicing tree (see
|
|
``Urb::Quad::Serialise``). The top-level mapping is the *lowest* level's root
|
|
quad; each level's root carries an ``above`` mapping pointing at the next storey
|
|
up. Only the lowest root carries plot geometry (``node``); all other coordinates
|
|
are derived top-down (see :mod:`homemaker.geometry`).
|
|
|
|
This module owns *structure and I/O only* — no geometry. Linkage fields
|
|
(``parent``, ``below``, ``position``) are populated after parsing so the geometry
|
|
port can mirror ``Urb::Quad`` exactly, including multi-storey wall-stacking where
|
|
an upper quad inherits its coordinates from the matching quad below.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
import yaml
|
|
|
|
# Root-level attributes that live only on a level's root quad.
|
|
_ROOT_FLOATS = ("height", "elevation", "wall_inner", "wall_outer")
|
|
|
|
|
|
@dataclass(eq=False)
|
|
class Node:
|
|
rotation: int = 0
|
|
division: list[float] | None = None
|
|
type: str | None = None
|
|
left: "Node | None" = None
|
|
right: "Node | None" = None
|
|
above: "Node | None" = None
|
|
|
|
# erc.3 leaf-sharing (DESIGN.md §13.3): how many same-code rooms this leaf
|
|
# covers (1 = a normal leaf). ``share_type`` records the type the share was
|
|
# assigned for; the fitness honours ``share`` only while ``type ==
|
|
# share_type``, so any retype/undivide automatically invalidates a stale
|
|
# share without the mutation operators needing to reset it.
|
|
share: int = 1
|
|
share_type: "str | None" = None
|
|
|
|
# level-root only
|
|
node: list[list[float]] | None = None # working corners (wall-inset)
|
|
node_file: list[list[float]] | None = None # raw outer corners as read from disk
|
|
perimeter: dict | None = None
|
|
height: float | None = None
|
|
elevation: float | None = None
|
|
wall_inner: float | None = None
|
|
wall_outer: float | None = None
|
|
|
|
# runtime linkage (never serialised)
|
|
parent: "Node | None" = field(default=None, repr=False, compare=False)
|
|
below: "Node | None" = field(default=None, repr=False, compare=False)
|
|
position: str = "" # 'l', 'r', or '' for a level root
|
|
|
|
@property
|
|
def divided(self) -> bool:
|
|
return self.division is not None and self.left is not None and self.right is not None
|
|
|
|
@property
|
|
def id(self) -> str:
|
|
"""Absolute id path within this level, e.g. '' (root), 'l', 'rlr'."""
|
|
parts: list[str] = []
|
|
n: Node | None = self
|
|
while n is not None and n.parent is not None:
|
|
parts.append(n.position)
|
|
n = n.parent
|
|
return "".join(reversed(parts))
|
|
|
|
def leaves(self) -> list["Node"]:
|
|
if not self.divided:
|
|
return [self]
|
|
return self.left.leaves() + self.right.leaves()
|
|
|
|
def by_id(self, path: str) -> "Node | None":
|
|
"""Walk an id path from this node; None if the path runs off a leaf."""
|
|
n: Node | None = self
|
|
for ch in path:
|
|
if n is None or not n.divided:
|
|
return None
|
|
n = n.left if ch == "l" else n.right
|
|
return n
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Parsing
|
|
# --------------------------------------------------------------------------- #
|
|
def _parse(d: dict) -> Node:
|
|
n = Node()
|
|
n.rotation = int(d.get("rotation") or 0)
|
|
if d.get("type") is not None:
|
|
n.type = str(d["type"])
|
|
if d.get("share") is not None:
|
|
# share is only emitted for leaves where it is live (share>1 and the
|
|
# type still matches), so on read the assigned-for type is this leaf's.
|
|
n.share = int(d["share"])
|
|
n.share_type = n.type
|
|
if d.get("node") is not None:
|
|
n.node = [[float(p[0]), float(p[1])] for p in d["node"]]
|
|
if d.get("perimeter") is not None:
|
|
n.perimeter = dict(d["perimeter"])
|
|
for k in _ROOT_FLOATS:
|
|
if d.get(k) is not None:
|
|
setattr(n, k, float(d[k]))
|
|
if d.get("division") is not None:
|
|
n.division = [float(x) for x in d["division"]]
|
|
n.left = _parse(d["l"])
|
|
n.right = _parse(d["r"])
|
|
if d.get("above") is not None:
|
|
n.above = _parse(d["above"])
|
|
return n
|
|
|
|
|
|
def _link_subtree(n: Node, parent: Node | None, position: str) -> None:
|
|
n.parent = parent
|
|
n.position = position
|
|
if n.divided:
|
|
_link_subtree(n.left, n, "l")
|
|
_link_subtree(n.right, n, "r")
|
|
|
|
|
|
def levels(root: Node) -> list[Node]:
|
|
"""Level roots, lowest first."""
|
|
out: list[Node] = []
|
|
lvl: Node | None = root
|
|
while lvl is not None:
|
|
out.append(lvl)
|
|
lvl = lvl.above
|
|
return out
|
|
|
|
|
|
def _link(root: Node) -> None:
|
|
lvls = levels(root)
|
|
for lvl in lvls:
|
|
_link_subtree(lvl, None, "") # each level root is parent-less
|
|
# Below-inheritance: a node links to the same-id node one level down,
|
|
# if that path exists there (mirrors Urb::Quad::Below via By_Id).
|
|
for i in range(1, len(lvls)):
|
|
below_root = lvls[i - 1]
|
|
|
|
def _set(n: Node, below_root: Node = below_root) -> None:
|
|
# always assign: re-linking a structurally mutated tree must CLEAR
|
|
# below-links whose path no longer exists on the storey below
|
|
n.below = below_root.by_id(n.id)
|
|
if n.divided:
|
|
_set(n.left)
|
|
_set(n.right)
|
|
|
|
_set(lvls[i])
|
|
|
|
|
|
def load(path: str) -> Node:
|
|
"""Load a ``.dom`` file and return the fully-linked lowest level root.
|
|
|
|
The plot stored on disk is the *outer* boundary; Urb::Dom insets it by
|
|
``wall_outer`` on load (and offsets back out on save). We mirror that so
|
|
leaf areas match ``urb-fitness.pl``, stashing the raw corners in
|
|
``node_file`` for byte-perfect plot round-tripping.
|
|
"""
|
|
from . import geometry # local import avoids a module-load cycle
|
|
|
|
with open(path) as fh:
|
|
root = _parse(yaml.safe_load(fh))
|
|
_link(root)
|
|
if root.wall_outer is None:
|
|
root.wall_outer = 0.25 # Urb::Dom::Wall_Outer default
|
|
if root.wall_inner is None:
|
|
root.wall_inner = 0.08 # Urb::Dom::Wall_Inner default
|
|
if root.node is not None:
|
|
root.node_file = [list(p) for p in root.node]
|
|
root.node = geometry.offset_quad(root.node, -root.wall_outer)
|
|
geometry.clear_cache() # fresh tree: drop any stale coordinates
|
|
return root
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Emit (mirrors Urb::Quad::Serialise; field order kept close for readability)
|
|
# --------------------------------------------------------------------------- #
|
|
def _emit(n: Node, is_level_root: bool) -> dict:
|
|
d: dict = {}
|
|
if is_level_root and (n.node_file is not None or n.node is not None):
|
|
# write the outer plot boundary Urb expects on disk
|
|
if n.node_file is not None:
|
|
d["node"] = [list(p) for p in n.node_file]
|
|
else:
|
|
from . import geometry
|
|
|
|
d["node"] = [list(p) for p in geometry.offset_quad(n.node, n.wall_outer or 0.25)]
|
|
if is_level_root and n.perimeter is not None:
|
|
d["perimeter"] = dict(n.perimeter)
|
|
if n.type and not n.divided:
|
|
d["type"] = n.type
|
|
if n.share > 1 and n.share_type == n.type: # erc.3: live leaf-share only
|
|
d["share"] = n.share
|
|
d["rotation"] = n.rotation
|
|
if n.divided:
|
|
d["division"] = list(n.division)
|
|
if is_level_root and n.height is not None:
|
|
d["height"] = n.height
|
|
if is_level_root and n.elevation is not None:
|
|
d["elevation"] = n.elevation
|
|
for k in ("wall_inner", "wall_outer"):
|
|
if is_level_root and getattr(n, k) is not None:
|
|
d[k] = getattr(n, k)
|
|
if n.divided:
|
|
d["l"] = _emit(n.left, False)
|
|
d["r"] = _emit(n.right, False)
|
|
if n.above is not None:
|
|
d["above"] = _emit(n.above, True)
|
|
return d
|
|
|
|
|
|
def dumps(root: Node) -> str:
|
|
return yaml.safe_dump(
|
|
_emit(root, True), default_flow_style=False, sort_keys=False, allow_unicode=True
|
|
)
|
|
|
|
|
|
def dump(root: Node, path: str) -> None:
|
|
with open(path, "w") as fh:
|
|
fh.write(dumps(root))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Structural predicates (mirrors Urb::Dom::Is_Outside etc.)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def is_outside(n: Node) -> bool:
|
|
"""True if n's type starts with 'o' or 's' (case-insensitive)."""
|
|
return n.type is not None and n.type[0].lower() in ("o", "s")
|
|
|
|
|
|
def _level_root(n: Node) -> Node:
|
|
while n.parent is not None:
|
|
n = n.parent
|
|
return n
|
|
|
|
|
|
def _below_more(n: Node) -> "Node | None":
|
|
"""Matching node on level below, walking up to parent if path absent;
|
|
mirrors ``Urb::Quad::Below_More``."""
|
|
lr = _level_root(n)
|
|
if lr.below is None:
|
|
return None
|
|
if n.below is not None:
|
|
return n.below
|
|
if n.parent is None:
|
|
return None
|
|
return _below_more(n.parent)
|
|
|
|
|
|
def _below_leaves(n: Node) -> "list[Node]":
|
|
bm = _below_more(n)
|
|
return bm.leaves() if bm is not None else []
|
|
|
|
|
|
def _above_node(n: Node) -> "Node | None":
|
|
"""Matching node on the level above; mirrors ``Urb::Quad::Above``."""
|
|
lr = _level_root(n)
|
|
if lr.above is None:
|
|
return None
|
|
return lr.above.by_id(n.id)
|
|
|
|
|
|
def _above_more(n: Node) -> "Node | None":
|
|
"""Matching node on level above, walking up to parent if path absent;
|
|
mirrors ``Urb::Quad::Above_More``."""
|
|
if _level_root(n).above is None:
|
|
return None
|
|
a = _above_node(n)
|
|
if a is not None:
|
|
return a
|
|
if n.parent is None:
|
|
return None
|
|
return _above_more(n.parent)
|
|
|
|
|
|
def _above_leaves(n: Node) -> "list[Node]":
|
|
am = _above_more(n)
|
|
return am.leaves() if am is not None else []
|
|
|
|
|
|
def level_of(n: Node) -> int:
|
|
"""Storey index of n (0 = ground); mirrors ``Urb::Quad::Level``
|
|
(= number of levels below n's level root)."""
|
|
lr = _level_root(n)
|
|
i = 0
|
|
while lr.below is not None:
|
|
lr = lr.below
|
|
i += 1
|
|
return i
|
|
|
|
|
|
def is_covered(n: Node) -> bool:
|
|
"""Any leaf above n is indoor; mirrors ``Urb::Dom::Is_Covered``."""
|
|
return any(not is_outside(lf) for lf in _above_leaves(n))
|
|
|
|
|
|
def is_supported(n: Node) -> bool:
|
|
"""All leaves below n are indoor; mirrors ``Urb::Dom::Is_Supported``."""
|
|
bl = _below_leaves(n)
|
|
return bool(bl) and all(not is_outside(lf) for lf in bl)
|
|
|
|
|
|
def is_unsupported(n: Node) -> bool:
|
|
"""All leaves below n are outdoor; mirrors ``Urb::Dom::Is_Unsupported``."""
|
|
bl = _below_leaves(n)
|
|
return bool(bl) and all(is_outside(lf) for lf in bl)
|
|
|
|
|
|
def is_usable(n: Node) -> bool:
|
|
"""Indoors, or on the ground floor, or supported by building below;
|
|
mirrors ``Urb::Dom::Is_Usable``."""
|
|
if not is_outside(n):
|
|
return True
|
|
if level_of(n) == 0:
|
|
return True
|
|
return is_supported(n)
|
|
|
|
|
|
def is_circulation(n: Node) -> bool:
|
|
"""Usable and type 'c'/'s'; mirrors ``Urb::Dom::Is_Circulation``."""
|
|
if not is_usable(n):
|
|
return False
|
|
return n.type is not None and n.type[0].lower() in ("c", "s")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Merge_Divided (Urb::Dom::Merge_Divided)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def _undivide(n: Node, new_type: str) -> None:
|
|
n.division = None
|
|
n.left = None
|
|
n.right = None
|
|
n.type = new_type
|
|
|
|
|
|
def _merge_node(n: Node) -> None:
|
|
"""Post-order recursive merge; mirrors ``Urb::Dom::Merge_Divided``."""
|
|
if n.divided:
|
|
_merge_node(n.left)
|
|
_merge_node(n.right)
|
|
if n.above is not None and n.parent is None:
|
|
_merge_node(n.above)
|
|
if not n.divided:
|
|
return
|
|
lt = n.left.type or ""
|
|
rt = n.right.type or ""
|
|
l_os = bool(lt) and lt[0].lower() in ("o", "s")
|
|
r_os = bool(rt) and rt[0].lower() in ("o", "s")
|
|
if not (l_os and r_os):
|
|
return
|
|
l_o = bool(lt) and lt[0].lower() == "o"
|
|
r_o = bool(rt) and rt[0].lower() == "o"
|
|
if is_supported(n) and l_o and r_o:
|
|
_undivide(n, "O")
|
|
elif is_supported(n):
|
|
_undivide(n, "S")
|
|
elif is_unsupported(n):
|
|
_undivide(n, "O")
|
|
elif _level_root(n).below is None: # ground floor: !$self->Level in Perl
|
|
_undivide(n, "S")
|
|
|
|
|
|
def merge_divided(root: Node) -> None:
|
|
"""Merge adjacent outdoor siblings into a single node in-place;
|
|
mirrors ``Urb::Dom::Merge_Divided``. Re-links the tree afterward so
|
|
``below`` / ``parent`` / ``position`` fields stay consistent.
|
|
"""
|
|
_merge_node(root)
|
|
_link(root)
|