diff --git a/DESIGN.md b/DESIGN.md index 98514ac..e6efaca 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -7814,3 +7814,88 @@ Next: `homemaker-py-hxi` — circulation at 0.07 return against a room's 0.66, which the owner has ruled needs fixing — and the corpus re-baseline (`homemaker-py-bk9`), which §39.19 made necessary and which every future number depends on. + +### 39.22 A corridor may be corridor-shaped (`homemaker-py-hxi`) + +Asked whether the scoring of outdoor and circulation space is likely correct, +the answer for circulation is no — but **not** for the reason the bead carried. + +**First, a retraction of the bead's own premise.** `hxi` was titled "search is +rewarded for deleting the circulation spine". §39.8 already measured that and +found the opposite: of 64 sampled deletions across harbor and maple, 7 broke +connectivity and **0 of those 7 were rewarded** (×0.00 to ×0.58). Severing costs +one or two connectivity fails plus the cascade behind them, which outweighs the +×6 value gain. The low value rate does not translate into the spine being +deleted. (A comment on the bead restating the retracted claim has been +corrected, and the bead retitled.) + +**What the parameters actually say.** Over the 111 circulation leaves in the +twelve baseline runs, per leaf: + +| factor | mean | fails | +|---|---|---| +| perpendicular | 0.982 | 0/111 | +| proportion | 0.710 | 7/111 | +| size | 0.539 | 16/111 | +| width | 0.772 | 2/111 | +| crinkliness | 0.485 | 29/111 | +| access | 1.000 | 0/111 | + +Median corridor 14.3 m², median aspect **1.67** — a stubby room, not a +corridor. Two parameters explain that, and both are the double-charge shape +§39.14 and §39.18 found elsewhere: + +1. **`proportion_circulation = [1.5, 0.5]`** caps aspect at 2.57. At the 1.97 m + minimum width the width factor allows, that is a corridor **5.1 m long**; at + the 2.4 m target width, 6.2 m. A corridor is long and thin by nature, and + the bigger the building the longer the spine must be. +2. **`size_circulation = [0.0, 14.0]`** — the target area of a corridor is + **zero**, so a median 14.3 m² corridor scores 0.592 on size purely for + existing. Circulation is then priced as overhead twice: once in + `value_circulation` = 50 (a sixth of a room) and again in a size factor + whose optimum is non-existence. + +**Owner's ruling on the first:** *there should be no cap on the proportion of a +corridor, especially for big buildings, the crinkliness rule is there to +prevent these becoming unpleasant spaces.* + +That justification is exactly right, and it is checkable. The unpleasant space +the aspect cap was being used to prevent is a long *buried* corridor — and +crinkliness already sends that to zero, because a buried leaf has `crink == 0`. +A long corridor *along a facade* is a perfectly good corridor, and crinkliness +scores it **0.90**. The cap was standing in for a rule that already exists and +does the job better, since it distinguishes the two cases where aspect cannot. + +**Shipped:** `proportion_circulation = None`, meaning no aspect requirement. +`quality_proportion` returns 1.0 for circulation; `shapecurve.leaf_constraints` +yields `rmax = inf` so the DP agrees with the fitness rather than pruning +topologies the objective would accept. A habitable room's aspect target is +untouched — the ruling is about corridors. + +The narrow side is still held: `width_circulation` keeps a corridor ≥ 1.97 m +wide, and `edge too long` still caps any single wall at 8 m. + +**Unlike §39.14/§39.18/§39.19, this changes the fail set** — that is the point +of it. Across the twelve baseline artefacts it removes exactly **7** corridor +proportion fails and adds none: + +| programme | fails before → after | +|---|---| +| harbor-house | 33/43/42 → 33/42/40 | +| maple-court | 54/73/55 → 54/71/54 | +| health-centre | 4/9/5 → 3/9/5 | +| programme-house | 1/1/1 → unchanged | + +**What binds next, and it is worth knowing before reading too much into this.** +Removing the aspect cap roughly doubles how long a single corridor leaf may be: +the binding constraint moves from proportion at ~6.2 m to `size_circulation` at +**12.5 m** (a 2.4 m wide leaf reaches the 30 m² fail edge there). A longer spine +is still representable as several corridor leaves in series, so this is a real +relaxation rather than a cosmetic one — but the zero-area size target is still +there, and it is the other half of the double-charge above. It has **not** been +changed: it is a distinct parameter with its own rationale (circulation is +overhead, minimise it), the owner has not ruled on it, and §39.16 is a standing +reminder about how often an inherited constant has turned out to be right. + +Whether any of this helps the search is unmeasured and, as with everything since +§39.19, gated on the re-baseline (`homemaker-py-bk9`). diff --git a/src/homemaker_layout/fitness.py b/src/homemaker_layout/fitness.py index 38e8b4f..483f201 100644 --- a/src/homemaker_layout/fitness.py +++ b/src/homemaker_layout/fitness.py @@ -233,7 +233,17 @@ CONF_DEFAULTS: dict = { "size_circulation": [0.0, 14.0], "size_inside": [16.0, 3.5], "proportion_outside": [1.5, 50], - "proportion_circulation": [1.5, 0.5], + # homemaker-py-hxi (DESIGN.md §39.22). Was [1.5, 0.5], which fails a + # corridor above aspect 2.57 -- at the 1.97 m minimum width the width factor + # allows, that is a corridor 5.1 m long. A corridor is long and thin by + # nature, and the longer the building the longer the spine has to be. + # + # `None` means NO proportion requirement. What stops a corridor becoming an + # unpleasant space is crinkliness, not aspect: a long buried corridor has + # crink == 0 and fails outright, while a long one along a facade scores + # 0.90 -- which is the right answer for both. Width still holds the + # narrow side (>= 1.97 m) and `edge too long` still caps a single wall. + "proportion_circulation": None, "proportion_inside": [1.5, 0.5], "width_outside": [3.0, 0.3], "width_circulation": [2.4, 0.2], @@ -1182,6 +1192,8 @@ class Fitness: params = self.conf("proportion_outside") elif t0 == "c": params = self.conf("proportion_circulation") + if params is None: + return 1.0 # no aspect requirement -- §39.22 else: params = self.get_space_params(leaf.type, "proportion") co_type = self._leaf_co_type(leaf) diff --git a/src/homemaker_layout/shapecurve.py b/src/homemaker_layout/shapecurve.py index 2f588c7..5b4ee10 100644 --- a/src/homemaker_layout/shapecurve.py +++ b/src/homemaker_layout/shapecurve.py @@ -233,8 +233,11 @@ def leaf_constraints(fit, leaf: dom_mod.Node) -> LeafBounds: params = fit.conf("proportion_circulation") else: params = fit.get_space_params(leaf.type, "proportion") - target, sigma = params[0], params[1] - rmax = max(1.0 + 1e-9, target + _K * sigma) + if params is None: + rmax = math.inf # no aspect requirement (§39.22, circulation) + else: + target, sigma = params[0], params[1] + rmax = max(1.0 + 1e-9, target + _K * sigma) return LeafBounds(amin=amin, amax=amax, wmin=wmin, rmax=rmax) diff --git a/tests/test_circulation_proportion.py b/tests/test_circulation_proportion.py new file mode 100644 index 0000000..5ab53bd --- /dev/null +++ b/tests/test_circulation_proportion.py @@ -0,0 +1,96 @@ +"""Corridors have no aspect requirement (homemaker-py-hxi, DESIGN.md §39.22). + +Owner's ruling: "there should be no cap on the proportion of a corridor, +especially for big buildings, the crinkliness rule is there to prevent these +becoming unpleasant spaces." + +The tests below pin both halves of that — the cap is gone, AND crinkliness +still does the job the cap was wrongly doing, so removing it did not leave +long buried corridors unpunished. +""" + +from __future__ import annotations + +import copy +import math +from pathlib import Path + +import pytest + +from homemaker_layout import dom as dom_mod +from homemaker_layout.fitness import ( + CONF_DEFAULTS, FAIL_THRESHOLD, Fitness, gaussian, load_config, +) + +EXAMPLES = Path(__file__).resolve().parent.parent / "examples" +pytestmark = pytest.mark.skipif(not (EXAMPLES / "harbor-house").is_dir(), + reason="examples absent") + + +def _fit(**ov): + conf, cost = load_config(EXAMPLES / "harbor-house", overrides=ov) + return Fitness(conf, cost) + + +def test_there_is_no_corridor_aspect_requirement(): + assert CONF_DEFAULTS["proportion_circulation"] is None + fit = _fit() + assert dom_mod.is_circulation(dom_mod.Node(type="C")) + # any aspect at all scores 1.0 -- checked through the real code path + for aspect in (1.0, 3.0, 12.0, 40.0): + fit_leaf = dom_mod.Node( + type="C", node=[[0.0, 0.0], [aspect, 0.0], [aspect, 1.0], [0.0, 1.0]]) + assert fit.quality_proportion(fit_leaf) == 1.0, aspect + + +def test_a_room_still_has_one(): + """The ruling is about corridors. A habitable room's aspect target stands.""" + fit = _fit() + room = dom_mod.Node(type="r", node=[[0.0, 0.0], [12.0, 0.0], [12.0, 1.0], [0.0, 1.0]]) + assert fit.quality_proportion(room) < FAIL_THRESHOLD + + +def test_crinkliness_is_what_punishes_an_unpleasant_corridor(): + """The ruling's own justification, as arithmetic. + + A long BURIED corridor is exactly the unpleasant space the aspect cap was + being used to prevent; crinkliness sends it to zero. A long corridor along + a facade is a perfectly good corridor, and crinkliness likes it. + """ + target, sigma = CONF_DEFAULTS["uncrinkliness_circulation"] + h, width, length = 3.0, 2.4, 30.0 + area = width * length + + buried = 0.0 # no illuminated wall at all + assert buried == 0.0 + + lit_one_side = (length * h) / area # the long wall is a facade + q = gaussian(1.0 / lit_one_side, 1.0, target, sigma) + assert q > 0.85, "a daylit corridor along a facade should score well" + + +def test_removing_the_cap_only_ever_removes_proportion_fails(): + """It cannot introduce a failure, and it touches nothing but corridors.""" + old = {"proportion_circulation": [1.5, 0.5]} + seen = 0 + for name in ("harbor-house", "maple-court", "health-centre", "programme-house"): + d = EXAMPLES / name + for p in sorted(d.glob("coldstart-500000-s*.dom")): + root = dom_mod.load(str(p)) + c_old, cost = load_config(d, overrides=old) + c_new, _ = load_config(d) + _, f_old = Fitness(c_old, cost).score_with_fails(copy.deepcopy(root)) + _, f_new = Fitness(c_new, cost).score_with_fails(copy.deepcopy(root)) + assert not (set(f_new) - set(f_old)), "must not add a failure" + assert all(f.endswith(" proportion") for f in set(f_old) - set(f_new)) + seen += 1 + assert seen >= 4 + + +def test_the_shape_curve_dp_accepts_an_unbounded_aspect(): + """`leaf_constraints` feeds rmax into the DP; None must become inf, not crash + and not silently fall back to a finite cap.""" + from homemaker_layout import shapecurve + fit = _fit() + leaf = dom_mod.Node(type="C", node=[[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]]) + assert math.isinf(shapecurve.leaf_constraints(fit, leaf).rmax)