From 189efdbfc7ceface1e4f06f17d984a80a3d00831 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 16:45:10 +0000 Subject: [PATCH] ssz: daylight is required of rooms that do not need it 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 Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB --- DESIGN.md | 101 ++++++++++++++++- experiments/ab_crinkliness_mode_ssz.py | 21 +++- experiments/ab_ssz_search.py | 145 +++++++++++++++++++++++++ experiments/results/ab_ssz_search.csv | 3 + src/homemaker_layout/fitness.py | 42 ++++++- src/homemaker_layout/programme.py | 6 + tests/test_fitness.py | 61 +++++++++++ 7 files changed, 367 insertions(+), 12 deletions(-) create mode 100644 experiments/ab_ssz_search.py create mode 100644 experiments/results/ab_ssz_search.csv diff --git a/DESIGN.md b/DESIGN.md index a7696b6..7ccd300 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -5075,10 +5075,16 @@ fails to move** — `homemaker-py-2v1` is the half that matters. rather than unmet") was aimed at the right target, and §38.3 supplies a cheap way to test it that does **not** need `2g7.1`'s traced human plans: the frontage bound is a pre-flight feasibility check computable from a plot and a -programme alone. Two of the four corpus programmes fail it by ~3×, which means -a share of the residual those runs are being judged on **is not reachable at -all** — and any A/B measured against that residual has been measuring, in -part, an unsatisfiable constraint. +programme alone. + +*(As first written this paragraph continued "two of the four corpus programmes +fail it by ~3×, which means a share of the residual those runs are being judged +on is not reachable at all". **That is withdrawn** — see §39.11. The ~3× came +from applying the bound to a fully built plot rather than to the area each +programme demands; harbor-house and maple-court are frontage-feasible with room +to spare. One programme is unsatisfiable, health-centre, and for a cruder +reason: it demands 131% of its plot. So the residual the other runs are judged +on is reachable, and the plateau is not explained by an unsatisfiable brief.)* Tracks 2 and 3 (cheaper evaluation, exact sub-solvers) remain sound but are orthogonal: making an evaluation 97× faster, or a labelling exact, does not @@ -5089,6 +5095,12 @@ dominant mechanism, and the one the §38.6 A/B isolated) → `ssz`/`hxi`/`gvb` pre-flight bound and re-baseline the corpus), and only then resume `2g7.9`/`2g7.10`. +*(Both halves of that ordering's rationale have since been measured and did not +survive. `2v1` closed NULL — severing the spine is already punished, §38.2 is +retracted — and `tdp`'s infeasibility claim is retracted above. What the +ordering got right is that `ssz` comes before `2g7.7`; see §38.8 for what `ssz` +turned out to be.)* + **Acceptance test for the combined fix, stated up front so it cannot be moved:** harbor-house must reach its known 15-fail floor in materially fewer than 1.7 M evals, *and* `level 0 not connected` / `level 1 not connected` must @@ -5099,6 +5111,87 @@ valley-crossing multi-edit against an objective that pays ×85 to delete the corridor it just inserted will have its work reverted by the next selection step. +### 38.8 What `ssz` actually was: the objective demands daylight for rooms that do not need it (`homemaker-py-ssz`) + +**First, why §38.6's A/B does not stand.** It measured the three modes against +the §38.2 *deletion test*, and it did so with a script that predates §39.4: +`experiments/ab_crinkliness_mode_ssz.py` selected "unpinned" leaves with +`(leaf.type or "")[:1].upper() in ("C", "O")`, the first-character prefix rule, +so every programme room whose code happens to begin with c or o — `cr1`, `of1` +— was swept in as circulation. Both the premise (§38.2, retracted) and the +selection were wrong. The script is kept, with the prefix rule fixed and a +warning in its docstring, but nothing is decided on its numbers. + +**Second, and simpler: none of the three modes ever touched the leaves `ssz` is +about.** `quality_uncrinkliness` reaches `if not crink: return ...` *before* any +of the mode logic that matters, so for a zero-exposure leaf: + +| mode | buried leaves | rescued to ≥ `FAIL_THRESHOLD` | windowless habitable rooms still failing | +|---|---|---|---| +| `urb` (stock) | 33 / 46 / 18 | 0% | 11/11, 13/13, 9/9 | +| `floor` | 33 / 46 / 18 | 0% | 11/11, 13/13, 9/9 | +| `compact_ok` *(as measured in §38.6)* | 33 / 46 / 18 | 0% | 11/11, 13/13, 9/9 | +| `exempt_circulation` | 33 / 46 / 18 | 21% / 24% / 33% | 11/11, 13/13, 9/9 | + +*(harbor-house / maple-court / health-centre, 3 constructed seeds each, full +default stack.)* + +`floor` returns 0.01 — one percent of a unit quality, multiplied into a product +and weighed against a whole leaf's cost, which is why it reads as inert. +`compact_ok` is worse than inert, it is **self-contradictory**: it announces +that being more compact than target is not a defect, and then returns the floor +for the most compact case of all, because `if not crink` fires before its clip +is ever reached. Only `exempt_circulation` moves anything, and it reaches at +most a third of the population. So §38.6's "none of them removes the incentive" +was reading a null that the modes' own implementation guaranteed. + +**What the buried leaves actually are.** §39.7 gave every space a declared +`usage:`, which lets the question be asked properly for the first time — of the +leaves scoring a hard zero for want of daylight, how many are rooms a person +sits in? + +| programme | buried | habitable (`living`/`kitchen`/`bedroom`) | store, toilet, plant, corridor, covered court | +|---|---|---|---| +| harbor-house | 33 | 11 (33%) | **22 (67%)** | +| maple-court | 46 | 13 (28%) | **33 (72%)** | +| health-centre | 18 | 9 (50%) | 9 (50%) | + +**Roughly two thirds of the zero-value leaves are spaces that architecturally +do not want a window at all** — a broom cupboard, a WC, a plant room, an +internal corridor, a covered courtyard. The objective scores them identically +with a windowless bedroom. That is the miscalibration, and it is not a gradient +problem to be patched with an epsilon; it is the wrong requirement applied to +the wrong rooms. + +**The repair: `crinkliness_mode="usage_daylight"`.** Daylight is required of +the uses a person occupies (`programme.DAYLIGHT_USAGES` = +`living`/`kitchen`/`bedroom`) and of nothing else. For every other usage, and +for the generic `C`/`O`/`S` types which carry no programme entry, the factor is +clipped on the **compact side only** — being buried stops being a defect, while +over-exposure still costs, because a crinkly leaf costs envelope whatever it is +used for. A windowless bedroom remains exactly the hard zero it is under stock. + +| programme | buried | rescued by `usage_daylight` | windowless habitable rooms still failing | +|---|---|---|---| +| harbor-house | 33 | 22 (67%) | 11/11 | +| maple-court | 46 | 33 (72%) | 13/13 | +| health-centre | 18 | 9 (50%) | 9/9 | + +`compact_ok` was also repaired to score the buried limit as compact (1.0), the +behaviour its name always claimed; it now rescues 100% and is kept as the +**upper-bound control** — the mode that deletes the daylight requirement +outright, including for bedrooms. It is not a candidate. + +**How this is scored, stated before the result.** `compact_ok`, +`exempt_circulation` and `usage_daylight` all return 1.0 where stock returns +below `FAIL_THRESHOLD`, so scoring an arm under its own objective deletes a fail +category for free and every arm "wins". Every arm below is therefore optimised +under its own objective and **re-scored under stock `urb`** — the comparable +yardstick, and the only one that answers *did optimising under this variant +steer the search to a better building?* The arm's own-objective count is +reported alongside solely to show the size of the definitional discount. A mode +passes on the stock column. `experiments/ab_ssz_search.py`. + ## 39. Config audit: requirements that actively fight the engine (`homemaker-py-ju3`) — measured 2026-08-25 The corpus `patterns.config` targets and `costs.config` values were estimated diff --git a/experiments/ab_crinkliness_mode_ssz.py b/experiments/ab_crinkliness_mode_ssz.py index 0947822..4826293 100644 --- a/experiments/ab_crinkliness_mode_ssz.py +++ b/experiments/ab_crinkliness_mode_ssz.py @@ -16,6 +16,17 @@ buried or lit is what separates the two mechanisms: 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 @@ -34,7 +45,8 @@ 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") +MODES = ("urb", "floor", "compact_ok", "exempt_circulation", + "usage_daylight") def make_fitness(progdir: str, mode: str) -> fitness.Fitness: @@ -74,7 +86,12 @@ def unpinned_leaves(fit: fitness.Fitness, root: dom_mod.Node) -> list[tuple]: 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"): + # §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], {}))) diff --git a/experiments/ab_ssz_search.py b/experiments/ab_ssz_search.py new file mode 100644 index 0000000..15d6051 --- /dev/null +++ b/experiments/ab_ssz_search.py @@ -0,0 +1,145 @@ +"""Fixed-budget search A/B for the crinkliness modes (`homemaker-py-ssz`). + +DESIGN.md §38.6 A/B'd the modes against the §38.2 *deletion test*, which has +since been retracted, and it used the pre-§39.4 `type[:1] in ("C","O")` prefix +rule that mislabels programme rooms as circulation. So the modes have never +been measured against what `ssz`'s acceptance criteria actually asks for: a +fixed-budget search, hard/soft fail split, on harbor-house and maple-court. + +**The scoring discipline is the point of this script.** `compact_ok`, +`exempt_circulation` and `usage_daylight` all return 1.0 for leaves that stock +scores below FAIL_THRESHOLD, so scoring an arm under its own objective deletes +a fail category for free and every arm "wins". Two numbers are therefore +reported per arm: + + urb the arm's final layout re-scored under the STOCK objective. This is + the comparable yardstick, and the one that answers "did optimising + under this variant steer the search to a better building?" + own the same layout under the arm's own objective. Lower than `urb` by + construction for the permissive modes; it is reported only so the + size of the definitional discount is visible, never as the result. + +A mode passes on `urb`, not on `own`. + +Usage:: + + python experiments/ab_ssz_search.py --budget 3000 --seeds 3 + python experiments/ab_ssz_search.py --modes urb usage_daylight --seeds 2 +""" + +from __future__ import annotations + +import argparse +import collections +import copy +import csv +import time +from pathlib import Path + +from homemaker_layout import dom as dom_mod +from homemaker_layout import driver, fitness + +CORPUS = ["examples/harbor-house", "examples/maple-court"] +MODES = ["urb", "floor", "compact_ok", "exempt_circulation", "usage_daylight"] + + +def _with_mode(mode: str): + """Patch `fitness.load_config` so every evaluator built during the run -- + the driver's, the inner loop's, the seeder's -- sees `crinkliness_mode`. + + `driver.search` has no parameter for it, and `driver._fitness_for` is + lru_cached on its arguments, so the cache is cleared around the patch or a + later arm would silently reuse the previous arm's evaluator. + """ + orig = fitness.load_config + + def patched(directory, overrides=None): + ov = dict(overrides or {}) + ov["crinkliness_mode"] = mode + return orig(directory, overrides=ov) + + return orig, patched + + +def tiers(fails) -> tuple[int, int]: + c = collections.Counter(fitness.classify_fail_tier(f) for f in fails) + return c["hard"], c["soft"] + + +def run_arm(progdir: str, seed: int, mode: str, budget: int, + child_budget: int) -> dict: + orig, patched = _with_mode(mode) + fitness.load_config = patched + driver._fitness_for.cache_clear() + t0 = time.perf_counter() + try: + res = driver.search( + dom_mod.load(f"{progdir}/init.dom"), progdir, + budget=budget, seed=seed, child_budget=child_budget, n_workers=1) + root = copy.deepcopy(res.best.root) + own_conf, own_cost = patched(progdir, overrides={"leaf_sharing": True, + "collapse_insearch": True}) + _, own_fails = fitness.Fitness(own_conf, own_cost).score_with_fails( + copy.deepcopy(root)) + finally: + fitness.load_config = orig + driver._fitness_for.cache_clear() + + # the comparable yardstick: stock objective, same layout + conf, cost = orig(progdir, overrides={"leaf_sharing": True, + "collapse_insearch": True}) + _, urb_fails = fitness.Fitness(conf, cost).score_with_fails(copy.deepcopy(root)) + + uh, us = tiers(urb_fails) + oh, os_ = tiers(own_fails) + return dict(programme=Path(progdir).name, seed=seed, mode=mode, + urb_hard=uh, urb_soft=us, urb_total=uh + us, + own_hard=oh, own_soft=os_, own_total=oh + os_, + elapsed_s=round(time.perf_counter() - t0, 1)) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--budget", type=int, default=3000) + ap.add_argument("--child-budget", type=int, default=80) + ap.add_argument("--seeds", type=int, default=3) + ap.add_argument("--modes", nargs="+", default=MODES) + ap.add_argument("--corpus", nargs="+", default=CORPUS) + ap.add_argument("--out", default="experiments/results/ab_ssz_search.csv") + args = ap.parse_args() + + rows = [] + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + for progdir in args.corpus: + for mode in args.modes: + for seed in range(args.seeds): + r = run_arm(progdir, seed, mode, args.budget, args.child_budget) + rows.append(r) + print(f" {r['programme']:<14} {mode:<20} seed={seed} " + f"urb {r['urb_hard']}h/{r['urb_soft']}s " + f"(own {r['own_hard']}h/{r['own_soft']}s) " + f"{r['elapsed_s']}s", flush=True) + with out.open("w", newline="") as fh: + w = csv.DictWriter(fh, fieldnames=list(rows[0])) + w.writeheader() + w.writerows(rows) + + print(f"\n=== stock-objective (urb) fail counts, budget {args.budget} ===") + print(f" {'programme':<14}{'mode':<22}{'hard':<14}{'soft':<14}total") + print(" " + "-" * 70) + for progdir in args.corpus: + name = Path(progdir).name + for mode in args.modes: + sel = [r for r in rows if r["programme"] == name and r["mode"] == mode] + if not sel: + continue + h = sum(r["urb_hard"] for r in sel) / len(sel) + s = sum(r["urb_soft"] for r in sel) / len(sel) + print(f" {name:<14}{mode:<22}{h:<14.1f}{s:<14.1f}{h + s:.1f}") + print(f"\nwrote {out}") + + +if __name__ == "__main__": + main() diff --git a/experiments/results/ab_ssz_search.csv b/experiments/results/ab_ssz_search.csv new file mode 100644 index 0000000..7f43c2a --- /dev/null +++ b/experiments/results/ab_ssz_search.csv @@ -0,0 +1,3 @@ +programme,seed,mode,urb_hard,urb_soft,urb_total,own_hard,own_soft,own_total,elapsed_s +harbor-house,0,urb,16,39,55,16,39,55,80.2 +harbor-house,1,urb,22,45,67,22,45,67,88.7 diff --git a/src/homemaker_layout/fitness.py b/src/homemaker_layout/fitness.py index 60bed20..2acff45 100644 --- a/src/homemaker_layout/fitness.py +++ b/src/homemaker_layout/fitness.py @@ -438,7 +438,8 @@ class Fitness: self._connectivity_weight = float(cw) self._crinkliness_mode = str(self.conf("crinkliness_mode") or "urb") if self._crinkliness_mode not in ( - "urb", "floor", "compact_ok", "exempt_circulation"): + "urb", "floor", "compact_ok", "exempt_circulation", + "usage_daylight"): raise ValueError( f"unknown crinkliness_mode: {self._crinkliness_mode!r}") # The floored value stays BELOW FAIL_THRESHOLD, so a buried leaf still @@ -463,6 +464,17 @@ class Fitness: req = (self._programme or {}).get(leaf.type) return req.usage if req else "" + def needs_daylight(self, leaf: Node) -> bool: + """Does this leaf's declared usage want a window? (homemaker-py-ssz) + + True only for uses a person occupies (``programme.DAYLIGHT_USAGES``). + A generic ``C``/``O``/``S`` leaf has no programme entry and so is False, + which is the intended reading: a corridor or a covered courtyard is not + failing when it has no daylit wall. + """ + from . import programme as _pr + return self.usage_of(leaf) in _pr.DAYLIGHT_USAGES + # ------------------------------------------------------------------ # # Type superposition + collapse (homemaker-py-9o5) # ------------------------------------------------------------------ # @@ -1207,15 +1219,33 @@ class Fitness: # (c) internal corridors are ordinary architecture; stop requiring # every circulation leaf to reach daylight. return 1.0 + + # (b)/(d) one-sided: being MORE compact than target is not a defect the + # way over-exposure is. Over-exposure still is one -- a crinkly leaf + # costs envelope whatever it is used for -- so this clips the compact + # side only, it does not switch the factor off. + # + # `compact_ok` applies that to every leaf; `usage_daylight` applies it + # only where nobody is sitting -- a store, a toilet, plant, a corridor, + # a covered courtyard -- and leaves habitable rooms on stock behaviour, + # so a windowless bedroom is still the hard failure it should be. + one_sided = mode == "compact_ok" or ( + mode == "usage_daylight" and not self.needs_daylight(leaf)) + if not crink: + # Zero exposure IS the compact limit (1/crink -> inf), so a + # one-sided factor has to score it 1.0. Reaching here and returning + # the floor instead was the flaw in the first `compact_ok`: it + # announced that compact is not a defect and then punished the most + # compact case of all hardest (§38.8). + if one_sided: + return 1.0 # (a) floor: keep buried leaves rankable by their other factors # instead of collapsing the whole quality product to zero. - return self._crinkliness_floor if mode in ("floor", "compact_ok") else 0.0 + return self._crinkliness_floor if mode == "floor" else 0.0 + q = gaussian(1 / crink, 1.0, distance, sigma) - if mode == "compact_ok" and 1 / crink > distance: - # (b) one-sided: being MORE compact than target is not a defect the - # way over-exposure is, so clip to 1.0 on the compact side rather - # than decaying symmetrically into a fail. + if one_sided and 1 / crink > distance: return 1.0 return max(q, self._crinkliness_floor) if mode in ("floor", "compact_ok") else q diff --git a/src/homemaker_layout/programme.py b/src/homemaker_layout/programme.py index b9f01ef..68c2322 100644 --- a/src/homemaker_layout/programme.py +++ b/src/homemaker_layout/programme.py @@ -104,6 +104,12 @@ TOILET_STRIPS = ("living", "kitchen", "toilet") # Sociable rooms keep their MOST central circulation neighbour; terminal rooms # and toilets keep their LEAST central one. SOCIABLE_USAGES = ("living", "kitchen") +# Uses a person OCCUPIES, and which therefore want a window. Everything else -- +# stores, toilets, plant, corridors, covered courtyards -- is ordinary buried +# architecture, and `crinkliness_mode="usage_daylight"` stops the objective +# demanding daylight for it (homemaker-py-ssz, DESIGN.md §38.8). A generic +# `C`/`O`/`S` leaf has no programme usage and is exempt for the same reason. +DAYLIGHT_USAGES = ("living", "kitchen", "bedroom") def validate_codes(codes) -> None: diff --git a/tests/test_fitness.py b/tests/test_fitness.py index 4c66a76..2229b6d 100644 --- a/tests/test_fitness.py +++ b/tests/test_fitness.py @@ -528,6 +528,67 @@ def test_crinkliness_exempt_circulation_only_exempts_circulation(): assert f.quality_uncrinkliness(room, None, {}) == 0.0 +def test_crinkliness_compact_ok_scores_the_buried_limit_as_compact(): + """Regression (§38.8): a zero-exposure leaf IS the compact limit. + + The first `compact_ok` returned the floor here, i.e. it announced that + being compact is not a defect and then punished the most compact case of + all hardest -- which is why it measured inert on buried leaves. + """ + f, leaf = _stub_fit("compact_ok", stub=0.0) + assert f.quality_uncrinkliness(leaf, None, {}) == 1.0 + + +def _usage_fit(mode, stub, code, usage): + """Stub Fitness carrying a one-space programme, so `usage_of` resolves.""" + conf = dict(CONF_DEFAULTS) + conf["crinkliness_mode"] = mode + conf["spaces"] = {code: {"usage": usage, "size": [4.0, 1.0]}} + f = _StubCrink(conf, dict(COST_DEFAULTS)) + f._stub = stub + return f, _leaf(code) + + +@pytest.mark.parametrize("usage", ["toilet", "utility", "none"]) +def test_usage_daylight_exempts_uses_nobody_sits_in(usage): + """A buried store or toilet is ordinary architecture, not a failure.""" + f, leaf = _usage_fit("usage_daylight", 0.0, "x1", usage) + assert f.needs_daylight(leaf) is False + assert f.quality_uncrinkliness(leaf, None, {}) == 1.0 + + +@pytest.mark.parametrize("usage", ["living", "kitchen", "bedroom"]) +def test_usage_daylight_still_fails_a_windowless_habitable_room(usage): + """The point of keying on usage: a bedroom with no daylight stays a hard + zero, exactly as stock. A mode that rescued this would be deleting the + fail category, not fixing the objective.""" + f, leaf = _usage_fit("usage_daylight", 0.0, "x1", usage) + assert f.needs_daylight(leaf) is True + assert f.quality_uncrinkliness(leaf, None, {}) == 0.0 + + +def test_usage_daylight_exempts_generic_types(): + """Generic `C`/`S` have no programme entry; a corridor needs no window.""" + f, _ = _usage_fit("usage_daylight", 0.0, "x1", "living") + for code in ("C", "S"): + assert f.quality_uncrinkliness(_leaf(code), None, {}) == 1.0 + + +def test_usage_daylight_still_punishes_over_exposure(): + """Exempt from needing daylight is not exempt from envelope cost: the + factor is clipped on the compact side only, never switched off.""" + target = CONF_DEFAULTS["uncrinkliness"][0] + f, leaf = _usage_fit("usage_daylight", 1.0 / (target / 2), "x1", "utility") + assert f.quality_uncrinkliness(leaf, None, {}) < 1.0 + + +def test_usage_daylight_leaves_stock_urb_untouched(): + """Same tree, mode off -> stock hard zero for every usage.""" + for usage in ("living", "toilet", "none"): + f, leaf = _usage_fit("urb", 0.0, "x1", usage) + assert f.quality_uncrinkliness(leaf, None, {}) == 0.0 + + def test_crinkliness_mode_unknown_raises(): with pytest.raises(ValueError, match="crinkliness_mode"): _stub_fit("nonsense")