diff --git a/DESIGN.md b/DESIGN.md index ca72a22..9dc05bf 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -5285,6 +5285,75 @@ It is retained as the mechanism — the compact-side clip is the right shape for the factor — pending a `daylight:` attribute to key it on. +### 38.10 The shipping fix: crinkliness is declared per space (`homemaker-py-ssz`) + +Owner's ruling, and it is the design as well as the classification: **there is +no daylight attribute, because the daylight requirement is already defined in +the crinkliness.** The crinkliness gaussian's two sides are two real +requirements — the *compact* side is "too little exposed wall per unit floor", +which is exactly a daylight requirement, and the *exposed* side is "too much +envelope for the area", which is cost. §38.9's proposed `daylight:` axis was +redundant, and keying it off `usage:` was worse than redundant. + +What was actually missing is that **crinkliness is the only leaf quality factor +with no per-space target**. `size`, `width` and `proportion` are all declared by +the space; crinkliness was one global number for every room in every building. +So a space now states its own: + +```yaml + st1: + usage: utility + crinkliness: none # no window needed +``` + +Three states, resolved exactly as `size`/`width`/`proportion` resolve: + +| in `patterns.config` | meaning | +|---|---| +| key absent | the global `uncrinkliness` target — today's behaviour | +| `crinkliness: none` (or a YAML null) | **no minimum-exposure requirement**: this space may be fully buried | +| `crinkliness: [target, sigma]` | that gaussian, this space's own target | + +`none` **clips the factor on the compact side, it does not switch it off**. +Over-exposure is still penalised, because a crinkly leaf costs envelope whatever +it holds. A store may be buried; a store may not be a starfish. + +**The mechanism is backward compatible.** An absent key resolves to the global +target, so shipping it changes no score anywhere. Behaviour changes only where a +config declares something — which makes the objective change visible, per +programme, in the config, rather than hidden in a default. + +**Owner's classification.** Everything a person occupies wants a window — WCs +and bathrooms included, reception/waiting/foyer included, offices and consulting +rooms included. Only stores, plant, records and laundry (`usage: utility`) do +not. `experiments/migrate_crinkliness_key.py` declared `crinkliness: none` on 18 +spaces across the corpus (harbor 5, harbor-l0 3, health-centre 4, maple 6; +programme-house has no utility spaces). + +**Effect so far**, from `experiments/audit_crinkliness_truth.py`: + +| | crinkliness fails | not defects | +|---|---|---| +| before | 271 | 136 (50%) | +| after the utility declarations | **243** | 108 (44%) | + +The 28 that went are exactly the utility fails. What remains is two populations +the corpus cannot reach, because neither is a `spaces:` entry: + +| remaining phantom | count | key | +|---|---|---| +| generic `C` — internal circulation | **85** | `uncrinkliness_circulation` | +| generic `O`/`S` — covered outside, sahn | 23 | falls through to `uncrinkliness` | + +`uncrinkliness_circulation` already exists as its own config key and holds +`[5/6, 1.1/3]` — **byte-identical to the habitable target**, which is the +"estimated years ago and never changed" case in its purest form: a key created +precisely so corridors could differ, never given a different value. It is now +settable to `none` like any space (tested), but the default is deliberately +**left unchanged pending a ruling** — corridors were not among the groups ruled +on, and this is 63% of the remaining phantom fails, so it is not a call to make +by inference. + ## 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/examples/harbor-house-l0/patterns.config b/examples/harbor-house-l0/patterns.config index d029015..b515e24 100644 --- a/examples/harbor-house-l0/patterns.config +++ b/examples/harbor-house-l0/patterns.config @@ -98,6 +98,7 @@ spaces: count: 3 la1: + crinkliness: none # no window needed usage: utility name: Laundry Room size: @@ -110,6 +111,7 @@ spaces: - c st1: + crinkliness: none # no window needed usage: utility name: Ground Floor Storage size: @@ -122,6 +124,7 @@ spaces: - c me1: + crinkliness: none # no window needed usage: utility name: Mechanical/Electrical Room size: diff --git a/examples/harbor-house/patterns.config b/examples/harbor-house/patterns.config index b267589..d98839f 100644 --- a/examples/harbor-house/patterns.config +++ b/examples/harbor-house/patterns.config @@ -141,6 +141,7 @@ spaces: count: 6 # 3 ground + 3 first floor la1: + crinkliness: none # no window needed usage: utility name: Laundry Room size: @@ -158,6 +159,7 @@ spaces: - me1 st1: + crinkliness: none # no window needed usage: utility name: Ground Floor Storage size: @@ -171,6 +173,7 @@ spaces: - c me1: + crinkliness: none # no window needed usage: utility name: Mechanical/Electrical Room size: @@ -231,6 +234,7 @@ spaces: # FIRST FLOOR - Storage st2: + crinkliness: none # no window needed usage: utility name: First Floor Storage size: @@ -244,6 +248,7 @@ spaces: - c ut1: + crinkliness: none # no window needed usage: utility name: Utilities Closet size: diff --git a/examples/health-centre/patterns.config b/examples/health-centre/patterns.config index f7bcc70..eb958f5 100644 --- a/examples/health-centre/patterns.config +++ b/examples/health-centre/patterns.config @@ -130,6 +130,7 @@ spaces: - ms1 zt1: + crinkliness: none # no window needed usage: utility name: Sterilisation Room size: @@ -229,6 +230,7 @@ spaces: - br1 re1: + crinkliness: none # no window needed usage: utility name: Records Room size: @@ -281,6 +283,7 @@ spaces: # ---- SERVICE ---- me1: + crinkliness: none # no window needed usage: utility name: Plant / Mechanical Room size: @@ -296,6 +299,7 @@ spaces: - c dp1: + crinkliness: none # no window needed usage: utility name: General Storage size: diff --git a/examples/maple-court/patterns.config b/examples/maple-court/patterns.config index 847c3af..fdb48e1 100644 --- a/examples/maple-court/patterns.config +++ b/examples/maple-court/patterns.config @@ -94,6 +94,7 @@ spaces: - o la1: + crinkliness: none # no window needed usage: utility name: Laundry Room size: @@ -107,6 +108,7 @@ spaces: - c me1: + crinkliness: none # no window needed usage: utility name: Mechanical/Electrical Room size: @@ -120,6 +122,7 @@ spaces: - c ur1: + crinkliness: none # no window needed usage: utility name: Ground Floor Storage size: @@ -267,6 +270,7 @@ spaces: count: 4 ur2: + crinkliness: none # no window needed usage: utility name: First Floor Storage size: @@ -341,6 +345,7 @@ spaces: - o ut1: + crinkliness: none # no window needed usage: utility name: Utilities Closet size: @@ -354,6 +359,7 @@ spaces: - c ur3: + crinkliness: none # no window needed usage: utility name: Second Floor Storage size: diff --git a/experiments/ab_crinkliness_mode_ssz.py b/experiments/ab_crinkliness_mode_ssz.py index 4826293..ed21570 100644 --- a/experiments/ab_crinkliness_mode_ssz.py +++ b/experiments/ab_crinkliness_mode_ssz.py @@ -45,8 +45,7 @@ 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") +MODES = ("urb", "floor", "compact_ok", "exempt_circulation") def make_fitness(progdir: str, mode: str) -> fitness.Fitness: diff --git a/experiments/ab_ssz_search.py b/experiments/ab_ssz_search.py index 15d6051..996c5c6 100644 --- a/experiments/ab_ssz_search.py +++ b/experiments/ab_ssz_search.py @@ -7,7 +7,7 @@ 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 +`exempt_circulation` and `compact_ok` 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: @@ -24,7 +24,7 @@ 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 + python experiments/ab_ssz_search.py --modes urb compact_ok --seeds 2 """ from __future__ import annotations @@ -40,7 +40,7 @@ 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"] +MODES = ["urb", "floor", "compact_ok", "exempt_circulation"] def _with_mode(mode: str): diff --git a/experiments/audit_crinkliness_truth.py b/experiments/audit_crinkliness_truth.py index 68711be..8462fc7 100644 --- a/experiments/audit_crinkliness_truth.py +++ b/experiments/audit_crinkliness_truth.py @@ -36,6 +36,13 @@ from homemaker_layout import operators, programme CORPUS = ["examples/harbor-house", "examples/maple-court", "examples/health-centre"] +# Ruled by the project owner: everything a person occupies wants a window -- +# WCs and bathrooms included, reception/waiting/foyer included, offices and +# consulting rooms included. Only stores, plant, records and laundry do not, +# together with the generic structural types (a corridor has its own +# `uncrinkliness_circulation` target; a courtyard is not a room). +NO_DAYLIGHT = {"utility"} + def stock_fitness(progdir: str) -> fitness.Fitness: """Stock objective -- `crinkliness_mode` left at its "urb" default.""" @@ -80,14 +87,16 @@ def audit(fit: fitness.Fitness, root: dom_mod.Node) -> collections.Counter: def report(label: str, tally: collections.Counter) -> tuple[int, int]: total = sum(tally.values()) - real = sum(n for u, n in tally.items() if u in programme.DAYLIGHT_USAGES) + real = sum(n for u, n in tally.items() if u not in NO_DAYLIGHT + and not u.startswith("4} {verdict}") print(f" -> {total - real}/{total} ({100 * (total - real) / total:.0f}%) " f"are reported against spaces that do not want daylight\n") diff --git a/experiments/migrate_crinkliness_key.py b/experiments/migrate_crinkliness_key.py new file mode 100644 index 0000000..0e0d3dc --- /dev/null +++ b/experiments/migrate_crinkliness_key.py @@ -0,0 +1,83 @@ +"""Declare `crinkliness:` where a space needs no window (`homemaker-py-ssz`). + +One-shot migration for DESIGN.md §38.10. The compact side of the crinkliness +gaussian IS the daylight requirement, so a space that does not need daylight +says so in its own `crinkliness:` target -- there is no separate attribute. + +Ruled by the project owner: everything a person occupies wants a window -- +WCs and bathrooms included, reception/waiting/foyer included, offices and +consulting rooms included. Only stores, plant, records and laundry +(`usage: utility`) do not. Internal corridors do not either, but those are +generic `C` leaves with no `spaces:` entry, so they are handled by the +`uncrinkliness_circulation` key rather than here. + +Edits the file as text, preserving comments and layout. Idempotent: a space +that already declares `crinkliness:` is left alone unless `--force`. + +Usage:: + + python experiments/migrate_crinkliness_key.py --check # dry run + python experiments/migrate_crinkliness_key.py # apply +""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +import yaml + +CORPUS = Path(__file__).resolve().parent.parent / "examples" +NO_DAYLIGHT_USAGES = {"utility"} + + +def migrate(path: Path, check: bool, force: bool) -> tuple[int, int]: + text = path.read_text() + spaces = (yaml.safe_load(text) or {}).get("spaces") or {} + space_key = re.compile(r"^ ([A-Za-z_][\w-]*):\s*$") + + written = skipped = 0 + out: list[str] = [] + for line in text.splitlines(keepends=True): + m = space_key.match(line) + code = m.group(1) if m and m.group(1) in spaces else None + + if force and re.match(r"^ crinkliness:\s", line): + continue + + out.append(line) + if code is None: + continue + c = spaces[code] + if c.get("usage") not in NO_DAYLIGHT_USAGES: + continue + if "crinkliness" in c and not force: + skipped += 1 + else: + out.append(" crinkliness: none # no window needed\n") + written += 1 + + if not check and written: + path.write_text("".join(out)) + return written, skipped + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--check", action="store_true", help="dry run") + ap.add_argument("--force", action="store_true", + help="rewrite a crinkliness: already present") + args = ap.parse_args() + + for cfg in sorted(CORPUS.glob("*/patterns.config")): + written, skipped = migrate(cfg, args.check, args.force) + verb = "would declare" if args.check else "declared" + print(f" {cfg.parent.name:<20} {verb} crinkliness:none on {written:>2} " + f"space(s), already present {skipped}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/homemaker_layout/fitness.py b/src/homemaker_layout/fitness.py index 51e4b33..2d02877 100644 --- a/src/homemaker_layout/fitness.py +++ b/src/homemaker_layout/fitness.py @@ -32,6 +32,7 @@ from . import programme as _programme from .dom import Node FAIL_THRESHOLD = 0.1 # Urb::Dom::Fitness::Base +_MISSING = object() # tells an absent config key from one declared null # Per-leaf quality factors that emit a failure when they drop below # FAIL_THRESHOLD (evaluate_leaf, in emission order). The graded objective @@ -438,8 +439,7 @@ 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", - "usage_daylight"): + "urb", "floor", "compact_ok", "exempt_circulation"): raise ValueError( f"unknown crinkliness_mode: {self._crinkliness_mode!r}") # The floored value stays BELOW FAIL_THRESHOLD, so a buried leaf still @@ -464,16 +464,6 @@ 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. - """ - return self.usage_of(leaf) in _programme.DAYLIGHT_USAGES - # ------------------------------------------------------------------ # # Type superposition + collapse (homemaker-py-9o5) # ------------------------------------------------------------------ # @@ -1194,53 +1184,74 @@ class Fitness: return 9999999999 return self.area_outside(leaf, G, groups) / area + def crinkliness_params(self, leaf: Node) -> "tuple[float, float] | None": + """``(target, sigma)`` for this leaf's crinkliness, or ``None`` when the + space declares no minimum-exposure requirement (homemaker-py-ssz). + + The COMPACT side of the crinkliness gaussian is the daylight + requirement -- too little exposed wall per unit floor -- so a space + declaring `crinkliness: none` is declaring that it does not need a + window. There is no separate daylight attribute; see DESIGN.md §38.10. + + Resolution order, mirroring how size/width/proportion resolve: + a programme space's own declaration wins; a generic circulation leaf + takes `uncrinkliness_circulation`; everything else takes the global + `uncrinkliness`. + """ + req = (self._programme or {}).get(leaf.type) + if req is not None and req.has_crinkliness: + if req.crinkliness is None: + return None + return req.crinkliness, req.crinkliness_sigma + key = ("uncrinkliness_circulation" if dom_mod.is_circulation(leaf) + else "uncrinkliness") + # An explicit null/`none` in the config means "no minimum-exposure + # requirement". `conf()` cannot express that -- it collapses None to the + # default -- so the raw dict is read here, the same way `_optional_pair` + # tells an absent per-space key from a declared-empty one. + raw = self._conf.get(key, _MISSING) + if raw is None or (isinstance(raw, str) and raw.strip().lower() == "none"): + return None + params = self.conf(key) + if params is None: + return None + return params[0], params[1] + def quality_uncrinkliness(self, leaf: Node, G: nx.Graph, groups: dict) -> float: if dom_mod.is_outside(leaf) and not dom_mod.is_covered(leaf): return 1.0 - key = "uncrinkliness_circulation" if dom_mod.is_circulation(leaf) else "uncrinkliness" - distance, sigma = self.conf(key) + params = self.crinkliness_params(leaf) crink = self.crinkliness(leaf, G, groups) - # homemaker-py-ssz (DESIGN.md §38.1), EXPERIMENTAL, all default OFF — - # `crinkliness_mode="urb"` reproduces the stock behaviour exactly. - # - # Stock Urb returns a hard 0.0 for a leaf with no daylit wall. That is - # the correct limit of the formula (1/crink -> inf, gaussian -> 0), but - # because evaluate_leaf MULTIPLIES factors into quality and - # process_storey accumulates `value += quality * rate * area`, such a - # leaf contributes EXACTLY ZERO value while still costing — so the - # objective cannot rank buried rooms at all, and buried circulation/ - # outside leaves (which no missing-space cascade pins) are pure - # liabilities worth ~x60-x85 to delete. Measured: 45-56% of interior - # leaves are in this state. These modes restore a gradient there. + if params is None: + # No minimum-exposure requirement: this space does not need a + # window, so being buried -- the fully compact limit -- is not a + # defect. Over-exposure still is: a crinkly leaf costs envelope + # whatever it holds. So the factor is CLIPPED on the compact side, + # never switched off, and the over-exposed side keeps the global + # bound. + if not crink: + return 1.0 + distance, sigma = self.conf("uncrinkliness") + if 1 / crink > distance: + return 1.0 + return gaussian(1 / crink, 1.0, distance, sigma) + + distance, sigma = params + + # homemaker-py-ssz (DESIGN.md §38.1), EXPERIMENTAL, all default OFF -- + # `crinkliness_mode="urb"` reproduces the stock behaviour exactly. These + # were the first attempt at the problem and are SUPERSEDED by the + # declared per-space target above (§38.10); they are kept only so the + # §38.6/§38.8 measurements remain reproducible. Do not build on them. mode = self._crinkliness_mode if mode == "exempt_circulation" and dom_mod.is_circulation(leaf): - # (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)) + one_sided = mode == "compact_ok" 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 == "floor" else 0.0 q = gaussian(1 / crink, 1.0, distance, sigma) @@ -2046,6 +2057,7 @@ class Fitness: sz = c.get("size") or [0.0, 1.0] w = c.get("width") or _DW pr = c.get("proportion") or _DP + _crink = _programme._optional_pair(c, "crinkliness") reqs[code] = SpaceReq( code=code, usage=c["usage"], @@ -2064,5 +2076,8 @@ class Fitness: has_size="size" in c, has_width="width" in c, has_proportion="proportion" in c, + has_crinkliness="crinkliness" in c, + crinkliness=_crink[0], + crinkliness_sigma=_crink[1], ) self._programme_cache = reqs diff --git a/src/homemaker_layout/programme.py b/src/homemaker_layout/programme.py index 68c2322..4f5a513 100644 --- a/src/homemaker_layout/programme.py +++ b/src/homemaker_layout/programme.py @@ -30,6 +30,24 @@ class SpaceReq: width_sigma: float = _DEFAULT_WIDTH[1] proportion: float = _DEFAULT_PROPORTION[0] # max length/width ratio proportion_sigma: float = _DEFAULT_PROPORTION[1] + # homemaker-py-ssz (DESIGN.md §38.10) — this space's own crinkliness target, + # i.e. how much exposed wall it wants per unit floor area. The COMPACT side + # of this gaussian IS the daylight requirement; there is no separate + # daylight attribute. Three states, matching how the key appears in + # patterns.config: + # + # key absent -> `crinkliness` is None and `has_crinkliness` False: + # fall back to the global `uncrinkliness` target + # `crinkliness: none` (or a YAML null) + # -> `crinkliness` is None and `has_crinkliness` True: + # NO minimum-exposure requirement. A store or a records + # room may be fully buried. Over-exposure is still + # penalised -- a crinkly leaf costs envelope whatever + # it holds -- so the factor is clipped, not switched off. + # `crinkliness: [t, s]` + # -> that gaussian, as size/width/proportion work. + crinkliness: float | None = None + crinkliness_sigma: float | None = None adjacency: list[str] = field(default_factory=list) level: int | None = None requires_below: str | None = None @@ -55,6 +73,7 @@ class SpaceReq: has_width: bool = False has_proportion: bool = False has_share: bool = False + has_crinkliness: bool = False def _pair(d: dict, key: str, default: tuple[float, float]) -> tuple[float, float]: @@ -64,6 +83,20 @@ def _pair(d: dict, key: str, default: tuple[float, float]) -> tuple[float, float return float(v[0]), float(v[1]) +def _optional_pair(d: dict, key: str) -> tuple[float | None, float | None]: + """A pair that may be explicitly declared absent. + + Returns ``(None, None)`` both when the key is missing and when it is + present but null/``none``; the caller distinguishes the two by ``key in d``. + """ + if key not in d: + return None, None + v = d[key] + if v is None or (isinstance(v, str) and v.strip().lower() == "none"): + return None, None + return float(v[0]), float(v[1]) + + # homemaker-py-ju3 (DESIGN.md §39.4): Urb has exactly three GENERIC structural # types — ``C`` circulation, ``O`` outside, ``S`` sahn — which are the leaves the # SEARCH creates, canonically uppercase. Programme room codes are a separate, @@ -104,12 +137,13 @@ 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") +# There is deliberately NO daylight vocabulary here. Daylight is not a separate +# axis: the COMPACT side of the crinkliness gaussian -- too little exposed wall +# per unit floor -- IS the daylight requirement, so a space states it in its own +# `crinkliness:` target like it states `size:` or `width:` (DESIGN.md §38.10). +# An earlier attempt keyed daylight off `usage:` instead; that was wrong, since +# `usage:` is an ACCESS class and the two questions come apart (a waiting room +# has no special access requirement and very much wants a window). See §38.9. def validate_codes(codes) -> None: @@ -169,6 +203,7 @@ def _parse_spaces(conf: dict) -> dict[str, SpaceReq]: size = _pair(c, "size", (0.0, 1.0)) width = _pair(c, "width", _DEFAULT_WIDTH) prop = _pair(c, "proportion", _DEFAULT_PROPORTION) + crink = _optional_pair(c, "crinkliness") out[code] = SpaceReq( code=code, name=c.get("name", ""), @@ -179,6 +214,8 @@ def _parse_spaces(conf: dict) -> dict[str, SpaceReq]: width_sigma=width[1], proportion=prop[0], proportion_sigma=prop[1], + crinkliness=crink[0], + crinkliness_sigma=crink[1], adjacency=list(c.get("adjacency") or []), level=c.get("level"), requires_below=c.get("requires_below"), @@ -190,6 +227,7 @@ def _parse_spaces(conf: dict) -> dict[str, SpaceReq]: has_width="width" in c, has_proportion="proportion" in c, has_share="share" in c, + has_crinkliness="crinkliness" in c, ) return out diff --git a/tests/test_fitness.py b/tests/test_fitness.py index 2229b6d..37dfe9a 100644 --- a/tests/test_fitness.py +++ b/tests/test_fitness.py @@ -539,54 +539,84 @@ def test_crinkliness_compact_ok_scores_the_buried_limit_as_compact(): 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.""" +# --------------------------------------------------------------------------- # +# homemaker-py-ssz / DESIGN.md §38.10 — per-space crinkliness (the SHIPPING fix) +# +# The compact side of the crinkliness gaussian IS the daylight requirement, so +# a space declares it in its own `crinkliness:` target, like `size:` or +# `width:`. There is no separate daylight attribute -- see §38.9 for why +# keying it off `usage:` (an ACCESS class) was wrong. +# --------------------------------------------------------------------------- # +def _declared_fit(stub, space=None, conf_extra=None, code="x1"): + """Stub Fitness with a one-space programme, optionally declaring + `crinkliness:`, so `crinkliness_params` resolves off real config.""" conf = dict(CONF_DEFAULTS) - conf["crinkliness_mode"] = mode - conf["spaces"] = {code: {"usage": usage, "size": [4.0, 1.0]}} + conf["spaces"] = {code: dict({"usage": "living", "size": [4.0, 1.0]}, + **(space or {}))} + conf.update(conf_extra or {}) 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 +def test_declared_crinkliness_absent_keeps_stock_behaviour(): + """No `crinkliness:` key -> the global target, unchanged: buried = 0.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 + This is what makes the mechanism backward compatible -- shipping it + changes no score until a config actually declares something. + """ + f, leaf = _declared_fit(0.0) + assert f.crinkliness_params(leaf) == tuple(CONF_DEFAULTS["uncrinkliness"]) 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_declared_crinkliness_none_lets_a_space_be_buried(): + """`crinkliness: none` says this space needs no window. Fully buried -- + the compact limit -- is then not a defect.""" + f, leaf = _declared_fit(0.0, {"crinkliness": None}) + assert f.crinkliness_params(leaf) is None + assert f.quality_uncrinkliness(leaf, 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.""" +def test_declared_crinkliness_none_accepts_the_literal_string(): + """`crinkliness: none` reads the same as a YAML null, so the corpus can + spell it the way it spells `usage: none`.""" + f, leaf = _declared_fit(0.0, {"crinkliness": "none"}) + assert f.crinkliness_params(leaf) is None + assert f.quality_uncrinkliness(leaf, None, {}) == 1.0 + + +def test_declared_crinkliness_none_still_penalises_over_exposure(): + """Needing no window is not exemption from envelope cost. The factor is + clipped on the compact side only, never switched off -- a crinkly store + still costs wall.""" target = CONF_DEFAULTS["uncrinkliness"][0] - f, leaf = _usage_fit("usage_daylight", 1.0 / (target / 2), "x1", "utility") + f, leaf = _declared_fit(1.0 / (target / 2), {"crinkliness": None}) 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_declared_crinkliness_pair_is_used_verbatim(): + """A space may instead ask for its own target, as it does for size.""" + f, leaf = _declared_fit(0.0, {"crinkliness": [2.0, 0.5]}) + assert f.crinkliness_params(leaf) == (2.0, 0.5) + assert f.quality_uncrinkliness(leaf, None, {}) == 0.0 # still wants light + + +def test_circulation_target_is_separately_declarable(): + """A generic corridor takes `uncrinkliness_circulation`, and that key can + say `none` -- an internal corridor with no windows is ordinary + architecture, not a failure (this was 63% of the phantom fails, §38.10).""" + f, _ = _declared_fit(0.0, conf_extra={"uncrinkliness_circulation": None}) + assert f.crinkliness_params(_leaf("C")) is None + assert f.quality_uncrinkliness(_leaf("C"), None, {}) == 1.0 + # a room is untouched by the circulation key + f2, room = _declared_fit(0.0, conf_extra={"uncrinkliness_circulation": None}) + assert f2.quality_uncrinkliness(room, None, {}) == 0.0 + + +def test_circulation_keeps_its_pair_when_declared(): + f, _ = _declared_fit(0.0, conf_extra={"uncrinkliness_circulation": [1.0, 0.3]}) + assert f.crinkliness_params(_leaf("C")) == (1.0, 0.3) def test_crinkliness_mode_unknown_raises():