Phase 7 §12.3: re-scoped 9gp — shape-feasibility filter + M3 reassociate (9gp.1, 9gp.2)

Land the two evidence-supported parts of the re-scoped 9gp capstone as
operators on the existing decoded Node tree (no Polish-expression rewrite),
each default-OFF and measured against the §12.2 leu.2 baseline.

9gp.1 shape-feasibility pre-filter: operators.predicted_shape_fails lays a
topology out at its proportion-aware target geometry and counts shape fails
(size/width/proportion/crinkliness); driver._evaluate prunes clearly-infeasible
topologies before the inner loop (1 eval vs ~80), guarded so nothing that could
beat the incumbent is discarded. search/search_staged feasibility_filter,
feasibility_max_shape_fails (env FEAS/MAXSHAPE), default OFF.

9gp.2 M3 Wong-Liu reassociate: operators.mutate_reassociate adds associativity
(a|b)|c <-> a|(b|c) on same-orientation live cuts — the canonical-slicing move
missing from swap(M1)/rotate(M2), attacking the §11.4/§11.5 reachability
bottleneck. enable_reassociate (env REASSOC), default OFF (weight 0 -> baseline
byte-identical).

Unit tests (operators + driver) green, full suite 211 passed; maple-court smoke
run clean under native fitness. A/B sweep handed off per the plan; DESIGN.md
§12.3 documents the design and the pending measurement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-06-20 18:54:48 +01:00
parent 0170cf2122
commit 6ee5d4b4ae
7 changed files with 357 additions and 17 deletions

File diff suppressed because one or more lines are too long

View file

@ -1274,3 +1274,72 @@ target dims; neither touches topology or type assignment. Threaded through
rotation-and-ratio sizing from target dims; the bare ratio is not enough rotation-and-ratio sizing from target dims; the bare ratio is not enough
(area-only regressed). Area sizing assumes total target ≈ plot area; choosing (area-only regressed). Area sizing assumes total target ≈ plot area; choosing
the cut *direction* for aspect is what makes it pay. the cut *direction* for aspect is what makes it pay.
### 12.3 Re-scoped 9gp: shape feasibility + reachability moves (`homemaker-py-9gp`)
Re-scoped capstone of the epic (2026-06-19): the original canonical-Polish-
expression rewrite was justified partly by a niching *signature*, but §11.5
falsified niching and `genome.signature` already supplies the cheap stand-in. The
two surviving, evidence-supported parts are landed here as operators on the
existing decoded `Node` tree — **no** Polish-expression rewrite — each measured
independently against the §12.2 leu.2 baseline (maple-court staged 136.0, harbor
74.0). A true canonical encoding is revisited only if the M3 measurement proves
associativity valuable at scale.
**9gp.1 — shape-feasibility pre-filter (scaling lever).** `operators.
predicted_shape_fails(root, reqs, fit)` lays a topology out at its proportion-
aware target geometry (reusing `_size_divisions_from_targets`, §12.2 — the
squarest layout the inner loop warm-starts from) and counts the
size/width/proportion/crinkliness fails the native fitness reports: a cheap
lower-bound proxy for the best shape the topology can reach. `driver._evaluate`
calls it *before* the inner loop and **prunes** (1 feasibility eval instead of
~80 inner-loop evals) when the predicted shape fails both exceed a tunable
threshold *and* are ≥ the incumbent's total fails — the second guard makes the
proxy safe (a topology whose shape floor is still below the incumbent is never
discarded). Pruned individuals are tagged `pruned/…`, counted as explored
topologies but never bred from or ranked, so budget flows to feasible topologies.
Seed/bootstrap/restart batches are never filtered (construction invariants must
survive). Threaded as `search(…, feasibility_filter, feasibility_max_shape_fails)`
through `search_staged`; **default OFF** so the §12.2 controls reproduce exactly
(`test_feasibility_filter_off_matches_baseline`). Env: `FEAS=1 MAXSHAPE=<n>`.
**9gp.2 — M3 Wong-Liu re-association move (reachability lever).** `operators.
mutate_reassociate` adds the associativity move `(a|b)|c ↔ a|(b|c)` on two
**same-orientation** live cuts (both directions, for reversibility): a pure-
topology move that preserves the leaf set and types but reaches tree shapes the
existing set cannot. M1 (operand swap) is `mutate_swap` and M2 (single-cut
orientation complement) is `mutate_rotate`; associativity was the missing
canonical-slicing move attacking the reachability bottleneck §11.4/§11.5 both
fingered. Only live cuts (`below is None`, as `mutate_rotate`) are restructured,
so dead inherited fields are untouched and `encode` re-anchors deltas; the two
restructured cuts default to `0.5` and the inner loop recovers their ratios.
Registered in `MUTATIONS`; **default OFF** via `enable_reassociate` (forces its
mutation weight to 0 so the baseline is byte-identical). Env: `REASSOC=1`.
- *Implementation status (this session):* both land with unit tests
(`tests/test_operators.py`: reassociate preserves the leaf multiset, changes
the signature, noops on perpendicular cuts, stays canonical on the harbor
corpus; `predicted_shape_fails` is non-negative, pure, deterministic.
`tests/test_driver.py`: filter-off reproduces the baseline trajectory;
filter-on prunes at 1 eval/topology and never admits a pruned individual).
Full suite green (211 passed). A short smoke run on maple-court confirms both
paths execute under the real native fitness.
- *Measurement (A/B sweep — TODO, handed off).* maple-court + harbor, seeds
0/1/2, 20000 evals, four configs vs the §12.2 baseline:
```bash
# baseline control (must reproduce 136.0 / 74.0)
URB_NO_OCCLUSION=1 python3 experiments/run_staged_search.py examples/maple-court 20000 <seed>
# 9gp.2 reassociate only
REASSOC=1 URB_NO_OCCLUSION=1 python3 experiments/run_staged_search.py …
# 9gp.1 shape-feasibility filter only (sweep MAXSHAPE)
FEAS=1 MAXSHAPE=<n> URB_NO_OCCLUSION=1 python3 experiments/run_staged_search.py …
# combined
REASSOC=1 FEAS=1 MAXSHAPE=<n> URB_NO_OCCLUSION=1 python3 experiments/run_staged_search.py …
```
Per the re-scoped bead, **either result is a valid verdict** — the discipline
is to MEASURE associativity's value at >16 rooms, not assume it (the §11.4/§11.5
failure mode). Record the table + one-line verdict here, then close 9gp.1,
9gp.2, 9gp.

View file

@ -57,6 +57,10 @@ def main() -> int:
restart_patience = int(rp) if rp else None restart_patience = int(rp) if rp else None
adj = os.environ.get("ADJ", "1") == "1" # s44/ld5 adjacency-aware seeding A/B adj = os.environ.get("ADJ", "1") == "1" # s44/ld5 adjacency-aware seeding A/B
prop = os.environ.get("PROP", "1") == "1" # leu.2 proportion-aware split sizing (default-on) prop = os.environ.get("PROP", "1") == "1" # leu.2 proportion-aware split sizing (default-on)
reassoc = os.environ.get("REASSOC", "0") == "1" # 9gp.2 M3 reassociate move A/B
feas = os.environ.get("FEAS", "0") == "1" # 9gp.1 shape-feasibility pre-filter A/B
_ms = os.environ.get("MAXSHAPE") # 9gp.1 prune threshold (shape-fail count)
max_shape = int(_ms) if _ms else None
print(f"programme : {programme_dir.name}") print(f"programme : {programme_dir.name}")
print(f"seed : {seed_file.name}") print(f"seed : {seed_file.name}")
@ -67,6 +71,8 @@ def main() -> int:
print(f"restart_p : {restart_patience}") print(f"restart_p : {restart_patience}")
print(f"adj_aware : {adj}") print(f"adj_aware : {adj}")
print(f"prop_aware: {prop}") print(f"prop_aware: {prop}")
print(f"reassoc : {reassoc}")
print(f"feas_filt : {feas} (max_shape={max_shape})")
print(flush=True) print(flush=True)
seed_root = dom.load(str(seed_file)) seed_root = dom.load(str(seed_file))
@ -89,6 +95,9 @@ def main() -> int:
restart_patience=restart_patience, restart_patience=restart_patience,
seed_adjacency_aware=adj, seed_adjacency_aware=adj,
seed_proportion_aware=prop, seed_proportion_aware=prop,
enable_reassociate=reassoc,
feasibility_filter=feas,
feasibility_max_shape_fails=max_shape,
) )
elapsed = time.perf_counter() - t0 elapsed = time.perf_counter() - t0

View file

@ -50,6 +50,13 @@ def _fitness_for(programme_dir: str) -> "fitness.Fitness":
conf, cost = fitness.load_config(programme_dir) conf, cost = fitness.load_config(programme_dir)
return fitness.Fitness(conf, cost) return fitness.Fitness(conf, cost)
@functools.lru_cache(maxsize=None)
def _reqs_for(programme_dir: str) -> dict:
"""Cached programme requirements per dir, for the §12.3 shape-feasibility
pre-filter (homemaker-py-9gp.1). Cached per process workers fork a copy."""
return programme.load_programme_dir(programme_dir)
# storey add/delete are drastic (geometry perturbation 0.25-0.33 and a # storey add/delete are drastic (geometry perturbation 0.25-0.33 and a
# deleted storey stacks missing-space failures) — sample them rarely. # deleted storey stacks missing-space failures) — sample them rarely.
# place_missing is the high-leverage §11.2 repair: it noops cheaply once the # place_missing is the high-leverage §11.2 repair: it noops cheaply once the
@ -110,7 +117,24 @@ def random_topology(seed_root: dom.Node, n_leaves: int,
def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw, def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw,
lineage: str, want_grade: bool = False) -> tuple[Individual, int]: lineage: str, want_grade: bool = False,
feasibility_max_shape_fails: int | None = None,
best_n_fails: int | None = None) -> tuple[Individual, int]:
# §12.3 shape-feasibility pre-filter (homemaker-py-9gp.1): if even the best
# achievable (proportion-aware) geometry of this topology already has at least
# as many shape fails as the incumbent's TOTAL fails — and exceeds the tunable
# threshold — it cannot beat the incumbent, so prune it for one feasibility
# eval instead of spending the full inner-loop budget. The best_n_fails guard
# makes the proxy safe: a topology whose shape-fail floor is still below the
# incumbent is never discarded. Pruned individuals are tagged and never admitted.
if (feasibility_max_shape_fails is not None and best_n_fails is not None):
pred = operators.predicted_shape_fails(
root, _reqs_for(str(programme_dir)), _fitness_for(str(programme_dir)))
if pred > feasibility_max_shape_fails and pred >= best_n_fails:
ind = Individual(root=root, fitness=0.0, n_fails=pred, ratios={},
lineage=f"pruned/{lineage}", grade=0.0,
sig=genome.signature(root))
return ind, 1
r = innerloop.optimise(root, programme_dir, x0=x0, budget=budget, r = innerloop.optimise(root, programme_dir, x0=x0, budget=budget,
urb_root=urb_root, **inner_kw) urb_root=urb_root, **inner_kw)
# §11.4: read the graded proximity scalar off the optimised tree. The inner # §11.4: read the graded proximity scalar off the optimised tree. The inner
@ -159,6 +183,9 @@ def search(
restart_elite: int = 1, restart_elite: int = 1,
seed_adjacency_aware: bool = True, seed_adjacency_aware: bool = True,
seed_proportion_aware: bool = True, seed_proportion_aware: bool = True,
enable_reassociate: bool = False,
feasibility_filter: bool = False,
feasibility_max_shape_fails: int | None = None,
) -> SearchResult: ) -> SearchResult:
"""Run the memetic loop from ``seed_root`` until ``budget`` oracle """Run the memetic loop from ``seed_root`` until ``budget`` oracle
evaluations are consumed. Returns the best individual found; its ``root`` evaluations are consumed. Returns the best individual found; its ``root``
@ -200,6 +227,12 @@ def search(
urb_root = urb_root or DEFAULT_URB_ROOT urb_root = urb_root or DEFAULT_URB_ROOT
rng = np.random.default_rng(seed) rng = np.random.default_rng(seed)
inner_kw = dict(_CHILD_INNER_KW, **(inner_kw or {})) inner_kw = dict(_CHILD_INNER_KW, **(inner_kw or {}))
# §12.3 M3 reassociate (homemaker-py-9gp.2) is default-OFF: force its weight to
# 0 unless enabled, so the leu.2 baseline reproduces byte-for-byte (the operator
# never fires) and the A/B is a clean single-variable toggle.
mutation_weights = dict(_MUTATION_WEIGHTS)
if not enable_reassociate:
mutation_weights["reassociate"] = 0.0
# Optional ranking bonus (DESIGN.md §11.3 Stage 1): bias selection toward # Optional ranking bonus (DESIGN.md §11.3 Stage 1): bias selection toward
# individuals with high substrate-readiness via a multiplicative factor # individuals with high substrate-readiness via a multiplicative factor
# (1 + W·bonus) on fitness. The reported fitness/history stay the TRUE # (1 + W·bonus) on fitness. The reported fitness/history stay the TRUE
@ -253,6 +286,10 @@ def search(
nonlocal n_topologies, last_improve nonlocal n_topologies, last_improve
n_topologies += 1 n_topologies += 1
seen_sigs.add(ind.sig) seen_sigs.add(ind.sig)
# §12.3 pruned by the shape-feasibility filter: counted as an explored
# topology (so the prune rate is visible) but never bred from or ranked.
if ind.lineage.startswith("pruned/"):
return
if result.best is None or _key(ind) > _key(result.best): if result.best is None or _key(ind) > _key(result.best):
result.best = ind result.best = ind
last_improve = n_evals last_improve = n_evals
@ -296,11 +333,18 @@ def search(
def _run_batch( def _run_batch(
tasks: list[tuple], # (root, x0, budget_, inner_kw_, lineage) tasks: list[tuple], # (root, x0, budget_, inner_kw_, lineage)
filter_on: bool = False,
) -> None: ) -> None:
"""Evaluate a batch of tasks and admit results; parallel when _pool set.""" """Evaluate a batch of tasks and admit results; parallel when _pool set.
``filter_on`` enables the §12.3 shape-feasibility pre-filter for this
batch used for mutation children only, never for the seed/bootstrap or
restart batches (construction invariants must survive)."""
nonlocal n_evals nonlocal n_evals
mx = feasibility_max_shape_fails if (filter_on and feasibility_filter) else None
best_nf = result.best.n_fails if result.best is not None else None
full = [ full = [
(root, programme_dir, urb_root, x0, budget_, kw_, lin, use_grade) (root, programme_dir, urb_root, x0, budget_, kw_, lin, use_grade, mx, best_nf)
for root, x0, budget_, kw_, lin in tasks for root, x0, budget_, kw_, lin in tasks
] ]
if _pool is not None: if _pool is not None:
@ -397,7 +441,7 @@ def search(
else: else:
parent = _tournament(pop, rng, _key) parent = _tournament(pop, rng, _key)
child_root, desc = operators.mutate(parent.root, rng, types, child_root, desc = operators.mutate(parent.root, rng, types,
weights=_MUTATION_WEIGHTS, weights=mutation_weights,
reqs=reqs, base_p=base_p) reqs=reqs, base_p=base_p)
# Carry operator-specified ratios for nodes that are genuinely # Carry operator-specified ratios for nodes that are genuinely
# newly divided (existed as leaves in the parent, are now # newly divided (existed as leaves in the parent, are now
@ -415,7 +459,7 @@ def search(
ratios = {**new_splits, **parent.ratios} ratios = {**new_splits, **parent.ratios}
x0 = innerloop.warm_x0(child_root, ratios) x0 = innerloop.warm_x0(child_root, ratios)
tasks.append((child_root, x0, child_budget, inner_kw, desc)) tasks.append((child_root, x0, child_budget, inner_kw, desc))
_run_batch(tasks) _run_batch(tasks, filter_on=True)
except KeyboardInterrupt: except KeyboardInterrupt:
interrupted = True interrupted = True
_log(f"[{n_evals:6d} evals] interrupted — returning best-so-far") _log(f"[{n_evals:6d} evals] interrupted — returning best-so-far")
@ -453,6 +497,9 @@ def search_staged(
restart_elite: int = 1, restart_elite: int = 1,
seed_adjacency_aware: bool = True, seed_adjacency_aware: bool = True,
seed_proportion_aware: bool = True, seed_proportion_aware: bool = True,
enable_reassociate: bool = False,
feasibility_filter: bool = False,
feasibility_max_shape_fails: int | None = None,
) -> SearchResult: ) -> SearchResult:
"""Staged per-floor topology search (DESIGN.md §11.3, ``homemaker-py-c4c.3``). """Staged per-floor topology search (DESIGN.md §11.3, ``homemaker-py-c4c.3``).
@ -496,7 +543,10 @@ def search_staged(
use_grade=use_grade, niche_by_signature=niche_by_signature, use_grade=use_grade, niche_by_signature=niche_by_signature,
restart_patience=restart_patience, restart_elite=restart_elite, restart_patience=restart_patience, restart_elite=restart_elite,
seed_adjacency_aware=seed_adjacency_aware, seed_adjacency_aware=seed_adjacency_aware,
seed_proportion_aware=seed_proportion_aware) seed_proportion_aware=seed_proportion_aware,
enable_reassociate=enable_reassociate,
feasibility_filter=feasibility_filter,
feasibility_max_shape_fails=feasibility_max_shape_fails)
if types is None: if types is None:
types = sorted(reqs) + ["C", "O"] types = sorted(reqs) + ["C", "O"]
@ -522,6 +572,9 @@ def search_staged(
restart_patience=restart_patience, restart_elite=restart_elite, restart_patience=restart_patience, restart_elite=restart_elite,
seed_adjacency_aware=seed_adjacency_aware, seed_adjacency_aware=seed_adjacency_aware,
seed_proportion_aware=seed_proportion_aware, seed_proportion_aware=seed_proportion_aware,
enable_reassociate=enable_reassociate,
feasibility_filter=feasibility_filter,
feasibility_max_shape_fails=feasibility_max_shape_fails,
) )
best_base = r1.best.root best_base = r1.best.root
_log(f"[staged] stage 1 done: base {r1.best.fitness:.6g} " _log(f"[staged] stage 1 done: base {r1.best.fitness:.6g} "
@ -552,6 +605,9 @@ def search_staged(
# substrate-selection semantics (§11.3) are unchanged. # substrate-selection semantics (§11.3) are unchanged.
use_grade=use_grade, niche_by_signature=niche_by_signature, use_grade=use_grade, niche_by_signature=niche_by_signature,
restart_patience=restart_patience, restart_elite=restart_elite, restart_patience=restart_patience, restart_elite=restart_elite,
enable_reassociate=enable_reassociate,
feasibility_filter=feasibility_filter,
feasibility_max_shape_fails=feasibility_max_shape_fails,
) )
# Stitch the two stages into one accounting (total evals, tagged history). # Stitch the two stages into one accounting (total evals, tagged history).

View file

@ -733,6 +733,81 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
return _finalise(child) return _finalise(child)
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, def mutate_core_divide(root: dom.Node, rng: np.random.Generator,
types: list[str]) -> tuple[dom.Node, str]: types: list[str]) -> tuple[dom.Node, str]:
"""Divide a circulation leaf at the same path across ALL storeys at once. """Divide a circulation leaf at the same path across ALL storeys at once.
@ -868,6 +943,7 @@ MUTATIONS = {
"retype": mutate_retype, "retype": mutate_retype,
"swap": mutate_swap, "swap": mutate_swap,
"rotate": mutate_rotate, "rotate": mutate_rotate,
"reassociate": mutate_reassociate,
"core_divide": mutate_core_divide, "core_divide": mutate_core_divide,
"core_undivide": mutate_core_undivide, "core_undivide": mutate_core_undivide,
"level_fix": mutate_level_fix, "level_fix": mutate_level_fix,

View file

@ -182,6 +182,51 @@ def test_restart_keeps_elite_and_counts(monkeypatch):
assert r.best is not None and r.best.fitness > 0 assert r.best is not None and r.best.fitness > 0
def test_feasibility_filter_off_matches_baseline(fake_inner):
"""§12.3: with the filter and reassociate OFF (defaults), the run is
identical to one that omits the params a clean A/B control."""
init_root = dom.load(str(INIT_FILE))
base = driver.search(init_root, CORPUS, budget=600, pop_size=4,
child_budget=60, seed_budget=100, seed=9)
off = driver.search(init_root, CORPUS, budget=600, pop_size=4,
child_budget=60, seed_budget=100, seed=9,
enable_reassociate=False, feasibility_filter=False,
feasibility_max_shape_fails=0)
# Same search trajectory: identical best topology and accounting. (Absolute
# fitness carries the fake_inner monotone tiebreaker, which shares one call
# counter across both runs in this fixture, so compare the signature.)
assert off.best.sig == base.best.sig
assert off.n_topologies == base.n_topologies
assert off.n_evals == base.n_evals
def test_feasibility_filter_prunes_cheaply(fake_inner, monkeypatch):
"""§12.3 (homemaker-py-9gp.1): a pruned topology costs one feasibility eval
instead of the full child_budget, so the filter explores far more topologies
per budget; pruned individuals never displace the incumbent."""
from homemaker_layout import operators
# Force every filtered child to be pruned (shape-fail floor above any
# threshold and ≥ the incumbent's fail count).
monkeypatch.setattr(operators, "predicted_shape_fails",
lambda root, reqs, fit: 999)
init_root = dom.load(str(INIT_FILE))
budget, child_budget, pop_size = 1200, 60, 4
on = driver.search(init_root, CORPUS, budget=budget, pop_size=pop_size,
child_budget=child_budget, seed_budget=100, seed=4,
feasibility_filter=True, feasibility_max_shape_fails=0)
# Bootstrap (pop_size topologies at child_budget) then 1-eval prunes: the
# remaining budget buys ~one topology per eval, far more than child_budget.
bootstrap_evals = pop_size * child_budget
assert on.n_topologies > pop_size + (budget - bootstrap_evals) // child_budget
assert on.n_evals >= budget
# No pruned (untuned, fitness=0) individual is admitted to the population.
assert all(p.lineage and not p.lineage.startswith("pruned/") for p in on.population)
assert on.best is not None and not on.best.lineage.startswith("pruned/")
def test_search_parallel_smoke(): def test_search_parallel_smoke():
"""n_workers>1 runs without error and produces valid results.""" """n_workers>1 runs without error and produces valid results."""
init_root = dom.load(str(INIT_FILE)) init_root = dom.load(str(INIT_FILE))

View file

@ -218,3 +218,86 @@ def test_crossover_yields_canonical_pair():
assert desc.startswith("crossover") assert desc.startswith("crossover")
canonical(ca) canonical(ca)
canonical(cb) canonical(cb)
# --------------------------------------------------------------------------- #
# 9gp.2 — M3 re-association move
# --------------------------------------------------------------------------- #
def _leaf_types(root: dom.Node) -> list[str]:
return sorted(lf.type or "." for lvl in dom.levels(root) for lf in lvl.leaves())
def _same_axis_chain() -> dom.Node:
"""A 3-leaf ``(a|b)|c`` tree with two parallel (same-orientation) cuts."""
root = dom.Node(rotation=0, division=[0.4, 0.4])
root.left = dom.Node(rotation=0, division=[0.5, 0.5])
root.left.left = dom.Node(type="A")
root.left.right = dom.Node(type="B")
root.right = dom.Node(type="C")
dom._link(root)
return root
def test_reassociate_preserves_leaves_changes_shape():
root = _same_axis_chain()
before_types = _leaf_types(root)
before_sig = genome.signature(root)
child, desc = operators.mutate_reassociate(root, np.random.default_rng(0), TYPES)
assert "noop" not in desc
# leaf set + types are an invariant; only the tree shape changes
assert _leaf_types(child) == before_types
assert genome.signature(child) != before_sig
canonical(child)
# parent untouched in place
assert genome.signature(root) == before_sig
canonical(root)
def test_reassociate_noop_on_perpendicular_cuts():
# Outer cut rotation 0, inner cut rotation 1 (perpendicular) → not the
# associativity precondition, so there is no candidate and it noops.
root = dom.Node(rotation=0, division=[0.4, 0.4])
root.left = dom.Node(rotation=1, division=[0.5, 0.5])
root.left.left = dom.Node(type="A")
root.left.right = dom.Node(type="B")
root.right = dom.Node(type="C")
dom._link(root)
_, desc = operators.mutate_reassociate(root, np.random.default_rng(0), TYPES)
assert desc == "reassociate noop"
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
def test_reassociate_on_corpus_is_canonical_and_total():
from homemaker_layout import programme
reqs = programme.load_programme_dir(str(HARBOR))
types = sorted(reqs) + ["C", "O"]
root = dom.load(str(HARBOR / "generated.dom"))
before = _leaf_types(root)
for seed in range(8):
child, desc = operators.mutate_reassociate(root, np.random.default_rng(seed), types)
canonical(child)
if "noop" not in desc:
# leaf multiset preserved even on a real multi-storey tree
assert _leaf_types(child) == before
# --------------------------------------------------------------------------- #
# 9gp.1 — shape-feasibility proxy
# --------------------------------------------------------------------------- #
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
def test_predicted_shape_fails_is_nonneg_and_pure():
from homemaker_layout import fitness, programme
reqs = programme.load_programme_dir(str(HARBOR))
conf, cost = fitness.load_config(str(HARBOR))
fit = fitness.Fitness(conf, cost)
root = dom.load(str(HARBOR / "generated.dom"))
n_leaves = sum(len(lvl.leaves()) for lvl in dom.levels(root))
pred = operators.predicted_shape_fails(root, reqs, fit)
assert isinstance(pred, int) and pred >= 0
# input root is untouched (a deep copy is laid out and scored)
assert sum(len(lvl.leaves()) for lvl in dom.levels(root)) == n_leaves
# deterministic
assert operators.predicted_shape_fails(root, reqs, fit) == pred