195 lines
7.2 KiB
Python
195 lines
7.2 KiB
Python
|
|
"""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 _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]) -> tuple[dom.Node, str]:
|
||
|
|
child = copy.deepcopy(root)
|
||
|
|
li, leaf = _pick(rng, _leaves(child))
|
||
|
|
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]) -> 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(rng, cands)
|
||
|
|
keep = [t for t in (n.left.type, n.right.type) if t and not t.startswith(("c", "o", "s"))]
|
||
|
|
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]) -> tuple[dom.Node, str]:
|
||
|
|
child = copy.deepcopy(root)
|
||
|
|
li, leaf = _pick(rng, _leaves(child))
|
||
|
|
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]) -> tuple[dom.Node, str]:
|
||
|
|
child = copy.deepcopy(root)
|
||
|
|
li, n = _pick(rng, _owned_branches(child))
|
||
|
|
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]) -> 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(rng, cands)
|
||
|
|
n.rotation = (n.rotation + int(rng.integers(1, 4))) % 4
|
||
|
|
return _finalise(child), f"rotate {li}/{n.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
|
||
|
|
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,
|
||
|
|
"level_add": mutate_level_add,
|
||
|
|
"level_delete": mutate_level_delete,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def mutate(root: dom.Node, rng: np.random.Generator, types: list[str],
|
||
|
|
weights: dict[str, float] | None = 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)
|
||
|
|
name = rng.choice(names, p=p / p.sum())
|
||
|
|
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
|