DESIGN.md 38.6 concluded the three crinkliness modes were inert against the
circulation-deletion incentive. Two things were wrong with that measurement.
Its premise, 38.2, is retracted. And its script selected leaves with the
pre-39.4 prefix rule `type[:1].upper() in ("C","O")`, which sweeps every
programme room starting with c or o -- cr1, of1 -- in as circulation.
The simpler problem is that none of the three modes ever touched the leaves
ssz is about. quality_uncrinkliness reaches `if not crink` before any mode
logic that matters, so for a zero-exposure leaf: floor returns 0.01 (one
percent of a unit quality, multiplied into a product and weighed against a
whole leaf's cost -- inert); compact_ok is self-contradictory, announcing
that compact is not a defect and then returning the floor for the most
compact case of all; exempt_circulation reaches at most a third of them.
Measured: 0% / 0% / 0% / 21-33% of buried leaves rescued.
What the buried leaves are, now that 39.7 gives every space a usage: two
thirds of them are spaces that architecturally do not want a window --
stores, WCs, plant, corridors, covered courtyards -- scored identically
with a windowless bedroom. harbor 22/33, maple 33/46, health 9/18.
- crinkliness_mode="usage_daylight": daylight required of the uses a
person occupies (programme.DAYLIGHT_USAGES) and nothing else. Elsewhere
the factor is clipped on the compact side only, so being buried stops
being a defect while over-exposure still costs -- a crinkly leaf costs
envelope whatever it is used for. A windowless bedroom stays the hard
zero it is under stock: 11/11, 13/13, 9/9 still failing.
- compact_ok repaired to score the buried limit as compact, the behaviour
its name always claimed. It now rescues 100% including bedrooms, and is
kept as the upper-bound control, not a candidate.
- ab_ssz_search.py: the fixed-budget search A/B ssz's acceptance criteria
actually asks for. Every arm is optimised under its own objective and
re-scored under stock urb, because the permissive modes return 1.0
where stock fails and would otherwise win by deleting a fail category.
- ab_crinkliness_mode_ssz.py: prefix rule fixed, retracted premise
flagged in its docstring.
- 38.7's remaining claims from the retracted 38.2/38.3 corrected.
Default is unchanged ("urb"), byte-identical to all prior runs. Lint at
parity (46 pre-existing); tests 366 passed, 10 new, same 7 pre-existing
fixture failures (homemaker-py-bdf).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
168 lines
6.8 KiB
Python
168 lines
6.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.
|
||
|
||
.. warning::
|
||
|
||
**The premise of this test is RETRACTED — see §38.2 and §38.8.** The ×6-vs-
|
||
×0.5 arithmetic assumed severing the spine costs one connectivity fail;
|
||
measured, the connectivity count is unchanged in every rewarded deletion, so
|
||
this script is not measuring what its docstring says. It is kept because the
|
||
per-mode buried/lit split is still a useful description of what each mode
|
||
touches, but a mode does **not** pass or fail on these numbers. The A/B that
|
||
decides `ssz` is ``experiments/ab_ssz_search.py`` (fixed-budget search,
|
||
scored under the stock objective).
|
||
|
||
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",
|
||
"usage_daylight")
|
||
|
||
|
||
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
|
||
# §39.4: generic structural types are EXACT `C`/`O`/`S`, never a
|
||
# first-character prefix -- `cr1` is a programme room. This script
|
||
# predates that rule and its original `type[:1].upper() in ("C","O")`
|
||
# test swept programme rooms into the "unpinned" set, which is one
|
||
# reason the §38.6 numbers do not reproduce.
|
||
if not dom_mod.is_generic(leaf.type):
|
||
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()
|