From 366a047a60dadda6409ec89e5fb7f80afc410ab8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 07:27:02 +0000 Subject: [PATCH] Make the crinkliness factor one-sided: stop billing the daylit wall twice The tail rescale shipped in cd392e7 is a measured NULL as a search intervention -- 12 of 12 pairs byte-identical on harbor and maple, 8000 evals from a plateau, not merely underpowered. Of course it is: the whole failing tail is 0.034% of corpus value. Looking at the rest of the factor, prompted by the owner, found something much larger above the threshold. crink = area_outside/area = (L*h)/A, so 1/crink = A/(L*h) is the room's mean depth from its daylit wall in storey-heights. That is the right variable for a daylight rule, and the fail boundary it implies (1/crink = 1.62, i.e. 4.86 m at h=3) is a sensible one that agrees with 38.3's frontage bound derived independently. What is wrong is hanging a TWO-sided gaussian on it: * The near side penalises a room for having MORE daylit wall than target -- while leaf_cost's siblings edge_cost and outside_edge_cost already charge that same wall at exterior_wall=100 and boundary_wall=133.3 per m2. The wall is billed once in cost and again as lost value. * It never earns its keep as a failure either: the over-exposed branch only reaches FAIL_THRESHOLD above crinkliness 21.5, and the corpus maximum is 3.95. It has never produced a single fail; it only removes value. * 133 of the 318 passing graded leaves in the 500k baseline (42%) sit on that side, mean quality 0.810. crinkliness_shape="daylight" (default OFF, "gaussian" is stock) clips it: a room shallower than the gaussian's peak scores 1.0, because daylight is a sufficiency requirement and surplus is the cost model's business, not this factor's. Clipping at the PEAK rather than at FAIL_THRESHOLD is deliberate -- it keeps the factor continuous and preserves the graded approach to the daylight limit, where clipping at the threshold would put a 10x cliff on the exact boundary the 0.5**n fail multiplier already steps on. Fail set byte-identical on all 21 corpus artefacts for all four shape/tail combinations, so stock stays a valid yardstick for every arm. Area-weighted crinkliness quality 0.480 -> 0.513, leaf quality product 0.2722 -> 0.2831; per-artefact score +0.2%..+19.6%, and unlike the ramp it reaches health-centre and programme-house, where the tail change was 0.000%. Note "daylight" clips the OPPOSITE side from 38.1's superseded compact_ok, which forgives being buried; composing either with those modes is refused. ab_9gj_ramp.py becomes ab_9gj_crinkliness.py and takes named arms, since it now covers both changes; its first arm is the baseline and the yardstick. Refs homemaker-py-9gj. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB --- .../{ab_9gj_ramp.py => ab_9gj_crinkliness.py} | 94 ++++++++++++------ experiments/results/ab_9gj_ramp.csv | 25 +++++ src/homemaker_layout/fitness.py | 38 ++++++++ ...ss_tail.py => test_fitness_crinkliness.py} | 95 +++++++++++++++++++ 4 files changed, 222 insertions(+), 30 deletions(-) rename experiments/{ab_9gj_ramp.py => ab_9gj_crinkliness.py} (70%) create mode 100644 experiments/results/ab_9gj_ramp.csv rename tests/{test_fitness_crinkliness_tail.py => test_fitness_crinkliness.py} (53%) diff --git a/experiments/ab_9gj_ramp.py b/experiments/ab_9gj_crinkliness.py similarity index 70% rename from experiments/ab_9gj_ramp.py rename to experiments/ab_9gj_crinkliness.py index 375cfab..7fbad89 100644 --- a/experiments/ab_9gj_ramp.py +++ b/experiments/ab_9gj_crinkliness.py @@ -1,11 +1,24 @@ -"""Search A/B for the crinkliness tail rescale (`homemaker-py-9gj`). +"""Search A/B for the crinkliness reformulation (`homemaker-py-9gj`). -`quality_uncrinkliness` evaluates a gaussian at `x = 1/crink`, so its exponent -grows like `1/crink^2` as exposure falls. Measured over the 500k cold-start -baseline (DESIGN.md §39.12), the FAILING compact tail spans crinkliness -0.12..0.59 and quality 1e-300..1e-1 -- every value of which is numerically -zero beside a passing leaf's ~1. `crinkliness_tail="ramp"` replaces that tail, -and only that tail, with a straight line in crinkliness. +Two independent changes to `quality_uncrinkliness`, either or both: + + crinkliness_tail="ramp" below FAIL_THRESHOLD. The factor evaluates a + gaussian at `x = 1/crink`, whose exponent + grows like 1/crink^2, so the failing tail + spans quality 1e-300..1e-1 -- all of it + numerically zero beside a passing leaf's ~1. + The ramp makes that tail a straight line in + crinkliness. (DESIGN.md §39.13. Measured a + complete null on its own: 12 of 12 pairs + byte-identical.) + crinkliness_shape="daylight" above it. `1/crink` is the room's mean depth + from its daylit wall in storey-heights, and + the stock gaussian is TWO-sided on it, so a + room with more daylight than target is + penalised for it -- while the cost model + already charges that wall via + `exterior_wall`/`boundary_wall`. "daylight" + clips that side to 1.0. (DESIGN.md §39.14.) **Why stock scoring is valid here** (the §38.9 trap, and the one case the `9gj` bead flags as exempt): the ramp is continuous at FAIL_THRESHOLD and @@ -30,18 +43,18 @@ gives N*M paired samples per programme. Usage:: - python experiments/ab_9gj_ramp.py --budget 8000 --seeds 2 --starts 3 - python experiments/ab_9gj_ramp.py --start init --budget 8000 --seeds 6 + python experiments/ab_9gj_crinkliness.py --budget 8000 --seeds 2 --starts 3 + python experiments/ab_9gj_crinkliness.py --start init --budget 8000 --seeds 6 Sharding, because one run is minutes and the job list is 4x that. Each shard keeps BOTH arms of a pair together, so the two halves of a comparison never land on differently-loaded processes:: for i in 0 1 2 3; do - python experiments/ab_9gj_ramp.py --budget 8000 --seeds 2 --starts 3 \ + python experiments/ab_9gj_crinkliness.py --budget 8000 --seeds 2 --starts 3 \ --shard $i --nshards 4 & done; wait - python experiments/ab_9gj_ramp.py --report + python experiments/ab_9gj_crinkliness.py --report A POWERED run needs more than the in-session pilot could afford. §39.12 puts harbor's minimum detectable difference at n=3 at 13.7 fails; the plateau-escape @@ -50,7 +63,7 @@ deltas here are single-digit, so budget for n >= 8 pairs per programme move at all -- the pilot's 8000 evals is 1.6% of what produced the plateau:: for i in $(seq 0 3); do - python experiments/ab_9gj_ramp.py --budget 100000 --seeds 3 --starts 3 \ + python experiments/ab_9gj_crinkliness.py --budget 100000 --seeds 3 --starts 3 \ --shard $i --nshards 4 & done; wait """ @@ -68,21 +81,33 @@ from homemaker_layout import dom as dom_mod from homemaker_layout import driver, fitness CORPUS = ["examples/harbor-house", "examples/maple-court"] -ARMS = ["gaussian", "ramp"] + +# Each arm names a crinkliness configuration. "stock" must stay first: it is +# the baseline every other arm is paired against, and the yardstick all arms +# are SCORED under. +ARM_CONF = { + "stock": {}, + "ramp": {"crinkliness_tail": "ramp"}, + "daylight": {"crinkliness_shape": "daylight"}, + "daylight+ramp": {"crinkliness_shape": "daylight", + "crinkliness_tail": "ramp"}, +} +ARMS = ["stock", "daylight", "daylight+ramp"] -def _with_tail(tail: str): +def _with_arm(arm: str): """Patch `fitness.load_config` so every evaluator built during the run -- - the driver's, the inner loop's, the seeder's -- sees `crinkliness_tail`. + the driver's, the inner loop's, the seeder's -- sees the arm's overrides. - `driver.search` has no parameter for it and `driver._fitness_for` is + `driver.search` has no parameter for them and `driver._fitness_for` is lru_cached, so the cache is cleared around the patch (see ab_ssz_search). """ orig = fitness.load_config + arm_ov = ARM_CONF[arm] def patched(directory, overrides=None): ov = dict(overrides or {}) - ov["crinkliness_tail"] = tail + ov.update(arm_ov) return orig(directory, overrides=ov) return orig, patched @@ -95,7 +120,7 @@ def tiers(fails) -> tuple[int, int]: def run_arm(progdir: str, start: Path, seed: int, tail: str, budget: int, child_budget: int) -> dict: - orig, patched = _with_tail(tail) + orig, patched = _with_arm(tail) fitness.load_config = patched driver._fitness_for.cache_clear() t0 = time.perf_counter() @@ -143,7 +168,9 @@ def main() -> None: help="plateau layouts to start from (plateau mode only)") ap.add_argument("--start", choices=("plateau", "init"), default="plateau") ap.add_argument("--corpus", nargs="+", default=CORPUS) - ap.add_argument("--out", default="experiments/results/ab_9gj_ramp.csv") + ap.add_argument("--arms", nargs="+", default=ARMS, + choices=sorted(ARM_CONF), help="first arm is the baseline") + ap.add_argument("--out", default="experiments/results/ab_9gj_crinkliness.csv") ap.add_argument("--shard", type=int, default=0, help="run only jobs i where i %% nshards == shard") ap.add_argument("--nshards", type=int, default=1, @@ -173,7 +200,7 @@ def main() -> None: w = csv.DictWriter(fh, fieldnames=list(rows[0])) w.writeheader() w.writerows(rows) - _report(rows, args.corpus) + _report(rows, args.corpus, args.arms) print(f"\nmerged {len(rows)} runs from {len(shards)} file(s) into {out}") return @@ -193,7 +220,7 @@ def main() -> None: for i, (progdir, start, seed) in enumerate(jobs): if i % args.nshards != args.shard: continue - for tail in ARMS: + for tail in args.arms: r = run_arm(progdir, start, seed, tail, args.budget, args.child_budget) rows.append(r) @@ -205,26 +232,33 @@ def main() -> None: w.writeheader() w.writerows(rows) if args.nshards == 1: - _report(rows, args.corpus) + _report(rows, args.corpus, args.arms) print(f"\nwrote {dest}") -def _report(rows, corpus) -> None: +def _report(rows, corpus, arms=None) -> None: """homemaker-py-tco: state what this N could resolve, beside the result.""" from ab_report import format_report, paired_report + seen = [] + for r in rows: + if r["tail"] not in seen: + seen.append(r["tail"]) + arms = [a for a in (arms or seen) if a in seen] or seen + base = arms[0] for progdir in corpus: name = Path(progdir).name by: dict[tuple, dict] = {} for r in rows: if r["programme"] == name: by.setdefault((r["start"], r["seed"]), {})[r["tail"]] = r["total"] - keys = sorted(k for k, v in by.items() if len(v) == 2) - if len(keys) < 2: - continue - print(f"\n--- {name}: ramp vs gaussian (stock-scored total fails) ---") - print(format_report(paired_report( - [by[k]["gaussian"] for k in keys], [by[k]["ramp"] for k in keys], - "gaussian", "ramp"))) + for arm in arms[1:]: + keys = sorted(k for k, v in by.items() if base in v and arm in v) + if len(keys) < 2: + continue + print(f"\n--- {name}: {arm} vs {base} (stock-scored total fails) ---") + print(format_report(paired_report( + [by[k][base] for k in keys], [by[k][arm] for k in keys], + base, arm))) if __name__ == "__main__": diff --git a/experiments/results/ab_9gj_ramp.csv b/experiments/results/ab_9gj_ramp.csv new file mode 100644 index 0000000..7637ba2 --- /dev/null +++ b/experiments/results/ab_9gj_ramp.csv @@ -0,0 +1,25 @@ +programme,start,seed,tail,hard,soft,total,score,elapsed_s +harbor-house,coldstart-500000-s0.dom,0,gaussian,8,25,33,1.85176240526207e-11,403.6 +harbor-house,coldstart-500000-s0.dom,0,ramp,8,25,33,1.85176240526207e-11,413.5 +harbor-house,coldstart-500000-s2.dom,0,gaussian,12,30,42,3.0491865573276097e-14,364.4 +harbor-house,coldstart-500000-s2.dom,0,ramp,12,30,42,3.0491865573276097e-14,361.9 +maple-court,coldstart-500000-s1.dom,0,gaussian,17,56,73,4.626623187133073e-25,445.0 +maple-court,coldstart-500000-s1.dom,0,ramp,17,56,73,4.626281900486264e-25,443.8 +harbor-house,coldstart-500000-s0.dom,1,gaussian,8,24,32,3.749216704165142e-11,417.1 +harbor-house,coldstart-500000-s0.dom,1,ramp,8,24,32,3.749216704165142e-11,425.6 +harbor-house,coldstart-500000-s2.dom,1,gaussian,12,30,42,3.0491865573276097e-14,355.6 +harbor-house,coldstart-500000-s2.dom,1,ramp,12,30,42,3.0491865573276097e-14,364.3 +maple-court,coldstart-500000-s1.dom,1,gaussian,17,56,73,4.629596572120101e-25,442.4 +maple-court,coldstart-500000-s1.dom,1,ramp,17,56,73,4.629661078538839e-25,444.8 +harbor-house,coldstart-500000-s1.dom,0,gaussian,7,36,43,1.4676873187056738e-15,373.2 +harbor-house,coldstart-500000-s1.dom,0,ramp,7,36,43,1.4676873187056738e-15,375.2 +maple-court,coldstart-500000-s0.dom,0,gaussian,18,35,53,1.1458353761153645e-17,483.1 +maple-court,coldstart-500000-s0.dom,0,ramp,18,35,53,1.1458353761153645e-17,462.6 +maple-court,coldstart-500000-s2.dom,0,gaussian,12,43,55,4.278297968488281e-18,481.2 +maple-court,coldstart-500000-s2.dom,0,ramp,12,43,55,4.267745195711144e-18,481.8 +harbor-house,coldstart-500000-s1.dom,1,gaussian,7,36,43,1.4676873187056738e-15,378.6 +harbor-house,coldstart-500000-s1.dom,1,ramp,7,36,43,1.4676873187056738e-15,380.1 +maple-court,coldstart-500000-s0.dom,1,gaussian,17,37,54,6.645342661051106e-18,456.4 +maple-court,coldstart-500000-s0.dom,1,ramp,17,37,54,6.645342661051106e-18,478.6 +maple-court,coldstart-500000-s2.dom,1,gaussian,12,43,55,4.258152742657858e-18,517.1 +maple-court,coldstart-500000-s2.dom,1,ramp,12,43,55,4.258152742657858e-18,515.6 diff --git a/src/homemaker_layout/fitness.py b/src/homemaker_layout/fitness.py index 65a6a83..1534b30 100644 --- a/src/homemaker_layout/fitness.py +++ b/src/homemaker_layout/fitness.py @@ -477,6 +477,23 @@ class Fitness: if self._crinkliness_tail not in ("gaussian", "ramp"): raise ValueError( f"unknown crinkliness_tail: {self._crinkliness_tail!r}") + # homemaker-py-9gj (DESIGN.md §39.14): what the factor rewards ABOVE the + # fail threshold, orthogonal to `crinkliness_tail` below it. + # "gaussian" (default) is stock: two-sided, so a room with MORE daylit + # wall than target is penalised for it. "daylight" clips that side to + # 1.0 -- daylight is a sufficiency requirement, and the envelope a + # well-lit room costs is already charged by `exterior_wall` and + # `boundary_wall` in the cost model, so penalising it again in value + # bills the same wall twice. + self._crinkliness_shape = str(self.conf("crinkliness_shape") or "gaussian") + if self._crinkliness_shape not in ("gaussian", "daylight"): + raise ValueError( + f"unknown crinkliness_shape: {self._crinkliness_shape!r}") + if self._crinkliness_shape == "daylight" and self._crinkliness_mode != "urb": + raise ValueError( + "crinkliness_shape='daylight' is incompatible with " + f"crinkliness_mode={self._crinkliness_mode!r} (§38.1's modes are " + "superseded; use one or the other, not both)") if self._crinkliness_tail == "ramp" and self._crinkliness_mode != "urb": # Both rewrite the same tail; composing them would give a shape # neither was measured under. @@ -1266,6 +1283,13 @@ class Fitness: # whatever it holds. So the factor is CLIPPED on the compact side, # never switched off, and the over-exposed side keeps the global # bound. + # + # ...unless `crinkliness_shape="daylight"`, under which the + # over-exposed side is not this factor's business at all (the cost + # model charges that wall). A space with no daylight requirement + # then has nothing left to be judged on. (§39.14) + if self._crinkliness_shape == "daylight": + return 1.0 if not crink: return 1.0 distance, sigma = self.conf("uncrinkliness") @@ -1290,6 +1314,20 @@ class Fitness: return 1.0 return self._crinkliness_floor if mode == "floor" else 0.0 + if self._crinkliness_shape == "daylight" and 1 / crink <= distance: + # homemaker-py-9gj (DESIGN.md §39.14). `1/crink` is the room's mean + # depth from its daylit wall in storey-heights, so `1/crink <= + # distance` means comfortably lit -- shallower than the point the + # stock gaussian peaks at. Stock decays from there as if surplus + # daylight were a defect; it is not one this factor should price, + # because the extra exterior wall is already billed in `cost`. + # Clipping here (rather than at the fail threshold) keeps the + # factor CONTINUOUS: the graded approach to the daylight limit + # survives, and no 10x cliff is introduced at the very boundary + # the fail multiplier already steps on. Note this is the OPPOSITE + # side from §38.1's `compact_ok`, which forgives being buried. + return 1.0 + q = gaussian(1 / crink, 1.0, distance, sigma) if one_sided and 1 / crink > distance: return 1.0 diff --git a/tests/test_fitness_crinkliness_tail.py b/tests/test_fitness_crinkliness.py similarity index 53% rename from tests/test_fitness_crinkliness_tail.py rename to tests/test_fitness_crinkliness.py index d8b0dbe..4605dfd 100644 --- a/tests/test_fitness_crinkliness_tail.py +++ b/tests/test_fitness_crinkliness.py @@ -111,3 +111,98 @@ def test_unknown_tail_is_rejected(): conf, cost = load_config(d, overrides={"crinkliness_tail": "linear"}) with pytest.raises(ValueError, match="unknown crinkliness_tail"): Fitness(conf, cost) + + +# --------------------------------------------------------------------------- # +# crinkliness_shape="daylight" (homemaker-py-9gj, DESIGN.md §39.14) +# --------------------------------------------------------------------------- # + +def _q(distance, sigma, crink, **conf): + """quality_uncrinkliness for a synthetic leaf at a given crinkliness. + + A bare `Node` with no type: `is_outside`/`is_covered` must both be False so + the factor is actually evaluated rather than short-circuited to 1.0 for an + uncovered outside leaf. + """ + d = EXAMPLES / "harbor-house" + c, cost = load_config(d, overrides=conf) + fit = Fitness(c, cost) + fit.crinkliness_params = lambda leaf: (distance, sigma) + fit.crinkliness = lambda leaf, G, groups: crink + leaf = dom_mod.Node(type="x1") + assert not dom_mod.is_outside(leaf) + return Fitness.quality_uncrinkliness(fit, leaf, None, None) + + +@pytest.mark.skipif(not (EXAMPLES / "harbor-house").is_dir(), reason="examples absent") +def test_daylight_shape_stops_penalising_surplus_daylight(): + """`1/crink` is depth-in-storey-heights; below `distance` the room is + shallower than the stock peak, i.e. better lit than asked for. Stock + decays from there; "daylight" does not.""" + b, s = 5.0 / 6, 1.1 / 3 + for crink in (1 / b, 1.5, 2.0, 4.0, 20.0): # 1/crink <= b + assert _q(b, s, crink, crinkliness_shape="daylight") == 1.0 + if crink > 1 / b: + assert _q(b, s, crink) < 1.0, "stock should penalise surplus daylight" + + +@pytest.mark.skipif(not (EXAMPLES / "harbor-house").is_dir(), reason="examples absent") +def test_daylight_shape_leaves_the_under_lit_side_alone(): + """Only the surplus side is clipped. The graded approach to the daylight + limit is the part that still does useful work, so it must not move.""" + b, s = 5.0 / 6, 1.1 / 3 + for crink in (0.62, 0.7, 0.9, 1.0, 1.19): # 1/crink > b, passing + assert _q(b, s, crink, crinkliness_shape="daylight") == _q(b, s, crink) + + +@pytest.mark.skipif(not (EXAMPLES / "harbor-house").is_dir(), reason="examples absent") +def test_daylight_shape_is_continuous_at_the_clip(): + """Clipping at the gaussian's peak rather than at FAIL_THRESHOLD is what + keeps this continuous. Clipping at the threshold would put a 10x cliff on + the exact boundary the 0.5**n fail multiplier already steps on.""" + b, s = 5.0 / 6, 1.1 / 3 + just_under = _q(b, s, 1 / b - 1e-9, crinkliness_shape="daylight") + assert just_under == pytest.approx(1.0, abs=1e-6) + + +@pytest.mark.skipif(not (EXAMPLES / "harbor-house").is_dir(), reason="examples absent") +def test_every_combination_keeps_the_fail_set_byte_identical(): + """The invariant that makes stock scoring a valid yardstick for all arms. + + The over-exposed branch of the stock gaussian only fires above crinkliness + 21.5, and the corpus maximum is 3.95, so clipping that side removes no + failure that any corpus artefact actually incurs. + """ + combos = [{"crinkliness_tail": "ramp"}, + {"crinkliness_shape": "daylight"}, + {"crinkliness_shape": "daylight", "crinkliness_tail": "ramp"}] + seen = 0 + for d, p in _artefacts(): + root = dom_mod.load(str(p)) + c_stock, cost = load_config(d) + _, f_stock = Fitness(c_stock, cost).score_with_fails(copy.deepcopy(root)) + for ov in combos: + c, _ = load_config(d, overrides=ov) + _, f = Fitness(c, cost).score_with_fails(copy.deepcopy(root)) + assert f == f_stock, f"{p} changed its fail set under {ov}" + seen += 1 + assert seen >= 4 + + +def test_unknown_shape_is_rejected(): + d = EXAMPLES / "harbor-house" + if not d.is_dir(): + pytest.skip("examples absent") + conf, cost = load_config(d, overrides={"crinkliness_shape": "onesided"}) + with pytest.raises(ValueError, match="unknown crinkliness_shape"): + Fitness(conf, cost) + + +def test_daylight_shape_refuses_to_compose_with_the_superseded_modes(): + d = EXAMPLES / "harbor-house" + if not d.is_dir(): + pytest.skip("examples absent") + conf, cost = load_config(d, overrides={"crinkliness_shape": "daylight", + "crinkliness_mode": "compact_ok"}) + with pytest.raises(ValueError, match="incompatible"): + Fitness(conf, cost)