From f07b3865ef187f7bb5f4808e44f603200ab21fd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 15:01:12 +0000 Subject: [PATCH] No size cap on circulation: twice the corridor is twice as bad, and no worse Owner's ruling: "as long as circulation is more expensive to build than it has value then we have a linear ramp. a gaussian ramp is probably not appropriate here as double the amount of corridor is simply twice as bad, so it should score the same as two half size corridors". Both halves check out. The linear ramp is already there -- value_circulation 50 against a build cost of 200, so every m2 of corridor is worth -150 and the objective pushes for less of it without needing a cap. And the AMOUNT of circulation is separately governed at building level by ratio_circulation [0.00, 0.20], a gaussian on the circulation fraction, which is where that question belongs. The per-leaf size gaussian was a third charge on the same thing. It was also the only one of the three that depended on how the corridor was cut up. One 20 m2 corridor scored gaussian(20,0,14) = 0.360 and contributed 360; two 10 m2 halves scored 0.775 each and contributed 775 between them. Splitting a corridor in half multiplied its value by 2.15x -- an artefact of where the tree happened to cut, rewarding the search for fragmenting its own spine. The ruling's test (one 2A leaf must score as two A leaves) is exactly what a gaussian on an amount cannot satisfy, and is now a test. size_circulation = None; quality_size returns 1.0 for circulation and shapecurve gives amin, amax = 0, inf. BUG this exposed: get_space_params falls through to a habitable default when a generic family key is missing and could not tell "missing" from "present but null", so a corridor silently inherited a room's 16 m2 size target. _generic_param now returns (found, value); pinned by a test. The same trap applied to 39.22's proportion_circulation. Fail-set effect of 39.22 and 39.23 together: 16 corridor size fails and 7 proportion fails removed, none added. harbor 33/43/42 -> 32/40/38, maple 54/73/55 -> 51/65/52, health-centre 4/9/5 -> 3/9/5, programme-house unchanged. The layouts are identical -- these are failures the objective should never have been reporting. Two shape-curve tests moved fixture: both built an infeasible upper storey from a 'C' leaf, infeasible precisely because of the bounds now removed. The fixture is a cr1 leaf, whose infeasibility is a contradiction between two of its own bounds (needs >= 180 m2 for its aspect bound, <= 101.5 m2 for its size bound across the box's fixed 23.52 m span) rather than a tight fit. The invariants they test are unchanged. Left open on hxi: the rate gap, value_circulation 50 against value_inside 300 on identical build cost. Whether a corridor is worth a sixth of a room per m2 is a design judgement, and the linear ramp is only as steep as that number. 419 passed. Refs homemaker-py-hxi. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB --- DESIGN.md | 72 ++++++++++++++++++++++++++++ src/homemaker_layout/fitness.py | 45 +++++++++++++++-- src/homemaker_layout/shapecurve.py | 12 +++-- tests/test_circulation_proportion.py | 66 +++++++++++++++++++++++++ tests/test_shapecurve.py | 25 +++++++--- 5 files changed, 203 insertions(+), 17 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index e6efaca..da956b6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -7899,3 +7899,75 @@ 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`). + +### 39.23 Twice the corridor is twice as bad, and no worse (`homemaker-py-hxi`) + +§39.22 removed the corridor aspect cap and found the next constraint waiting +behind it: `size_circulation = [0.0, 14.0]`, a gaussian on a corridor's **area** +centred on zero — the ideal corridor being one that does not exist — failing any +corridor leaf over 30 m². Owner's ruling: + +> as long as circulation is more expensive to build than it has value then we +> have a linear ramp. a gaussian ramp is probably not appropriate here as double +> the amount of corridor is simply twice as bad, so it should score the same as +> two half size corridors + +Both halves of that check out. + +**The linear ramp already exists.** `value_circulation` is 50 against a build +cost of 200, so every square metre of corridor is worth −150 — the objective is +already pushing the search to use less of it, linearly, with no cap needed. And +the *amount* of circulation is separately governed at building level by +`ratio_circulation = [0.00, 0.20]`, a gaussian on the circulation **fraction**, +which is where a question about how much corridor a building should have +belongs. The per-leaf size gaussian was a **third** charge on the same thing. + +**And it was the only one of the three that depended on how the corridor was cut +up.** Under `value += quality × rate × area`, one 20 m² corridor scores +`gaussian(20, 0, 14) = 0.360` and contributes 360; two 10 m² halves score 0.775 +each and contribute 775 between them. **Splitting a corridor in half multiplied +its value by 2.15×** — a pure artefact of where the tree happened to cut, and +one that rewarded the search for fragmenting its own spine. The ruling's test — +one 2A leaf must score as two A leaves — is exactly the invariant a gaussian on +an *amount* cannot satisfy, and it is now a test. + +**Shipped:** `size_circulation = None`. `quality_size` returns 1.0 for +circulation, and `shapecurve.leaf_constraints` gives `amin, amax = 0, inf`. + +**One bug this exposed.** `get_space_params` falls through to a habitable +default when a generic family key is missing, and could not tell "missing" from +"present but null" — so with `size_circulation = None` a corridor silently +inherited a room's 16 m² target. `_generic_param` now returns `(found, value)` +so a declared null is honoured, and a test pins it. The same latent trap applies +to §39.22's `proportion_circulation`. + +**Fail-set effect** of §39.22 and §39.23 together, across the twelve baseline +artefacts — 16 corridor size fails and 7 proportion fails removed, none added: + +| programme | before → after | +|---|---| +| harbor-house | 33/43/42 → 32/40/38 | +| maple-court | 54/73/55 → 51/65/52 | +| health-centre | 4/9/5 → 3/9/5 | +| programme-house | 1/1/1 → unchanged | + +maple s1 drops 8 fails, harbor s2 drops 4. These are not improvements in the +layouts — the layouts are unchanged — they are failures the objective should +never have been reporting. + +**Two shape-curve tests had to move fixture.** Both constructed an infeasible +upper storey out of a `C` leaf, which was infeasible precisely *because* of +circulation's proportion and size bounds. A corridor can no longer be +shape-infeasible at all, so the fixture is now a `cr1` leaf, whose infeasibility +is a contradiction between two of its own bounds rather than a tight fit: across +the box's fixed 23.52 m span it needs ≥ 180 m² to satisfy its aspect bound and +≤ 101.5 m² to satisfy its size bound. The tests themselves — that `solve` rolls +back every level, and that `is_feasible` never writes — are unchanged and still +worth having. + +**What is left of `hxi`.** The two shape factors are fixed. The rate gap remains +untouched and unruled: `value_circulation` = 50 against `value_inside` = 300, a +6× difference, on identical build cost. Whether a corridor is worth a sixth of a +room per square metre is a design judgement, not something measurement settles, +and the linear ramp the owner describes is only as steep as that number makes +it. Left open on the bead. diff --git a/src/homemaker_layout/fitness.py b/src/homemaker_layout/fitness.py index 483f201..1146046 100644 --- a/src/homemaker_layout/fitness.py +++ b/src/homemaker_layout/fitness.py @@ -230,7 +230,21 @@ CONF_DEFAULTS: dict = { # best-supported, and the last that should be retuned. "uncrinkliness": [5.0 / 6, 1.1 / 3], "uncrinkliness_circulation": [5.0 / 6, 1.1 / 3], - "size_circulation": [0.0, 14.0], + # homemaker-py-hxi (DESIGN.md §39.23). Was [0.0, 14.0] -- a gaussian on a + # corridor's AREA centred on zero, i.e. "the ideal corridor does not exist", + # which failed any corridor leaf over 30 m2. + # + # `None` means no size requirement, because the amount of circulation is + # already priced twice and better: linearly, by value_circulation = 50 + # against a build cost of 200 (every m2 of corridor is net -150), and at + # BUILDING level by ratio_circulation, which is where "how much corridor" + # belongs. A per-leaf gaussian was a third charge on the same thing. + # + # It was also the only one of the three that depended on how the corridor + # happened to be cut up. Twice the corridor is twice as bad and no worse, so + # one 20 m2 leaf must score as two 10 m2 leaves -- under the gaussian it did + # not, and splitting a corridor in half more than doubled its value. + "size_circulation": None, "size_inside": [16.0, 3.5], "proportion_outside": [1.5, 50], # homemaker-py-hxi (DESIGN.md §39.22). Was [1.5, 0.5], which fails a @@ -1139,6 +1153,19 @@ class Fitness: # Programme-driven parameter lookup (ProgrammeDriven.pm:29-69) # ------------------------------------------------------------------ # + def _generic_param(self, key: str): + """``(found, value)`` for a generic C/O/S parameter family. + + ``found`` is False only when the key appears in neither the programme's + config nor ``CONF_DEFAULTS``; a present key whose value is ``None`` is + found, and means the requirement has been switched off. + """ + if key in self._conf: + return True, self._conf[key] + if key in CONF_DEFAULTS: + return True, CONF_DEFAULTS[key] + return False, None + def get_space_params(self, code: str, param: str) -> list[float]: # §39.4: only the GENERIC types take the circulation/outside parameter # families. A programme code is looked up in ``spaces`` regardless of @@ -1147,13 +1174,19 @@ class Fitness: # families split it the outside way: the circulation branch is exactly # C, and S takes the *_outside params (preserved from the original # c0 == "c" / c0 in ("o", "s") dispatch). + # A generic family key that is PRESENT but null means "no requirement" + # (size_circulation, proportion_circulation -- §39.22/§39.23), which is + # a different thing from the key being absent. `conf()` cannot tell + # them apart, so the tables are consulted directly; returning None here + # rather than falling through is what stops a corridor silently + # inheriting a habitable room's 16 m2 size target. if code == "C": - v = self.conf(f"{param}_circulation") - if v is not None: + found, v = self._generic_param(f"{param}_circulation") + if found: return v if code in dom_mod.GENERIC_OUTSIDE: - v = self.conf(f"{param}_outside") - if v is not None: + found, v = self._generic_param(f"{param}_outside") + if found: return v sp = self.spaces.get(code) # exact-key match, as in Perl if sp is not None and param in sp: @@ -1217,6 +1250,8 @@ class Fitness: return 1.0 if t0 == "c": params = self.conf("size_circulation") + if params is None: + return 1.0 # no size requirement -- §39.23 else: params = self.get_space_params(leaf.type, "size") target, sigma = params[0], params[1] diff --git a/src/homemaker_layout/shapecurve.py b/src/homemaker_layout/shapecurve.py index 5b4ee10..cd07b8c 100644 --- a/src/homemaker_layout/shapecurve.py +++ b/src/homemaker_layout/shapecurve.py @@ -182,16 +182,18 @@ def leaf_constraints(fit, leaf: dom_mod.Node) -> LeafBounds: else "c" if leaf.type == "C" else "") # --- size -> (amin, amax) --- - if t0 in ("o", "s"): + params = (fit.conf("size_circulation") if t0 == "c" + else None if t0 in ("o", "s") + else fit.get_space_params(leaf.type, "size")) + if params is None: + # o/s never had a size target; circulation's was removed in §39.23 amin, amax = 0.0, math.inf else: - params = fit.conf("size_circulation") if t0 == "c" else fit.get_space_params(leaf.type, "size") target, sigma = params[0], params[1] # NB: quality_size's ``target > 0`` gate governs only the leaf-sharing/ # co_type k-scaling of (target, sigma) -- the underlying - # gaussian(area, target, sigma) test always applies, including - # target==0 (e.g. size_circulation's [0.0, 14.0] default: a real - # one-sided "as small as possible" constraint, not "unconstrained"). + # gaussian(area, target, sigma) test always applies where a target + # exists. Circulation no longer has one (§39.23). if t0 != "c" and target > 0: # homemaker-py-tym: mirror quality_size exactly. A shared leaf holds # k same-code rooms, so the gaussian is centred on k*target with diff --git a/tests/test_circulation_proportion.py b/tests/test_circulation_proportion.py index 5ab53bd..0309c5c 100644 --- a/tests/test_circulation_proportion.py +++ b/tests/test_circulation_proportion.py @@ -94,3 +94,69 @@ def test_the_shape_curve_dp_accepts_an_unbounded_aspect(): 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) + + +# --------------------------------------------------------------------------- # +# No size requirement either (homemaker-py-hxi, DESIGN.md §39.23) +# --------------------------------------------------------------------------- # + +def test_there_is_no_corridor_size_requirement(): + assert CONF_DEFAULTS["size_circulation"] is None + fit = _fit() + for area in (5.0, 14.0, 30.0, 60.0, 200.0): + leaf = dom_mod.Node( + type="C", node=[[0.0, 0.0], [area, 0.0], [area, 1.0], [0.0, 1.0]]) + assert fit.quality_size(leaf) == 1.0, area + + +def test_a_corridor_does_not_inherit_a_rooms_size_target(): + """`get_space_params` falls through to a habitable default when a generic + family key is missing. A key that is present but null must not fall + through -- or a corridor silently acquires a 16 m2 target.""" + fit = _fit() + assert fit.get_space_params("C", "size") is None + assert fit.get_space_params("C", "proportion") is None + assert fit.get_space_params("C", "width") == CONF_DEFAULTS["width_circulation"] + + +def test_twice_the_corridor_is_exactly_twice_as_bad(): + """The owner's argument, as arithmetic. + + "double the amount of corridor is simply twice as bad, so it should score + the same as two half size corridors". Under a gaussian on area that was + false -- splitting a corridor in two raised its total value, which is a + pure artefact of how the tree happens to be cut. Value must be linear in + corridor area, so that one 2A leaf and two A leaves contribute the same. + """ + fit = _fit() + rate = fit.conf("value_circulation") + + def value(area): + leaf = dom_mod.Node( + type="C", node=[[0.0, 0.0], [area, 0.0], [area, 1.0], [0.0, 1.0]]) + return fit.quality_size(leaf) * rate * area + + assert value(20.0) == pytest.approx(2 * value(10.0)) + assert value(60.0) == pytest.approx(6 * value(10.0)) + + # and under the old gaussian it was not -- this is what changed. + # One 20 m2 corridor scored gaussian(20) = 0.360, two 10 m2 halves + # gaussian(10) = 0.775 each, so merely cutting the same corridor in two + # multiplied its value by 2.15x. + old_whole = gaussian(20.0, 1.0, 0.0, 14.0) * rate * 20.0 + old_halves = 2 * (gaussian(10.0, 1.0, 0.0, 14.0) * rate * 10.0) + assert old_halves / old_whole == pytest.approx(2.15, abs=0.02), ( + "the old gaussian rewarded splitting a corridor; if that is no longer " + "so, §39.23's justification needs revisiting") + + +def test_the_amount_of_circulation_is_still_priced(): + """Removing the per-leaf cap must not make corridors free. Two charges + remain, and they are the ones that do not depend on how it is cut up.""" + fit = _fit() + assert fit.conf("value_circulation") < fit.cost("inside"), ( + "a corridor must cost more to build than it is worth, or there is no " + "linear ramp pushing the search to use less of it") + assert fit.conf("ratio_circulation") is not None, ( + "the building-level circulation fraction check is the other charge" + ) diff --git a/tests/test_shapecurve.py b/tests/test_shapecurve.py index dff5a67..66c0edd 100644 --- a/tests/test_shapecurve.py +++ b/tests/test_shapecurve.py @@ -236,12 +236,22 @@ def test_solve_multistorey_matches_free_branches(tmp_path): def test_solve_multistorey_infeasible_restores_every_level(): - """When an upper-storey free split is infeasible (a 'C' leaf forced into - a below-fixed box too tall for its proportion/size bounds -- verified by - inspection, not tuned to just barely fail), ``solve`` must roll back - ALL levels, including the ground storey it already realised earlier in - the same call -- not just the storey where infeasibility was detected.""" - root, target = _two_storey_mixed_topology(child_types=("C", "O")) + """When an upper-storey free split is infeasible, ``solve`` must roll back + ALL levels, including the ground storey it already realised earlier in the + same call -- not just the storey where infeasibility was detected. + + The infeasibility is a `cr1` leaf in the below-fixed box, and it is a + contradiction between two of its own bounds rather than a tight fit: across + the box's fixed 23.52 m span, cr1 needs >= 180 m2 to satisfy its aspect + bound (3.07) and <= 101.5 m2 to satisfy its size bound -- a factor of 1.8 + apart. Verified by inspection, not tuned to just barely fail. + + This used to be a 'C' leaf, infeasible on circulation's own proportion and + size bounds. §39.22/§39.23 removed both, so a corridor can no longer be + shape-infeasible at all and the fixture had to move to a leaf that still + carries the constraints. + """ + root, target = _two_storey_mixed_topology(child_types=("cr1", "O")) level1 = root.above fit = _fit() @@ -269,7 +279,8 @@ def test_is_feasible_multistorey_never_writes(): after = [tuple(b.division) for b in solver.free_branches(feasible_root)] assert before == after - infeasible_root, _ = _two_storey_mixed_topology(child_types=("C", "O")) + # 'C' is no longer shape-constrained (§39.22/§39.23); cr1 still is. + infeasible_root, _ = _two_storey_mixed_topology(child_types=("cr1", "O")) before = [tuple(b.division) for b in solver.free_branches(infeasible_root)] assert shapecurve.is_feasible(infeasible_root, fit) is False after = [tuple(b.division) for b in solver.free_branches(infeasible_root)]