Quality aggregation: divide out how many questions a leaf was asked
39.17 left the search's storey choice unexplained and blamed value_rate. It is not the rate, or not only. Measured over the twelve baseline runs, value/cost by leaf kind: outside ground 7.40, roof terrace 2.69, room 0.34, circulation 0.02. A terrace returns 2.7x its cost where a room returns a third of it, so filling upper storeys with terrace is not the search leaving value on the table -- it is by a wide margin the most profitable thing the objective offers. 7% of the corpus area produces 32% of its value. Most of that gap is mean quality: 0.986 for a terrace against 0.223 for a room. Quality is a PRODUCT of factors and the kinds are not asked the same number of questions -- an outside leaf is exempt from size, crinkliness and access, so 3 of 7 factors can ever bite it against a room's 6. Each exemption is individually right (no programme size target; uncovered outside is lit by definition; ground-level outside needs no access). The consequence is not: a leaf exempt from the two harshest factors out-scores one judged on them and doing well, purely by not being asked, and quality multiplies the value rate. Stated generally, and this is not about outside space: under a product, adding any new quality criterion mechanically devalues every leaf it applies to, including leaves that score 1.0 on it. The objective's scale should not depend on how many things it measures. quality_aggregate="geometric_mean" (default OFF, "product" is stock) divides that out. 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 either way. Telling "exempt" from "asked and scored 1.0" needs factor_is_asked, which restates conditions that live inside the quality_* methods. That duplication can drift, so tests/test_fitness_aggregate.py pins it against every leaf in the corpus: wherever the predicate says exempt, the factor really is 1.0. Fail set byte-identical everywhere, and for a stronger reason than 39.13/39.14 had: evaluate_leaf emits each fail from the factor itself before anything is combined, so no aggregation can move one. Score effect +37% to +169%, reaching all four programmes where the crinkliness changes reached two; room value/cost 0.34 -> 0.66, circulation 0.02 -> 0.07. Deliberately not fixed: a terrace still out-earns a room 4:1, which is the rates (value_supported = value_inside = 300 against costs of 110 and 200), not the aggregation. That is a design judgement for the programme author, and 39.16 is a standing reminder that "this inherited constant looks wrong" has been wrong twice already in this section. Left open on ecx with the numbers. A/B running; verdict to follow. Refs homemaker-py-ecx. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
This commit is contained in:
parent
fb70193074
commit
a76ed3b9b8
4 changed files with 276 additions and 0 deletions
93
DESIGN.md
93
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
|
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
|
found in crinkliness. Both are objective changes and neither is smuggled in
|
||||||
here.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,12 @@ ARM_CONF = {
|
||||||
"daylight": {"crinkliness_shape": "daylight"},
|
"daylight": {"crinkliness_shape": "daylight"},
|
||||||
"daylight+ramp": {"crinkliness_shape": "daylight",
|
"daylight+ramp": {"crinkliness_shape": "daylight",
|
||||||
"crinkliness_tail": "ramp"},
|
"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"]
|
ARMS = ["stock", "daylight", "daylight+ramp"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -502,6 +502,18 @@ class Fitness:
|
||||||
# well-lit room costs is already charged by `exterior_wall` and
|
# well-lit room costs is already charged by `exterior_wall` and
|
||||||
# `boundary_wall` in the cost model, so penalising it again in value
|
# `boundary_wall` in the cost model, so penalising it again in value
|
||||||
# bills the same wall twice.
|
# 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")
|
self._crinkliness_shape = str(self.conf("crinkliness_shape") or "gaussian")
|
||||||
if self._crinkliness_shape not in ("gaussian", "daylight"):
|
if self._crinkliness_shape not in ("gaussian", "daylight"):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|
@ -1444,8 +1456,50 @@ class Fitness:
|
||||||
factors["access"] = f
|
factors["access"] = f
|
||||||
quality *= f
|
quality *= f
|
||||||
|
|
||||||
|
if self._quality_aggregate == "geometric_mean":
|
||||||
|
quality = self._aggregate_geometric(leaf, factors)
|
||||||
return quality, 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)
|
# Value rates and costs (Leaf.pm:146-251, Storey.pm:122-147)
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
|
||||||
123
tests/test_fitness_aggregate.py
Normal file
123
tests/test_fitness_aggregate.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Reference in a new issue