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-
|
||||
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 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
|
||||
# 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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue