Make the §13.3 lever a first-class feature, not experiment-only.
- programme.py: SpaceReq.share (default 1) + has_share, parsed from
patterns.config 'share: N'.
- operators._share_grain: resolve per-code grain from leaf_share_factor
selector — 0 = per-code opt-in (share iff share:N>=2), >=2 = global with
per-code override (share:1 opts OUT, share:N sets grain). _share_rooms
groups per resolved grain.
- End-to-end conf injection without monkeypatch: load_config(overrides=)
merges run-level keys last; driver.search / innerloop.optimise /
NativeEvaluator / _fitness_for thread conf_overrides={leaf_sharing:True}
through both inner-loop and off-tree scorers when sharing is on.
- homemaker-evolve: --leaf-sharing/--no-leaf-sharing + --leaf-share-factor
(env HOMEMAKER_LEAF_SHARING / HOMEMAKER_LEAF_SHARE_FACTOR).
- Example programmes untouched (§13.3/§13.9 stay reproducible). Experiment
load_config monkeypatches updated to accept overrides=.
Tests: grain modes, opt-out, default-OFF parity, load_config overrides,
programme parse, CLI parse. 233 pass. Smoke: harbor 37 vs 95 fails on/off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
166 lines
6.2 KiB
Python
166 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
||
"""Leaf-sharing floor probe (homemaker-py-erc.3, DESIGN.md §13.3).
|
||
|
||
Cheap de-risk BEFORE the full 20k A/B: does collapsing same-code rooms into
|
||
fewer, larger SHARED leaves actually lower the achievable fail floor, or does
|
||
the gain leak back as missing/size fails under the relaxed objective?
|
||
|
||
§13.1 found the per-leaf shape tax is ~1.8 and FLAT vs slicing density, so total
|
||
shape fails track leaf count linearly → fewer leaves is the only floor-mover.
|
||
Leaf-sharing reduces ROOM-leaf count: a leaf sized to k×target counts as k
|
||
same-code rooms (graph._leaf_share_mult), so presence holds without a missing
|
||
fail and size is scored against k×target. This script builds the §12.2
|
||
constructive seed both ways (baseline OFF vs sharing ON, share_factor sweep),
|
||
scores each at its own seed geometry, and reports the fail breakdown.
|
||
|
||
DECISION RULE: if sharing-ON total fails drop well below baseline (and the drop
|
||
is in size+crinkliness, NOT bought back by missing) → the floor moves → proceed
|
||
to thread the flag through the driver for the staged 20k A/B. If missing fails
|
||
balloon or totals don't move → stop; same-code sharing cannot pay here.
|
||
|
||
Usage:
|
||
URB_NO_OCCLUSION=1 python3 experiments/diag_leaf_sharing.py
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||
from homemaker_layout import dom, fitness, innerloop, operators, programme # noqa: E402
|
||
|
||
PROGRAMMES = ["harbor-house", "maple-court"]
|
||
SEEDS = (0, 1, 2)
|
||
BUDGET = 80 # bootstrap child budget, as in Diagnostic B
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
|
||
|
||
class _force_sharing:
|
||
"""Context manager: make innerloop's NativeEvaluator build its fitness in
|
||
leaf_sharing mode (the dir's patterns.config has no such key), so the inner
|
||
loop optimises against the SAME relaxed objective the seed was scored under."""
|
||
|
||
def __init__(self, on: bool):
|
||
self.on = on
|
||
|
||
def __enter__(self):
|
||
self._orig = fitness.load_config
|
||
if self.on:
|
||
def patched(directory, overrides=None):
|
||
conf, cost = self._orig(directory, overrides=overrides)
|
||
conf = dict(conf)
|
||
conf["leaf_sharing"] = True
|
||
return conf, cost
|
||
fitness.load_config = patched
|
||
return self
|
||
|
||
def __exit__(self, *exc):
|
||
fitness.load_config = self._orig
|
||
|
||
# fail-string buckets (order matters: first match wins)
|
||
CATS = ("missing", "size", "width", "proportion", "crinkliness",
|
||
"adjacency", "access", "other")
|
||
|
||
|
||
def _bucket(fails) -> dict[str, int]:
|
||
out = {k: 0 for k in CATS}
|
||
for f in fails:
|
||
if "missing" in f or "too many" in f:
|
||
out["missing"] += 1
|
||
elif f.endswith(" size"):
|
||
out["size"] += 1
|
||
elif f.endswith(" width"):
|
||
out["width"] += 1
|
||
elif f.endswith(" proportion"):
|
||
out["proportion"] += 1
|
||
elif f.endswith(" crinkliness"):
|
||
out["crinkliness"] += 1
|
||
elif "adjacen" in f:
|
||
out["adjacency"] += 1
|
||
elif "access" in f or "inaccessible" in f:
|
||
out["access"] += 1
|
||
else:
|
||
out["other"] += 1
|
||
return out
|
||
|
||
|
||
def _build(seed_root, reqs, types, s, sharing, factor):
|
||
rng = np.random.default_rng(s)
|
||
return operators.constructive_topology(
|
||
seed_root, reqs, rng, types,
|
||
adjacency_aware=True, proportion_aware=True,
|
||
leaf_sharing=sharing, leaf_share_factor=factor)
|
||
|
||
|
||
def _measure(fit, pdir, seed_root, reqs, types, s, sharing, factor):
|
||
topo = _build(seed_root, reqs, types, s, sharing, factor)
|
||
n_leaves = sum(len(lvl.leaves()) for lvl in dom.levels(topo))
|
||
_score, fails = fit.score_with_fails(copy.deepcopy(topo))
|
||
before = {"n_leaves": n_leaves, "total": len(fails), **_bucket(fails)}
|
||
|
||
after_tree = copy.deepcopy(topo)
|
||
with _force_sharing(sharing):
|
||
innerloop.optimise(after_tree, str(pdir), x0=None, budget=BUDGET,
|
||
method="nm", use_native=True)
|
||
_s2, fails2 = fit.score_with_fails(copy.deepcopy(after_tree))
|
||
after = {"n_leaves": n_leaves, "total": len(fails2), **_bucket(fails2)}
|
||
return before, after
|
||
|
||
|
||
def _avg(rows, k):
|
||
return sum(r[k] for r in rows) / len(rows)
|
||
|
||
|
||
def main() -> int:
|
||
print("Leaf-sharing floor probe (§13.3)\n")
|
||
print("Seed geometry = constructive proportion-aware target (built per mode).")
|
||
print(f"Seeds: {SEEDS}. 'OFF' = baseline fitness; 'shareN' = leaf_sharing, "
|
||
"share_factor=N.")
|
||
print(f"seed = constructive seed; +il = after innerloop.optimise (nm, "
|
||
f"budget={BUDGET}) under the same objective.\n")
|
||
cols = ("leaves", "total", "missing", "size", "crink", "width", "prop",
|
||
"adj", "access", "other")
|
||
hdr = f"{'programme':<14}{'mode':>10}" + "".join(f"{c:>8}" for c in cols)
|
||
|
||
def _row(name, label, rows, k):
|
||
vals = [_avg(rows, "n_leaves"), _avg(rows, "total"),
|
||
_avg(rows, "missing"), _avg(rows, "size"),
|
||
_avg(rows, "crinkliness"), _avg(rows, "width"),
|
||
_avg(rows, "proportion"), _avg(rows, "adjacency"),
|
||
_avg(rows, "access"), _avg(rows, "other")]
|
||
print(f"{name:<14}{label:>10}" + "".join(f"{v:>8.1f}" for v in vals))
|
||
|
||
for name in PROGRAMMES:
|
||
pdir = ROOT / "examples" / name
|
||
reqs = programme.load_programme_dir(pdir)
|
||
types = sorted(reqs) + ["C", "O"]
|
||
conf, cost = fitness.load_config(pdir)
|
||
seed_root = dom.load(str(pdir / "init.dom"))
|
||
|
||
fit_off = fitness.Fitness(conf, cost)
|
||
conf_on = dict(conf)
|
||
conf_on["leaf_sharing"] = True
|
||
fit_on = fitness.Fitness(conf_on, cost)
|
||
|
||
print(hdr)
|
||
print("-" * len(hdr))
|
||
modes = [("OFF", fit_off, False, 1),
|
||
("share2", fit_on, True, 2),
|
||
("share3", fit_on, True, 3)]
|
||
for label, fit, sharing, factor in modes:
|
||
pairs = [_measure(fit, pdir, seed_root, reqs, types, s, sharing, factor)
|
||
for s in SEEDS]
|
||
befores = [b for b, _a in pairs]
|
||
afters = [a for _b, a in pairs]
|
||
_row(name, label, befores, "before")
|
||
_row(name, label + "+il", afters, "after")
|
||
print()
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|