diff --git a/DESIGN.md b/DESIGN.md index 7cd342b..0465731 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -4835,7 +4835,7 @@ future effort on that plateau (`homemaker-py-2g7.7`'s LLM repair operator, or a level-connectivity-targeted operator) is better aimed than a graph-dual construction pass would have been. -## 38. The plateau is an objective-gradient problem, not a search problem (`homemaker-py-ssz`/`hxi`/`tdp`/`gvb`/`1i8`) — measured 2026-08-25 +## 38. The plateau is an objective-gradient problem, not a search problem (`homemaker-py-2v1`/`ssz`/`hxi`/`tdp`/`gvb`/`1i8`) — measured 2026-08-25 Independent review of why the search "finds solutions that are clearly not the best and gets stuck in local minima", prompted by the §37 scoreboard: *every* @@ -4898,8 +4898,41 @@ Measured on a constructed harbor-house seed (`value` report): circulation spine.** Observed live: in the 20 000-eval harbor-house run above, `undivide`/`core_undivide` appear 16 times in the improvement log. -This retro-explains three prior results as one mechanism, and suggests two of -them were measuring a broken gradient rather than a bad idea: +**Refinement (measured 2026-08-25, after the first draft of this section): +zero-exposure is only half of it, and not the half that matters most.** +Repeating the deletion test separately for *lit* and *buried* circulation: + +| leaf | exposed wall | `q_crink` | deleting it | +|---|---|---|---| +| `0/llll` `O` | buried | 0 | **×85.6 rewarded** | +| `0/lllr` `C` | buried | 0 | **×61.6 rewarded** | +| `1/rlrr` `C` | buried | 0 | **×6.9 rewarded** | +| `0/rlll` `C` | 27.4 m² | 0.105 | ×0.01 kept | +| `0/lrrl` `O` | 21.2 m² | 0.0004 | ×0.61 kept | +| `0/rrll` `C` | 40.7 m² | **0.736** | **×4.1 rewarded** | + +The last row is the important one: a **well-lit** circulation leaf, scoring +0.736 on the very factor §38.1 is about, is *still* worth ×4 to delete. So +there is a second, independent mechanism, and it is the structural one: + +**Circulation is priced at `value_circulation = 50` against +`value_inside = 300`** — one sixth the value per m² of the habitable space it +could become. Deleting a circulation leaf merges it into its sibling, which +converts corridor into room: a flat **×6 value gain**. The only thing pushing +back is the `level N not connected` fail, worth **×0.5**. Break-even needs +`0.5^k < 50/300`, i.e. **k > 2.58 — severing must cost at least 3 fails to be +net-negative. It costs 1.** Net incentive to sever: `6 × 0.5 = ×3.0` in +favour, against a measured ×4.06. **The connectivity fail is under-priced by +roughly 3×, so the objective is net-positive on destroying the circulation +spine even when the circulation is perfectly daylit.** + +That is the cleanest available explanation of why `level 0 not connected` and +`level 1 not connected` are still present in the best layout found after +1.7 M evals: the search is not failing to fix them, it is being paid ×3–4 to +create them. + +Together these retro-explain three prior results as one mechanism, and suggest +two of them were measuring a broken gradient rather than a bad idea: - **§18 graded circulation-connectivity — NEGATIVE.** A secondary comparator key cannot beat a ×60 primary-scalar gradient pulling the other way. @@ -4917,16 +4950,33 @@ so every interior leaf needs exposed wall `L ≥ A/(1.6202·h)` — per storey, `fortified` perimeter edges, and harbor-house/maple-court mark **half their plot perimeter `private`**: -| programme | daylit frontage | needed per built storey | verdict | observed floor | -|---|---|---|---|---| -| harbor-house | 54 m | 148 m | **2.7× short** | plateaus 30–40 fails | -| maple-court | 56 m | 162 m | **2.9× short** | plateaus 74–84 fails | -| health-centre | 43 m | 41 m | feasible | §32 clean null | -| programme-house | 24 m | 12 m | 2× surplus | **1 fail** (12k evals) | +| programme | daylit frontage | needed per built storey | verdict | floor @20k evals | best known | +|---|---|---|---|---|---| +| harbor-house | 54 m | 148 m | **2.7× short** | 30–40 fails (§13.11) | 15 (`evolved-3M-nols-3`, **1.7 M evals / 2.4 days**) | +| maple-court | 56 m | 162 m | **2.9× short** | 74–84 fails (§13.11) | — | +| health-centre | 43 m | 41 m | feasible | §32 clean null | — | +| programme-house | 24 m | 12 m | 2× surplus | — | **1 fail @ 12k evals** | -**The corpus fail-count plateau is predicted by frontage deficit alone.** The -two programmes that are frontage-short are exactly the two that plateau; the -two with surplus are the two that effectively solve. Causal check +**Frontage deficit predicts the COST of solving, not impossibility.** An +earlier draft of this section claimed the deficit predicts the plateau +outright, quoting §13.11's 20k-budget figure as harbor's floor; that was +wrong. Harbor-house *does* reach 15 fails — it just needs 1.7 M evals and 2.4 +days to get there, against programme-house's 1 fail in 12 k. That ~150× +budget gap between a frontage-short and a frontage-surplus programme is the +real signature, and it is what §38.1/§38.2 predict: the deficit forces the +search to find a specific courtyard topology, and the objective punishes every +intermediate step toward one. + +**The best-known harbor layout corroborates the mechanism directly.** Its +residual (§37.8) is 8 geometry fails plus 4 structural — and two of those four +are `level 0 not connected` and `level 1 not connected`. After 1.7 M evals, +the best layout ever found still has a **severed circulation spine on both +storeys**. That is not a search failure; it is §38.2 working as designed: the +objective pays ×60 to delete buried circulation, so connectivity is the one +thing that never survives to the end. Any fix to §38.1 should be judged first +on whether those two fails disappear. + +Causal check (`experiments/diag_exposure_frontage.py`, 6 seeds): relabelling harbor's two `private` edges as open — identical geometry, identical programme, perimeter labels only — cuts zero-exposure leaves **52% → 19%** and seeder crinkliness @@ -4971,7 +5021,36 @@ fitness weight between two single rooms**. In programme-house, missing `b1` 3 fails = 1/8. The tiered comparator inherits it: `n_hard` is dominated by these cascades, so the primary search key is weighted by config verbosity. -### 38.6 Consequences for the Phase 9 plan +### 38.6 First repair attempt: three crinkliness modes — NOT SUFFICIENT ALONE + +`fitness.quality_uncrinkliness` gained `crinkliness_mode` (config key, +EXPERIMENTAL, default `"urb"` = stock hard 0.0, byte-identical to all prior +runs). Three candidate repairs, A/B'd on the §38.2 deletion test (harbor-house, +3 constructed seeds, unpinned `C`/`O` leaves only): + +Splitting the deleted leaves by whether they were buried or lit is what makes +the result legible (`experiments/ab_crinkliness_mode_ssz.py`): + +| mode | buried rewarded | **lit rewarded** | all | median × | +|---|---|---|---|---| +| `urb` (stock) | 5/8 | **3/8** | 8/16 | ×1.02 | +| `floor` (clamp to 0.01, keep the fail) | 5/8 | **3/8** | 8/16 | ×1.02 | +| `compact_ok` (one-sided: compact is not a defect) | 5/8 | **3/8** | 8/16 | ×1.00 | +| `exempt_circulation` (corridors need no daylight) | **4/8** | **3/8** | 7/16 | ×0.61 | + +**None of them removes the incentive, and the `lit` column does not move at +all — 3/8 under every mode, including stock.** That column is mechanism (2) +in isolation: deleting a *well-daylit* corridor is rewarded for reasons that +have nothing to do with crinkliness, so nothing written inside +`quality_uncrinkliness` can ever reach it. `floor` is inert (0.01 of a unit +quality is still ~zero against the cost saving); `exempt_circulation` removes +exactly one buried case. + +The modes are kept, default off, as one half of a fix that needs both halves. +**Do not ship any of them as a standalone lever and expect the connectivity +fails to move** — `homemaker-py-2v1` is the half that matters. + +### 38.7 Consequences for the Phase 9 plan §37 track 1 ("no ground truth … the residual taxonomy may be miscalibrated rather than unmet") was aimed at the right target, and §38.3 supplies a cheap @@ -4985,9 +5064,17 @@ part, an unsatisfiable constraint. Tracks 2 and 3 (cheaper evaluation, exact sub-solvers) remain sound but are orthogonal: making an evaluation 97× faster, or a labelling exact, does not change which direction the objective points. Recommended ordering is now -`ssz` → `hxi`/`gvb` (restore a value gradient for interior space, re-tier), -then `tdp` (ship the pre-flight bound and re-baseline the corpus), and only -then resume `2g7.9`/`2g7.10`. In particular `2g7.7` (LLM repair operator at +`2v1` (price connectivity above the ×6 circulation→habitable value gap — the +dominant mechanism, and the one the §38.6 A/B isolated) → `ssz`/`hxi`/`gvb` +(restore a value gradient for interior space, re-tier), then `tdp` (ship the +pre-flight bound and re-baseline the corpus), and only then resume +`2g7.9`/`2g7.10`. + +**Acceptance test for the combined fix, stated up front so it cannot be +moved:** harbor-house must reach its known 15-fail floor in materially fewer +than 1.7 M evals, *and* `level 0 not connected` / `level 1 not connected` must +be absent from the result. Fail-count parity alone is not a pass — the whole +claim of §38 is that those two fails are bought, not missed. In particular `2g7.7` (LLM repair operator at stagnation) is worth deferring until after `ssz`: an LLM asked to propose a valley-crossing multi-edit against an objective that pays ×85 to delete the corridor it just inserted will have its work reverted by the next selection diff --git a/experiments/ab_crinkliness_mode_ssz.py b/experiments/ab_crinkliness_mode_ssz.py new file mode 100644 index 0000000..0947822 --- /dev/null +++ b/experiments/ab_crinkliness_mode_ssz.py @@ -0,0 +1,151 @@ +"""A/B the `crinkliness_mode` repairs against the circulation-deletion incentive. + +Evidence for `homemaker-py-ssz` / `homemaker-py-2v1` (DESIGN.md §38.6). The +test: on a constructed layout, delete each unpinned circulation/outside leaf +(by undividing its parent, which merges it into its sibling) and re-score. A +healthy objective should not pay you to do that. + +Reports the count of deletions that still improve the score, and the median +score ratio, per mode. Splitting the rows by whether the deleted leaf was +buried or lit is what separates the two mechanisms: + +* buried leaves are rewarded because ``quality_uncrinkliness`` returns a hard + 0.0, so they contribute zero value (§38.1); +* **lit** leaves are rewarded because ``value_circulation`` (50) is one sixth + of ``value_inside`` (300), so merging corridor into room is a flat ×6 gain + against a ×0.5 connectivity penalty (§38.2 refinement) — which no + ``crinkliness_mode`` can touch. + +Usage:: + + python experiments/ab_crinkliness_mode_ssz.py + python experiments/ab_crinkliness_mode_ssz.py --seeds 5 --progdir examples/maple-court +""" + +from __future__ import annotations + +import argparse +import copy + +import numpy as np + +from homemaker_layout import dom as dom_mod +from homemaker_layout import driver, fitness, geometry +from homemaker_layout import graph as graph_mod +from homemaker_layout import operators, programme + +MODES = ("urb", "floor", "compact_ok", "exempt_circulation") + + +def make_fitness(progdir: str, mode: str) -> fitness.Fitness: + """Evaluator matching ``driver.search``'s defaults, with one mode override.""" + overrides = dict(driver._overrides_for( + leaf_sharing=True, superpose=False, max_share=None, conn_grade=False, + collapse_insearch=True, multi_use=False) or {}) + overrides["crinkliness_mode"] = mode + conf, cost = fitness.load_config(progdir, overrides=overrides) + return fitness.Fitness(conf, cost) + + +def constructed_seed(progdir: str, seed: int) -> dom_mod.Node: + reqs = programme.load_programme_dir(progdir) + return operators.constructive_topology( + dom_mod.load(f"{progdir}/init.dom"), reqs, np.random.default_rng(seed), + sorted(reqs) + ["C", "O"], + min_storeys=programme.storey_minimum(progdir), + adjacency_aware=True, proportion_aware=True, circ_divisor=3, + leaf_sharing=True, leaf_share_factor=3, depth_balanced=True, + interior_outside=True, outside_divisor=3) + + +def unpinned_leaves(fit: fitness.Fitness, root: dom_mod.Node) -> list[tuple]: + """(level, id, type, exposed_area) for circulation/outside leaves — the ones + no missing-space cascade pins in place.""" + tree = copy.deepcopy(root) + geometry.clear_cache() + dom_mod.canonicalize_shares(tree) + fit.preprocess_building(tree) + dom_mod.merge_divided(tree) + geometry.clear_cache() + graphs = graph_mod.build_graphs(tree, fit.conf("door_width") or 1.2) + + out = [] + for li, lvl in enumerate(dom_mod.levels(tree)): + for leaf in lvl.leaves(): + if dom_mod.is_outside(leaf) and not dom_mod.is_covered(leaf): + continue + if (leaf.type or "")[:1].upper() not in ("C", "O"): + continue + out.append((li, leaf.id, leaf.type, + fit.area_outside(leaf, graphs[li], {}))) + return out + + +def delete_leaf(root: dom_mod.Node, li: int, lid: str) -> "dom_mod.Node | None": + """Undivide the leaf's parent, merging it into its sibling. None if the cut + is inherited (not owned at this storey) or the sibling is itself divided.""" + cand = copy.deepcopy(root) + lvls = dom_mod.levels(cand) + if li >= len(lvls): + return None + node = lvls[li].by_id(lid) + if node is None or node.parent is None: + return None + parent = node.parent + if parent.below is not None and parent.below.divided: + return None + sibling = parent.right if parent.left is node else parent.left + if sibling is None or sibling.divided: + return None + parent.division = None + parent.left = parent.right = None + parent.type = sibling.type + dom_mod.link(cand) + geometry.clear_cache() + return cand + + +def run(progdir: str, seeds: int) -> None: + print(f"programme: {progdir}, {seeds} constructed seeds") + print("a healthy objective rewards NO deletions\n") + header = f"{'mode':<20}{'buried':<14}{'lit':<14}{'all':<14}median x" + print(header) + print("-" * len(header)) + + for mode in MODES: + fit = make_fitness(progdir, mode) + counts = {"buried": [0, 0], "lit": [0, 0]} + ratios = [] + for s in range(seeds): + root = constructed_seed(progdir, s) + base, _ = fit.score_with_fails(copy.deepcopy(root)) + for li, lid, _typ, exposed in unpinned_leaves(fit, root): + cand = delete_leaf(root, li, lid) + if cand is None: + continue + score, _ = fit.score_with_fails(copy.deepcopy(cand)) + bucket = "buried" if exposed == 0 else "lit" + counts[bucket][1] += 1 + ratios.append(score / base) + if score > base: + counts[bucket][0] += 1 + tot = [counts["buried"][0] + counts["lit"][0], + counts["buried"][1] + counts["lit"][1]] + median = sorted(ratios)[len(ratios) // 2] if ratios else float("nan") + buried = "%d/%d" % tuple(counts["buried"]) + lit = "%d/%d" % tuple(counts["lit"]) + both = "%d/%d" % tuple(tot) + print(f"{mode:<20}{buried:<14}{lit:<14}{both:<14}x{median:.2f}") + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--progdir", default="examples/harbor-house") + ap.add_argument("--seeds", type=int, default=3) + args = ap.parse_args() + run(args.progdir, args.seeds) + + +if __name__ == "__main__": + main() diff --git a/src/homemaker_layout/fitness.py b/src/homemaker_layout/fitness.py index 96ad320..0307c0a 100644 --- a/src/homemaker_layout/fitness.py +++ b/src/homemaker_layout/fitness.py @@ -372,6 +372,21 @@ class Fitness: # target combines both codes' area (quality_size). self._multi_use = bool(self.conf("multi_use")) self._colocate_pairs: list | None = None # lazily derived + # homemaker-py-ssz (DESIGN.md §38.1): how quality_uncrinkliness treats a + # leaf with no daylit wall. "urb" (default) = stock hard 0.0, byte- + # identical to every prior run. "floor"/"compact_ok"/"exempt_circulation" + # are the three candidate repairs — see quality_uncrinkliness. + self._crinkliness_mode = str(self.conf("crinkliness_mode") or "urb") + if self._crinkliness_mode not in ( + "urb", "floor", "compact_ok", "exempt_circulation"): + raise ValueError( + f"unknown crinkliness_mode: {self._crinkliness_mode!r}") + # The floored value stays BELOW FAIL_THRESHOLD, so a buried leaf still + # emits its crinkliness failure and the fail count is unchanged — only + # the value gradient is restored. Raising this above FAIL_THRESHOLD + # would silently delete a whole fail category. + self._crinkliness_floor = float( + self.conf("crinkliness_floor") or 0.01) # ------------------------------------------------------------------ # # Type superposition + collapse (homemaker-py-9o5) @@ -1093,9 +1108,35 @@ class Fitness: key = "uncrinkliness_circulation" if dom_mod.is_circulation(leaf) else "uncrinkliness" distance, sigma = self.conf(key) crink = self.crinkliness(leaf, G, groups) + + # homemaker-py-ssz (DESIGN.md §38.1), EXPERIMENTAL, all default OFF — + # `crinkliness_mode="urb"` reproduces the stock behaviour exactly. + # + # Stock Urb returns a hard 0.0 for a leaf with no daylit wall. That is + # the correct limit of the formula (1/crink -> inf, gaussian -> 0), but + # because evaluate_leaf MULTIPLIES factors into quality and + # process_storey accumulates `value += quality * rate * area`, such a + # leaf contributes EXACTLY ZERO value while still costing — so the + # objective cannot rank buried rooms at all, and buried circulation/ + # outside leaves (which no missing-space cascade pins) are pure + # liabilities worth ~x60-x85 to delete. Measured: 45-56% of interior + # leaves are in this state. These modes restore a gradient there. + mode = self._crinkliness_mode + if mode == "exempt_circulation" and dom_mod.is_circulation(leaf): + # (c) internal corridors are ordinary architecture; stop requiring + # every circulation leaf to reach daylight. + return 1.0 if not crink: - return 0.0 - return gaussian(1 / crink, 1.0, distance, sigma) + # (a) floor: keep buried leaves rankable by their other factors + # instead of collapsing the whole quality product to zero. + return self._crinkliness_floor if mode in ("floor", "compact_ok") else 0.0 + q = gaussian(1 / crink, 1.0, distance, sigma) + if mode == "compact_ok" and 1 / crink > distance: + # (b) one-sided: being MORE compact than target is not a defect the + # way over-exposure is, so clip to 1.0 on the compact side rather + # than decaying symmetrically into a fail. + return 1.0 + return max(q, self._crinkliness_floor) if mode in ("floor", "compact_ok") else q # --- access --- # diff --git a/tests/test_fitness.py b/tests/test_fitness.py index b5005da..662709d 100644 --- a/tests/test_fitness.py +++ b/tests/test_fitness.py @@ -468,3 +468,65 @@ def test_classify_fail_tier_covers_full_corpus(): classify_fail_tier(line) # raises on failure checked += 1 assert checked > 0 + + +# --------------------------------------------------------------------------- # +# homemaker-py-ssz / DESIGN.md §38.1 — crinkliness_mode (EXPERIMENTAL) +# --------------------------------------------------------------------------- # +class _StubCrink(Fitness): + """Fitness with ``crinkliness`` stubbed, so the modes can be tested without + building a real tree/graph (the value under test is the branch, not the + geometry).""" + + _stub = 0.0 + + def crinkliness(self, leaf, G, groups): # noqa: D102 - test stub + return self._stub + + +def _stub_fit(mode=None, stub=0.0, type_="t1"): + conf = dict(CONF_DEFAULTS) + if mode is not None: + conf["crinkliness_mode"] = mode + f = _StubCrink(conf, dict(COST_DEFAULTS)) + f._stub = stub + return f, _leaf(type_) + + +def test_crinkliness_mode_defaults_to_urb_and_reproduces_hard_zero(): + """Default must be byte-identical to stock Urb: buried leaf -> exactly 0.0.""" + f, leaf = _stub_fit() + assert f._crinkliness_mode == "urb" + assert f.quality_uncrinkliness(leaf, None, {}) == 0.0 + + +def test_crinkliness_floor_restores_gradient_but_keeps_the_failure(): + """The floor must stay BELOW FAIL_THRESHOLD: it restores a value gradient + without silently deleting a whole fail category.""" + f, leaf = _stub_fit("floor") + q = f.quality_uncrinkliness(leaf, None, {}) + assert q > 0.0, "buried leaf should no longer be worth exactly nothing" + assert q < FAIL_THRESHOLD, "buried leaf must still emit its crinkliness fail" + + +def test_crinkliness_compact_ok_clips_on_the_compact_side_only(): + """Being more compact than target is not a defect; being over-exposed is.""" + target = CONF_DEFAULTS["uncrinkliness"][0] + # 1/crink > target => more compact than target => clipped to 1.0 + f, leaf = _stub_fit("compact_ok", stub=1.0 / (target * 2)) + assert f.quality_uncrinkliness(leaf, None, {}) == 1.0 + # 1/crink < target => over-exposed => still decays + f, leaf = _stub_fit("compact_ok", stub=1.0 / (target / 2)) + assert f.quality_uncrinkliness(leaf, None, {}) < 1.0 + + +def test_crinkliness_exempt_circulation_only_exempts_circulation(): + f, circ = _stub_fit("exempt_circulation", type_="C") + assert f.quality_uncrinkliness(circ, None, {}) == 1.0 + f, room = _stub_fit("exempt_circulation", type_="t1") + assert f.quality_uncrinkliness(room, None, {}) == 0.0 + + +def test_crinkliness_mode_unknown_raises(): + with pytest.raises(ValueError, match="crinkliness_mode"): + _stub_fit("nonsense")