From f5612ce06c91449c0424f3e629be99493d0f5ff5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 17:46:52 +0000 Subject: [PATCH] Pre-flight: check the ground floor's frontage, not the per-storey average 39.16 relocated the crinkliness residual to a plan-form question. Four measurements answer it, and two of them refute the premises 773 was filed on. The search DOES build courtyards -- harbor 8 (273 m2), maple 16 (404 m2), health-centre 13 (107 m2) over three seeds -- and they work: of 524 lit edges 44% come from the plot wall, 30% from a courtyard, 26% from a perimeter void, and a courtyard supplies at least one side for 53% of the two-aspect leaves. No operator is missing. (The shape-curve DP does NOT model exposure -- shapecurve.py:25 -- but per 38.24 it fires ~8 times in 500k evals, so that gap is not what is costing anything.) The answer is per-storey. Comparing each storey's demand, sum A_i/(1.6202*h), with the lit wall its leaves actually hold: every harbor and maple ground floor is below 1.0 and every top floor above 1.2, and the ratio predicts the fail rate almost exactly -- above ~1.2 near-zero fails, below 1.0 40-55% of the storey. health-centre and programme-house sit at 1.6-4.2 throughout and fail essentially nothing. That corrects 39.11, which divided demand evenly across storeys and concluded harbor and maple were frontage-feasible "with room to spare". Programmes pin rooms to level 0 and the ground floor cannot set itself back to buy perimeter: harbor's pinned 347 m2 needs 71.4 m against the plot's 53.0 m, maple's 414 m2 needs 85.2 m against 55.0 m, while health-centre and programme-house have 51.0 and 14.2 m spare. Same ordering as the corpus fail counts, and fixed before any search runs. The averaged check is not just weaker: on maple it asks for a 22 m2 courtyard where the ground floor needs 57 m2. New third _preflight check, advisory like the others, silent on the two programmes with slack. tests/test_evolve_preflight.py covers all three checks and asserts the ground-floor figure exceeds the averaged one -- if they ever agree, one has stopped earning its place. 39.11 annotated in place. Also recorded, not acted on: the open space is on the wrong storey (harbor puts 50 m2 of courtyard on the starved ground floor and 223 m2 on the surplus first floor), because value_rate pays an outside leaf above ground value_supported = 300 -- a room's rate -- against a cost of 110, with nothing tying its value to whether it illuminates anything. Filed as homemaker-py-ecx. 411 passed, 72 skipped. Refs homemaker-py-773. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB --- DESIGN.md | 108 +++++++++++++++++++++++++++++++++ src/homemaker_layout/evolve.py | 32 ++++++++-- tests/test_evolve_preflight.py | 68 +++++++++++++++++++++ 3 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 tests/test_evolve_preflight.py diff --git a/DESIGN.md b/DESIGN.md index 00db1c4..db2aae7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -6815,6 +6815,17 @@ unsatisfiable. Of the four programmes, three fit their plots and one does not and that one fails a much cruder test than daylight. §38.3's claim that the plateau programmes are "frontage-infeasible as specified" is withdrawn. +*(**Qualified by §39.17.** The table above divides demand evenly across +storeys, and on that basis harbor-house and maple-court pass "with room to +spare". They do not pass per storey. A programme pins rooms to level 0, and +the ground floor is the one storey that cannot set itself back to buy more +perimeter: harbor's pinned 347 m² needs 71 m of daylit wall against the plot's +53 m, and maple's 414 m² needs 85 m against 55 m. That per-storey shortfall +orders the corpus exactly as the fail counts do, and it is where the whole +crinkliness residual sits. §38.3's "frontage-infeasible as specified" was +withdrawn against the averaged test; against the binding storey the two +plateau programmes are short. `_preflight` now checks both.)* + ### 39.12 The cold-start re-baseline, and what it does to §38.7's acceptance test (`homemaker-py-ut5`) Every corpus fail count published before §39.4/§39.7/§38.11 was measured @@ -7405,3 +7416,100 @@ reading a ratio as a length: `1/crink` was taken for room depth, and the median constant with a physical interpretation needs that interpretation written down next to it — and, where one exists, its provenance. `uncrinkliness: [5/6, 1.1/3]` now carries both in `fitness.py`. + +### 39.17 The ground floor is the storey that fails, and §39.11 averaged it away (`homemaker-py-773`) + +§39.16 relocated the crinkliness residual to a plan-form question: light on two +sides all but guarantees a pass, only 33.7% of leaves get it, so why can the +search not deliver more? Four measurements answer it, and the first two +refute the premises the bead was filed on. + +**The search does build courtyards.** Classifying every outside leaf in the +twelve baseline artefacts by how much of its own boundary is external — a leaf +with none is an interior void, and only an interior void converts single-aspect +rooms into two-aspect ones: + +| programme | courtyard | perimeter garden/terrace | +|---|---|---| +| harbor-house | 8 leaves, 273 m² | 18 leaves, 712 m² | +| maple-court | 16 leaves, 404 m² | 29 leaves, 1417 m² | +| health-centre | 13 leaves, 107 m² | 3 leaves, 32 m² | + +**And they work.** Tracing where each lit edge's light comes from, over 524 lit +edges: 44% plot wall, **30% courtyard**, 26% perimeter void. Of the 145 leaves +with light on two or more sides, a courtyard supplies at least one side for 77 +of them — **53%**. The plan form is not structurally incapable, and no operator +is missing. + +**But every ground floor is starved and every top floor has surplus.** A leaf +passes iff `L_i >= A_i/(1.6202·h)`. Summing that demand over a storey's graded +leaves and comparing with the lit wall those leaves actually hold: + +| storey | supply/demand (3 seeds) | crinkliness fails | +|---|---|---| +| harbor ground | 0.95 / 0.97 / 0.62 | 14/27, 10/24, 12/24 | +| harbor first | 1.67 / 1.26 / 1.75 | 1/21, 3/18, 2/20 | +| maple ground | 0.96 / 0.77 / 0.52 | 12/27, 11/26, 15/27 | +| maple first | 0.64 / 0.89 / 0.92 | 6/14, 6/15, 5/16 | +| maple second | 1.35 / 1.25 / 1.30 | 1/21, 7/20, 4/24 | +| health-centre (single) | 1.77 / 1.60 / 2.07 | 1/27, 2/24, 0/25 | +| programme-house (all) | 2.07 – 4.15 | 0 everywhere | + +The ratio predicts the fail rate almost perfectly: above ~1.2, near-zero fails; +below 1.0, 40–55% of the storey's leaves fail. + +**And the open space is on the wrong storey.** Harbor puts **50 m²** of +courtyard on the starved ground floor and **223 m²** on the first floor, which +already has 1.3–1.8× the frontage it needs; its perimeter terraces go 136 m² +ground against 521 m² above. The upper storeys set back and become mostly +terrace — which is where all the spare frontage in the table comes from — while +the ground floor is packed to the plot edge. + +`value_rate` explains it: an outside leaf above ground takes `value_supported` += 300, the same rate as an interior room, against a cost of 110. A roof terrace +returns 2.7× its cost while a room returns 1.5×, and nothing in the objective +connects an outside leaf's value to whether it illuminates anything. Open space +is paid the same wherever it is put. + +**The brief was already short before the search started, and §39.11 could not +see it.** §39.11 divides total demand by storey count, and on that basis +declared harbor and maple "frontage-feasible with room to spare". But a +programme *pins* rooms to the ground floor with `level: 0`, and the ground +floor is the one storey that cannot set itself back to buy more perimeter: + +| programme | pinned to level 0 | frontage needed | plot supplies | | +|---|---|---|---|---| +| harbor-house | 347 m² (50% of plot) | 71.4 m | 53.0 m | **short 18.4 m** | +| maple-court | 414 m² (54%) | 85.2 m | 55.0 m | **short 30.2 m** | +| health-centre | 50 m² (13%) | 10.3 m | 61.3 m | 51.0 m spare | +| programme-house | 38 m² (73%) | 7.8 m | 22.1 m | 14.2 m spare | + +That is the same ordering as the corpus fail counts — 39.3, 60.7, 6.0, 1.0 — +and it is a property of the brief and the plot, fixed before any search runs. +The averaged check is not merely weaker: on maple it asks for a 22 m² courtyard +where the ground floor needs **57 m²**. + +**Shipped: a third `_preflight` check.** Advisory, like the others. + +``` +WARNING: 347 m2 is pinned to level 0 by the programme and needs ~71 m of daylit +wall, against the 53 m the plot perimeter gives. The ground floor cannot set +itself back to buy more, so a square courtyard of roughly 21 m2 (4.6 m a side) +is what closes it. +``` + +Silent on health-centre and programme-house. `tests/test_evolve_preflight.py` +asserts that, and asserts that the ground-floor figure exceeds the averaged one +on maple — if the two ever agree, one of the checks has stopped earning its +place. + +**What this does and does not settle.** It says where the residual comes from +and gives the author a number to act on. It does not say the search is +blameless: harbor's *achieved* ground floor carries ~614 m² of room against the +347 m² the programme pins there, so the search chooses to overload the storey +it is already short on. Whether that is rational under the current value rates, +and whether an outside leaf's value should depend on the daylight it delivers, +are the open questions — the second especially, since it is the same "value +prices what cost already charges, and prices it context-free" shape that §39.14 +found in crinkliness. Both are objective changes and neither is smuggled in +here. diff --git a/src/homemaker_layout/evolve.py b/src/homemaker_layout/evolve.py index b29a350..6defed6 100644 --- a/src/homemaker_layout/evolve.py +++ b/src/homemaker_layout/evolve.py @@ -231,13 +231,20 @@ def _parse_args(argv=None) -> argparse.Namespace: def _preflight(programme_dir) -> None: """Warn before the run if the programme cannot fit its plot (homemaker-py-tdp). - Two checks, cheap and closed-form (DESIGN.md §38.3/§39.11). Neither can be - fixed by searching harder, so it is worth saying so up front rather than - letting a multi-hour run bottom out against it: + Three checks, cheap and closed-form (DESIGN.md §38.3/§39.11/§39.17). None + can be fixed by searching harder, so it is worth saying so up front rather + than letting a multi-hour run bottom out against it: 1. does the demanded floor area fit the plot at all; 2. is there enough daylit wall for that area, given every interior leaf - needs ``L >= A/(1.6202*h)`` before it fails crinkliness. + needs ``L >= A/(1.6202*h)`` before it fails crinkliness; + 3. the same question for the GROUND FLOOR alone. Check 2 divides the + demand evenly across storeys, but a programme pins rooms to level 0 + with ``level: 0`` and the ground floor is the one storey that cannot be + made smaller or set back to buy itself more perimeter. Averaging hides + that: harbor-house and maple-court both pass check 2 comfortably while + their pinned ground floors are short by 18 m and 30 m of daylit wall + (§39.17), which is where their whole crinkliness residual lives. "Daylit" is measured exactly as ``Fitness.area_outside`` does: an external boundary counts unless its perimeter type is ``private`` or ``fortified``. @@ -260,6 +267,11 @@ def _preflight(programme_dir) -> None: storeys = max(_prog.n_storeys_required(reqs), _prog.storey_minimum(str(programme_dir))) built = sum(r.size * r.count for r in reqs.values()) / max(storeys, 1) + from .fitness import Fitness, load_config + conf, cost = load_config(str(programme_dir)) + spaces = Fitness(conf, cost).spaces + pinned = sum(r.size * r.count for code, r in reqs.items() + if (spaces.get(code) or {}).get("level") == 0) except Exception: return # advisory only; never block a run @@ -281,6 +293,18 @@ def _preflight(programme_dir) -> None: f"Roughly {court:.0f} m2 of courtyard closes the gap{note}. " f"(DESIGN.md §38.3)", file=sys.stderr) + # ...and the same question for the one storey that cannot set itself back. + ground = pinned / (1.6202 * height) + if pinned and ground > daylit: + # a square courtyard of area x contributes its whole perimeter, 4*sqrt(x) + court = ((ground - daylit) / 4.0) ** 2 + print(f"WARNING: {pinned:.0f} m2 is pinned to level 0 by the programme and " + f"needs ~{ground:.0f} m of daylit wall, against the {daylit:.0f} m the " + f"plot perimeter gives. The ground floor cannot set itself back to " + f"buy more, so a square courtyard of roughly {court:.0f} m2 " + f"({court ** 0.5:.1f} m a side) is what closes it. " + f"(DESIGN.md §39.17)", file=sys.stderr) + def main(argv=None) -> int: args = _parse_args(argv) diff --git a/tests/test_evolve_preflight.py b/tests/test_evolve_preflight.py new file mode 100644 index 0000000..348e5b4 --- /dev/null +++ b/tests/test_evolve_preflight.py @@ -0,0 +1,68 @@ +"""`evolve._preflight` — the advisory pre-run feasibility warnings. + +DESIGN.md §39.11 shipped two checks (does the demand fit the plot; is there +enough daylit wall for it). §39.17 adds the third, and the reason it is a +separate check is the whole point: check 2 divides demand evenly across +storeys, but a programme pins rooms to level 0 and the ground floor is the one +storey that cannot set itself back to buy more perimeter. Averaging hides a +ground floor that is short. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from homemaker_layout import evolve + +EXAMPLES = Path(__file__).resolve().parent.parent / "examples" +pytestmark = pytest.mark.skipif(not (EXAMPLES / "harbor-house").is_dir(), + reason="examples absent") + + +def _warnings(progdir, capsys) -> list[str]: + evolve._preflight(str(progdir)) + return [ln for ln in capsys.readouterr().err.splitlines() if ln.startswith("WARNING")] + + +def _ground(lines): + return [ln for ln in lines if "pinned to level 0" in ln] + + +@pytest.mark.parametrize("name", ["harbor-house", "maple-court"]) +def test_ground_floor_shortfall_is_reported(name, capsys): + """Both plateau programmes pass the averaged check and fail the ground-floor + one -- which is the case §39.17 exists to catch.""" + ground = _ground(_warnings(EXAMPLES / name, capsys)) + assert len(ground) == 1, f"{name} should warn about its pinned ground floor" + assert "courtyard" in ground[0] + assert "§39.17" in ground[0] + + +@pytest.mark.parametrize("name", ["health-centre", "programme-house"]) +def test_programmes_with_a_slack_ground_floor_stay_quiet(name, capsys): + """The two programmes that reach near-zero fails have ground-floor frontage + to spare, and must not be warned about it.""" + assert _ground(_warnings(EXAMPLES / name, capsys)) == [] + + +def test_the_ground_floor_check_is_stricter_than_the_averaged_one(capsys): + """maple-court is the case that shows why averaging is not enough: the + per-storey average asks for a far smaller courtyard than the ground floor + actually needs. If these ever agree, one of the two checks is redundant.""" + lines = _warnings(EXAMPLES / "maple-court", capsys) + averaged = [ln for ln in lines if "per storey needs" in ln] + assert averaged and _ground(lines) + + def m2(line, after): + tail = line.split(after, 1)[1] + return float("".join(c for c in tail.split("m2")[0] if c.isdigit() or c == ".")) + + assert m2(_ground(lines)[0], "roughly") > m2(averaged[0], "Roughly") + + +def test_preflight_never_raises_on_a_directory_it_cannot_read(tmp_path, capsys): + """Advisory only: it must never be able to stop a run.""" + evolve._preflight(str(tmp_path)) + assert _warnings(tmp_path, capsys) == []