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:
parent
bc11394499
commit
0d94e58119
8 changed files with 391 additions and 25 deletions
File diff suppressed because one or more lines are too long
94
DESIGN.md
94
DESIGN.md
|
|
@ -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-
|
(`--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
|
targeting behaviour despite the neutral aggregate measurement, but is not a candidate for a default
|
||||||
flip on the current evidence.
|
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 1–3): 1 loss (74→81), 2 ties.
|
||||||
|
- **programme-house** (budget 3000, seeds 1–5): 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 1–5): **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 1–3): 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 (6–15) and 5 fresh harbor-house seeds (4–8) 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
53
experiments/run_f1d_ab.sh
Executable 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
50
experiments/run_f1d_larger_n.sh
Executable 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
45
experiments/run_f1d_w3_ab.sh
Executable 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"
|
||||||
|
|
@ -103,7 +103,8 @@ def _reqs_for(programme_dir: str) -> dict:
|
||||||
# (homemaker-py-qjg, DESIGN.md §22) found no total-fail benefit and MORE
|
# (homemaker-py-qjg, DESIGN.md §22) found no total-fail benefit and MORE
|
||||||
# trajectory-divergence-induced new not-connected fails than at the
|
# trajectory-divergence-induced new not-connected fails than at the
|
||||||
# uniform default weight -- reverted, left at implicit uniform weight.
|
# 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:
|
def _worker_init() -> None:
|
||||||
|
|
@ -241,6 +242,7 @@ def search(
|
||||||
enable_reassociate: bool = False,
|
enable_reassociate: bool = False,
|
||||||
enable_shape_repair: bool = False,
|
enable_shape_repair: bool = False,
|
||||||
enable_bridge_circulation: bool = False,
|
enable_bridge_circulation: bool = False,
|
||||||
|
enable_ruin_recreate: bool = False,
|
||||||
feasibility_filter: bool = False,
|
feasibility_filter: bool = False,
|
||||||
feasibility_max_shape_fails: int | None = None,
|
feasibility_max_shape_fails: int | None = None,
|
||||||
circ_divisor: int = 3,
|
circ_divisor: int = 3,
|
||||||
|
|
@ -323,6 +325,16 @@ def search(
|
||||||
because it needs no ``fitness.Fitness`` instance — only the tree's own
|
because it needs no ``fitness.Fitness`` instance — only the tree's own
|
||||||
adjacency graph — so it is otherwise unconditionally live once landed in
|
adjacency graph — so it is otherwise unconditionally live once landed in
|
||||||
``operators.MUTATIONS``.
|
``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
|
from .oracle import DEFAULT_URB_ROOT
|
||||||
|
|
||||||
|
|
@ -337,6 +349,8 @@ def search(
|
||||||
mutation_weights["reassociate"] = 0.0
|
mutation_weights["reassociate"] = 0.0
|
||||||
if not enable_bridge_circulation:
|
if not enable_bridge_circulation:
|
||||||
mutation_weights["bridge_circulation"] = 0.0
|
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
|
# 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
|
# (fit_ops go to zero probability when fit=None) — only build the Fitness
|
||||||
# instance, and thus only let them fire, when explicitly enabled.
|
# instance, and thus only let them fire, when explicitly enabled.
|
||||||
|
|
@ -936,6 +950,7 @@ def search_staged(
|
||||||
enable_reassociate: bool = False,
|
enable_reassociate: bool = False,
|
||||||
enable_shape_repair: bool = False,
|
enable_shape_repair: bool = False,
|
||||||
enable_bridge_circulation: bool = False,
|
enable_bridge_circulation: bool = False,
|
||||||
|
enable_ruin_recreate: bool = False,
|
||||||
feasibility_filter: bool = False,
|
feasibility_filter: bool = False,
|
||||||
feasibility_max_shape_fails: int | None = None,
|
feasibility_max_shape_fails: int | None = None,
|
||||||
circ_divisor: int = 3,
|
circ_divisor: int = 3,
|
||||||
|
|
@ -993,6 +1008,7 @@ def search_staged(
|
||||||
enable_reassociate=enable_reassociate,
|
enable_reassociate=enable_reassociate,
|
||||||
enable_shape_repair=enable_shape_repair,
|
enable_shape_repair=enable_shape_repair,
|
||||||
enable_bridge_circulation=enable_bridge_circulation,
|
enable_bridge_circulation=enable_bridge_circulation,
|
||||||
|
enable_ruin_recreate=enable_ruin_recreate,
|
||||||
feasibility_filter=feasibility_filter,
|
feasibility_filter=feasibility_filter,
|
||||||
feasibility_max_shape_fails=feasibility_max_shape_fails,
|
feasibility_max_shape_fails=feasibility_max_shape_fails,
|
||||||
circ_divisor=circ_divisor,
|
circ_divisor=circ_divisor,
|
||||||
|
|
@ -1031,6 +1047,7 @@ def search_staged(
|
||||||
enable_reassociate=enable_reassociate,
|
enable_reassociate=enable_reassociate,
|
||||||
enable_shape_repair=enable_shape_repair,
|
enable_shape_repair=enable_shape_repair,
|
||||||
enable_bridge_circulation=enable_bridge_circulation,
|
enable_bridge_circulation=enable_bridge_circulation,
|
||||||
|
enable_ruin_recreate=enable_ruin_recreate,
|
||||||
feasibility_filter=feasibility_filter,
|
feasibility_filter=feasibility_filter,
|
||||||
feasibility_max_shape_fails=feasibility_max_shape_fails,
|
feasibility_max_shape_fails=feasibility_max_shape_fails,
|
||||||
circ_divisor=circ_divisor,
|
circ_divisor=circ_divisor,
|
||||||
|
|
@ -1078,6 +1095,7 @@ def search_staged(
|
||||||
enable_reassociate=enable_reassociate,
|
enable_reassociate=enable_reassociate,
|
||||||
enable_shape_repair=enable_shape_repair,
|
enable_shape_repair=enable_shape_repair,
|
||||||
enable_bridge_circulation=enable_bridge_circulation,
|
enable_bridge_circulation=enable_bridge_circulation,
|
||||||
|
enable_ruin_recreate=enable_ruin_recreate,
|
||||||
feasibility_filter=feasibility_filter,
|
feasibility_filter=feasibility_filter,
|
||||||
feasibility_max_shape_fails=feasibility_max_shape_fails,
|
feasibility_max_shape_fails=feasibility_max_shape_fails,
|
||||||
circ_divisor=circ_divisor,
|
circ_divisor=circ_divisor,
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,16 @@ def _parse_args(argv=None) -> argparse.Namespace:
|
||||||
"to circulation, directly clearing a 'level N not "
|
"to circulation, directly clearing a 'level N not "
|
||||||
"connected' fail instead of relying on the qi6 graded "
|
"connected' fail instead of relying on the qi6 graded "
|
||||||
"comparator key (measured negative, §18) (default: off)")
|
"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",
|
p.add_argument("--collapse-insearch", dest="collapse_insearch",
|
||||||
action=argparse.BooleanOptionalAction,
|
action=argparse.BooleanOptionalAction,
|
||||||
default=_env_bool("HOMEMAKER_COLLAPSE_INSEARCH", True),
|
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"superpose : {args.superpose}", file=sys.stderr)
|
||||||
print(f"conn grade : {args.conn_grade}", 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"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"collapse in-search : {args.collapse_insearch}", file=sys.stderr)
|
||||||
print(f"output : {out or 'stdout'}", file=sys.stderr, flush=True)
|
print(f"output : {out or 'stdout'}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
@ -243,6 +254,7 @@ def main(argv=None) -> int:
|
||||||
superpose=args.superpose,
|
superpose=args.superpose,
|
||||||
conn_grade=args.conn_grade,
|
conn_grade=args.conn_grade,
|
||||||
enable_bridge_circulation=args.bridge_circulation,
|
enable_bridge_circulation=args.bridge_circulation,
|
||||||
|
enable_ruin_recreate=args.ruin_recreate,
|
||||||
collapse_insearch=args.collapse_insearch,
|
collapse_insearch=args.collapse_insearch,
|
||||||
log=lambda m: print(m, file=sys.stderr, flush=True),
|
log=lambda m: print(m, file=sys.stderr, flush=True),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
rng: np.random.Generator, door_width: float = 1.2,
|
||||||
fixed_circ: "list[dom.Node] | None" = None,
|
fixed_circ: "list[dom.Node] | None" = None,
|
||||||
interior_outside: bool = False,
|
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.
|
"""Assign leaf types so rooms cluster around a connected circulation spine.
|
||||||
|
|
||||||
s44 (DESIGN.md §11.2 follow-up): random type assignment leaves rooms stranded
|
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
|
``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
|
(re)written in place. Stochastic where it is free (room order, tie-breaks) so
|
||||||
a bootstrap batch stays diverse.
|
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
|
from . import geometry
|
||||||
|
|
||||||
reqs = reqs or {}
|
reqs = reqs or {}
|
||||||
leaves = lvl.leaves()
|
leaves = lvl.leaves()
|
||||||
n = len(leaves)
|
|
||||||
idx = {leaf: i for i, leaf in enumerate(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)
|
R = len(room_codes)
|
||||||
n_circ = max(1, n - (R + max(1, n_outside))) # leftover after rooms + outside
|
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]
|
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
|
# 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
|
# the most central leaf), then repeatedly add the frontier leaf that newly
|
||||||
# dominates the most leaves (keeping the set connected).
|
# dominates the most leaves (keeping the set connected). Growth is confined to
|
||||||
circ = set(seeds) if seeds else {max(leaves, key=lambda L: (deg.get(L, 0), -idx[L]))}
|
# ``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))
|
dominated = set().union(*( _nbrs(s) | {s} for s in circ))
|
||||||
while len(circ) < n_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:
|
if frontier:
|
||||||
pick = max(frontier, key=lambda L: (len(_nbrs(L) - dominated),
|
pick = max(frontier, key=lambda L: (len(_nbrs(L) - dominated),
|
||||||
deg.get(L, 0), -idx[L]))
|
deg.get(L, 0), -idx[L]))
|
||||||
else: # disconnected remainder — seed a new component by degree
|
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:
|
if not rest:
|
||||||
break
|
break
|
||||||
pick = max(rest, key=lambda L: (deg.get(L, 0), -idx[L]))
|
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}
|
dominated |= _nbrs(pick) | {pick}
|
||||||
|
|
||||||
for s in circ:
|
for s in circ:
|
||||||
|
if s in assignable: # never retype a fixed_circ seed outside scope
|
||||||
s.type = "C"
|
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:
|
if interior_outside:
|
||||||
# ld2 (§13.6): seed ``O`` as INTERIOR light wells instead of one
|
# ld2 (§13.6): seed ``O`` as INTERIOR light wells instead of one
|
||||||
# peripheral leaf. A landlocked room (no plot facade, no uncovered-O
|
# 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)
|
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,
|
def mutate_reassociate(root: dom.Node, rng: np.random.Generator,
|
||||||
types: list[str]) -> tuple[dom.Node, str]:
|
types: list[str]) -> tuple[dom.Node, str]:
|
||||||
"""Wong-Liu M3 associativity move: ``(a|b)|c <-> a|(b|c)`` on parallel cuts.
|
"""Wong-Liu M3 associativity move: ``(a|b)|c <-> a|(b|c)`` on parallel cuts.
|
||||||
|
|
@ -1418,6 +1505,7 @@ MUTATIONS = {
|
||||||
"level_delete": mutate_level_delete,
|
"level_delete": mutate_level_delete,
|
||||||
"shape_rotate": mutate_shape_rotate,
|
"shape_rotate": mutate_shape_rotate,
|
||||||
"deslim": mutate_deslim,
|
"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)
|
names = sorted(MUTATIONS)
|
||||||
p = np.array([(weights or {}).get(n, 1.0) for n in names], dtype=float)
|
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
|
# 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
|
# also takes reqs (to avoid displacing a required room) but works without
|
||||||
# it — never zero-weighted, unlike reqs_ops above
|
# it — never zero-weighted, unlike reqs_ops above
|
||||||
reqs_optional_ops = ("bridge_circulation",)
|
reqs_optional_ops = ("bridge_circulation",)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue