homemaker-layout/src/homemaker_layout/operators.py

1753 lines
79 KiB
Python
Raw Normal View History

"""High-locality topology operators: mutation + subtree crossover.
Operators edit a *decoded* Node tree (the canonical phenotype) and re-link it;
``genome.encode`` then re-derives the genome, which makes every operator
total: dangling per-storey deltas after an undivide below, or storey
misalignment after crossover, are absorbed by encode's parallel walk (cuts
that stop existing below simply become owned above). Geometry moves (Urb's
``slide``, floor heights) are deliberately absent the inner loop owns all
continuous DOF (DESIGN.md §5), and the warm-vs-cold result (homemaker-py-8cs)
makes Lamarckian re-optimisation after every topology move mandatory anyway.
Each ``mutate_*`` helper applies one random instance to a deep copy and
returns ``(child_root, descriptor)``; ``crossover`` returns two children.
Candidate selection respects ownership: cuts are swappable/rotatable only
where they are live (below is None / below undivided the free-branch
criterion), so operators never edit dead fields.
"""
from __future__ import annotations
import copy
import numpy as np
from . import dom
def _finalise(root: dom.Node) -> dom.Node:
from . import geometry
dom._link(root)
geometry.clear_cache()
return root
def _level_nodes(lvl: dom.Node) -> list[dom.Node]:
out = [lvl]
if lvl.divided:
out += _level_nodes(lvl.left) + _level_nodes(lvl.right)
return out
def _pick(rng: np.random.Generator, items: list):
return items[int(rng.integers(len(items)))]
def _pick_weighted_by_storey(rng: np.random.Generator, items: list, base_p: float):
"""Pick one ``(level_index, node)`` tuple, downweighting the base storey.
Base-storey leaves/branches (``li == 0``) are sampled with relative weight
``base_p``; everything else with weight 1.0. ``base_p == 1.0`` (default)
reproduces a uniform pick exactly (DESIGN.md §11.3 Stage 2 keeps the base
mutable at low probability rather than freezing it).
"""
if base_p >= 1.0 or not items:
return _pick(rng, items)
w = np.array([base_p if li == 0 else 1.0 for li, _ in items], dtype=float)
if w.sum() == 0:
w[:] = 1.0
return items[int(rng.choice(len(items), p=w / w.sum()))]
def _owned_branches(root: dom.Node) -> list[tuple[int, dom.Node]]:
"""(level_index, node) for every divided node whose cut is live here."""
out = []
for li, lvl in enumerate(dom.levels(root)):
for n in _level_nodes(lvl):
if n.divided and (n.below is None or not n.below.divided):
out.append((li, n))
return out
def _leaves(root: dom.Node) -> list[tuple[int, dom.Node]]:
return [(li, leaf) for li, lvl in enumerate(dom.levels(root)) for leaf in lvl.leaves()]
# --------------------------------------------------------------------------- #
# Mutations
# --------------------------------------------------------------------------- #
def mutate_divide(root: dom.Node, rng: np.random.Generator,
types: list[str], base_p: float = 1.0) -> tuple[dom.Node, str]:
child = copy.deepcopy(root)
li, leaf = _pick_weighted_by_storey(rng, _leaves(child), base_p)
leaf.division = [0.5, 0.5]
leaf.rotation = int(rng.integers(4))
leaf.left = dom.Node(type=leaf.type)
leaf.right = dom.Node(type=str(_pick(rng, types)))
leaf.type = None
return _finalise(child), f"divide {li}/{leaf.id or 'root'}"
def mutate_undivide(root: dom.Node, rng: np.random.Generator,
types: list[str], base_p: float = 1.0) -> tuple[dom.Node, str]:
child = copy.deepcopy(root)
cands = [(li, n) for li, n in _owned_branches(child)
if not n.left.divided and not n.right.divided]
if not cands:
return _finalise(child), "undivide noop"
li, n = _pick_weighted_by_storey(rng, cands, base_p)
# generic classes (circulation/outside/sahn) match case-insensitively,
# cf. Urb Is_Circulation/Is_Outside
keep = [t for t in (n.left.type, n.right.type) if t and t[0].lower() not in "cos"]
n.type = keep[0] if keep else (n.left.type or str(_pick(rng, types)))
n.division = None
n.left = n.right = None
return _finalise(child), f"undivide {li}/{n.id or 'root'}"
def mutate_retype(root: dom.Node, rng: np.random.Generator,
types: list[str], base_p: float = 1.0) -> tuple[dom.Node, str]:
child = copy.deepcopy(root)
li, leaf = _pick_weighted_by_storey(rng, _leaves(child), base_p)
leaf.type = str(_pick(rng, [t for t in types if t != leaf.type] or types))
return _finalise(child), f"retype {li}/{leaf.id or 'root'}->{leaf.type}"
def mutate_swap(root: dom.Node, rng: np.random.Generator,
types: list[str], base_p: float = 1.0) -> tuple[dom.Node, str]:
child = copy.deepcopy(root)
cands = _owned_branches(child)
if not cands: # undivided topology (e.g. a bare plot seed)
return _finalise(child), "swap noop"
li, n = _pick_weighted_by_storey(rng, cands, base_p)
n.left, n.right = n.right, n.left
return _finalise(child), f"swap {li}/{n.id or 'root'}"
def mutate_rotate(root: dom.Node, rng: np.random.Generator,
types: list[str], base_p: float = 1.0) -> tuple[dom.Node, str]:
# re-orient a live cut; live rotation = node without a below link (base
# storey or inside an upper-storey divide delta)
child = copy.deepcopy(root)
cands = [(li, n) for li, n in _owned_branches(child) if n.below is None]
if not cands:
return _finalise(child), "rotate noop"
li, n = _pick_weighted_by_storey(rng, cands, base_p)
n.rotation = (n.rotation + int(rng.integers(1, 4))) % 4
return _finalise(child), f"rotate {li}/{n.id or 'root'}"
def mutate_level_fix(root: dom.Node, rng: np.random.Generator,
types: list[str], reqs=None) -> tuple[dom.Node, str]:
"""Atomically move a level-constrained room to its required floor.
Finds a room type with a ``level: N`` constraint that currently sits on the
wrong storey. Retypes the LARGEST leaf on the required floor to that room,
and retypes the vacated wrong-floor leaf to a generic (C or O). Does not
undivide anything, so the size may still be suboptimal the inner NM loop
fixes geometry, and subsequent core_divide / retype mutations fill in any
displaced rooms.
Requires ``reqs`` (dict[str, SpaceReq] from programme.load_programme_dir).
"""
if not reqs:
return _finalise(copy.deepcopy(root)), "level_fix noop"
from . import geometry as _geo
level_types = {code: req.level for code, req in reqs.items()
if getattr(req, "level", None) is not None}
if not level_types:
return _finalise(copy.deepcopy(root)), "level_fix noop"
child = copy.deepcopy(root)
lvls = dom.levels(child)
violations = [
(li, lf, code, req_level)
for code, req_level in level_types.items()
for li, lvl in enumerate(lvls)
for lf in lvl.leaves()
if lf.type == code and li != req_level
]
if not violations:
return _finalise(child), "level_fix noop"
li_wrong, wrong_leaf, code, req_level = _pick(rng, violations)
if req_level >= len(lvls):
return _finalise(child), "level_fix noop"
correct_leaves = lvls[req_level].leaves()
if not correct_leaves:
return _finalise(child), "level_fix noop"
# Pick the largest leaf on the correct floor as the best landing spot
target = max(correct_leaves, key=lambda lf: _geo.area(lf))
target.type = code
generics = [t for t in types if t.upper() in ("C", "O")]
wrong_leaf.type = str(rng.choice(generics)) if generics else "C"
return _finalise(child), (
f"level_fix {code}: lvl{li_wrong}/{wrong_leaf.id or 'root'}"
f" → lvl{req_level}/{target.id or 'root'}"
)
def mutate_level_compound_fix(root: dom.Node, rng: np.random.Generator,
types: list[str], reqs=None) -> tuple[dom.Node, str]:
"""Compound level fix: move level-constrained room + re-insert displaced room.
Extends level_fix: after landing the constrained room (e.g. l1) on its
required floor, the displaced room (e.g. t3) is re-inserted by splitting
the SIBLING of the largest C leaf on that floor. The C sibling is always
geometrically adjacent to C (they share the same parent split), so the
displaced room inherits that adjacency. Division is applied to the target
floor only (not core-divide style), since the displaced room only needs to
appear on its required floor.
This avoids the 5-fail "missing t3" penalty that level_fix alone causes
when the landing spot displaces a required room.
"""
if not reqs:
return _finalise(copy.deepcopy(root)), "level_compound_fix noop"
from . import geometry as _geo
level_types = {code: req.level for code, req in reqs.items()
if getattr(req, "level", None) is not None}
if not level_types:
return _finalise(copy.deepcopy(root)), "level_compound_fix noop"
child = copy.deepcopy(root)
lvls = dom.levels(child)
violations = [
(li, lf, code, req_level)
for code, req_level in level_types.items()
for li, lvl in enumerate(lvls)
for lf in lvl.leaves()
if lf.type == code and li != req_level
]
if not violations:
return _finalise(child), "level_compound_fix noop"
li_wrong, wrong_leaf, code, req_level = _pick(rng, violations)
if req_level >= len(lvls):
return _finalise(child), "level_compound_fix noop"
correct_leaves = lvls[req_level].leaves()
if not correct_leaves:
return _finalise(child), "level_compound_fix noop"
target = max(correct_leaves, key=lambda lf: _geo.area(lf))
displaced_type = target.type
# Apply level_fix part
target.type = code
generics = [t for t in types if t.upper() in ("C", "O")]
wrong_leaf.type = str(rng.choice(generics)) if generics else "C"
desc = (f"level_compound_fix {code}: lvl{li_wrong} → lvl{req_level}/{target.id or 'root'}")
# Re-insert displaced room if it was a named room
if displaced_type and displaced_type.upper() not in ("C", "O", "S"):
displaced_req = reqs.get(displaced_type)
displaced_level = getattr(displaced_req, "level", None) if displaced_req else None
insert_level = displaced_level if displaced_level is not None else req_level
lvls = dom.levels(child)
# Find C-sibling pairs: (parent, sibling_of_C) on insert_level.
# The sibling of a C leaf shares a parent split → guaranteed adjacent to C.
# Pick the largest such sibling as the host for the displaced room.
sibling_cands: list[dom.Node] = []
for li2, n in _owned_branches(child):
if li2 != insert_level:
continue
l_is_c = n.left.type and n.left.type.upper() == "C" and not n.left.divided
r_is_c = n.right.type and n.right.type.upper() == "C" and not n.right.divided
if l_is_c and not n.right.divided and n.right.id:
sibling_cands.append(n.right)
if r_is_c and not n.left.divided and n.left.id:
sibling_cands.append(n.left)
if sibling_cands:
host = max(sibling_cands, key=lambda lf: _geo.area(lf))
host_path = host.id
node = lvls[insert_level].by_id(host_path)
if node is not None and not node.divided:
host_orig_type = node.type
# rotation=0 (vertical left-right split): left child neighbours C;
# displaced room goes left (small), host type preserved right (large).
# Inner NM tunes the exact split ratio.
node.division = [0.25, 0.25]
node.rotation = 0
node.left = dom.Node(type=displaced_type) # small, adjacent to C
node.right = dom.Node(type=host_orig_type) # large, preserves host
node.type = None
desc += f" + insert {displaced_type} into {host_path}/lvl{insert_level}"
return _finalise(child), desc
def _programme_codes(reqs) -> dict:
"""Required programme spaces only (drop generic circulation/outside/sahn)."""
return {c: r for c, r in reqs.items() if c[0].lower() not in "cos"}
def mutate_place_missing(root: dom.Node, rng: np.random.Generator,
types: list[str], reqs=None) -> tuple[dom.Node, str]:
"""Repair operator: insert a required-but-absent space (DESIGN.md §11.2).
Detects a missing required room via ``graph.check_space_counts`` and inserts
one instance by dividing a host leaf into ``[new room | remainder]``. Lex-
safety (cf. the §4.10 deceptive-valley lesson): the host is chosen to *not*
create more new fails than the missing-stack it removes generic ``O``
leaves are preferred (unbounded, no "too many", nothing displaced), then
other non-required leaves; a required room is never displaced. The new room
is forced onto its required storey when the programme constrains its level.
"""
if not reqs:
return _finalise(copy.deepcopy(root)), "place_missing noop"
from . import geometry as _geo, graph as _graph
child = copy.deepcopy(root)
_failures, missing = _graph.check_space_counts(child, reqs)
if not missing:
return _finalise(child), "place_missing noop"
mid = _pick(rng, missing)
code = mid.split("#")[0]
req = reqs.get(code)
target_level = getattr(req, "level", None)
lvls = dom.levels(child)
if target_level is not None and target_level < len(lvls):
host_levels = [target_level]
else:
host_levels = list(range(len(lvls)))
# Rank candidate hosts: 0 = generic outside (safest — nothing displaced),
# 1 = other non-required leaf, 2 = circulation/stair (carve only as last
# resort — disrupts the core). Required rooms are never candidates.
cands: list[tuple[int, float, dom.Node]] = []
for li in host_levels:
for leaf in lvls[li].leaves():
if not leaf.type:
continue
t0 = leaf.type[0].lower()
if t0 == "o":
pref = 0
elif t0 in ("c", "s"):
pref = 2
elif leaf.type in reqs:
continue
else:
pref = 1
cands.append((pref, _geo.area(leaf), leaf))
if cands:
best_pref = min(p for p, _, _ in cands)
pool = [(a, lf) for p, a, lf in cands if p == best_pref]
_, host = max(pool, key=lambda x: x[0])
keep = host.type if host.type and host.type[0].lower() != "o" else "O"
else:
# No safe host on the required storey — split its largest leaf and
# preserve that leaf's type on the large side.
all_leaves = [lf for li in host_levels for lf in lvls[li].leaves()]
if not all_leaves:
return _finalise(child), "place_missing noop"
host = max(all_leaves, key=_geo.area)
keep = host.type or "O"
host_id = host.id or "root"
# New room small (left, adjacent to remainder); inner NM tunes the ratio.
host.division = [0.3, 0.3]
host.rotation = int(rng.integers(4))
host.left = dom.Node(type=code)
host.right = dom.Node(type=keep)
host.type = None
return _finalise(child), f"place_missing {code} -> {host_id}"
def mutate_bridge_circulation(root: dom.Node, rng: np.random.Generator,
types: list[str], reqs=None) -> tuple[dom.Node, str]:
"""Repair operator (homemaker-py-8sh): retype the leaves on the cheapest
path between two circulation components to circulation, directly clearing
a ``level N not connected`` fail (``graph.connected_circulation``).
Follow-on to homemaker-py-qi6 mechanism (a). Mechanism (b)/(c) a graded
circulation-connectivity comparator key (DESIGN.md §18) measured
NEGATIVE: it never fired on harbor-house and never cleared a genuine
not-connected fail on programme-house, because the outer search rarely
hits the fail-count tie the grade needs to break. This operator does not
depend on a tie: for each storey it finds the two circulation components
(``graph.build_graphs``' per-leaf adjacency, restricted to
``dom.is_circulation`` leaves) joined by the shortest, least-disruptive
path and retypes the intermediate leaves to ``C``. Path cost prefers
generic outside (``O``) leaves (free nothing displaced, same rationale
as ``mutate_place_missing``'s host ranking), then other non-required
leaves, and only crosses a required programme room (from ``reqs``) if no
other route exists. A displaced required room becomes a missing-space
fail for ``mutate_place_missing`` to re-insert elsewhere on a later step,
the same division of labour ``mutate_deslim`` uses.
"""
import networkx as nx
from . import geometry as _geo, graph as _graph
child = copy.deepcopy(root)
lvls = dom.levels(child)
def _cost(n: dom.Node) -> int:
if dom.is_circulation(n):
return 0
if not n.type:
return 1
if n.type[0].lower() == "o":
return 0
if reqs and n.type in reqs:
return 5
return 1
per_level: list[tuple[int, list[dom.Node]]] = []
for li, lvl in enumerate(lvls):
G = _geo.leaf_graph(lvl, _graph.DOOR_WIDTH)
circ = [n for n in G.nodes() if dom.is_circulation(n)]
if not circ:
continue
comps = list(nx.connected_components(G.subgraph(circ)))
if len(comps) <= 1:
continue
# Node cost split across its edges (average of endpoint costs) so a
# weighted shortest path sums to (approximately) total intermediate-
# leaf conversion cost — a plain hop-count shortest path would pick an
# arbitrary same-length route and could cross a required room even
# when an equal-length free ('O') route exists.
weighted = G.copy()
for u, v, data in weighted.edges(data=True):
data["bridge_weight"] = (_cost(u) + _cost(v)) / 2.0
best_path: list[dom.Node] | None = None
best_weight = None
for i in range(len(comps)):
for j in range(i + 1, len(comps)):
tmp = weighted.copy()
tmp.add_node("SRC")
tmp.add_node("DST")
tmp.add_edges_from(("SRC", n, {"bridge_weight": 0.0}) for n in comps[i])
tmp.add_edges_from(("DST", n, {"bridge_weight": 0.0}) for n in comps[j])
try:
weight, path = nx.single_source_dijkstra(
tmp, "SRC", "DST", weight="bridge_weight")
except nx.NetworkXNoPath:
continue
if best_weight is None or weight < best_weight:
between = [n for n in path[1:-1]
if n not in comps[i] and n not in comps[j]]
best_weight, best_path = weight, between
if best_path:
per_level.append((li, best_path))
if not per_level:
return _finalise(child), "bridge_circulation noop"
li, path = _pick(rng, per_level)
for leaf in path:
leaf.type = "C"
names = ",".join(leaf.id or "root" for leaf in path)
return _finalise(child), f"bridge_circulation lvl{li}: {names} -> C"
def _shape_failing(leaf: dom.Node, fit) -> bool:
"""A named-room leaf whose width or proportion factor actually fails
(``< fitness.FAIL_THRESHOLD``) under ``fit``, the same Gaussian quality
functions the scorer uses (``Fitness.quality_width``/``quality_proportion``)
not a geometric proxy, which over-flags leaves the gaussian tail still
passes. Generic circulation/outside/sahn leaves are never candidates
they absorb slack by design (solver.py ``min_width_generic``), not a
repair target."""
if not leaf.type or leaf.type[0].lower() in "cos":
return False
from . import fitness as _fit_mod
return (fit.quality_width(leaf) < _fit_mod.FAIL_THRESHOLD
or fit.quality_proportion(leaf) < _fit_mod.FAIL_THRESHOLD)
def mutate_shape_rotate(root: dom.Node, rng: np.random.Generator,
types: list[str], fit=None) -> tuple[dom.Node, str]:
"""Repair operator (homemaker-py-7fm): re-orient the cut that produced a
shape-failing (long-thin) leaf.
Diagnosis (bd memory, 7fm): re-running the full-fitness ratio inner loop
with a large budget does not clear these fails they are not local optima
of the ratio, because the offending leaf is the *thin* side of a cut whose
orientation runs parallel to its parent rectangle's long axis, so any ratio
value on that axis yields a thin sliver. Rotating the defining (live) cut
changes which axis the ratio divides; the inner loop then re-tunes the
ratio on the new axis. Targets only the cut that actually produced a
failing leaf, unlike the untargeted ``mutate_rotate``. Requires ``fit``
(a ``fitness.Fitness``) to identify genuinely failing leaves.
"""
if fit is None:
return _finalise(copy.deepcopy(root)), "shape_rotate noop"
child = copy.deepcopy(root)
cands: list[tuple[int, dom.Node, dom.Node]] = []
for li, n in _owned_branches(child):
if n.below is not None:
continue
for side in ("l", "r"):
leaf = n.left if side == "l" else n.right
if not leaf.divided and _shape_failing(leaf, fit):
cands.append((li, n, leaf))
if not cands:
return _finalise(child), "shape_rotate noop"
li, n, leaf = _pick(rng, cands)
n.rotation = (n.rotation + int(rng.integers(1, 4))) % 4
return _finalise(child), f"shape_rotate {li}/{n.id or 'root'} (fixing {leaf.id})"
def mutate_deslim(root: dom.Node, rng: np.random.Generator,
types: list[str], fit=None) -> tuple[dom.Node, str]:
"""Repair operator (homemaker-py-7fm): merge a shape-failing (long-thin)
leaf into its sibling, undoing the division that starved it.
Unlike ``mutate_shape_rotate`` this addresses cuts whose *area* share is
wrong (an upstream branch several levels up gave the whole subtree too
little area to satisfy every leaf inside it no ratio or rotation on the
local cut can fix that, bd memory 7fm), not just its orientation. The
displaced room becomes a missing-space fail that ``mutate_place_missing``
(already in ``MUTATIONS``) re-inserts elsewhere on a later step. Requires
``fit`` (a ``fitness.Fitness``) to identify genuinely failing leaves.
"""
if fit is None:
return _finalise(copy.deepcopy(root)), "deslim noop"
from . import geometry as _geo
child = copy.deepcopy(root)
cands = [
(li, n) for li, n in _owned_branches(child)
if not n.left.divided and not n.right.divided
and (_shape_failing(n.left, fit) or _shape_failing(n.right, fit))
]
if not cands:
return _finalise(child), "deslim noop"
li, n = _pick(rng, cands)
l_fail, r_fail = _shape_failing(n.left, fit), _shape_failing(n.right, fit)
if l_fail and not r_fail:
survivor = n.right
elif r_fail and not l_fail:
survivor = n.left
else:
survivor = max((n.left, n.right), key=_geo.area)
n.type = survivor.type if survivor.type and survivor.type[0].lower() not in "cos" else "C"
n.division = None
n.left = n.right = None
return _finalise(child), f"deslim {li}/{n.id or 'root'} (kept {n.type})"
def _leaves_with_depth(n: dom.Node, d: int = 0) -> list[tuple[dom.Node, int]]:
"""Every leaf under ``n`` paired with its depth below ``n``."""
if not n.divided:
return [(n, d)]
return _leaves_with_depth(n.left, d + 1) + _leaves_with_depth(n.right, d + 1)
def _grow_leaves(lvl: dom.Node, n_leaves: int, rng: np.random.Generator,
balance: bool = False) -> None:
"""Subdivide ``lvl``'s subtree in place until it has ``n_leaves`` leaves.
``balance`` (erc.4, §13.4): always split a *shallowest* current leaf, growing
a near-complete binary tree instead of the default random caterpillar. Diag B
(§13.2) localized the size fails to depth-driven MALDISTRIBUTION leaf area is
set by the product of cut fractions down its ancestry, so a random unbalanced
tree lands equal-target rooms at depths that differ by many levels (same code
seen at 0.05× and 14.7× target). Keeping all leaves at comparable depth lets
the proportion-aware sizing pass hit each target with cut fractions near their
proportional value, instead of compounding fmin/fmax clamp error down a deep
spine."""
while len(lvl.leaves()) < n_leaves:
if balance:
ld = _leaves_with_depth(lvl)
dmin = min(d for _l, d in ld)
leaf = _pick(rng, [l for l, d in ld if d == dmin])
else:
leaf = _pick(rng, lvl.leaves())
leaf.division = [0.5, 0.5]
leaf.rotation = int(rng.integers(4))
leaf.left = dom.Node(type=leaf.type)
leaf.right = dom.Node(type=leaf.type)
leaf.type = None
def _share_grain(req, share_factor: int) -> int:
"""Per-code leaf-sharing grain (homemaker-py-x3b, §13.3).
Returns the maximum number of same-code rooms that may collapse into one
shared leaf, or 1 when the code must not be shared. Only sized codes are ever
shareable (an unsized circulation/outside code absorbs slack and has no target
to centre k rooms on). ``share_factor`` is the global selector:
- ``0`` per-code opt-in: a code is shared iff it carries an explicit
``share: N`` (>=2); everything else stays unshared. This is the safe
default-on philosophy the programme author chooses per space.
- ``>=2`` global mode: every sized code shares at grain ``share_factor``,
except codes with an explicit ``share`` which overrides it (``share: 1``
opts the code OUT, ``share: N`` sets that code's grain to N). This
reproduces the §13.3 experiment without editing example programmes.
"""
if req is None or not (req.has_size and req.size > 0):
return 1
if share_factor == 0:
return req.share if req.has_share else 1
return req.share if req.has_share else share_factor
def _share_rooms(rooms: list[str], reqs,
share_factor: int) -> tuple[list[str], dict[str, list[int]]]:
"""Collapse same-code room instances into fewer, larger shared leaves (erc.3).
Each shareable code in ``rooms`` (grain from :func:`_share_grain`) is grouped
into runs of up to its grain one leaf per run carrying that run's
multiplicity. Returns ``(reduced_codes, mult_plan)`` where ``reduced_codes``
is the new per-leaf code list (fewer entries) and ``mult_plan[code]`` lists
the multiplicities of that code's leaves (summing to the original count).
Circulation/outside and single-instance, non-sized, or opted-out codes are
untouched (multiplicity 1), so they cannot incur a missing fail under sharing.
"""
from collections import Counter
counts = Counter(rooms)
reduced: list[str] = []
plan: dict[str, list[int]] = {}
for code in counts:
c = counts[code]
grain = _share_grain(reqs.get(code) if reqs else None, share_factor)
if grain < 2 or c < 2:
mults = [1] * c
else:
mults, remaining = [], c
while remaining > 0:
m = min(grain, remaining)
mults.append(m)
remaining -= m
plan[code] = mults
reduced.extend([code] * len(mults))
return reduced, plan
def _leaf_mult_from_plan(lvl: dom.Node, plan: dict[str, list[int]]) -> dict:
"""Stamp each typed leaf with its share multiplicity from a ``_share_rooms``
plan and return a leafmultiplicity map for sizing.
Sets ``leaf.share = k`` and ``leaf.share_type = leaf.type`` (the explicit,
type-guarded multiplicity the fitness reads, §13.3) on shared leaves, and
returns ``{leaf: k}`` so ``_size_divisions_from_targets`` sizes them to
k×target. Bigger multiplicities go to whichever leaves already read largest,
so the proportional sizing pass has the least work to do. Defensive against a
leaf count that differs from the plan (assignment dropped/added a slot):
extra leaves stay multiplicity 1, surplus plan entries are ignored."""
from . import geometry
by_code: dict[str, list[dom.Node]] = {}
for lf in lvl.leaves():
if lf.type:
by_code.setdefault(lf.type, []).append(lf)
leaf_mult: dict = {}
for code, mults in plan.items():
leaves = sorted(by_code.get(code, []), key=geometry.area, reverse=True)
for lf, m in zip(leaves, sorted(mults, reverse=True)):
if m > 1:
leaf_mult[lf] = m
lf.share = m
lf.share_type = lf.type
return leaf_mult
def _colocate_rooms(rooms: list[str], colocate_pairs,
rng: np.random.Generator) -> tuple[list[str], dict[str, list[str]]]:
"""Fuse single instances of DIFFERENT compatible codes onto one leaf
(homemaker-py-1s3, §26 path b) the same structural lever as
``_share_rooms`` (fewer, larger leaves), extended from *same*-code
multiplicity to *different*-but-compatible codes.
For each valid declared pair (``programme.derive_colocate_pairs``, order
shuffled via ``rng``), pair up ``n = min(available a, available b)``
instances: one code is chosen (via ``rng``, once per pair NOT
re-rolled per instance, which would let a code flip between primary and
secondary roles across iterations and over-pair beyond what's actually
available) as the "secondary" and fully removed (``n`` instances), the
other (the "primary") keeps its slot in ``rooms`` unchanged and gets ``n``
entries in ``plan``. A primary code may accumulate several secondaries
from different declared pairs; ``_leaf_colocate_from_plan`` matches each
to a distinct physical leaf. Codes with no available partner are
untouched.
"""
from collections import Counter
counts = Counter(rooms)
plan: dict[str, list[str]] = {}
pairs = list(colocate_pairs)
order = [pairs[i] for i in rng.permutation(len(pairs))] if pairs else []
for pair in order:
a, b = sorted(pair)
n = min(counts[a], counts[b])
if n <= 0:
continue
primary, secondary = (a, b) if rng.integers(2) == 0 else (b, a)
counts[secondary] -= n
plan.setdefault(primary, []).extend([secondary] * n)
reduced: list[str] = []
for code, c in counts.items():
reduced.extend([code] * c)
return reduced, plan
def _leaf_colocate_from_plan(lvl: dom.Node, plan: dict[str, list[str]], reqs) -> dict:
"""Stamp each plan's secondary codes onto that primary code's typed
leaves (mirrors ``_leaf_mult_from_plan``) and return a leafco_type map
for sizing. Secondaries are matched biggest-target-first to biggest-area
leaves first, same descending-to-descending heuristic as leaf-sharing."""
from . import geometry
by_code: dict[str, list[dom.Node]] = {}
for lf in lvl.leaves():
if lf.type:
by_code.setdefault(lf.type, []).append(lf)
leaf_co: dict = {}
for code, secondaries in plan.items():
leaves = sorted(by_code.get(code, []), key=geometry.area, reverse=True)
secs = sorted(secondaries, key=lambda c: reqs[c].size if c in reqs else 0.0,
reverse=True)
for lf, co in zip(leaves, secs):
lf.co_type = co
leaf_co[lf] = co
return leaf_co
def _size_divisions_from_targets(lvl: dom.Node, reqs, fmin: float = 0.04,
fmax: float = 0.96, leaf_mult: dict | None = None,
leaf_extra: dict | None = None) -> None:
"""Resize each divided node's split ratio from its leaves' TARGET areas.
leu.2 (DESIGN.md §12.2, follow-up to §11.6/§11.7): the constructive seeders
grow geometry with uniform ``[0.5, 0.5]`` cuts *before* types are assigned, so
the raw seed is "more, smaller leaves" of equal area rooms with a large
programme target come out too small, small rooms too big, and the inner loop
must recover all of size/width/proportion from scratch. Once types are known,
every leaf carries a target area (a sized room's ``size``; circulation/outside
absorb the slack), and because ``division=[f, f]`` cuts off left area-fraction
``f`` (rotation-independent), bottom-up target sums compose multiplicatively to
give every leaf area its target.
Area alone is not enough: choosing only the cut *fraction* to hit a target
*area* slices thin slivers with terrible aspect (proportion/width/edge-too-long
fails swamp the size gain measured, §12.2). So each cut also picks the
**rotation** (the two distinct cut directions) that makes its two children
squarest. Rotation depends on the realised parent geometry, so the pass runs
*top-down*; both the ratio and the rotation derive from the target dims, and
neither touches topology or type assignment (§11.6/§11.7 placement is intact).
Generic (non-sized) leaves get a nominal target: the per-leaf share of the
plot slack, floored at ``0.4 ×`` mean room target so a circulation leaf never
shrinks to a sub-door-width sliver (which would undo the §11.6 adjacency win).
"""
from . import geometry
reqs = reqs or {}
geometry.clear_cache()
leaves = lvl.leaves()
if len(leaves) < 2:
return
leaf_mult = leaf_mult or {}
leaf_extra = leaf_extra or {}
sized = {lf: reqs[lf.type].size * leaf_mult.get(lf, 1) + leaf_extra.get(lf, 0.0)
for lf in leaves if lf.type in reqs and reqs[lf.type].size > 0}
mean_sized = (sum(sized.values()) / len(sized)) if sized else 1.0
n_generic = len(leaves) - len(sized)
slack = geometry.area(lvl) - sum(sized.values())
floor = 0.4 * mean_sized # keep circulation/outside above door-width scale
generic_t = max(floor, slack / n_generic) if n_generic else floor
target = {lf: sized.get(lf, generic_t) for lf in leaves}
def _subtree_target(n: dom.Node) -> float:
if not n.divided:
return max(target.get(n, floor), 1e-6)
return _subtree_target(n.left) + _subtree_target(n.right)
def _rec(n: dom.Node) -> None:
if not n.divided:
return
left = _subtree_target(n.left)
f = min(max(left / (left + _subtree_target(n.right)), fmin), fmax)
# Pick the cut direction (rotation 0 vs 1; 2/3 mirror these for aspect)
# that makes the worse child squarest, given this node's settled geometry.
best_rot, best_aspect = n.rotation, None
for rot in (0, 1):
n.rotation = rot
n.division = [f, f]
geometry.clear_cache()
worst = max(geometry.aspect(n.left), geometry.aspect(n.right))
if best_aspect is None or worst < best_aspect:
best_aspect, best_rot = worst, rot
n.rotation = best_rot
n.division = [f, f]
geometry.clear_cache()
_rec(n.left)
_rec(n.right)
_rec(lvl)
geometry.clear_cache()
def _grow_balanced(node: dom.Node, code: str, k: int) -> None:
"""Turn ``node`` (a leaf) into a balanced binary subtree of ``k`` leaves, all
typed ``code``. Split ratio/rotation are placeholders ([0.5,0.5], rot 0);
``_size_subtree_equal`` settles them from realised geometry afterwards."""
if k < 2:
node.type = code
node.share = 1
node.share_type = None
return
kl = k // 2
node.type = None
node.share = 1
node.share_type = None
node.division = [0.5, 0.5]
node.rotation = 0
node.left = dom.Node()
node.right = dom.Node()
_grow_balanced(node.left, code, kl)
_grow_balanced(node.right, code, k - kl)
def _size_subtree_equal(node: dom.Node) -> None:
"""Size a freshly-grown balanced subtree (all leaves equal target) so each
cut splits by leaf-count fraction and picks the rotation that makes its
children squarest, given the subtree root's *settled* outer geometry. Unlike
``_size_divisions_from_targets`` this touches only ``node``'s subtree, so the
unfold leaves every sibling leaf's evolved geometry untouched."""
from . import geometry
def _nleaves(n: dom.Node) -> int:
return 1 if not n.divided else _nleaves(n.left) + _nleaves(n.right)
def _rec(n: dom.Node) -> None:
if not n.divided:
return
nl, nr = _nleaves(n.left), _nleaves(n.right)
f = nl / (nl + nr)
best_rot, best_aspect = n.rotation, None
for rot in (0, 1):
n.rotation = rot
n.division = [f, f]
geometry.clear_cache()
worst = max(geometry.aspect(n.left), geometry.aspect(n.right))
if best_aspect is None or worst < best_aspect:
best_aspect, best_rot = worst, rot
n.rotation = best_rot
n.division = [f, f]
geometry.clear_cache()
_rec(n.left)
_rec(n.right)
_rec(node)
kpu: Schedule B in-run leaf-share grain annealing (search_annealed) Ramp the leaf-share grain down within one continuous run (e.g. 4->3->2->off), carrying the whole population across each step — graduated non-convexity over the single hard sharing->off transition of the §15 finish. - operators.unfold_shared_leaves(above=cap): unfold only leaves whose share exceeds the new grain cap, leaving smaller-share leaves collapsed for the next step. above=1 (default) keeps the full-unfold §15 behaviour. - driver: max_share override threaded through _overrides_for/_fitness_for/ _evaluate so a phase can rebuild the evaluator at a lower leaf_share_max cap; search(seed_pop=) evaluates an explicit initial population so a phase hands its whole population to the next instead of restarting from a single best. - driver.search_annealed: one phase per descending grain then a de-share polish; unfold-above-cap between steps; cumulative accounting + grain-tagged history; honest canonical best (byte-for-byte verified vs homemaker-fitness). - evolve: --anneal-grain LADDER CLI (self-finishing; §15 finish not applied). 8iv settled the primitive (grid unfold beat the circulation-aware slice), so the ramp reuses the plain balanced-grid unfold at every step. Tests: unfold above-cap selectivity, seed_pop seeding, search_annealed phase stitching / honest finish / degenerate-ladder fallback. 258 pass. DESIGN §16. Head-to-head A/B on harbor-house still to run; verdict pending (issue open). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-16 08:38:08 +01:00
def unfold_shared_leaves(root: dom.Node, above: int = 1) -> int:
"""Materialise every live shared leaf into ``k`` distinct sibling leaves.
homemaker-py-yaa: at the sharingno-sharing phase change a leaf carrying
``share=k`` credits k same-code programme rooms but is a single physical
space, so a de-shared genome starts k1 rooms short per shared leaf a deep
'missing required space (critical)' hole that place_missing/divide dig out of
only slowly (measured: naive warm-start from a sharing seed stalled ~60× worse
than the direct no-sharing route). This replaces each live shared leaf
(``share>1`` and ``share_type==type``) with a balanced binary subtree of k
leaves of the SAME code, splitting the footprint into k equal-target children,
then sizes each new subtree for squarest proportions. Share stamps are
cleared and topology (adjacency skeleton) is otherwise preserved. Returns the
kpu: Schedule B in-run leaf-share grain annealing (search_annealed) Ramp the leaf-share grain down within one continuous run (e.g. 4->3->2->off), carrying the whole population across each step — graduated non-convexity over the single hard sharing->off transition of the §15 finish. - operators.unfold_shared_leaves(above=cap): unfold only leaves whose share exceeds the new grain cap, leaving smaller-share leaves collapsed for the next step. above=1 (default) keeps the full-unfold §15 behaviour. - driver: max_share override threaded through _overrides_for/_fitness_for/ _evaluate so a phase can rebuild the evaluator at a lower leaf_share_max cap; search(seed_pop=) evaluates an explicit initial population so a phase hands its whole population to the next instead of restarting from a single best. - driver.search_annealed: one phase per descending grain then a de-share polish; unfold-above-cap between steps; cumulative accounting + grain-tagged history; honest canonical best (byte-for-byte verified vs homemaker-fitness). - evolve: --anneal-grain LADDER CLI (self-finishing; §15 finish not applied). 8iv settled the primitive (grid unfold beat the circulation-aware slice), so the ramp reuses the plain balanced-grid unfold at every step. Tests: unfold above-cap selectivity, seed_pop seeding, search_annealed phase stitching / honest finish / degenerate-ladder fallback. 258 pass. DESIGN §16. Head-to-head A/B on harbor-house still to run; verdict pending (issue open). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-16 08:38:08 +01:00
number of extra leaves created (materialisation deficit paid down).
``above`` (homemaker-py-kpu, Schedule B grain ramp): unfold only leaves whose
``share`` *exceeds* this grain cap, leaving smaller-share leaves collapsed for
the next (lower) grain. ``above=1`` (default) unfolds every shared leaf, the
full materialisation the single-transition finish (§15) uses. At grain step
``g``, ``above=g`` materialises exactly the leaves the new evaluator cap
(``leaf_share_max=g``) would under-credit, so no fresh missing fail appears."""
from . import geometry
grown: list[dom.Node] = []
created = 0
for lvl in dom.levels(root):
for leaf in lvl.leaves():
kpu: Schedule B in-run leaf-share grain annealing (search_annealed) Ramp the leaf-share grain down within one continuous run (e.g. 4->3->2->off), carrying the whole population across each step — graduated non-convexity over the single hard sharing->off transition of the §15 finish. - operators.unfold_shared_leaves(above=cap): unfold only leaves whose share exceeds the new grain cap, leaving smaller-share leaves collapsed for the next step. above=1 (default) keeps the full-unfold §15 behaviour. - driver: max_share override threaded through _overrides_for/_fitness_for/ _evaluate so a phase can rebuild the evaluator at a lower leaf_share_max cap; search(seed_pop=) evaluates an explicit initial population so a phase hands its whole population to the next instead of restarting from a single best. - driver.search_annealed: one phase per descending grain then a de-share polish; unfold-above-cap between steps; cumulative accounting + grain-tagged history; honest canonical best (byte-for-byte verified vs homemaker-fitness). - evolve: --anneal-grain LADDER CLI (self-finishing; §15 finish not applied). 8iv settled the primitive (grid unfold beat the circulation-aware slice), so the ramp reuses the plain balanced-grid unfold at every step. Tests: unfold above-cap selectivity, seed_pop seeding, search_annealed phase stitching / honest finish / degenerate-ladder fallback. 258 pass. DESIGN §16. Head-to-head A/B on harbor-house still to run; verdict pending (issue open). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
2026-07-16 08:38:08 +01:00
if leaf.share > above and leaf.share_type == leaf.type and leaf.type:
created += leaf.share - 1
_grow_balanced(leaf, leaf.type, leaf.share)
grown.append(leaf)
if grown:
dom._link(root)
geometry.clear_cache()
for sub in grown:
_size_subtree_equal(sub)
geometry.clear_cache()
return created
def _ext_exposure(leaf: dom.Node) -> int:
"""Number of the leaf's four edges that lie on the external plot perimeter
('a'/'b'/'c'/'d'); 0 means a fully landlocked (interior) leaf. Used by the
interior-``O`` light-well placement (ld2, §13.6) to find the most landlocked
leaves those whose room neighbours have no facade and so would otherwise
fail crinkliness (``area_outside`` ~ 0)."""
from . import geometry
return sum(1 for e in range(4)
if geometry.boundary_id(leaf, e) in geometry._EXTERNAL)
def _assign_adjacency_aware(lvl: dom.Node, room_codes: list[str], reqs,
rng: np.random.Generator, door_width: float = 1.2,
fixed_circ: "list[dom.Node] | None" = None,
interior_outside: bool = False,
n_outside: int = 1,
scope: "set[dom.Node] | None" = None,
beam_width: int = 1) -> None:
"""Assign leaf types so rooms cluster around a connected circulation spine.
s44 (DESIGN.md §11.2 follow-up): random type assignment leaves rooms stranded
from circulation, so adjacency-to-``c`` and access ("inaccessible usable
space") fails dominate the seeded design. Here the leftover (non-room,
non-outside) leaf budget is spent on a **connected dominating set** of the
geometric leaf-adjacency graph: every room leaf ends up adjacent to a
circulation leaf, and the circulation set is connected, so access is
satisfied by construction at the seed geometry. Rooms are placed on dominated
leaves; one peripheral leaf becomes the outside ``O``.
``fixed_circ`` (ld5, §11.7): leaves that must stay circulation and seed the
dominating set the inherited vertical core when lifting upper storeys, so
the spine grows *off the core* rather than from scratch. Rooms with a
secondary adjacency requirement (beyond ``c``, e.g. ``k1da1``, ``da1o``)
are then placed next to an already-typed neighbour of the required code.
``lvl`` already has the right number of leaves grown; their types are
(re)written in place. Stochastic where it is free (room order, tie-breaks) so
a bootstrap batch stays diverse.
``scope`` (homemaker-py-f1d): restrict retyping to this subset of ``lvl``'s
leaves used by the ruin-and-recreate LNS move to rebuild one wing of an
already-typed storey in place. ``fixed_circ`` may then name leaves OUTSIDE
``scope`` (the surviving circulation bordering the wing) purely as
dominating-set seeds; they anchor the spine but are never retyped, and the
dominating-set growth and room/outside placement only ever touch ``scope``.
``None`` (default) reproduces the unrestricted whole-``lvl`` behaviour
exactly every existing caller is unaffected.
``beam_width`` (homemaker-py-c94, EXPERIMENTAL, default 1): the circulation
spine and outside leaves are always placed by the single greedy pass above
(unchanged, no beam). ``beam_width=1`` also keeps the *room* placement pass
below exactly the prior one-shot greedy walk (byte-identical output for
every existing caller). ``beam_width>1`` instead explores the same
room-to-slot decisions with :func:`_beam_place_rooms`: several partial
placements are kept alive and ranked by a cheap proxy (running total
secondary-adjacency satisfaction no extra geometry or fitness calls,
since circulation/outside are already fixed and shared across every beam
branch), so a room whose best slot is later needed by a harder-to-place
room is no longer locked in by one irrevocable greedy step.
"""
from . import geometry
reqs = reqs or {}
leaves = lvl.leaves()
idx = {leaf: i for i, leaf in enumerate(leaves)}
assignable = scope if scope is not None else set(leaves)
n = len(assignable)
R = len(room_codes)
n_circ = max(1, n - (R + max(1, n_outside))) # leftover after rooms + outside
seeds = [c for c in (fixed_circ or []) if c in idx]
n_circ = max(n_circ, len(seeds)) # never fewer circ leaves than the fixed core
# Geometry is type-independent (coords derive from divisions/rotations/plot);
# clear the id-keyed cache so freshly grown leaves never hit stale entries.
geometry.clear_cache()
G = geometry.leaf_graph(lvl, door_width)
deg = dict(G.degree())
def _nbrs(leaf):
return set(G.neighbors(leaf)) if G.has_node(leaf) else set()
# Greedy connected dominating set of size n_circ: seed from the fixed core (or
# the most central leaf), then repeatedly add the frontier leaf that newly
# dominates the most leaves (keeping the set connected). Growth is confined to
# ``assignable`` so a scoped call never annexes a leaf outside the wing.
circ = (set(seeds) if seeds
else {max(assignable, key=lambda L: (deg.get(L, 0), -idx[L]))})
dominated = set().union(*( _nbrs(s) | {s} for s in circ))
while len(circ) < n_circ:
frontier = ((set().union(*(_nbrs(s) for s in circ)) - circ) & assignable
if circ else set())
if frontier:
pick = max(frontier, key=lambda L: (len(_nbrs(L) - dominated),
deg.get(L, 0), -idx[L]))
else: # disconnected remainder — seed a new component by degree
rest = [L for L in assignable if L not in circ]
if not rest:
break
pick = max(rest, key=lambda L: (deg.get(L, 0), -idx[L]))
circ.add(pick)
dominated |= _nbrs(pick) | {pick}
for s in circ:
if s in assignable: # never retype a fixed_circ seed outside scope
s.type = "C"
noncirc = [L for L in assignable if L not in circ]
if interior_outside:
# ld2 (§13.6): seed ``O`` as INTERIOR light wells instead of one
# peripheral leaf. A landlocked room (no plot facade, no uncovered-O
# neighbour) has area_outside ~ 0 → crinkliness ~ 0 → fail (the erc
# crinkliness residual). Placing the outside leaves on the most
# landlocked slots (fewest external edges, then highest degree = most
# room neighbours to illuminate) gives those rooms a daylight source by
# construction. Wells are spread greedily so each covers a fresh set of
# rooms rather than clustering on one over-lit pocket.
o_leaves: list[dom.Node] = []
covered: set = set()
cands = list(noncirc)
for _ in range(max(1, n_outside)):
if not cands:
break
pick = max(cands, key=lambda L: (-_ext_exposure(L),
len(_nbrs(L) - covered),
deg.get(L, 0), -idx[L]))
o_leaves.append(pick)
cands.remove(pick)
covered |= _nbrs(pick) | {pick}
for L in o_leaves:
L.type = "O"
else:
# Outside on the most peripheral non-circulation leaf (fewest circulation
# neighbours, then lowest degree) so it does not steal a circulation-
# adjacent slot a room needs.
o_leaf = min(noncirc, key=lambda L: (sum(1 for nb in _nbrs(L) if nb in circ),
deg.get(L, 0), idx[L]))
o_leaf.type = "O"
o_leaves = [o_leaf]
# Rooms onto the remaining leaves, dominated (circulation-adjacent) slots
# first so adjacency-to-c holds. Codes are placed hardest-constrained first
# (most adjacency requirements), each onto the open slot that satisfies the
# most of its requirements against already-typed neighbours (circulation and
# rooms placed so far) — clustering k1↔da1, da1↔o, etc. Ties broken randomly.
o_set = set(o_leaves)
room_slots = [L for L in noncirc if L not in o_set]
codes = [room_codes[i] for i in rng.permutation(len(room_codes))]
def _n_secondary(code: str) -> int:
r = reqs.get(code)
return len([a for a in (r.adjacency if r else []) if a and a[0].lower() != "c"])
codes.sort(key=_n_secondary, reverse=True)
if beam_width <= 1:
open_slots = sorted(room_slots,
key=lambda L: (L in dominated, deg.get(L, 0), -idx[L]),
reverse=True)
for code in codes:
if not open_slots:
break
req_adj = [a[0].lower() for a in (reqs.get(code).adjacency if reqs.get(code) else [])]
secondary = [a for a in req_adj if a != "c"]
def _sat(slot, secondary=secondary) -> int:
nb_types = {(nb.type or "")[:1].lower() for nb in _nbrs(slot) if nb.type}
return sum(1 for a in secondary if a in nb_types)
best = max(open_slots, key=lambda L: (_sat(L), L in dominated,
deg.get(L, 0), -idx[L]))
best.type = code
open_slots.remove(best)
placed, leftover = None, open_slots
else:
placed = _beam_place_rooms(codes, room_slots, dominated, deg, idx, _nbrs,
reqs, beam_width)
for leaf, code in placed.items():
leaf.type = code
leftover = [L for L in room_slots if L not in placed]
for leaf in leftover: # any leftover slot (count mismatch) → outside
leaf.type = "O"
def _beam_place_rooms(codes: list[str], slots: list, dominated: set,
deg: dict, idx: dict, _nbrs, reqs,
beam_width: int) -> dict:
"""Beam/best-first search over room-to-slot placement (homemaker-py-c94).
Same decision ``_assign_adjacency_aware`` makes greedily (which open slot a
room lands on, hardest-constrained code first) but keeps up to
``beam_width`` partial placements alive per step instead of committing to
one. Each step branches every surviving state into its top ``beam_width``
candidate slots for the current code (by the same ``_sat``/dominated/
degree ranking the greedy pass uses), scores each branch by the running
total of secondary-adjacency matches satisfied so far cheap: no
geometry or fitness calls, since circulation/outside are already fixed and
the leaf graph is shared read-only across every branch then prunes back
to the ``beam_width`` best-scoring states before the next code. Returns
the highest-scoring complete placement as ``{leaf: code}``.
"""
def secondary_of(code: str) -> list[str]:
r = reqs.get(code)
return [a[0].lower() for a in (r.adjacency if r else []) if a and a[0].lower() != "c"]
def sat(slot, assign: dict, secondary: list[str]) -> int:
nb_types = set()
for nb in _nbrs(slot):
t = assign.get(nb, nb.type)
if t:
nb_types.add(t[:1].lower())
return sum(1 for a in secondary if a in nb_types)
beam: list[tuple[int, dict]] = [(0, {})]
for code in codes:
secondary = secondary_of(code)
candidates = []
for score, assign in beam:
open_slots = [L for L in slots if L not in assign]
if not open_slots:
candidates.append((score, assign))
continue
ranked = sorted(
open_slots,
key=lambda L: (sat(L, assign, secondary), L in dominated,
deg.get(L, 0), -idx[L]),
reverse=True)
for slot in ranked[:beam_width]:
new_assign = dict(assign)
new_assign[slot] = code
candidates.append((score + sat(slot, assign, secondary), new_assign))
candidates.sort(key=lambda c: c[0], reverse=True)
beam = candidates[:beam_width]
return max(beam, key=lambda c: c[0])[1] if beam else {}
def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
types: list[str], min_storeys: int = 1,
adjacency_aware: bool = True,
proportion_aware: bool = True,
circ_divisor: int = 3,
leaf_sharing: bool = False,
leaf_share_factor: int = 2,
depth_balanced: bool = False,
interior_outside: bool = True,
outside_divisor: int = 3,
construction_beam_width: int = 1,
multi_use: bool = False) -> dom.Node:
"""Build a seed that instantiates every required space by construction.
The §11.0 diagnosis: random divide+retype chains leave required programme
rooms missing on large programmes, so ``missing`` stacking dominates fitness.
This seeder makes the required room set a *constructive invariant*: it sizes
each storey to its required rooms (partitioning by ``level``; level-free
rooms distributed across storeys), plus one circulation ``C`` and one
outside ``O`` per storey, then assigns the types. Stochastic (random split
ratios/rotations and a shuffled type assignment) so a bootstrap batch is
still a diverse population.
``construction_beam_width`` (homemaker-py-c94, EXPERIMENTAL, default 1):
forwarded to ``_assign_adjacency_aware``'s ``beam_width`` — see there.
``1`` reproduces the prior greedy room placement exactly.
Returns a finalised deep copy; ``seed_root`` is unchanged.
"""
from . import genome as _g
from . import programme as _prog
child = copy.deepcopy(seed_root)
prog = _programme_codes(reqs)
levels_needed = [r.level for r in prog.values() if r.level is not None]
n_storeys = max((max(levels_needed) + 1) if levels_needed else 1, min_storeys)
colocate_pairs = _prog.derive_colocate_pairs(reqs) if multi_use else []
# grow storeys from the bare base by duplicating the top storey (cf.
# mutate_level_add / genome._copy_storey), inheriting floor height.
while len(dom.levels(child)) < n_storeys:
top = dom.levels(child)[-1]
dup = _g._copy_storey(top)
dup.height = top.height
top.above = dup
lvls = dom.levels(child)
# Partition required instances across storeys: level-constrained rooms to
# their storey, level-free rooms round-robin over a shuffled order.
buckets: list[list[str]] = [[] for _ in range(n_storeys)]
free: list[str] = []
for code, req in prog.items():
for _ in range(req.count):
if req.level is not None and req.level < n_storeys:
buckets[req.level].append(code)
else:
free.append(code)
free = [free[i] for i in rng.permutation(len(free))]
for i, code in enumerate(free):
buckets[i % n_storeys].append(code)
for li, lvl in enumerate(lvls):
rooms = list(buckets[li])
# erc.3 leaf-sharing (§13.3): collapse same-code rooms into fewer, larger
# shared leaves BEFORE growing the tree, so the storey carries fewer total
# leaves (each paying the ~1.8 shape-fail tax once, §13.1). The fitness
# recovers each leaf's multiplicity from area; here we only reduce the
# code list and remember the plan to size shared leaves to k×target.
# 1s3 §26 path b: fuse different-but-compatible codes onto one leaf
# BEFORE leaf-sharing, so leaf-sharing still groups whichever code
# was kept as primary with its remaining same-code siblings.
colocate_plan: dict[str, list[str]] = {}
if multi_use:
rooms, colocate_plan = _colocate_rooms(rooms, colocate_pairs, rng)
share_plan: dict[str, list[int]] = {}
if leaf_sharing:
rooms, share_plan = _share_rooms(rooms, reqs, leaf_share_factor)
if adjacency_aware:
# Spend extra leaves on a circulation spine (~one circ per 3 rooms),
# then assign so every room is adjacent to it (s44). Geometry must be
# available to read the leaf-adjacency graph; _grow_leaves leaves the
# tree finalisable and geometry.leaf_graph derives coords on demand.
# c3g granularity knob: ~one circ per `circ_divisor` rooms (default 3).
n_circ = max(1, -(-len(rooms) // circ_divisor))
# ld2 (§13.6): scale the outside-leaf count with the room count when
# seeding interior light wells (default 1 peripheral O otherwise).
n_o = max(1, round(len(rooms) / outside_divisor)) if interior_outside else 1
_grow_leaves(lvl, len(rooms) + n_o + n_circ, rng, balance=depth_balanced)
dom._link(child)
_assign_adjacency_aware(lvl, rooms, reqs, rng,
interior_outside=interior_outside, n_outside=n_o,
beam_width=construction_beam_width)
else:
assign = rooms + ["C", "O"] # +core circulation, +outside
_grow_leaves(lvl, len(assign), rng, balance=depth_balanced)
leaves = lvl.leaves()
order = rng.permutation(len(leaves))
for slot, leaf_idx in enumerate(order):
leaves[int(leaf_idx)].type = assign[slot] if slot < len(assign) else "O"
if proportion_aware:
# leu.2: now that leaves are typed, replace the uniform 0.5 cuts with
# target-proportional ratios so the raw seed sits near feasible size/
# width/proportion. Topology and type assignment are unchanged. Link
# first so upper-storey roots resolve geometry (the else branch above
# does not link, unlike the adjacency-aware branch).
dom._link(child)
leaf_co = _leaf_colocate_from_plan(lvl, colocate_plan, reqs) if multi_use else {}
leaf_extra = {lf: reqs[co].size for lf, co in leaf_co.items()
if co in reqs and reqs[co].size > 0}
_size_divisions_from_targets(
lvl, reqs, leaf_mult=_leaf_mult_from_plan(lvl, share_plan),
leaf_extra=leaf_extra)
return _finalise(child)
def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]],
rng: np.random.Generator, types: list[str],
reqs=None, adjacency_aware: bool = True,
proportion_aware: bool = True,
circ_divisor: int = 3,
leaf_sharing: bool = False,
leaf_share_factor: int = 2,
depth_balanced: bool = False,
interior_outside: bool = True,
outside_divisor: int = 3,
construction_beam_width: int = 1,
multi_use: bool = False) -> dom.Node:
"""Stack upper storeys onto an evolved single-storey base (DESIGN.md §11.3).
Stage 2 seeder: the Stage-1 base is the credible ground floor and is left
**untouched**; each upper storey is constructed as a delta that (a) inherits
and preserves the base's largest circulation ``C`` leaf as a vertically-aligned
core (so Stage 2 does not carve a core from scratch the anti-bungalow
invariant) and (b) instantiates its required room multiset (``upper_buckets``,
one dict per storey >= 1) by construction, plus one outside ``O``. Stochastic
splits/assignment keep a bootstrap batch diverse; ``mutate_place_missing``
repairs any residual gaps during the loop.
Returns a finalised deep copy; ``base_root`` is unchanged.
"""
from . import genome as _g, geometry as _geo
from . import programme as _prog
child = copy.deepcopy(base_root)
base = dom.levels(child)[0]
base.above = None # start from the single-storey base only
base_cs = [lf for lf in base.leaves()
if lf.type and lf.type[0].lower() == "c"]
core_path = max(base_cs, key=_geo.area).id if base_cs else None
colocate_pairs = _prog.derive_colocate_pairs(reqs) if multi_use and reqs else []
prev = base
for bucket in upper_buckets:
dup = _g._copy_storey(prev)
dup.height = prev.height
core_node = dup.by_id(core_path) if core_path is not None else None
rooms = [code for code, cnt in bucket.items() for _ in range(cnt)]
# 1s3: fuse different-but-compatible codes onto one leaf on this storey
# too, before leaf-sharing (same ordering as constructive_topology).
colocate_plan: dict[str, list[str]] = {}
if multi_use:
rooms, colocate_plan = _colocate_rooms(rooms, colocate_pairs, rng)
# erc.3: collapse same-code rooms into fewer shared leaves on this storey
# too (§13.3), so upper floors get the same per-leaf-tax saving.
share_plan: dict[str, list[int]] = {}
if leaf_sharing:
rooms, share_plan = _share_rooms(rooms, reqs, leaf_share_factor)
def _free() -> list[dom.Node]:
return [lf for lf in dup.leaves() if lf is not core_node]
if adjacency_aware:
# ld5 (§11.7): grow the upper floor a circulation spine (~one circ per
# 3 rooms, the inherited core counted) and assign rooms around it via
# the geometric leaf graph, seeding the dominating set from the
# inherited vertical core so the spine grows off the core, not anew.
n_circ = max(1, -(-len(rooms) // circ_divisor)) # c3g granularity knob
# ld2 (§13.6): scale interior light-well count with room count.
n_o = max(1, round(len(rooms) / outside_divisor)) if interior_outside else 1
target_total = len(rooms) + n_o + n_circ
n_free_target = target_total - (1 if core_node is not None else 0)
while len(_free()) < n_free_target:
frees = _free()
if depth_balanced:
fd = [(l, d) for l, d in _leaves_with_depth(dup) if l in frees]
dmin = min(d for _l, d in fd)
leaf = _pick(rng, [l for l, d in fd if d == dmin])
else:
leaf = _pick(rng, frees)
leaf.division = [0.5, 0.5]
leaf.rotation = int(rng.integers(4))
leaf.left = dom.Node(type=leaf.type)
leaf.right = dom.Node(type=leaf.type)
leaf.type = None
prev.above = dup
dom._link(child) # link so the upper storey's geometry is computable
_assign_adjacency_aware(
dup, rooms, reqs, rng,
fixed_circ=[core_node] if core_node is not None else None,
interior_outside=interior_outside, n_outside=n_o,
beam_width=construction_beam_width)
else:
assign = rooms + ["O"] # courtyard / outside on the upper floor
if core_node is None:
assign.append("C") # no inherited core to reuse — make one
while len(_free()) < len(assign):
frees = _free()
if depth_balanced:
fd = [(l, d) for l, d in _leaves_with_depth(dup) if l in frees]
dmin = min(d for _l, d in fd)
leaf = _pick(rng, [l for l, d in fd if d == dmin])
else:
leaf = _pick(rng, frees)
leaf.division = [0.5, 0.5]
leaf.rotation = int(rng.integers(4))
leaf.left = dom.Node(type=leaf.type)
leaf.right = dom.Node(type=leaf.type)
leaf.type = None
frees = _free()
order = rng.permutation(len(frees))
for slot, leaf_idx in enumerate(order):
frees[int(leaf_idx)].type = assign[slot] if slot < len(assign) else "O"
if core_node is not None:
core_node.type = "C" # keep the inherited core as circulation
prev.above = dup
if proportion_aware:
# leu.2: size the upper-floor cuts from target areas too. The base is
# the evolved Stage-1 ground floor and is left untouched; only the
# constructed upper storey's ratios are rewritten. (Cuts inherited from
# the base via below-links are no-ops here — their geometry is fixed
# below — so this best-effort sizes the floor's own new divisions.)
dom._link(child)
leaf_co = _leaf_colocate_from_plan(dup, colocate_plan, reqs) if multi_use else {}
leaf_extra = {lf: reqs[co].size for lf, co in leaf_co.items()
if co in reqs and reqs[co].size > 0}
_size_divisions_from_targets(
dup, reqs, leaf_mult=_leaf_mult_from_plan(dup, share_plan),
leaf_extra=leaf_extra)
prev = dup
return _finalise(child)
def mutate_ruin_recreate(root: dom.Node, rng: np.random.Generator,
types: list[str], reqs=None) -> tuple[dom.Node, str]:
"""LNS ruin-and-recreate: rebuild one wing of a storey with the constructor.
homemaker-py-f1d (DESIGN.md's experiment log): every "search machinery"
change tried so far (niching+restarts, graded objective, Wong-Liu
reassociation, granularity, island model, grain annealing, circulation-
repair ops) has come back null-to-negative, while construction/seeding
quality (adjacency-aware seeding, proportion-aware seeding) is the only
lever that has ever moved the fail count. ``_assign_adjacency_aware``
currently only runs once, at seeding. This move reuses it repeatedly
during search: pick a divided, live-cut subtree ("wing") of one storey
holding a genuine partial neighbourhood of that storey's leaves (at least
2, at most half), un-divide it back to a single leaf, then regrow and
retype it with the same adjacency-aware constructor the seeders use
seeded (``fixed_circ``) from whichever already-typed circulation leaves
border the wing, exactly the mechanism ``lift_base_to_storeys`` uses to
grow an upper storey off an inherited core (ld5, §11.7), so the rebuilt
interior spine reconnects to the surviving one instead of growing a
disconnected island.
The wing's programme room-code budget (the multiset of required-space
types already inside it) is preserved exactly; only its internal
circulation/outside counts and split are rebuilt, at the same
circ_divisor=3/outside_divisor=3 ratio the constructive seeders default
to (not threaded from the run config an experimental repair op, like
``bridge_circulation``, kept parameter-light).
"""
if not reqs:
return _finalise(copy.deepcopy(root)), "ruin_recreate noop"
from . import geometry
child = copy.deepcopy(root)
_finalise(child)
lvls = dom.levels(child)
totals = {li: len(lvl.leaves()) for li, lvl in enumerate(lvls)}
cands = [(li, n) for li, n in _owned_branches(child)
if totals[li] >= 4 and 2 <= len(n.leaves()) <= max(2, totals[li] // 2)]
if not cands:
return _finalise(child), "ruin_recreate noop"
li, wing = _pick(rng, cands)
lvl = lvls[li]
G = geometry.leaf_graph(lvl)
wing_leaves = set(wing.leaves())
border_circ = sorted(
{nb for lf in wing_leaves for nb in G.neighbors(lf)
if nb not in wing_leaves and nb.type and nb.type[0].lower() == "c"},
key=lambda n: n.id or "")
rooms = [lf.type for lf in wing.leaves() if lf.type in reqs]
n_circ_total = max(1, -(-len(rooms) // 3)) # circ_divisor=3
n_o = max(1, round(len(rooms) / 3)) # outside_divisor=3
n_new = len(rooms) + n_o + max(0, n_circ_total - len(border_circ))
wing.left = wing.right = None
wing.division = None
wing.type = None
_grow_leaves(wing, max(1, n_new), rng, balance=True)
dom._link(child)
_assign_adjacency_aware(
lvl, rooms, reqs, rng, fixed_circ=border_circ or None,
interior_outside=True, n_outside=n_o, scope=set(wing.leaves()))
dom._link(child)
_size_divisions_from_targets(wing, reqs)
return _finalise(child), (
f"ruin_recreate {li}/{wing.id or 'root'} "
f"({len(rooms)} rooms, {len(border_circ)} anchors)")
def mutate_reassociate(root: dom.Node, rng: np.random.Generator,
types: list[str]) -> tuple[dom.Node, str]:
"""Wong-Liu M3 associativity move: ``(a|b)|c <-> a|(b|c)`` on parallel cuts.
A pure-topology reachability move (homemaker-py-9gp.2, DESIGN.md §12.3). M1
(operand swap) is ``mutate_swap`` and M2 (single-cut orientation complement)
is ``mutate_rotate``; the missing canonical-slicing move is *associativity*
regrouping three regions split by two **same-orientation** cuts into the
mirror tree shape. It preserves the leaf set and types but reaches tree
structures the divide/undivide/swap/rotate set cannot, attacking the
reachability bottleneck §11.4/§11.5 both fingered.
Only **live** cuts are restructured (``below is None``, as ``mutate_rotate``),
so dead inherited fields are never touched and ``encode`` re-anchors any
upper-storey deltas (operators edit the phenotype; the genome re-derives).
The two restructured cuts default to ``[0.5, 0.5]`` and the inner loop
recovers their ratios (cold, cf. ``mutate_divide``'s new cut).
"""
child = copy.deepcopy(root)
# Candidate parents P with a same-orientation, live, divided child on a side.
cands: list[tuple[int, dom.Node, str]] = []
for li, P in _owned_branches(child):
if P.below is not None:
continue
for side in ("l", "r"):
kid = P.left if side == "l" else P.right
if (kid.divided and kid.below is None
and (kid.rotation % 2) == (P.rotation % 2)):
cands.append((li, P, side))
if not cands:
return _finalise(child), "reassociate noop"
li, P, side = _pick(rng, cands)
rot = P.rotation
if side == "l": # (a|b)|c -> a|(b|c)
a, b, c = P.left.left, P.left.right, P.right
inner = dom.Node(rotation=rot)
inner.division = [0.5, 0.5]
inner.left, inner.right = b, c
P.left, P.right = a, inner
else: # a|(b|c) -> (a|b)|c
a, b, c = P.left, P.right.left, P.right.right
inner = dom.Node(rotation=rot)
inner.division = [0.5, 0.5]
inner.left, inner.right = a, b
P.left, P.right = inner, c
P.division = [0.5, 0.5]
return _finalise(child), f"reassociate {li}/{P.id or 'root'}"
def predicted_shape_fails(root: dom.Node, reqs, fit) -> int:
"""Predicted per-leaf shape fails at the proportion-aware target geometry.
Shape-feasibility proxy (homemaker-py-9gp.1, DESIGN.md §12.3). Lays the
topology out with :func:`_size_divisions_from_targets` the squarest
target-proportional geometry the inner loop warm-starts from, i.e. the best
shape this topology can plausibly reach then counts the
size/width/proportion/crinkliness fails the native ``fit`` reports. Used to
prune clearly-infeasible topologies *before* the inner loop, so budget flows
to feasible ones. A heuristic lower-bound proxy, not a true bound; the caller
guards against pruning anything that could still beat the incumbent.
``root`` is left untouched (a deep copy is laid out and scored).
"""
child = copy.deepcopy(root)
dom._link(child)
for lvl in dom.levels(child):
_size_divisions_from_targets(lvl, reqs)
_, fails = fit.score_with_fails(child)
return sum(1 for f in fails if f.endswith(_SHAPE_FAIL_SUFFIXES))
_SHAPE_FAIL_SUFFIXES = (" size", " width", " proportion", " crinkliness")
def mutate_core_divide(root: dom.Node, rng: np.random.Generator,
types: list[str]) -> tuple[dom.Node, str]:
"""Divide a circulation leaf at the same path across ALL storeys at once.
Staircase cores (C leaves at the same path on 2+ consecutive floors) are
disrupted if a single-storey divide changes the C path on only one floor.
This operator applies the same rotation and division to every floor that
has a C leaf at the chosen path, maintaining staircase consistency as an
atomic invariant rather than a multi-step recovery task.
"""
child = copy.deepcopy(root)
lvls = dom.levels(child)
# Collect paths that are C leaves on 2+ floors
c_paths: dict[str, list[int]] = {}
for li, lvl in enumerate(lvls):
for lf in lvl.leaves():
if lf.type and lf.type.upper() == "C":
c_paths.setdefault(lf.id, []).append(li)
core_paths = [(path, lis) for path, lis in c_paths.items() if len(lis) >= 2]
if not core_paths:
return _finalise(child), "core_divide noop"
path, level_indices = _pick(rng, core_paths)
rotation = int(rng.integers(4))
division = [0.5, 0.5]
for li in level_indices:
node = lvls[li].by_id(path)
if node is None or node.divided:
continue
node.division = list(division)
node.rotation = rotation
node.left = dom.Node(type="C")
node.right = dom.Node(type=str(_pick(rng, types)))
node.type = None
return _finalise(child), f"core_divide {path} ({len(level_indices)} floors)"
def mutate_core_undivide(root: dom.Node, rng: np.random.Generator,
types: list[str]) -> tuple[dom.Node, str]:
"""Reverse of core_divide: merge a C sub-core back into a single C leaf on all floors.
Picks a C leaf (e.g. 'rll') whose parent is also a C leaf on 2+ floors,
then undivides the parent on every floor simultaneously, restoring the
larger staircase footprint without a temporary path-mismatch fail.
"""
child = copy.deepcopy(root)
lvls = dom.levels(child)
# Find divided nodes whose left child is C (candidate for core_undivide):
# the parent path must have C.left on 2+ floors.
parent_paths: dict[str, list[int]] = {}
for li, lvl in enumerate(lvls):
for n in [n for li2, n in _owned_branches(child) if li2 == li]:
if (n.left.type and n.left.type.upper() == "C"
and not n.left.divided and not n.right.divided):
parent_paths.setdefault(n.id or "", []).append(li)
core_parents = [(p, lis) for p, lis in parent_paths.items() if len(lis) >= 2]
if not core_parents:
return _finalise(child), "core_undivide noop"
path, level_indices = _pick(rng, core_parents)
for li in level_indices:
node = lvls[li].by_id(path)
if node is None or not node.divided:
continue
keep = [t for t in (node.left.type, node.right.type)
if t and t[0].lower() not in "cos"]
node.type = keep[0] if keep else (node.left.type or str(_pick(rng, types)))
node.division = None
node.left = node.right = None
return _finalise(child), f"core_undivide {path} ({len(level_indices)} floors)"
def mutate_level_retype(root: dom.Node, rng: np.random.Generator,
types: list[str]) -> tuple[dom.Node, str]:
"""Swap the types of two leaves on different storeys.
The cross-storey equivalent of mutate_retype; directly addresses
level-constraint failures (e.g. "l1 on wrong level") by moving a room
type from one floor to another without changing topology or geometry.
"""
child = copy.deepcopy(root)
lvls = dom.levels(child)
if len(lvls) < 2:
return _finalise(child), "level_retype noop"
all_lv = _leaves(child)
li_a, a = _pick(rng, all_lv)
other = [(li, lf) for li, lf in all_lv if li != li_a]
if not other:
return _finalise(child), "level_retype noop"
li_b, b = _pick(rng, other)
a.type, b.type = b.type, a.type
return _finalise(child), f"level_retype {li_a}/{a.id or 'root'}<->{li_b}/{b.id or 'root'}"
def mutate_level_add(root: dom.Node, rng: np.random.Generator,
types: list[str]) -> tuple[dom.Node, str]:
from . import genome as _g
child = copy.deepcopy(root)
top = dom.levels(child)[-1]
dup = _g._copy_storey(top)
dup.height = top.height
# Retype all named-room leaves to generic C/O so the new storey carries no
# duplicated programme rooms. The outer search retypes them incrementally.
generic = [t for t in types if t.upper() in ("C", "O")]
if not generic:
generic = ["C"]
for leaf in dup.leaves():
if leaf.type not in ("C", "O", None):
leaf.type = str(rng.choice(generic))
top.above = dup
return _finalise(child), f"level_add ({len(dom.levels(child))} storeys)"
def mutate_level_delete(root: dom.Node, rng: np.random.Generator,
types: list[str]) -> tuple[dom.Node, str]:
child = copy.deepcopy(root)
lvls = dom.levels(child)
if len(lvls) < 2:
return _finalise(child), "level_delete noop"
lvls[-2].above = None
return _finalise(child), f"level_delete ({len(lvls) - 1} storeys)"
MUTATIONS = {
"divide": mutate_divide,
"undivide": mutate_undivide,
"retype": mutate_retype,
"swap": mutate_swap,
"rotate": mutate_rotate,
"reassociate": mutate_reassociate,
"core_divide": mutate_core_divide,
"core_undivide": mutate_core_undivide,
"level_fix": mutate_level_fix,
"level_compound_fix": mutate_level_compound_fix,
"place_missing": mutate_place_missing,
"bridge_circulation": mutate_bridge_circulation,
"level_retype": mutate_level_retype,
"level_add": mutate_level_add,
"level_delete": mutate_level_delete,
"shape_rotate": mutate_shape_rotate,
"deslim": mutate_deslim,
"ruin_recreate": mutate_ruin_recreate,
}
# Exploratory ops that freely pick any leaf/branch; Stage 2 downweights the
# base storey for these via ``base_p`` (DESIGN.md §11.3). The repair op
# ``place_missing`` is deliberately excluded — a missing base room must still be
# repairable — as are the core_* ops, which exist to MAINTAIN the core.
_BASE_P_OPS = ("divide", "undivide", "retype", "swap", "rotate")
def mutate(root: dom.Node, rng: np.random.Generator, types: list[str],
weights: dict[str, float] | None = None,
reqs=None, base_p: float = 1.0, fit=None) -> tuple[dom.Node, str]:
"""Apply one random mutation drawn from MUTATIONS."""
names = sorted(MUTATIONS)
p = np.array([(weights or {}).get(n, 1.0) for n in names], dtype=float)
# these operators need programme reqs; disable them when not available
reqs_ops = ("level_fix", "level_compound_fix", "place_missing", "ruin_recreate")
# also takes reqs (to avoid displacing a required room) but works without
# it — never zero-weighted, unlike reqs_ops above
reqs_optional_ops = ("bridge_circulation",)
# these need a Fitness instance to identify genuinely shape-failing leaves
fit_ops = ("shape_rotate", "deslim")
if reqs is None:
for op in reqs_ops:
p[names.index(op)] = 0.0
if fit is None:
for op in fit_ops:
p[names.index(op)] = 0.0
if p.sum() == 0:
p[:] = 1.0
name = str(rng.choice(names, p=p / p.sum()))
if name in reqs_ops or name in reqs_optional_ops:
return MUTATIONS[name](root, rng, types, reqs=reqs)
if name in fit_ops:
return MUTATIONS[name](root, rng, types, fit=fit)
if name in _BASE_P_OPS:
return MUTATIONS[name](root, rng, types, base_p=base_p)
return MUTATIONS[name](root, rng, types)
# --------------------------------------------------------------------------- #
# Crossover
# --------------------------------------------------------------------------- #
def _graft(dst: dom.Node, src: dom.Node) -> None:
"""Replace dst's subtree content with a copy of src's (cf. Urb Crossover)."""
sub = copy.deepcopy(src)
dst.type = sub.type
dst.rotation = sub.rotation
dst.division = sub.division
dst.left, dst.right = sub.left, sub.right
def crossover(a: dom.Node, b: dom.Node,
rng: np.random.Generator) -> tuple[dom.Node, dom.Node, str]:
"""Area-matched base-storey subtree exchange (Urb Crossover.pm style):
pick a random subtree of A's base storey, find the area-closest third of
B's base subtrees, exchange. A subtree is a contiguous region, so this
recombines whole neighbourhoods; storeys above re-anchor via encode."""
from . import geometry
ca, cb = copy.deepcopy(a), copy.deepcopy(b)
_finalise(ca)
_finalise(cb)
base_a, base_b = dom.levels(ca)[0], dom.levels(cb)[0]
na = _pick(rng, _level_nodes(base_a))
by_area = sorted(_level_nodes(base_b),
key=lambda n: abs(geometry.area(n) - geometry.area(na)))
nb = by_area[int(rng.integers(max(1, len(by_area) // 3)))]
tmp = copy.deepcopy(na)
_graft(na, nb)
_graft(nb, tmp)
desc = f"crossover {na.id or 'root'}<->{nb.id or 'root'}"
return _finalise(ca), _finalise(cb), desc