ssz: crinkliness is declared per space; there is no daylight attribute

Owner's ruling, and it corrects the design not just the classification: the
daylight requirement is already defined in the crinkliness. The gaussian's
compact side IS "too little exposed wall per unit floor"; its exposed side
is envelope cost. 38.9's proposed daylight: axis was redundant, and keying
it off usage: was worse than redundant.

What was actually missing: 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.

  crinkliness: none        -> no minimum-exposure requirement, may be buried
  crinkliness: [t, s]      -> this space's own target
  key absent               -> the global uncrinkliness target, as today

`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. Behaviour changes only
where a config declares something, which keeps the objective change
visible per programme in config rather than hidden in a default.

Owner's classification: everything a person occupies wants a window, WCs
and reception/waiting/foyer included; only stores, plant, records and
laundry do not. migrate_crinkliness_key.py declared crinkliness: none on 18
corpus spaces. Crinkliness fails 271 -> 243, of which not-defects 136 (50%)
-> 108 (44%); the 28 that went are exactly the utility fails.

usage_daylight and needs_daylight are removed as mis-keyed, and
DAYLIGHT_USAGES with them -- a vocabulary value should exist only where the
engine treats it differently. The historical crinkliness_mode modes stay,
default off, so 38.6/38.8 remain reproducible.

uncrinkliness_circulation is now settable to none like any space, but its
default is left unchanged pending a ruling: corridors were not among the
groups ruled on and are 63% of the remaining phantom fails.

Lint at parity (46); tests 364 passed, same 7 pre-existing fixture failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
This commit is contained in:
Claude 2026-08-28 22:38:54 +00:00
parent 0d4ae7ad54
commit 055d7101cd
No known key found for this signature in database
12 changed files with 354 additions and 93 deletions

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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):

View file

@ -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("<generic"))
print(f"=== {label}: {total} crinkliness fails")
if not total:
print(" none\n")
return 0, 0
for usage, n in tally.most_common():
verdict = ("REAL DEFECT" if usage in programme.DAYLIGHT_USAGES
else "not a defect -- no daylight wanted")
exempt = usage in NO_DAYLIGHT or usage.startswith("<generic")
verdict = ("not a defect -- no daylight wanted" if exempt
else "REAL DEFECT")
print(f" {usage:<18}{n:>4} {verdict}")
print(f" -> {total - real}/{total} ({100 * (total - real) / total:.0f}%) "
f"are reported against spaces that do not want daylight\n")

View file

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

View file

@ -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

View file

@ -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

View file

@ -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():