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. 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 ## 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 The corpus `patterns.config` targets and `costs.config` values were estimated

View file

@ -98,6 +98,7 @@ spaces:
count: 3 count: 3
la1: la1:
crinkliness: none # no window needed
usage: utility usage: utility
name: Laundry Room name: Laundry Room
size: size:
@ -110,6 +111,7 @@ spaces:
- c - c
st1: st1:
crinkliness: none # no window needed
usage: utility usage: utility
name: Ground Floor Storage name: Ground Floor Storage
size: size:
@ -122,6 +124,7 @@ spaces:
- c - c
me1: me1:
crinkliness: none # no window needed
usage: utility usage: utility
name: Mechanical/Electrical Room name: Mechanical/Electrical Room
size: size:

View file

@ -141,6 +141,7 @@ spaces:
count: 6 # 3 ground + 3 first floor count: 6 # 3 ground + 3 first floor
la1: la1:
crinkliness: none # no window needed
usage: utility usage: utility
name: Laundry Room name: Laundry Room
size: size:
@ -158,6 +159,7 @@ spaces:
- me1 - me1
st1: st1:
crinkliness: none # no window needed
usage: utility usage: utility
name: Ground Floor Storage name: Ground Floor Storage
size: size:
@ -171,6 +173,7 @@ spaces:
- c - c
me1: me1:
crinkliness: none # no window needed
usage: utility usage: utility
name: Mechanical/Electrical Room name: Mechanical/Electrical Room
size: size:
@ -231,6 +234,7 @@ spaces:
# FIRST FLOOR - Storage # FIRST FLOOR - Storage
st2: st2:
crinkliness: none # no window needed
usage: utility usage: utility
name: First Floor Storage name: First Floor Storage
size: size:
@ -244,6 +248,7 @@ spaces:
- c - c
ut1: ut1:
crinkliness: none # no window needed
usage: utility usage: utility
name: Utilities Closet name: Utilities Closet
size: size:

View file

@ -130,6 +130,7 @@ spaces:
- ms1 - ms1
zt1: zt1:
crinkliness: none # no window needed
usage: utility usage: utility
name: Sterilisation Room name: Sterilisation Room
size: size:
@ -229,6 +230,7 @@ spaces:
- br1 - br1
re1: re1:
crinkliness: none # no window needed
usage: utility usage: utility
name: Records Room name: Records Room
size: size:
@ -281,6 +283,7 @@ spaces:
# ---- SERVICE ---- # ---- SERVICE ----
me1: me1:
crinkliness: none # no window needed
usage: utility usage: utility
name: Plant / Mechanical Room name: Plant / Mechanical Room
size: size:
@ -296,6 +299,7 @@ spaces:
- c - c
dp1: dp1:
crinkliness: none # no window needed
usage: utility usage: utility
name: General Storage name: General Storage
size: size:

View file

@ -94,6 +94,7 @@ spaces:
- o - o
la1: la1:
crinkliness: none # no window needed
usage: utility usage: utility
name: Laundry Room name: Laundry Room
size: size:
@ -107,6 +108,7 @@ spaces:
- c - c
me1: me1:
crinkliness: none # no window needed
usage: utility usage: utility
name: Mechanical/Electrical Room name: Mechanical/Electrical Room
size: size:
@ -120,6 +122,7 @@ spaces:
- c - c
ur1: ur1:
crinkliness: none # no window needed
usage: utility usage: utility
name: Ground Floor Storage name: Ground Floor Storage
size: size:
@ -267,6 +270,7 @@ spaces:
count: 4 count: 4
ur2: ur2:
crinkliness: none # no window needed
usage: utility usage: utility
name: First Floor Storage name: First Floor Storage
size: size:
@ -341,6 +345,7 @@ spaces:
- o - o
ut1: ut1:
crinkliness: none # no window needed
usage: utility usage: utility
name: Utilities Closet name: Utilities Closet
size: size:
@ -354,6 +359,7 @@ spaces:
- c - c
ur3: ur3:
crinkliness: none # no window needed
usage: utility usage: utility
name: Second Floor Storage name: Second Floor Storage
size: 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 graph as graph_mod
from homemaker_layout import operators, programme 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: 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. fixed-budget search, hard/soft fail split, on harbor-house and maple-court.
**The scoring discipline is the point of this script.** `compact_ok`, **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 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 a fail category for free and every arm "wins". Two numbers are therefore
reported per arm: reported per arm:
@ -24,7 +24,7 @@ A mode passes on `urb`, not on `own`.
Usage:: Usage::
python experiments/ab_ssz_search.py --budget 3000 --seeds 3 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 from __future__ import annotations
@ -40,7 +40,7 @@ from homemaker_layout import dom as dom_mod
from homemaker_layout import driver, fitness from homemaker_layout import driver, fitness
CORPUS = ["examples/harbor-house", "examples/maple-court"] 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): 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"] 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: def stock_fitness(progdir: str) -> fitness.Fitness:
"""Stock objective -- `crinkliness_mode` left at its "urb" default.""" """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]: def report(label: str, tally: collections.Counter) -> tuple[int, int]:
total = sum(tally.values()) 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") print(f"=== {label}: {total} crinkliness fails")
if not total: if not total:
print(" none\n") print(" none\n")
return 0, 0 return 0, 0
for usage, n in tally.most_common(): for usage, n in tally.most_common():
verdict = ("REAL DEFECT" if usage in programme.DAYLIGHT_USAGES exempt = usage in NO_DAYLIGHT or usage.startswith("<generic")
else "not a defect -- no daylight wanted") verdict = ("not a defect -- no daylight wanted" if exempt
else "REAL DEFECT")
print(f" {usage:<18}{n:>4} {verdict}") print(f" {usage:<18}{n:>4} {verdict}")
print(f" -> {total - real}/{total} ({100 * (total - real) / total:.0f}%) " print(f" -> {total - real}/{total} ({100 * (total - real) / total:.0f}%) "
f"are reported against spaces that do not want daylight\n") 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 from .dom import Node
FAIL_THRESHOLD = 0.1 # Urb::Dom::Fitness::Base 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 # Per-leaf quality factors that emit a failure when they drop below
# FAIL_THRESHOLD (evaluate_leaf, in emission order). The graded objective # FAIL_THRESHOLD (evaluate_leaf, in emission order). The graded objective
@ -438,8 +439,7 @@ class Fitness:
self._connectivity_weight = float(cw) self._connectivity_weight = float(cw)
self._crinkliness_mode = str(self.conf("crinkliness_mode") or "urb") self._crinkliness_mode = str(self.conf("crinkliness_mode") or "urb")
if self._crinkliness_mode not in ( if self._crinkliness_mode not in (
"urb", "floor", "compact_ok", "exempt_circulation", "urb", "floor", "compact_ok", "exempt_circulation"):
"usage_daylight"):
raise ValueError( raise ValueError(
f"unknown crinkliness_mode: {self._crinkliness_mode!r}") f"unknown crinkliness_mode: {self._crinkliness_mode!r}")
# The floored value stays BELOW FAIL_THRESHOLD, so a buried leaf still # 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) req = (self._programme or {}).get(leaf.type)
return req.usage if req else "" 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) # Type superposition + collapse (homemaker-py-9o5)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@ -1194,53 +1184,74 @@ class Fitness:
return 9999999999 return 9999999999
return self.area_outside(leaf, G, groups) / area 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: 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): if dom_mod.is_outside(leaf) and not dom_mod.is_covered(leaf):
return 1.0 return 1.0
key = "uncrinkliness_circulation" if dom_mod.is_circulation(leaf) else "uncrinkliness" params = self.crinkliness_params(leaf)
distance, sigma = self.conf(key)
crink = self.crinkliness(leaf, G, groups) crink = self.crinkliness(leaf, G, groups)
# homemaker-py-ssz (DESIGN.md §38.1), EXPERIMENTAL, all default OFF — if params is None:
# `crinkliness_mode="urb"` reproduces the stock behaviour exactly. # No minimum-exposure requirement: this space does not need a
# # window, so being buried -- the fully compact limit -- is not a
# Stock Urb returns a hard 0.0 for a leaf with no daylit wall. That is # defect. Over-exposure still is: a crinkly leaf costs envelope
# the correct limit of the formula (1/crink -> inf, gaussian -> 0), but # whatever it holds. So the factor is CLIPPED on the compact side,
# because evaluate_leaf MULTIPLIES factors into quality and # never switched off, and the over-exposed side keeps the global
# process_storey accumulates `value += quality * rate * area`, such a # bound.
# leaf contributes EXACTLY ZERO value while still costing — so the if not crink:
# objective cannot rank buried rooms at all, and buried circulation/ return 1.0
# outside leaves (which no missing-space cascade pins) are pure distance, sigma = self.conf("uncrinkliness")
# liabilities worth ~x60-x85 to delete. Measured: 45-56% of interior if 1 / crink > distance:
# leaves are in this state. These modes restore a gradient there. 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 mode = self._crinkliness_mode
if mode == "exempt_circulation" and dom_mod.is_circulation(leaf): 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 return 1.0
one_sided = mode == "compact_ok"
# (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: 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: if one_sided:
return 1.0 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 return self._crinkliness_floor if mode == "floor" else 0.0
q = gaussian(1 / crink, 1.0, distance, sigma) q = gaussian(1 / crink, 1.0, distance, sigma)
@ -2046,6 +2057,7 @@ class Fitness:
sz = c.get("size") or [0.0, 1.0] sz = c.get("size") or [0.0, 1.0]
w = c.get("width") or _DW w = c.get("width") or _DW
pr = c.get("proportion") or _DP pr = c.get("proportion") or _DP
_crink = _programme._optional_pair(c, "crinkliness")
reqs[code] = SpaceReq( reqs[code] = SpaceReq(
code=code, code=code,
usage=c["usage"], usage=c["usage"],
@ -2064,5 +2076,8 @@ class Fitness:
has_size="size" in c, has_size="size" in c,
has_width="width" in c, has_width="width" in c,
has_proportion="proportion" in c, has_proportion="proportion" in c,
has_crinkliness="crinkliness" in c,
crinkliness=_crink[0],
crinkliness_sigma=_crink[1],
) )
self._programme_cache = reqs self._programme_cache = reqs

View file

@ -30,6 +30,24 @@ class SpaceReq:
width_sigma: float = _DEFAULT_WIDTH[1] width_sigma: float = _DEFAULT_WIDTH[1]
proportion: float = _DEFAULT_PROPORTION[0] # max length/width ratio proportion: float = _DEFAULT_PROPORTION[0] # max length/width ratio
proportion_sigma: float = _DEFAULT_PROPORTION[1] 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) adjacency: list[str] = field(default_factory=list)
level: int | None = None level: int | None = None
requires_below: str | None = None requires_below: str | None = None
@ -55,6 +73,7 @@ class SpaceReq:
has_width: bool = False has_width: bool = False
has_proportion: bool = False has_proportion: bool = False
has_share: bool = False has_share: bool = False
has_crinkliness: bool = False
def _pair(d: dict, key: str, default: tuple[float, float]) -> tuple[float, float]: 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]) 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 # 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 # types — ``C`` circulation, ``O`` outside, ``S`` sahn — which are the leaves the
# SEARCH creates, canonically uppercase. Programme room codes are a separate, # 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 # Sociable rooms keep their MOST central circulation neighbour; terminal rooms
# and toilets keep their LEAST central one. # and toilets keep their LEAST central one.
SOCIABLE_USAGES = ("living", "kitchen") SOCIABLE_USAGES = ("living", "kitchen")
# Uses a person OCCUPIES, and which therefore want a window. Everything else -- # There is deliberately NO daylight vocabulary here. Daylight is not a separate
# stores, toilets, plant, corridors, covered courtyards -- is ordinary buried # axis: the COMPACT side of the crinkliness gaussian -- too little exposed wall
# architecture, and `crinkliness_mode="usage_daylight"` stops the objective # per unit floor -- IS the daylight requirement, so a space states it in its own
# demanding daylight for it (homemaker-py-ssz, DESIGN.md §38.8). A generic # `crinkliness:` target like it states `size:` or `width:` (DESIGN.md §38.10).
# `C`/`O`/`S` leaf has no programme usage and is exempt for the same reason. # An earlier attempt keyed daylight off `usage:` instead; that was wrong, since
DAYLIGHT_USAGES = ("living", "kitchen", "bedroom") # `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: 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)) size = _pair(c, "size", (0.0, 1.0))
width = _pair(c, "width", _DEFAULT_WIDTH) width = _pair(c, "width", _DEFAULT_WIDTH)
prop = _pair(c, "proportion", _DEFAULT_PROPORTION) prop = _pair(c, "proportion", _DEFAULT_PROPORTION)
crink = _optional_pair(c, "crinkliness")
out[code] = SpaceReq( out[code] = SpaceReq(
code=code, code=code,
name=c.get("name", ""), name=c.get("name", ""),
@ -179,6 +214,8 @@ def _parse_spaces(conf: dict) -> dict[str, SpaceReq]:
width_sigma=width[1], width_sigma=width[1],
proportion=prop[0], proportion=prop[0],
proportion_sigma=prop[1], proportion_sigma=prop[1],
crinkliness=crink[0],
crinkliness_sigma=crink[1],
adjacency=list(c.get("adjacency") or []), adjacency=list(c.get("adjacency") or []),
level=c.get("level"), level=c.get("level"),
requires_below=c.get("requires_below"), requires_below=c.get("requires_below"),
@ -190,6 +227,7 @@ def _parse_spaces(conf: dict) -> dict[str, SpaceReq]:
has_width="width" in c, has_width="width" in c,
has_proportion="proportion" in c, has_proportion="proportion" in c,
has_share="share" in c, has_share="share" in c,
has_crinkliness="crinkliness" in c,
) )
return out 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 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 = dict(CONF_DEFAULTS)
conf["crinkliness_mode"] = mode conf["spaces"] = {code: dict({"usage": "living", "size": [4.0, 1.0]},
conf["spaces"] = {code: {"usage": usage, "size": [4.0, 1.0]}} **(space or {}))}
conf.update(conf_extra or {})
f = _StubCrink(conf, dict(COST_DEFAULTS)) f = _StubCrink(conf, dict(COST_DEFAULTS))
f._stub = stub f._stub = stub
return f, _leaf(code) return f, _leaf(code)
@pytest.mark.parametrize("usage", ["toilet", "utility", "none"]) def test_declared_crinkliness_absent_keeps_stock_behaviour():
def test_usage_daylight_exempts_uses_nobody_sits_in(usage): """No `crinkliness:` key -> the global target, unchanged: buried = 0.0.
"""A buried store or toilet is ordinary architecture, not a failure."""
f, leaf = _usage_fit("usage_daylight", 0.0, "x1", usage) This is what makes the mechanism backward compatible -- shipping it
assert f.needs_daylight(leaf) is False 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_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 assert f.quality_uncrinkliness(leaf, None, {}) == 1.0
@pytest.mark.parametrize("usage", ["living", "kitchen", "bedroom"]) def test_declared_crinkliness_none_accepts_the_literal_string():
def test_usage_daylight_still_fails_a_windowless_habitable_room(usage): """`crinkliness: none` reads the same as a YAML null, so the corpus can
"""The point of keying on usage: a bedroom with no daylight stays a hard spell it the way it spells `usage: none`."""
zero, exactly as stock. A mode that rescued this would be deleting the f, leaf = _declared_fit(0.0, {"crinkliness": "none"})
fail category, not fixing the objective.""" assert f.crinkliness_params(leaf) is None
f, leaf = _usage_fit("usage_daylight", 0.0, "x1", usage) assert f.quality_uncrinkliness(leaf, None, {}) == 1.0
assert f.needs_daylight(leaf) is True
assert f.quality_uncrinkliness(leaf, None, {}) == 0.0
def test_usage_daylight_exempts_generic_types(): def test_declared_crinkliness_none_still_penalises_over_exposure():
"""Generic `C`/`S` have no programme entry; a corridor needs no window.""" """Needing no window is not exemption from envelope cost. The factor is
f, _ = _usage_fit("usage_daylight", 0.0, "x1", "living") clipped on the compact side only, never switched off -- a crinkly store
for code in ("C", "S"): still costs wall."""
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] 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 assert f.quality_uncrinkliness(leaf, None, {}) < 1.0
def test_usage_daylight_leaves_stock_urb_untouched(): def test_declared_crinkliness_pair_is_used_verbatim():
"""Same tree, mode off -> stock hard zero for every usage.""" """A space may instead ask for its own target, as it does for size."""
for usage in ("living", "toilet", "none"): f, leaf = _declared_fit(0.0, {"crinkliness": [2.0, 0.5]})
f, leaf = _usage_fit("urb", 0.0, "x1", usage) assert f.crinkliness_params(leaf) == (2.0, 0.5)
assert f.quality_uncrinkliness(leaf, None, {}) == 0.0 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(): def test_crinkliness_mode_unknown_raises():