homemaker-layout/src/homemaker/driver.py

150 lines
5.5 KiB
Python
Raw Normal View History

"""Memetic search driver, small-scale (DESIGN.md §5, §7 Phase 2).
Steady-state memetic GA over topology: the outer loop owns *topology only*
(operators.py moves on decoded Node trees); every child's geometry is
delegated to the warm-started inner loop (innerloop.optimise), and the
optimised ratios are written back into the individual (Lamarckian measured
mandatory, homemaker-py-8cs: cold starts never catch up at equal budget).
Budgets are stated and accounted in **oracle evaluations** (scored .dom
files), never generations (§4.6 arithmetic). This driver is deliberately
small-scale for the Phase-2 proof on the batched Perl oracle; scaling up
waits for the native fitness (Phase 3).
"""
from __future__ import annotations
import copy
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
from . import dom, innerloop, operators, programme
# children refine a near-optimal inherited geometry: one local CMA phase
# (the exploratory ladder phase exists for brutal cold projections, which
# warm-started children never face)
_CHILD_INNER_KW = {"sigmas": (0.05,)}
# storey add/delete are drastic (geometry perturbation 0.25-0.33 and a
# deleted storey stacks missing-space failures) — sample them rarely
_MUTATION_WEIGHTS = {"level_add": 0.2, "level_delete": 0.2}
@dataclass
class Individual:
root: dom.Node
fitness: float
n_fails: int
ratios: dict[tuple[int, str], float]
lineage: str = "seed"
@dataclass
class SearchResult:
best: Individual
population: list[Individual]
n_evals: int
n_topologies: int
history: list[tuple[int, float, str]] = field(default_factory=list)
# (oracle evals consumed, new best fitness, lineage) per improvement
def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw,
lineage: str) -> tuple[Individual, int]:
r = innerloop.optimise(root, programme_dir, x0=x0, budget=budget,
urb_root=urb_root, **inner_kw)
ind = Individual(root=root, fitness=r.fitness, n_fails=r.n_fails,
ratios=innerloop.ratio_map(root), lineage=lineage)
return ind, r.n_evals
def _tournament(pop: list[Individual], rng: np.random.Generator, k: int = 2) -> Individual:
picks = rng.integers(len(pop), size=k)
return max((pop[int(i)] for i in picks), key=lambda ind: ind.fitness)
def search(
seed_root: dom.Node,
programme_dir: str | Path,
budget: int = 2000,
pop_size: int = 8,
child_budget: int = 80,
seed_budget: int = 200,
p_crossover: float = 0.2,
seed: int = 0,
types: list[str] | None = None,
inner_kw: dict | None = None,
urb_root=None,
log=None,
) -> SearchResult:
"""Run the memetic loop from ``seed_root`` until ``budget`` oracle
evaluations are consumed. Returns the best individual found; its ``root``
carries the optimised geometry and dumps to a valid ``.dom``."""
from .oracle import DEFAULT_URB_ROOT
urb_root = urb_root or DEFAULT_URB_ROOT
rng = np.random.default_rng(seed)
inner_kw = dict(_CHILD_INNER_KW, **(inner_kw or {}))
if types is None:
reqs = programme.load_programme(str(Path(programme_dir) / "patterns.config"))
types = sorted(reqs) + ["c", "o"]
def _log(msg: str) -> None:
if log:
log(msg)
n_evals = 0
n_topologies = 0
result = SearchResult(best=None, population=[], n_evals=0, n_topologies=0)
def admit(ind: Individual, pop: list[Individual]) -> None:
nonlocal n_topologies
n_topologies += 1
if result.best is None or ind.fitness > result.best.fitness:
result.best = ind
result.history.append((n_evals, ind.fitness, ind.lineage))
_log(f"[{n_evals:6d} evals] best {ind.fitness:.6g} "
f"(fails {ind.n_fails}) via {ind.lineage}")
# reject near-duplicates of existing members (population collapse
# guard — neutral mutations are common, homemaker-py-8cs)
if any(abs(ind.fitness - p.fitness) <= 1e-9 * max(abs(p.fitness), 1e-300)
for p in pop):
return
if len(pop) < pop_size:
pop.append(ind)
return
worst = min(range(len(pop)), key=lambda i: pop[i].fitness)
if ind.fitness > pop[worst].fitness:
pop[worst] = ind
pop: list[Individual] = []
seed_ind, used = _evaluate(copy.deepcopy(seed_root), programme_dir, urb_root,
x0=None, budget=seed_budget,
inner_kw={}, lineage="seed")
n_evals += used
admit(seed_ind, pop)
while n_evals < budget:
if len(pop) >= 2 and rng.random() < p_crossover:
a, b = _tournament(pop, rng), _tournament(pop, rng)
child_root, _, desc = operators.crossover(a.root, b.root, rng)
ratios = {**b.ratios, **a.ratios} # primary parent wins
else:
parent = _tournament(pop, rng)
child_root, desc = operators.mutate(parent.root, rng, types,
weights=_MUTATION_WEIGHTS)
ratios = parent.ratios
x0 = innerloop.warm_x0(child_root, ratios)
child, used = _evaluate(child_root, programme_dir, urb_root,
x0=x0, budget=child_budget,
inner_kw=inner_kw, lineage=desc)
n_evals += used
admit(child, pop)
result.population = sorted(pop, key=lambda i: -i.fitness)
result.n_evals = n_evals
result.n_topologies = n_topologies
return result