2026-06-12 14:22:26 +01:00
|
|
|
"""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).
|
2026-06-13 23:29:12 +01:00
|
|
|
|
|
|
|
|
Cold-start bootstrap (homemaker-py-0px): when the seed is an undivided bare
|
|
|
|
|
plot, the search auto-generates a diverse initial population by randomly
|
|
|
|
|
applying divide mutations until each topology has approximately the programme
|
|
|
|
|
room count, then evaluates all pop_size individuals before the memetic loop
|
|
|
|
|
begins. This crosses the zero-feasibility region that single-seed chaining
|
|
|
|
|
cannot escape.
|
2026-06-14 06:55:58 +01:00
|
|
|
|
|
|
|
|
Parallelism (homemaker-py-5l6): ``n_workers > 1`` evaluates a batch of
|
|
|
|
|
children per iteration using ``concurrent.futures.ProcessPoolExecutor``.
|
|
|
|
|
Each worker is independent (NativeEvaluator has no shared mutable state).
|
|
|
|
|
The geometry module-level cache is cleared in each worker after fork to
|
|
|
|
|
prevent stale id-keyed entries inherited from the parent process.
|
2026-06-12 14:22:26 +01:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 06:55:58 +01:00
|
|
|
def _worker_init() -> None:
|
|
|
|
|
"""Clear the geometry cache in each forked worker process.
|
|
|
|
|
|
|
|
|
|
geometry._cache is keyed by id(node) (Python memory address). After
|
|
|
|
|
fork the inherited cache holds parent-process ids that could collide
|
|
|
|
|
with freshly allocated nodes in the worker, producing wrong hits.
|
|
|
|
|
"""
|
|
|
|
|
from . import geometry
|
|
|
|
|
geometry.clear_cache()
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 14:22:26 +01:00
|
|
|
@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
|
2026-06-14 07:48:13 +01:00
|
|
|
interrupted: bool = False
|
2026-06-12 14:22:26 +01:00
|
|
|
|
|
|
|
|
|
2026-06-13 23:29:12 +01:00
|
|
|
def random_topology(seed_root: dom.Node, n_leaves: int,
|
|
|
|
|
rng: np.random.Generator, types: list[str]) -> dom.Node:
|
|
|
|
|
"""Grow a random topology from ``seed_root`` by repeated divide mutations.
|
|
|
|
|
|
|
|
|
|
Applies ``mutate_divide`` until the total leaf count across all storeys
|
|
|
|
|
reaches ``n_leaves``. The result is a deep copy; ``seed_root`` is
|
|
|
|
|
unchanged.
|
|
|
|
|
"""
|
|
|
|
|
root = copy.deepcopy(seed_root)
|
|
|
|
|
while sum(len(lvl.leaves()) for lvl in dom.levels(root)) < n_leaves:
|
|
|
|
|
root, _ = operators.mutate_divide(root, rng, types)
|
|
|
|
|
return root
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 14:22:26 +01:00
|
|
|
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,
|
2026-06-13 23:29:12 +01:00
|
|
|
bootstrap: bool | None = None,
|
|
|
|
|
bootstrap_n_leaves: int | None = None,
|
2026-06-12 14:22:26 +01:00
|
|
|
p_crossover: float = 0.2,
|
|
|
|
|
seed: int = 0,
|
|
|
|
|
types: list[str] | None = None,
|
|
|
|
|
inner_kw: dict | None = None,
|
|
|
|
|
urb_root=None,
|
|
|
|
|
log=None,
|
2026-06-14 06:55:58 +01:00
|
|
|
n_workers: int = 1,
|
2026-06-12 14:22:26 +01:00
|
|
|
) -> SearchResult:
|
|
|
|
|
"""Run the memetic loop from ``seed_root`` until ``budget`` oracle
|
|
|
|
|
evaluations are consumed. Returns the best individual found; its ``root``
|
2026-06-13 23:29:12 +01:00
|
|
|
carries the optimised geometry and dumps to a valid ``.dom``.
|
|
|
|
|
|
|
|
|
|
``bootstrap=None`` (default) auto-detects: if ``seed_root`` is an
|
|
|
|
|
undivided bare plot, generates a diverse initial population of ``pop_size``
|
|
|
|
|
random topologies (each with approximately ``bootstrap_n_leaves`` leaves)
|
|
|
|
|
before the memetic loop starts. Pass ``bootstrap=False`` to force the
|
|
|
|
|
legacy single-seed path (appropriate for warm starts from existing designs).
|
2026-06-14 06:55:58 +01:00
|
|
|
|
|
|
|
|
``n_workers=1`` (default) runs serially; ``n_workers > 1`` evaluates
|
|
|
|
|
children in parallel using ``ProcessPoolExecutor``. The bootstrap batch
|
|
|
|
|
is fully parallel; the main loop generates ``n_workers`` children per
|
|
|
|
|
iteration from the current population snapshot and evaluates them in
|
|
|
|
|
parallel. Results are admitted in completion order (fastest first), so
|
|
|
|
|
later children in each batch see an already-updated population.
|
2026-06-13 23:29:12 +01:00
|
|
|
"""
|
2026-06-12 14:22:26 +01:00
|
|
|
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 {}))
|
2026-06-13 23:29:12 +01:00
|
|
|
# Always load reqs so bootstrap_n_leaves can be auto-derived from programme.
|
2026-06-14 07:50:39 +01:00
|
|
|
reqs = programme.load_programme_dir(programme_dir)
|
2026-06-12 14:22:26 +01:00
|
|
|
if types is None:
|
2026-06-12 19:01:53 +01:00
|
|
|
# Urb's generic types are canonically UPPERCASE (get_space_types:
|
|
|
|
|
# qw/C O S/; the corpus is 100% uppercase). Predicates match
|
|
|
|
|
# case-insensitively but Dom->Ratios keys raw strings — mixing cases
|
|
|
|
|
# fragments the class buckets, so never emit lowercase generics.
|
|
|
|
|
types = sorted(reqs) + ["C", "O"]
|
2026-06-12 14:22:26 +01:00
|
|
|
|
2026-06-13 23:29:12 +01:00
|
|
|
do_bootstrap = (not seed_root.divided) if bootstrap is None else bootstrap
|
|
|
|
|
|
2026-06-12 14:22:26 +01:00
|
|
|
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] = []
|
2026-06-14 06:55:58 +01:00
|
|
|
|
|
|
|
|
# Set up optional process pool for parallel child evaluation.
|
|
|
|
|
_pool = None
|
|
|
|
|
if n_workers > 1:
|
|
|
|
|
from concurrent.futures import ProcessPoolExecutor
|
|
|
|
|
_pool = ProcessPoolExecutor(max_workers=n_workers, initializer=_worker_init)
|
|
|
|
|
|
|
|
|
|
def _run_batch(
|
|
|
|
|
tasks: list[tuple], # (root, x0, budget_, inner_kw_, lineage)
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Evaluate a batch of tasks and admit results; parallel when _pool set."""
|
|
|
|
|
nonlocal n_evals
|
|
|
|
|
full = [
|
|
|
|
|
(root, programme_dir, urb_root, x0, budget_, kw_, lin)
|
|
|
|
|
for root, x0, budget_, kw_, lin in tasks
|
|
|
|
|
]
|
|
|
|
|
if _pool is not None:
|
|
|
|
|
from concurrent.futures import as_completed
|
|
|
|
|
futs = [_pool.submit(_evaluate, *t) for t in full]
|
|
|
|
|
for f in as_completed(futs):
|
|
|
|
|
ind, used = f.result()
|
|
|
|
|
n_evals += used
|
|
|
|
|
admit(ind, pop)
|
|
|
|
|
else:
|
|
|
|
|
for t in full:
|
|
|
|
|
ind, used = _evaluate(*t)
|
|
|
|
|
n_evals += used
|
|
|
|
|
admit(ind, pop)
|
|
|
|
|
|
2026-06-14 07:48:13 +01:00
|
|
|
interrupted = False
|
2026-06-14 06:55:58 +01:00
|
|
|
try:
|
|
|
|
|
if do_bootstrap:
|
|
|
|
|
# Bootstrap: diverse initial population from random topologies.
|
|
|
|
|
# Each individual is a cold start, so use the exploratory sigma
|
|
|
|
|
# schedule (inner_kw={} → cma_search defaults: sigmas=(0.05, 0.15)).
|
|
|
|
|
# Leaf count varied ±1 around the target to increase structural diversity.
|
|
|
|
|
n_target = bootstrap_n_leaves or max(len(reqs), 3)
|
|
|
|
|
tasks = []
|
|
|
|
|
for i in range(pop_size):
|
|
|
|
|
n = int(rng.integers(max(1, n_target - 1), n_target + 2))
|
|
|
|
|
topo = random_topology(seed_root, n, rng, types)
|
|
|
|
|
tasks.append((topo, None, child_budget, {}, f"bootstrap/{i}"))
|
|
|
|
|
_run_batch(tasks)
|
2026-06-12 14:22:26 +01:00
|
|
|
else:
|
2026-06-14 06:55:58 +01:00
|
|
|
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:
|
|
|
|
|
# How many children to generate this iteration: n_workers in parallel,
|
|
|
|
|
# but cap at what the remaining budget can afford (ceiling division).
|
|
|
|
|
batch_n = (
|
|
|
|
|
min(n_workers,
|
|
|
|
|
max(1, (budget - n_evals + child_budget - 1) // child_budget))
|
|
|
|
|
if _pool is not None else 1
|
|
|
|
|
)
|
|
|
|
|
tasks = []
|
|
|
|
|
for _ in range(batch_n):
|
|
|
|
|
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)
|
|
|
|
|
tasks.append((child_root, x0, child_budget, inner_kw, desc))
|
|
|
|
|
_run_batch(tasks)
|
2026-06-14 07:48:13 +01:00
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
interrupted = True
|
|
|
|
|
_log(f"[{n_evals:6d} evals] interrupted — returning best-so-far")
|
2026-06-14 06:55:58 +01:00
|
|
|
finally:
|
|
|
|
|
if _pool is not None:
|
|
|
|
|
_pool.shutdown(wait=True)
|
2026-06-12 14:22:26 +01:00
|
|
|
|
|
|
|
|
result.population = sorted(pop, key=lambda i: -i.fitness)
|
|
|
|
|
result.n_evals = n_evals
|
|
|
|
|
result.n_topologies = n_topologies
|
2026-06-14 07:48:13 +01:00
|
|
|
result.interrupted = interrupted
|
2026-06-12 14:22:26 +01:00
|
|
|
return result
|