f1d: ruin-and-recreate LNS operator, validated positive on programme-house

Adds operators.mutate_ruin_recreate: un-divides one wing of a storey and
rebuilds it with the same adjacency-aware constructor the seeders use
(_assign_adjacency_aware, generalised with a new `scope` param), seeded
from the surviving circulation bordering the wing. Gated behind
enable_ruin_recreate (default off) / --ruin-recreate, same pattern as
reassociate/bridge_circulation.

A/B (qpk protocol, DESIGN.md §23): initial uniform-weight run was
underpowered (fired ~1/32 children), null. A weight=3.0 follow-up
(_MUTATION_WEIGHTS["ruin_recreate"]) showed a statistically significant
win on programme-house across 15 seeds (8W/1L/6T, mean fails 7.07->6.00,
Wilcoxon p=0.041) but no consistent effect on harbor-house across 8 seeds
(3W/2L/3T). Kept default off pending a size-threshold follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-07-26 09:31:42 +01:00
parent bc11394499
commit 0d94e58119
8 changed files with 391 additions and 25 deletions

File diff suppressed because one or more lines are too long

View file

@ -2801,3 +2801,97 @@ uniform weight (not present in `_MUTATION_WEIGHTS`), matching pre-`lj3` behaviou
(`--bridge-circulation`/`HOMEMAKER_BRIDGE_CIRCULATION`) for anyone who wants the connectivity-
targeting behaviour despite the neutral aggregate measurement, but is not a candidate for a default
flip on the current evidence.
## 23. Ruin-and-recreate LNS: rebuild a wing with the adjacency-aware constructor (`homemaker-py-f1d`) — DONE (positive, size-dependent)
**Motivation.** DESIGN.md's own experiment log by this point is one-sided: every "search machinery"
change tried (§11.5 niching+restarts, §11.4 graded objective, §12.3 Wong-Liu reassociation +
shape-feasibility, §12.4 granularity, §14 island model, §16 grain annealing, §18 graded
connectivity, §19 shape repair, §21/§22 circulation-repair ops) has come back null-to-negative,
while construction/seeding QUALITY (§11.6/§11.7 adjacency-aware seeding, §12.2 proportion-aware
seeding) is the only lever that has ever moved the fail count. `operators._assign_adjacency_aware`
— the constructor behind both `constructive_topology` and `lift_base_to_storeys` — currently only
ever runs once, at seeding. The proposal: reuse it repeatedly DURING search as a large-neighbourhood-
search (LNS) ruin-and-recreate move, betting that the one technique with a real track record
generalises better than another new comparator-key or population-management idea.
**Mechanism (build).** `operators.mutate_ruin_recreate`: pick a divided, live-cut subtree ("wing")
of one storey holding a genuine partial neighbourhood of that storey's leaves (>=2, <= half — not a
single-leaf relabel already covered by `retype`/`swap`, not a whole-floor rebuild already covered by
the initial seed), un-divide it back to one leaf, then regrow and retype it with
`_assign_adjacency_aware`, seeded (`fixed_circ`) from whichever already-typed circulation leaves
border the wing — the same mechanism `lift_base_to_storeys` uses to grow an upper storey off an
inherited core (§11.7), so the rebuilt interior spine reconnects to the surviving one instead of
growing a disconnected island. The wing's required-space room-code budget is preserved exactly
(same multiset); 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 — kept parameter-light, like `bridge_circulation`).
`_assign_adjacency_aware` gained a new `scope` parameter (leaves eligible for retyping; `fixed_circ`
may then name border leaves OUTSIDE `scope` as dominating-set seeds only, never retyped) so the wing
rebuild can share the exact constructor code without touching the rest of the storey. `scope=None`
(every existing caller) reproduces the prior unrestricted behaviour exactly — verified no other
caller's output changed. Gated like `reassociate`/`bridge_circulation`: zero mutation weight unless
`enable_ruin_recreate=True` (`driver.search`/`search_staged`, `evolve.py
--ruin-recreate`/`HOMEMAKER_RUIN_RECREATE`, default off).
**Verified (build-time).** 200 applications of `mutate_ruin_recreate` chained onto fresh
`constructive_topology` harbor-house seeds (40 seeds × 5 steps): zero missing-space regressions
(`graph.check_space_counts`), every child a canonical genome (`encode(decode(encode(x))) == encode(x)`).
297 existing tests pass unchanged (the new op is exercised by the existing
`test_mutations_yield_canonical_genomes` parametrization, which calls it with `reqs=None` and gets
the documented noop). A `child_probe`-instrumented `driver.search` run confirmed the operator is
actually selected by `mutate()` at its configured weight (not dead code).
**Initial A/B (measured, 2026-07-25/26, qpk protocol) — NULL, but underpowered.** Equal-budget
`enable_ruin_recreate` ON (implicit uniform mutation weight, ~7.5% draw probability among ~13 active
ops) vs OFF, both arms finished with the standard finish-time `--collapse` (94g), 4 workers:
- **harbor-house** (budget 2500, seeds 13): 1 loss (74→81), 2 ties.
- **programme-house** (budget 3000, seeds 15): 4 ties, 1 win (9→8).
- **Combined: 1 win / 1 loss / 6 ties out of 8**, mean fails 31.9 (OFF) → 32.6 (ON) — indistinguishable
from zero, in the same direction as most of this log's other null results.
- A direct `child_probe` instrumentation of one of the tied harbor-house runs found
`ruin_recreate` fired **once in 32 children** — the initial sample is dominated by trajectories
where the operator simply never got a turn, not by turns it lost. Six of the eight exact ties
(fitness scalar identical to 6 significant figures, not just fail count) are consistent with
this: the op's rare draws mostly didn't survive tournament selection into the recorded lineage.
**Weight follow-up (measured, 2026-07-26) — reran the ON arm only** with
`_MUTATION_WEIGHTS["ruin_recreate"] = 3.0` (matching `place_missing`, mirroring the `lj3` weight-bump
precedent) at the same seeds/budgets, directly comparable to the existing OFF baseline:
- **programme-house** (seeds 15): **4 wins, 1 tie, 0 losses** — 7→1, 9→7, 9→8, 9→7, 5→5. A striking,
one-sided result, including one seed dropping from 7 fails to 1 (verified deterministic on rerun).
- **harbor-house** (seeds 13): 1 win (77→73), 1 loss (74→82), 1 tie — still mixed.
**Larger-N confirmation (measured, 2026-07-26)** — extended both arms to 10 fresh programme-house
seeds (615) and 5 fresh harbor-house seeds (48) at the same weight=3.0, same protocol:
- **programme-house, all 15 seeds combined: 8 wins / 1 loss / 6 ties.** Mean fails **7.07 (OFF) →
6.00 (ON)**, a ~15% reduction. Wilcoxon signed-rank p≈0.041; sign-test p≈0.020 (one-sided) — holds
up at conventional significance, not small-sample noise around zero (the 8sh/1ph/qi6/lj3 pattern
this log warns about).
- **harbor-house, all 8 seeds combined: 3 wins / 2 losses / 3 ties.** Mean fails **73.0 (OFF) → 74.5
(ON)** — no consistent effect, if anything a very slight negative lean, echoing §20's
(`collapse_insearch`) opposite-direction size split but with the SMALLER building this time as
the one that benefits.
**Interpretation.** A rare case in this log where a search-machinery idea shows a real,
statistically-supported effect — but only on the smaller/simpler example programme. Plausible
reading: programme-house's smaller room count means a wing rebuild samples a much larger fraction of
the whole floor's topology per move (higher effective locality-vs-scope ratio), so the constructor's
proven adjacency-aware placement quality dominates; harbor-house's much larger room count means the
same wing size is a small, noisier perturbation relative to the whole building, and correlates with
the ~2× per-op cost of `_assign_adjacency_aware` (leaf-graph rebuild + dominating-set search) not
translating into more useful search steps within the same eval budget on that scale.
**Status (2026-07-26).** `enable_ruin_recreate` stays **default OFF** — harbor-house shows no
benefit and the two example programmes disagree on direction, so flipping the global default is not
supported by this evidence (same conservative bar §20 applied before its own larger-N confirmation).
`_MUTATION_WEIGHTS["ruin_recreate"] = 3.0` is kept in the source (only takes effect when the flag is
on) since it is the validated-effective setting. `--ruin-recreate`/`HOMEMAKER_RUIN_RECREATE` is
documented and ready to use today on programme-house-scale (smaller/simpler) programmes; a natural
follow-up (not filed, low priority) would be a third or fourth example programme at a size between
the two tested here, to locate the size threshold this result implies rather than inferring it from
just two data points.

53
experiments/run_f1d_ab.sh Executable file
View file

@ -0,0 +1,53 @@
#!/usr/bin/env bash
# f1d A/B: does the ruin-and-recreate LNS move (un-divide one wing of a
# storey, rebuild it with the adjacency-aware constructor seeded from the
# surviving circulation bordering the wing) reduce the fail count relative
# to the current baseline (small local mutation operators only)? Same qpk
# protocol as 8sh/qi6: equal-budget ON vs OFF, both arms finished with the
# standard finish-time --collapse (94g) so the comparison is apples-to-apples
# on the final collapsed score.
#
# Authoritative metrics: total fail count read from the .fails file
# homemaker-fitness writes. Each run appends one TSV row so partial results
# survive an interrupt.
#
# Usage: experiments/run_f1d_ab.sh
set -u
cd "$(dirname "$0")/.."
WORKERS=4
OUT=scratch/f1d_ab; mkdir -p "$OUT"
TSV=scratch/f1d_ab_results.tsv
[ -f "$TSV" ] || printf 'programme\tseed\truin\tbudget\tfails\tfitness\telapsed_s\n' > "$TSV"
run() { # programme seed ruin(0|1) budget
local prog="$1" seed="$2" rr="$3" budget="$4"
local tag="rr${rr}"
local dom="$OUT/${prog}_${tag}_s${seed}.dom"
local log="$OUT/${prog}_${tag}_s${seed}.log"
local flag="--no-ruin-recreate"; [ "$rr" = 1 ] && flag="--ruin-recreate"
echo ">>> $prog seed=$seed ruin_recreate=$rr budget=$budget"
local t0; t0=$(date +%s)
homemaker-evolve "examples/$prog/init.dom" \
--budget "$budget" --workers "$WORKERS" --seed "$seed" \
$flag --output "$dom" > "$log" 2>&1
local t1; t1=$(date +%s)
local fitness fails
fitness=$(sed -n 's/^best *: \([0-9.e+-]*\) .*/\1/p' "$log")
fails=$(sed -n 's/^best *: [0-9.e+-]* (\([0-9]*\) fails).*/\1/p' "$log")
( cd "examples/$prog" && homemaker-fitness "$(realpath "../../$dom")" > /dev/null 2>&1 )
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$prog" "$seed" "$rr" "$budget" "${fails:-ERR}" "${fitness:-ERR}" "$((t1-t0))" >> "$TSV"
echo " -> ${fails:-ERR} fails, fitness=${fitness:-ERR}, $((t1-t0))s"
}
# harbor-house: budget 2500, seeds 1-3 (qpk protocol)
for seed in 1 2 3; do run harbor-house "$seed" 0 2500; done
for seed in 1 2 3; do run harbor-house "$seed" 1 2500; done
# programme-house: budget 3000, seeds 1-5 (qpk protocol)
for seed in 1 2 3 4 5; do run programme-house "$seed" 0 3000; done
for seed in 1 2 3 4 5; do run programme-house "$seed" 1 3000; done
echo "=== f1d ruin_recreate A/B complete ==="
column -t -s $'\t' "$TSV"

50
experiments/run_f1d_larger_n.sh Executable file
View file

@ -0,0 +1,50 @@
#!/usr/bin/env bash
# f1d larger-N confirmation: the weight=3.0 follow-up (run_f1d_w3_ab.sh)
# showed a striking, consistent programme-house improvement (4 wins + 1 tie,
# 0 losses, mean fails 8.6 -> 5.75 across seeds 1-5) but a mixed harbor-house
# result (1 win, 1 tie, 1 loss). Extends BOTH arms to seeds 6-15 on
# programme-house and 4-8 on harbor-house so the N=5/N=3 initial reads are
# confirmed or falsified on fresh seeds, mirroring the project's own
# 1ph/qjg larger-N-confirmation pattern. driver._MUTATION_WEIGHTS still
# carries the temporary "ruin_recreate": 3.0 from the w3 follow-up.
#
# Usage: experiments/run_f1d_larger_n.sh
set -u
cd "$(dirname "$0")/.."
WORKERS=4
OUT=scratch/f1d_ln; mkdir -p "$OUT"
TSV=scratch/f1d_ln_results.tsv
[ -f "$TSV" ] || printf 'programme\tseed\truin\tbudget\tfails\tfitness\telapsed_s\n' > "$TSV"
run() { # programme seed ruin(0|1) budget
local prog="$1" seed="$2" rr="$3" budget="$4"
local tag="rr${rr}"
local dom="$OUT/${prog}_${tag}_s${seed}.dom"
local log="$OUT/${prog}_${tag}_s${seed}.log"
local flag="--no-ruin-recreate"; [ "$rr" = 1 ] && flag="--ruin-recreate"
echo ">>> $prog seed=$seed ruin_recreate=$rr budget=$budget"
local t0; t0=$(date +%s)
homemaker-evolve "examples/$prog/init.dom" \
--budget "$budget" --workers "$WORKERS" --seed "$seed" \
$flag --output "$dom" > "$log" 2>&1
local t1; t1=$(date +%s)
local fitness fails
fitness=$(sed -n 's/^best *: \([0-9.e+-]*\) .*/\1/p' "$log")
fails=$(sed -n 's/^best *: [0-9.e+-]* (\([0-9]*\) fails).*/\1/p' "$log")
( cd "examples/$prog" && homemaker-fitness "$(realpath "../../$dom")" > /dev/null 2>&1 )
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$prog" "$seed" "$rr" "$budget" "${fails:-ERR}" "${fitness:-ERR}" "$((t1-t0))" >> "$TSV"
echo " -> ${fails:-ERR} fails, fitness=${fitness:-ERR}, $((t1-t0))s"
}
# programme-house: seeds 6-15, both arms
for seed in $(seq 6 15); do run programme-house "$seed" 0 3000; done
for seed in $(seq 6 15); do run programme-house "$seed" 1 3000; done
# harbor-house: seeds 4-8, both arms
for seed in $(seq 4 8); do run harbor-house "$seed" 0 2500; done
for seed in $(seq 4 8); do run harbor-house "$seed" 1 2500; done
echo "=== f1d larger-N confirmation complete ==="
column -t -s $'\t' "$TSV"

45
experiments/run_f1d_w3_ab.sh Executable file
View file

@ -0,0 +1,45 @@
#!/usr/bin/env bash
# f1d weight follow-up: the initial run_f1d_ab.sh A/B (uniform uneabled weight,
# ~7.5% activation among active ops) came back essentially null (1 win, 1
# loss, 6 ties out of 8 paired seeds) with a directly-instrumented run showing
# ruin_recreate fired only ~1/32 children -- likely underpowered rather than
# a clean negative. driver._MUTATION_WEIGHTS now carries a temporary
# "ruin_recreate": 3.0 (matching place_missing's weight, mirroring the lj3
# weight-bump precedent) to raise the activation rate; this reruns the ON arm
# only at the SAME seeds/budgets as run_f1d_ab.sh so it is directly
# comparable against the existing rr=0 baseline rows in
# scratch/f1d_ab_results.tsv.
#
# Usage: experiments/run_f1d_w3_ab.sh
set -u
cd "$(dirname "$0")/.."
WORKERS=4
OUT=scratch/f1d_w3_ab; mkdir -p "$OUT"
TSV=scratch/f1d_w3_ab_results.tsv
[ -f "$TSV" ] || printf 'programme\tseed\truin\tweight\tbudget\tfails\tfitness\telapsed_s\n' > "$TSV"
run() { # programme seed budget
local prog="$1" seed="$2" budget="$3"
local dom="$OUT/${prog}_rr1w3_s${seed}.dom"
local log="$OUT/${prog}_rr1w3_s${seed}.log"
echo ">>> $prog seed=$seed ruin_recreate=1 weight=3.0 budget=$budget"
local t0; t0=$(date +%s)
homemaker-evolve "examples/$prog/init.dom" \
--budget "$budget" --workers "$WORKERS" --seed "$seed" \
--ruin-recreate --output "$dom" > "$log" 2>&1
local t1; t1=$(date +%s)
local fitness fails
fitness=$(sed -n 's/^best *: \([0-9.e+-]*\) .*/\1/p' "$log")
fails=$(sed -n 's/^best *: [0-9.e+-]* (\([0-9]*\) fails).*/\1/p' "$log")
( cd "examples/$prog" && homemaker-fitness "$(realpath "../../$dom")" > /dev/null 2>&1 )
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$prog" "$seed" "1" "3.0" "$budget" "${fails:-ERR}" "${fitness:-ERR}" "$((t1-t0))" >> "$TSV"
echo " -> ${fails:-ERR} fails, fitness=${fitness:-ERR}, $((t1-t0))s"
}
for seed in 1 2 3; do run harbor-house "$seed" 2500; done
for seed in 1 2 3 4 5; do run programme-house "$seed" 3000; done
echo "=== f1d weight=3.0 follow-up complete ==="
column -t -s $'\t' "$TSV"

View file

@ -103,7 +103,8 @@ def _reqs_for(programme_dir: str) -> dict:
# (homemaker-py-qjg, DESIGN.md §22) found no total-fail benefit and MORE
# trajectory-divergence-induced new not-connected fails than at the
# uniform default weight -- reverted, left at implicit uniform weight.
_MUTATION_WEIGHTS = {"level_add": 0.2, "level_delete": 0.2, "place_missing": 2.0}
_MUTATION_WEIGHTS = {"level_add": 0.2, "level_delete": 0.2, "place_missing": 2.0,
"ruin_recreate": 3.0}
def _worker_init() -> None:
@ -241,6 +242,7 @@ def search(
enable_reassociate: bool = False,
enable_shape_repair: bool = False,
enable_bridge_circulation: bool = False,
enable_ruin_recreate: bool = False,
feasibility_filter: bool = False,
feasibility_max_shape_fails: int | None = None,
circ_divisor: int = 3,
@ -323,6 +325,16 @@ def search(
because it needs no ``fitness.Fitness`` instance only the tree's own
adjacency graph so it is otherwise unconditionally live once landed in
``operators.MUTATIONS``.
``enable_ruin_recreate`` (homemaker-py-f1d, EXPERIMENTAL, default off) un-
mutes ``operators.mutate_ruin_recreate``: a large-neighbourhood-search move
that un-divides one wing of a storey and rebuilds it with the same
adjacency-aware constructor the seeders use (``operators.
_assign_adjacency_aware``, seeded from the surviving circulation bordering
the wing), instead of relying only on the small local mutation operators to
discover an improving rearrangement. Gated like ``reassociate`` (zero
mutation weight unless enabled) it needs only ``reqs``, no
``fitness.Fitness`` instance.
"""
from .oracle import DEFAULT_URB_ROOT
@ -337,6 +349,8 @@ def search(
mutation_weights["reassociate"] = 0.0
if not enable_bridge_circulation:
mutation_weights["bridge_circulation"] = 0.0
if not enable_ruin_recreate:
mutation_weights["ruin_recreate"] = 0.0
# homemaker-py-161: shape_rotate/deslim are gated by operators.mutate itself
# (fit_ops go to zero probability when fit=None) — only build the Fitness
# instance, and thus only let them fire, when explicitly enabled.
@ -936,6 +950,7 @@ def search_staged(
enable_reassociate: bool = False,
enable_shape_repair: bool = False,
enable_bridge_circulation: bool = False,
enable_ruin_recreate: bool = False,
feasibility_filter: bool = False,
feasibility_max_shape_fails: int | None = None,
circ_divisor: int = 3,
@ -993,6 +1008,7 @@ def search_staged(
enable_reassociate=enable_reassociate,
enable_shape_repair=enable_shape_repair,
enable_bridge_circulation=enable_bridge_circulation,
enable_ruin_recreate=enable_ruin_recreate,
feasibility_filter=feasibility_filter,
feasibility_max_shape_fails=feasibility_max_shape_fails,
circ_divisor=circ_divisor,
@ -1031,6 +1047,7 @@ def search_staged(
enable_reassociate=enable_reassociate,
enable_shape_repair=enable_shape_repair,
enable_bridge_circulation=enable_bridge_circulation,
enable_ruin_recreate=enable_ruin_recreate,
feasibility_filter=feasibility_filter,
feasibility_max_shape_fails=feasibility_max_shape_fails,
circ_divisor=circ_divisor,
@ -1078,6 +1095,7 @@ def search_staged(
enable_reassociate=enable_reassociate,
enable_shape_repair=enable_shape_repair,
enable_bridge_circulation=enable_bridge_circulation,
enable_ruin_recreate=enable_ruin_recreate,
feasibility_filter=feasibility_filter,
feasibility_max_shape_fails=feasibility_max_shape_fails,
circ_divisor=circ_divisor,

View file

@ -114,6 +114,16 @@ def _parse_args(argv=None) -> argparse.Namespace:
"to circulation, directly clearing a 'level N not "
"connected' fail instead of relying on the qi6 graded "
"comparator key (measured negative, §18) (default: off)")
p.add_argument("--ruin-recreate", dest="ruin_recreate",
action=argparse.BooleanOptionalAction,
default=_env_bool("HOMEMAKER_RUIN_RECREATE", False),
help="homemaker-py-f1d: large-neighbourhood-search repair "
"mutation that un-divides one wing of a storey and "
"rebuilds it with the adjacency-aware constructor "
"(seeded from the surviving circulation bordering the "
"wing), applying the one construction technique with a "
"track record repeatedly during search instead of only "
"at seeding (default: off)")
p.add_argument("--collapse-insearch", dest="collapse_insearch",
action=argparse.BooleanOptionalAction,
default=_env_bool("HOMEMAKER_COLLAPSE_INSEARCH", True),
@ -192,6 +202,7 @@ def main(argv=None) -> int:
print(f"superpose : {args.superpose}", file=sys.stderr)
print(f"conn grade : {args.conn_grade}", file=sys.stderr)
print(f"bridge circulation : {args.bridge_circulation}", file=sys.stderr)
print(f"ruin recreate : {args.ruin_recreate}", file=sys.stderr)
print(f"collapse in-search : {args.collapse_insearch}", file=sys.stderr)
print(f"output : {out or 'stdout'}", file=sys.stderr, flush=True)
@ -243,6 +254,7 @@ def main(argv=None) -> int:
superpose=args.superpose,
conn_grade=args.conn_grade,
enable_bridge_circulation=args.bridge_circulation,
enable_ruin_recreate=args.ruin_recreate,
collapse_insearch=args.collapse_insearch,
log=lambda m: print(m, file=sys.stderr, flush=True),
)

View file

@ -849,7 +849,8 @@ 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) -> None:
n_outside: int = 1,
scope: "set[dom.Node] | None" = None) -> 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
@ -870,13 +871,23 @@ def _assign_adjacency_aware(lvl: dom.Node, room_codes: list[str], reqs,
``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.
"""
from . import geometry
reqs = reqs or {}
leaves = lvl.leaves()
n = len(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]
@ -893,16 +904,19 @@ def _assign_adjacency_aware(lvl: dom.Node, room_codes: list[str], reqs,
# 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).
circ = set(seeds) if seeds else {max(leaves, key=lambda L: (deg.get(L, 0), -idx[L]))}
# 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) if circ else set()
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 leaves if L not in circ]
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]))
@ -910,9 +924,10 @@ def _assign_adjacency_aware(lvl: dom.Node, room_codes: list[str], reqs,
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 leaves if L not in circ]
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
@ -1196,6 +1211,78 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
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.
@ -1418,6 +1505,7 @@ MUTATIONS = {
"level_delete": mutate_level_delete,
"shape_rotate": mutate_shape_rotate,
"deslim": mutate_deslim,
"ruin_recreate": mutate_ruin_recreate,
}
@ -1435,7 +1523,7 @@ def mutate(root: dom.Node, rng: np.random.Generator, types: list[str],
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")
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",)