Follow-up measurement corrects the first draft of §38 in two ways. 1. Harbor-house's floor is 15 fails (evolved-3M-nols-3, 1.7M evals), not the 30-40 I quoted from §13.11's 20k-budget runs. Frontage deficit predicts the COST of solving, not impossibility: ~150x budget gap between a frontage-short and a frontage-surplus programme. Table corrected. 2. Zero-exposure is only half the mechanism, and not the dominant half. Splitting the deletion test by lit vs buried shows a WELL-DAYLIT corridor (q_crink=0.736) is still worth x4.06 to delete. Cause: value_circulation=50 vs value_inside=300, so merging corridor into room is a flat x6 gain, while 'level N not connected' costs only x0.5. Break-even needs 0.5^k < 50/300, i.e. k > 2.58 -- severing must cost at least 3 fails and costs 1. Net x3.0 predicted, x4.06 measured. The objective is net-positive on severing the spine even when the circulation is perfectly lit, which explains why both 'level N not connected' fails survive in the best layout after 1.7M evals. Adds fitness.quality_uncrinkliness crinkliness_mode (EXPERIMENTAL, default "urb" = stock hard 0.0, byte-identical: 336 passed vs 331 before, same 7 pre-existing fixture failures). A/B harness ab_crinkliness_mode_ssz.py shows none of the three modes removes the incentive, and the lit column is 3/8 under every mode including stock -- clean isolation of the two mechanisms. Filed homemaker-py-2v1 (P0) for the pricing fix; ssz/hxi now depend on it. Acceptance test recorded up front: harbor must reach 15 fails in materially fewer than 1.7M evals AND without either not-connected fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
151 lines
5.8 KiB
Python
151 lines
5.8 KiB
Python
"""A/B the `crinkliness_mode` repairs against the circulation-deletion incentive.
|
||
|
||
Evidence for `homemaker-py-ssz` / `homemaker-py-2v1` (DESIGN.md §38.6). The
|
||
test: on a constructed layout, delete each unpinned circulation/outside leaf
|
||
(by undividing its parent, which merges it into its sibling) and re-score. A
|
||
healthy objective should not pay you to do that.
|
||
|
||
Reports the count of deletions that still improve the score, and the median
|
||
score ratio, per mode. Splitting the rows by whether the deleted leaf was
|
||
buried or lit is what separates the two mechanisms:
|
||
|
||
* buried leaves are rewarded because ``quality_uncrinkliness`` returns a hard
|
||
0.0, so they contribute zero value (§38.1);
|
||
* **lit** leaves are rewarded because ``value_circulation`` (50) is one sixth
|
||
of ``value_inside`` (300), so merging corridor into room is a flat ×6 gain
|
||
against a ×0.5 connectivity penalty (§38.2 refinement) — which no
|
||
``crinkliness_mode`` can touch.
|
||
|
||
Usage::
|
||
|
||
python experiments/ab_crinkliness_mode_ssz.py
|
||
python experiments/ab_crinkliness_mode_ssz.py --seeds 5 --progdir examples/maple-court
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import copy
|
||
|
||
import numpy as np
|
||
|
||
from homemaker_layout import dom as dom_mod
|
||
from homemaker_layout import driver, fitness, geometry
|
||
from homemaker_layout import graph as graph_mod
|
||
from homemaker_layout import operators, programme
|
||
|
||
MODES = ("urb", "floor", "compact_ok", "exempt_circulation")
|
||
|
||
|
||
def make_fitness(progdir: str, mode: str) -> fitness.Fitness:
|
||
"""Evaluator matching ``driver.search``'s defaults, with one mode override."""
|
||
overrides = dict(driver._overrides_for(
|
||
leaf_sharing=True, superpose=False, max_share=None, conn_grade=False,
|
||
collapse_insearch=True, multi_use=False) or {})
|
||
overrides["crinkliness_mode"] = mode
|
||
conf, cost = fitness.load_config(progdir, overrides=overrides)
|
||
return fitness.Fitness(conf, cost)
|
||
|
||
|
||
def constructed_seed(progdir: str, seed: int) -> dom_mod.Node:
|
||
reqs = programme.load_programme_dir(progdir)
|
||
return operators.constructive_topology(
|
||
dom_mod.load(f"{progdir}/init.dom"), reqs, np.random.default_rng(seed),
|
||
sorted(reqs) + ["C", "O"],
|
||
min_storeys=programme.storey_minimum(progdir),
|
||
adjacency_aware=True, proportion_aware=True, circ_divisor=3,
|
||
leaf_sharing=True, leaf_share_factor=3, depth_balanced=True,
|
||
interior_outside=True, outside_divisor=3)
|
||
|
||
|
||
def unpinned_leaves(fit: fitness.Fitness, root: dom_mod.Node) -> list[tuple]:
|
||
"""(level, id, type, exposed_area) for circulation/outside leaves — the ones
|
||
no missing-space cascade pins in place."""
|
||
tree = copy.deepcopy(root)
|
||
geometry.clear_cache()
|
||
dom_mod.canonicalize_shares(tree)
|
||
fit.preprocess_building(tree)
|
||
dom_mod.merge_divided(tree)
|
||
geometry.clear_cache()
|
||
graphs = graph_mod.build_graphs(tree, fit.conf("door_width") or 1.2)
|
||
|
||
out = []
|
||
for li, lvl in enumerate(dom_mod.levels(tree)):
|
||
for leaf in lvl.leaves():
|
||
if dom_mod.is_outside(leaf) and not dom_mod.is_covered(leaf):
|
||
continue
|
||
if (leaf.type or "")[:1].upper() not in ("C", "O"):
|
||
continue
|
||
out.append((li, leaf.id, leaf.type,
|
||
fit.area_outside(leaf, graphs[li], {})))
|
||
return out
|
||
|
||
|
||
def delete_leaf(root: dom_mod.Node, li: int, lid: str) -> "dom_mod.Node | None":
|
||
"""Undivide the leaf's parent, merging it into its sibling. None if the cut
|
||
is inherited (not owned at this storey) or the sibling is itself divided."""
|
||
cand = copy.deepcopy(root)
|
||
lvls = dom_mod.levels(cand)
|
||
if li >= len(lvls):
|
||
return None
|
||
node = lvls[li].by_id(lid)
|
||
if node is None or node.parent is None:
|
||
return None
|
||
parent = node.parent
|
||
if parent.below is not None and parent.below.divided:
|
||
return None
|
||
sibling = parent.right if parent.left is node else parent.left
|
||
if sibling is None or sibling.divided:
|
||
return None
|
||
parent.division = None
|
||
parent.left = parent.right = None
|
||
parent.type = sibling.type
|
||
dom_mod.link(cand)
|
||
geometry.clear_cache()
|
||
return cand
|
||
|
||
|
||
def run(progdir: str, seeds: int) -> None:
|
||
print(f"programme: {progdir}, {seeds} constructed seeds")
|
||
print("a healthy objective rewards NO deletions\n")
|
||
header = f"{'mode':<20}{'buried':<14}{'lit':<14}{'all':<14}median x"
|
||
print(header)
|
||
print("-" * len(header))
|
||
|
||
for mode in MODES:
|
||
fit = make_fitness(progdir, mode)
|
||
counts = {"buried": [0, 0], "lit": [0, 0]}
|
||
ratios = []
|
||
for s in range(seeds):
|
||
root = constructed_seed(progdir, s)
|
||
base, _ = fit.score_with_fails(copy.deepcopy(root))
|
||
for li, lid, _typ, exposed in unpinned_leaves(fit, root):
|
||
cand = delete_leaf(root, li, lid)
|
||
if cand is None:
|
||
continue
|
||
score, _ = fit.score_with_fails(copy.deepcopy(cand))
|
||
bucket = "buried" if exposed == 0 else "lit"
|
||
counts[bucket][1] += 1
|
||
ratios.append(score / base)
|
||
if score > base:
|
||
counts[bucket][0] += 1
|
||
tot = [counts["buried"][0] + counts["lit"][0],
|
||
counts["buried"][1] + counts["lit"][1]]
|
||
median = sorted(ratios)[len(ratios) // 2] if ratios else float("nan")
|
||
buried = "%d/%d" % tuple(counts["buried"])
|
||
lit = "%d/%d" % tuple(counts["lit"])
|
||
both = "%d/%d" % tuple(tot)
|
||
print(f"{mode:<20}{buried:<14}{lit:<14}{both:<14}x{median:.2f}")
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser(description=__doc__,
|
||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
ap.add_argument("--progdir", default="examples/harbor-house")
|
||
ap.add_argument("--seeds", type=int, default=3)
|
||
args = ap.parse_args()
|
||
run(args.progdir, args.seeds)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|