diff --git a/DESIGN.md b/DESIGN.md index db2aae7..d441be1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -7513,3 +7513,96 @@ 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. + +### 39.18 Quality is a product over a variable number of questions (`homemaker-py-ecx`) + +§39.17 left an observation unexplained: the search puts its open space on the +storey that already has surplus frontage, and it does so consistently. Filed as +a suspicion about `value_rate`. It is not the rate, or not only the rate. + +**What a square metre actually earns**, over the twelve baseline runs: + +| leaf kind | n | area | mean quality | value/m² | cost/m² | **value/cost** | +|---|---|---|---|---|---|---| +| outside, ground | 32 | 556 m² | 0.247 | 74.0 | 10.0 | **7.40** | +| outside, upper (terrace) | 25 | 834 m² | **0.986** | 295.8 | 110.0 | **2.69** | +| room | 345 | 7090 m² | **0.223** | 67.0 | 200.0 | **0.34** | +| circulation | 111 | 2453 m² | 0.076 | 3.8 | 200.0 | **0.02** | + +A roof terrace returns 2.7× its cost; a room returns 0.34×; a corridor 0.02×. +The search is not leaving value on the table by filling upper storeys with +terrace — that is by a wide margin the most profitable thing the objective +offers it. 7% of the corpus area produces 32% of its value. + +**And the mean-quality column is where most of that comes from.** Quality is a +*product* of factors, and the leaf kinds are not asked the same number of +questions: + +| factor | room | circulation | outside | +|---|---|---|---| +| perpendicular | 0.978 | 0.980 | — | +| proportion | 0.872 | 0.699 | 1.000 | +| size | 0.518 | 0.270 | **exempt** | +| width | 0.937 | 0.918 | 0.907 | +| crinkliness | 0.427 | 0.376 | **exempt** | +| access | 0.940 | 1.000 | **exempt** | +| **product** | **0.223** | **0.076** | **0.766** | +| factors that ever bite | 6/7 | 5/7 | 3/7 | + +Every exemption is individually correct. An outside leaf has no programme size +target to be measured against; an uncovered one is lit by definition; ground +level outside needs no access. What is not correct is the consequence: **a leaf +exempt from the two harshest factors scores higher than one judged on them and +doing well, purely by not being asked.** Quality then multiplies the value +rate, so the exemption is worth money. + +The general form of the defect is worth stating, because it is not about +outside space: under a product, **adding any new quality criterion mechanically +devalues every leaf it applies to, relative to every leaf it does not** — even +a leaf that scores 1.0 on it. The objective's scale should not depend on how +many things it happens to measure. + +**What shipped: `quality_aggregate="geometric_mean"`, default OFF.** The +product, normalised by how many questions the leaf was asked. A leaf good at +everything asked of it scores the same whether three things were asked or six. +Computed in log space, so six small factors cannot underflow the product before +the root is taken; a zero factor still gives zero, so a fully buried leaf is +worth nothing under either. + +Telling "exempt" from "asked and scored 1.0" needs a predicate, +`factor_is_asked`, that restates conditions living inside the `quality_*` +methods — duplication that can drift. `tests/test_fitness_aggregate.py` pins it +against every leaf in the corpus: wherever the predicate says exempt, the +factor really is exactly 1.0. + +**The fail set is byte-identical** on every corpus artefact, and for a stronger +reason than in §39.13/§39.14: `evaluate_leaf` emits each fail from the factor +itself, before anything is combined, so no choice of aggregation can move a +fail. Stock scoring is therefore a sound yardstick here by construction. + +**Effect**, larger than either crinkliness change and reaching all four +programmes where those reached two: + +| programme | score delta | +|---|---| +| harbor-house | +70.4% / +36.9% / +42.6% | +| maple-court | +93.0% / +60.8% / +57.4% | +| health-centre | +74.9% / +169.3% / +86.7% | +| programme-house | +60.3% / +108.2% / +39.3% | + +| leaf kind | value/cost, product | value/cost, geometric mean | +|---|---|---| +| outside, upper | 2.60 | 2.62 | +| outside, ground | 1.30 | 1.64 | +| room | 0.34 | **0.66** | +| circulation | 0.02 | **0.07** | + +**What it does not fix, deliberately.** A terrace still out-earns a room 4:1. +That residue is the rates, not the aggregation: `value_supported` is 300, the +same as `value_inside`, while an upper outside leaf costs 110 against a room's +200 — so even at quality 1.0 a terrace returns 2.73 and a room 1.5. Whether a +roof terrace is worth as much per square metre as interior floor is a design +judgement belonging to the programme author, and §39.16 is a standing reminder +that "this inherited constant looks wrong" has already been wrong twice in this +section. The aggregation defect is a formula choice and is fixed here; the rate +question is left open on `homemaker-py-ecx` with the numbers attached. diff --git a/experiments/ab_9gj_crinkliness.py b/experiments/ab_9gj_crinkliness.py index 7fbad89..44024d6 100644 --- a/experiments/ab_9gj_crinkliness.py +++ b/experiments/ab_9gj_crinkliness.py @@ -91,6 +91,12 @@ ARM_CONF = { "daylight": {"crinkliness_shape": "daylight"}, "daylight+ramp": {"crinkliness_shape": "daylight", "crinkliness_tail": "ramp"}, + # homemaker-py-ecx (§39.18): orthogonal to the crinkliness arms -- it + # changes how the factors are COMBINED, not what any of them says. + "geomean": {"quality_aggregate": "geometric_mean"}, + "geomean+daylight": {"quality_aggregate": "geometric_mean", + "crinkliness_shape": "daylight", + "crinkliness_tail": "ramp"}, } ARMS = ["stock", "daylight", "daylight+ramp"] diff --git a/src/homemaker_layout/fitness.py b/src/homemaker_layout/fitness.py index 536fa7b..768c555 100644 --- a/src/homemaker_layout/fitness.py +++ b/src/homemaker_layout/fitness.py @@ -502,6 +502,18 @@ class Fitness: # well-lit room costs is already charged by `exterior_wall` and # `boundary_wall` in the cost model, so penalising it again in value # bills the same wall twice. + # homemaker-py-ecx (DESIGN.md §39.18): how a leaf's quality factors are + # combined. "product" (default) is stock. "geometric_mean" divides out + # how many questions the leaf was ASKED, because quality is a product + # and an outside leaf is exempt from size, crinkliness and access while + # a room is judged on all three -- so exemption alone buys a higher + # quality, and quality multiplies the value rate. Fails are emitted per + # factor inside evaluate_leaf, before any combining, so the fail set + # cannot move either way. + self._quality_aggregate = str(self.conf("quality_aggregate") or "product") + if self._quality_aggregate not in ("product", "geometric_mean"): + raise ValueError( + f"unknown quality_aggregate: {self._quality_aggregate!r}") self._crinkliness_shape = str(self.conf("crinkliness_shape") or "gaussian") if self._crinkliness_shape not in ("gaussian", "daylight"): raise ValueError( @@ -1444,8 +1456,50 @@ class Fitness: factors["access"] = f quality *= f + if self._quality_aggregate == "geometric_mean": + quality = self._aggregate_geometric(leaf, factors) return quality, factors + def factor_is_asked(self, name: str, leaf: Node) -> bool: + """Is this factor a real question for this leaf, or an exemption? + + Exempt factors return exactly 1.0 from their `quality_*` method, which + is indistinguishable from a leaf that was asked and answered perfectly + -- so the two cases have to be told apart here. + `tests/test_fitness_aggregate.py` asserts the invariant this duplication + rests on: whenever this returns False, the factor really is 1.0. + """ + if name == "daylight": + return False # pinned to 1.0, URB_NO_OCCLUSION §6 + if name == "size": + return _generic_class(leaf) not in ("o", "s") + if name == "crinkliness": + if dom_mod.is_outside(leaf) and not dom_mod.is_covered(leaf): + return False # uncovered outside is lit by definition + if self.crinkliness_params(leaf) is None: + # no minimum-exposure requirement; under "daylight" nothing is + # left to ask, under "gaussian" the over-exposed side still bites + return self._crinkliness_shape != "daylight" + return True + if name == "access": + return not (not dom_mod.level_of(leaf) and dom_mod.is_outside(leaf)) + return True + + def _aggregate_geometric(self, leaf: Node, factors: dict[str, float]) -> float: + """Geometric mean over the factors this leaf was actually asked. + + Computed in log space so six factors near zero cannot underflow the + product before the root is taken. A zero factor stays zero -- a fully + buried leaf is worth nothing under either aggregation. + """ + asked = [v for name, v in factors.items() + if self.factor_is_asked(name, leaf)] + if not asked: + return 1.0 + if any(v <= 0.0 for v in asked): + return 0.0 + return math.exp(sum(math.log(v) for v in asked) / len(asked)) + # ------------------------------------------------------------------ # # Value rates and costs (Leaf.pm:146-251, Storey.pm:122-147) # ------------------------------------------------------------------ # diff --git a/tests/test_fitness_aggregate.py b/tests/test_fitness_aggregate.py new file mode 100644 index 0000000..bdfddec --- /dev/null +++ b/tests/test_fitness_aggregate.py @@ -0,0 +1,123 @@ +"""`quality_aggregate="geometric_mean"` (homemaker-py-ecx, DESIGN.md §39.18). + +Quality is a product over the factors, and leaf kinds face different numbers of +them: a room is judged on size, crinkliness and access, an outside leaf is +exempt from all three. Exemption alone therefore buys a higher quality, and +quality multiplies the value rate. The geometric mean divides that out. + +Two invariants matter and both are asserted here: + +* the fail set cannot move, because `evaluate_leaf` emits each fail from the + factor itself before anything is combined; +* `factor_is_asked` must agree with the `quality_*` methods -- whenever it says + a factor is exempt, that factor really is exactly 1.0. It is a separate + statement of the same conditions, so it can drift; this pins it. +""" + +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 Fitness, load_config + +EXAMPLES = Path(__file__).resolve().parent.parent / "examples" +PROGRAMMES = ["harbor-house", "maple-court", "health-centre", "programme-house"] +pytestmark = pytest.mark.skipif(not (EXAMPLES / "harbor-house").is_dir(), + reason="examples absent") + + +def _artefacts(): + for name in PROGRAMMES: + d = EXAMPLES / name + if not d.is_dir(): + continue + for p in sorted(d.glob("coldstart-500000-s*.dom")) + [d / "init.dom"]: + if p.exists(): + yield d, p + + +def test_exempt_factors_really_are_one(): + """The invariant `factor_is_asked` rests on, checked against every leaf in + the corpus rather than assumed from reading the code.""" + checked = 0 + for d, p in _artefacts(): + conf, cost = load_config(d) + fit = Fitness(conf, cost) + seen = [] + orig = Fitness.evaluate_leaf + + def ev(self, leaf, G, level_id, groups, fail, _o=orig, _s=seen): + q, f = _o(self, leaf, G, level_id, groups, fail) + _s.append((leaf, dict(f))) + return q, f + + Fitness.evaluate_leaf = ev + try: + fit.score_with_fails(dom_mod.load(str(p))) + finally: + Fitness.evaluate_leaf = orig + + for leaf, factors in seen: + for name, value in factors.items(): + if not fit.factor_is_asked(name, leaf): + assert value == 1.0, ( + f"{p.name}: {name} is marked exempt for leaf " + f"{leaf.id!r} ({leaf.type!r}) but scored {value}") + checked += 1 + assert checked > 100, "expected plenty of exempt factors to check" + + +def test_fail_set_is_byte_identical(): + for d, p in _artefacts(): + root = dom_mod.load(str(p)) + c_prod, cost = load_config(d) + c_geo, _ = load_config(d, overrides={"quality_aggregate": "geometric_mean"}) + _, f_prod = Fitness(c_prod, cost).score_with_fails(copy.deepcopy(root)) + _, f_geo = Fitness(c_geo, cost).score_with_fails(copy.deepcopy(root)) + assert f_prod == f_geo, f"{p} changed its fail set under the geometric mean" + + +def test_geometric_mean_is_the_product_when_every_factor_is_asked(): + """No free lunch: a leaf asked all six should agree with `prod ** (1/6)`.""" + fit = Fitness(*load_config(EXAMPLES / "harbor-house")) + leaf = dom_mod.Node(type="r") + factors = {"perpendicular": 0.9, "proportion": 0.8, "size": 0.5, + "width": 0.95, "crinkliness": 0.4, "access": 1.0, "daylight": 1.0} + asked = [v for k, v in factors.items() if fit.factor_is_asked(k, leaf)] + expected = math.prod(asked) ** (1.0 / len(asked)) + assert fit._aggregate_geometric(leaf, factors) == pytest.approx(expected) + + +def test_a_zero_factor_still_makes_the_leaf_worthless(): + """A fully buried leaf is worth nothing under either aggregation -- the + geometric mean must not launder a zero into 0.4-ish.""" + fit = Fitness(*load_config(EXAMPLES / "harbor-house")) + leaf = dom_mod.Node(type="r") + factors = {"perpendicular": 1.0, "proportion": 1.0, "size": 1.0, + "width": 1.0, "crinkliness": 0.0, "access": 1.0, "daylight": 1.0} + assert fit._aggregate_geometric(leaf, factors) == 0.0 + + +def test_it_does_not_underflow_where_the_product_would(): + """The point of computing in log space: six small factors multiply to a + denormal, but their geometric mean is an ordinary number.""" + fit = Fitness(*load_config(EXAMPLES / "harbor-house")) + leaf = dom_mod.Node(type="r") + tiny = 1e-60 + factors = {k: tiny for k in ("perpendicular", "proportion", "size", + "width", "crinkliness", "access")} + factors["daylight"] = 1.0 + assert math.prod(factors[k] for k in factors) == 0.0 # product underflows + assert fit._aggregate_geometric(leaf, factors) == pytest.approx(tiny, rel=1e-6) + + +def test_unknown_aggregate_is_rejected(): + conf, cost = load_config(EXAMPLES / "harbor-house", + overrides={"quality_aggregate": "mean"}) + with pytest.raises(ValueError, match="unknown quality_aggregate"): + Fitness(conf, cost)