homemaker-layout/experiments/ab_crinkliness_mode_ssz.py
Claude 50b5cfd476
§38.2 refinement: connectivity is under-priced ~3x, not just a crinkliness bug
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
2026-08-26 07:40:37 +00:00

151 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()