erc.3: explicit per-leaf multiplicity closes the leak; driver A/B wired (§13.3)

Replace the area-derived share recovery with explicit, type-guarded per-leaf
multiplicity: construction stamps leaf.share=k and leaf.share_type=code; the
fitness (graph.leaf_share) honours k only while leaf.type==share_type, so any
retype/undivide auto-invalidates a stale share — no operator resets, and a
small leaf cannot retype its way into covering rooms it does not provide. Two
Node fields survive the whole search via deepcopy (genome.decode is unused in
the hot path); .dom emits `share` only on a live shared leaf.

This closes the §13.3 missing-fail leak: floor probe missing 17–44 → 0, and the
achievable floor drops −39% harbor (120.3→73.3) / −32% maple (194.7→133.0) with
no re-emergence as size fails.

Flag threaded through driver.search/search_staged → constructive_topology /
lift_base_to_storeys, exposed via LEAFSHARE/LEAFSHAREFAC in run_staged_search.py
(injects the objective into inner-loop + final-score fitness so both A/B arms
share one programme dir). run_leafshare_ab.sh runs the staged 20k A/B.
Smoke-tested end-to-end (harbor, factor 3, re-score OK). 214 tests pass;
default-OFF reproduces baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-06-24 18:16:17 +01:00
parent bf3ff43837
commit e983229857
9 changed files with 189 additions and 79 deletions

View file

@ -1586,7 +1586,7 @@ splitting construction**), reinforcing §13.1's call to advance leaf-sharing
(`erc.3`) for the starved tail. Recommendation: re-scope `erc.4`, deprioritise (`erc.3`) for the starved tail. Recommendation: re-scope `erc.4`, deprioritise
`erc.6`. `erc.6`.
### 13.3 Experiment: leaf-sharing / multi-room leaves (`homemaker-py-erc.3`) — IN PROGRESS ### 13.3 Experiment: leaf-sharing / multi-room leaves (`homemaker-py-erc.3`) — A/B PENDING
The lever §13.1 named as the *only* one that moves the floor: collapse same-code The lever §13.1 named as the *only* one that moves the floor: collapse same-code
rooms into fewer, larger **shared** leaves so the per-leaf ~1.8 shape tax is paid rooms into fewer, larger **shared** leaves so the per-leaf ~1.8 shape tax is paid
@ -1594,61 +1594,63 @@ once per group instead of once per room. Unlike c3g (§12.4) this removes
ROOM-leaf count, not circulation, so the access/adjacency penalty that sank c3g ROOM-leaf count, not circulation, so the access/adjacency penalty that sank c3g
need not apply. need not apply.
**Mechanism (no genome change).** A shared leaf carries no explicit state — it is **Mechanism — explicit, type-guarded per-leaf multiplicity.** A construction
just a larger leaf of the code, which construction sizes to `k × target` area. stamps `leaf.share = k` and `leaf.share_type = code` on each shared leaf
Its multiplicity `k` is *recovered from area* at scoring time, (`operators._share_rooms` groups a sized, multi-instance code into runs of ≤ `N`
`k = clamp(round(area/target), 1, max_share)` (`graph._leaf_share_mult`), used in = `leaf_share_factor`; `_leaf_mult_from_plan` stamps the survivors and
two places, both gated by a default-OFF `leaf_sharing` config key (controls `_size_divisions_from_targets` sizes them to `k × target`). The fitness honours
reproduce the §12.2 baseline exactly — all 212 tests pass with it off): `k` only while `leaf.type == leaf.share_type` (`graph.leaf_share`), so any
retype/undivide silently invalidates a stale share — the mutation operators need
no resets, and a small leaf can never *retype* its way into claiming rooms it
does not provide. Two scoring sites, both gated by a default-OFF `leaf_sharing`
key (controls reproduce the §12.2 baseline exactly — 214 tests pass with it off):
- `graph.check_space_counts` counts **coverage** (Σ per-leaf `k`) against - `graph.check_space_counts` counts **coverage** (Σ per-leaf `k`) against
`req.count`, so one shared leaf satisfies several same-code rooms with no `req.count`, so one shared leaf satisfies several same-code rooms with no
missing fail; missing fail;
- `fitness.quality_size` centres the size Gaussian on `k × target` (σ scaled by - `fitness.quality_size` centres the size Gaussian on `k × target` (σ scaled by
`k`, preserving the *fractional* tolerance) so the shared leaf is not read as `k`). `quality_proportion`/`quality_width` need no change — a
oversize. `quality_proportion`/`quality_width` need no change — a
proportionally-scaled leaf keeps its aspect and only gets wider. proportionally-scaled leaf keeps its aspect and only gets wider.
Construction (`operators._share_rooms`, `constructive_topology` with *Design history:* the first cut recovered `k` from area
`leaf_sharing=True`, `leaf_share_factor=N`) groups each sized, multi-instance (`round(area/target)`) to avoid genome state, but the §13.2 depth
code into runs of ≤ N instances → one leaf per run, then `_size_divisions_from_ maldistribution left shared leaves below `k × target`, so `round` undercounted
targets` sizes that leaf to the run's `k × target`. Only same-code merges (rooms and **1744 missing fails leaked back** (harbor `share3`+il: 87.3 total, 16.7
with identical adjacency/level reqs) so the spine assignment stays valid. missing; the inner loop could not close it — frozen-topology ratios, §13.2).
Switching to **explicit** `share` (an undersize shared leaf is *present* → a
light size fail, not a heavy missing fail) closes the leak. Because the phenotype
tree is never rebuilt from the genome in the hot path (`genome.decode` is unused;
operators edit `dom.Node` trees in place), the two `Node` fields survive the whole
search via deepcopy without threading through `GNode`/encode/decode; `.dom`
serialisation emits `share` only on a live shared leaf.
**Floor probe** (`experiments/diag_leaf_sharing.py`, harbor + maple, seeds 0/1/2) **Floor probe** (`experiments/diag_leaf_sharing.py`, harbor + maple, seeds 0/1/2)
— a cheap de-risk before the full A/B: build the §12.2 seed both ways, score at — build the §12.2 seed both ways, score at the seed geometry and again after
the seed geometry and again after `innerloop.optimise` (nm, budget 80) under the `innerloop.optimise` (nm, budget 80) under the *same* objective. Averaged fails:
*same* objective. Averaged fails:
| programme | mode | leaves | total | missing | size | crink | | programme | mode | leaves | total | missing | size | crink |
|-----------|-------------|-------:|------:|--------:|-----:|------:| |-----------|-------------|-------:|------:|--------:|-----:|------:|
| harbor | OFF +il | 45.0 | 120.3 | 0.0 | 21.7 | 33.7 | | harbor | OFF +il | 45.0 | 120.3 | 0.0 | 21.7 | 33.7 |
| harbor | share2 +il | 31.7 | 106.0 | 24.0 | 14.0 | 22.7 | | harbor | share2 +il | 31.7 | 86.0 | 0.0 | 15.3 | 22.0 |
| harbor | share3 +il | 25.7 | 87.3 | 16.7 | 10.0 | 18.0 | | harbor | share3 +il | 25.7 | 73.3 | 0.0 | 12.7 | 17.7 |
| maple | OFF +il | 73.0 | 194.7 | 0.0 | 37.3 | 58.3 | | maple | OFF +il | 73.0 | 194.7 | 0.0 | 37.3 | 58.3 |
| maple | share2 +il | 52.0 | 184.3 | 44.3 | 23.3 | 41.0 | | maple | share2 +il | 52.0 | 145.7 | 0.0 | 25.7 | 41.3 |
| maple | share3 +il | 47.0 | 162.7 | 35.3 | 17.7 | 39.0 | | maple | share3 +il | 47.0 | 133.0 | 0.0 | 21.0 | 39.3 |
**The floor moves** — total fails drop **27 % harbor / 16 % maple** at **The floor moves and the leak is closed** — `share3` cuts the achievable floor
`share3`, and the drop is exactly where §13.1 predicted: the shape factors fall **39 % harbor (120.3 → 73.3) / 32 % maple (194.7 → 133.0)** with **zero missing
roughly with leaf count (harbor size 22→10, crinkliness 34→18 as leaves 45→26). fails**, and the missing did *not* re-emerge as size fails (size still falls,
The §13.1 linear-in-leaves model holds. 22→13 harbor / 37→21 maple). The drop is exactly where §13.1 predicted: shape
factors fall with leaf count (harbor leaves 45→26, crinkliness 34→18). Larger
`leaf_share_factor` helps monotonically here (share2 → share3), bounded by
`leaf_share_max` (default 4).
**But a missing-fail leak caps the gain, and the inner loop cannot close it.** **Verdict — leaf-sharing is the floor-mover §13.1/§13.2 called for: 32…39 % on
Sharing buys back 1744 *missing* fails, and `innerloop.optimise` barely moves the achievable floor, no missing-fail leak.** The flag is threaded through the
them (harbor 17→17, maple 37→35). This is the §13.2 mechanism on the new axis: staged driver (`driver.search`/`search_staged``constructive_topology` /
the area-derived `k` recovery is defeated by binary-tree **depth maldistribution** `lift_base_to_storeys`) and exposed for the A/B via `LEAFSHARE`/`LEAFSHAREFAC` in
— a leaf "sized to `k × target`" lands at the wrong *absolute* area because `run_staged_search.py` (which injects the objective into the inner-loop and
ratios multiply down the ancestry, so `round(area/target) < k` and the uncovered final-score fitness, both arms on one programme dir). Smoke-tested end-to-end
rooms read as missing; the frozen-topology ratio DOF then cannot grow it back (harbor, staged, leaf_sharing+factor 3: re-score OK). **Remaining: the staged
(0.5ⁿ cliff). Net is still positive because the shape savings outweigh the leak. 20 000-eval A/B (maple + harbor, seeds 0/1/2) vs the §12.2 baseline
(maple 136.0, harbor 74.0) to confirm the seed-floor gain survives end-to-end,
**Verdict so far — leaf-sharing is validated as a real floor-mover (16…27 %), recorded here as the closing verdict.**
and its cap is the predicted `erc.3``erc.4` synergy: shared leaves only pay off
fully when construction puts them at the right absolute area (depth-balancing).**
Open design fork for the full 20 000-eval A/B: (a) thread the (working, tested)
area-derived flag through the driver and run as-is — a valid experiment that
already shows net gain; or (b) first replace area-derived `k` with an **explicit
per-leaf multiplicity** (present-but-undersize → light size fail, not a heavy
missing fail) and/or pair with `erc.4` depth-balancing to land shared leaves at
`k × target`. The implementation (operators + fitness + graph, default-OFF) and
the probe are committed; driver plumbing + the staged A/B remain.

43
experiments/run_leafshare_ab.sh Executable file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Leaf-sharing A/B (erc.3, DESIGN.md §13.3): does collapsing same-code rooms into
# fewer, larger SHARED leaves lower the end-to-end fail count, given the floor
# probe showed 32…39 % on the achievable seed floor with zero missing-fail leak
# (explicit, type-guarded per-leaf multiplicity)?
#
# Baseline arm (LEAFSHARE=0) must reproduce the §12.2 figures (maple 136.0,
# harbor 74.0); the share arm (LEAFSHARE=1, factor 3) is the experiment. One
# programme dir per programme — run_staged_search.py injects the leaf_sharing
# objective into the whole pipeline so both arms share patterns.config.
set -u
cd "$(dirname "$0")/.."
BUDGET="${1:-20000}"
FAC="${2:-3}"
OUT=scratch/leafshare_ab; mkdir -p "$OUT"
TSV=scratch/leafshare_results.tsv
[ -f "$TSV" ] || printf 'programme\tseed\tshare\tfactor\tfails\ttopologies\telapsed_s\n' > "$TSV"
run() { # programme seed share(0|1)
local prog="$1" seed="$2" share="$3"
local tag="ls${share}"; [ "$share" = 1 ] && tag="ls1f${FAC}"
local log="$OUT/${prog}_${tag}_s${seed}.log"
echo ">>> $prog seed=$seed leafshare=$share factor=$FAC"
local t0; t0=$(date +%s)
env URB_NO_OCCLUSION=1 LEAFSHARE="$share" LEAFSHAREFAC="$FAC" \
python3 experiments/run_staged_search.py "examples/$prog" "$BUDGET" "$seed" \
"examples/$prog/init.dom" "$OUT/${prog}_${tag}_s${seed}.dom" > "$log" 2>&1
local t1; t1=$(date +%s)
local fails topos
fails=$(grep 're-scored (native)' "$log" | tail -1 | sed -n 's/.*(\([0-9]*\) fails).*/\1/p')
topos=$(grep -m1 '^evals' "$log" | sed -n 's/.*across \([0-9]*\) topologies.*/\1/p')
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$prog" "$seed" "$share" "$FAC" "${fails:-ERR}" "${topos:-?}" "$((t1-t0))" >> "$TSV"
echo " -> ${fails:-ERR} fails, ${topos:-?} topologies, $((t1-t0))s"
}
# baseline controls (reproduce §12.2) then the leaf-sharing arm, both seeds 0/1/2
for prog in maple-court harbor-house; do
for seed in 0 1 2; do run "$prog" "$seed" 0; done
for seed in 0 1 2; do run "$prog" "$seed" 1; done
done
echo "=== leaf-sharing A/B complete ==="
column -t -s $'\t' "$TSV"

View file

@ -62,6 +62,23 @@ def main() -> int:
_ms = os.environ.get("MAXSHAPE") # 9gp.1 prune threshold (shape-fail count) _ms = os.environ.get("MAXSHAPE") # 9gp.1 prune threshold (shape-fail count)
max_shape = int(_ms) if _ms else None max_shape = int(_ms) if _ms else None
circ_div = int(os.environ.get("CIRCDIV", "3")) # c3g circ-per-room granularity knob circ_div = int(os.environ.get("CIRCDIV", "3")) # c3g circ-per-room granularity knob
leaf_share = os.environ.get("LEAFSHARE", "0") == "1" # erc.3 leaf-sharing A/B
leaf_share_fac = int(os.environ.get("LEAFSHAREFAC", "2"))
if leaf_share:
# erc.3 §13.3: the inner-loop and final-score fitness are built from the
# dir's patterns.config (which has no leaf_sharing key); inject it here so
# the WHOLE pipeline scores under the same relaxed objective the
# constructed shared leaves target. Keeps both A/B arms on one dir.
_orig_load = fitness.load_config
def _load_with_sharing(directory):
conf, cost = _orig_load(directory)
conf = dict(conf)
conf["leaf_sharing"] = True
return conf, cost
fitness.load_config = _load_with_sharing
print(f"programme : {programme_dir.name}") print(f"programme : {programme_dir.name}")
print(f"seed : {seed_file.name}") print(f"seed : {seed_file.name}")
@ -75,6 +92,7 @@ def main() -> int:
print(f"reassoc : {reassoc}") print(f"reassoc : {reassoc}")
print(f"feas_filt : {feas} (max_shape={max_shape})") print(f"feas_filt : {feas} (max_shape={max_shape})")
print(f"circ_div : {circ_div}") print(f"circ_div : {circ_div}")
print(f"leaf_share: {leaf_share} (factor={leaf_share_fac})")
print(flush=True) print(flush=True)
seed_root = dom.load(str(seed_file)) seed_root = dom.load(str(seed_file))
@ -101,6 +119,8 @@ def main() -> int:
feasibility_filter=feas, feasibility_filter=feas,
feasibility_max_shape_fails=max_shape, feasibility_max_shape_fails=max_shape,
circ_divisor=circ_div, circ_divisor=circ_div,
leaf_sharing=leaf_share,
leaf_share_factor=leaf_share_fac,
) )
elapsed = time.perf_counter() - t0 elapsed = time.perf_counter() - t0

View file

@ -31,6 +31,14 @@ class Node:
right: "Node | None" = None right: "Node | None" = None
above: "Node | None" = None above: "Node | None" = None
# erc.3 leaf-sharing (DESIGN.md §13.3): how many same-code rooms this leaf
# covers (1 = a normal leaf). ``share_type`` records the type the share was
# assigned for; the fitness honours ``share`` only while ``type ==
# share_type``, so any retype/undivide automatically invalidates a stale
# share without the mutation operators needing to reset it.
share: int = 1
share_type: "str | None" = None
# level-root only # level-root only
node: list[list[float]] | None = None # working corners (wall-inset) node: list[list[float]] | None = None # working corners (wall-inset)
node_file: list[list[float]] | None = None # raw outer corners as read from disk node_file: list[list[float]] | None = None # raw outer corners as read from disk
@ -82,6 +90,11 @@ def _parse(d: dict) -> Node:
n.rotation = int(d.get("rotation") or 0) n.rotation = int(d.get("rotation") or 0)
if d.get("type") is not None: if d.get("type") is not None:
n.type = str(d["type"]) n.type = str(d["type"])
if d.get("share") is not None:
# share is only emitted for leaves where it is live (share>1 and the
# type still matches), so on read the assigned-for type is this leaf's.
n.share = int(d["share"])
n.share_type = n.type
if d.get("node") is not None: if d.get("node") is not None:
n.node = [[float(p[0]), float(p[1])] for p in d["node"]] n.node = [[float(p[0]), float(p[1])] for p in d["node"]]
if d.get("perimeter") is not None: if d.get("perimeter") is not None:
@ -177,6 +190,8 @@ def _emit(n: Node, is_level_root: bool) -> dict:
d["perimeter"] = dict(n.perimeter) d["perimeter"] = dict(n.perimeter)
if n.type and not n.divided: if n.type and not n.divided:
d["type"] = n.type d["type"] = n.type
if n.share > 1 and n.share_type == n.type: # erc.3: live leaf-share only
d["share"] = n.share
d["rotation"] = n.rotation d["rotation"] = n.rotation
if n.divided: if n.divided:
d["division"] = list(n.division) d["division"] = list(n.division)

View file

@ -187,6 +187,8 @@ def search(
feasibility_filter: bool = False, feasibility_filter: bool = False,
feasibility_max_shape_fails: int | None = None, feasibility_max_shape_fails: int | None = None,
circ_divisor: int = 3, circ_divisor: int = 3,
leaf_sharing: bool = False,
leaf_share_factor: int = 2,
) -> SearchResult: ) -> SearchResult:
"""Run the memetic loop from ``seed_root`` until ``budget`` oracle """Run the memetic loop from ``seed_root`` until ``budget`` oracle
evaluations are consumed. Returns the best individual found; its ``root`` evaluations are consumed. Returns the best individual found; its ``root``
@ -386,7 +388,8 @@ def search(
seed_root, reqs, rng, types, min_storeys=min_storeys, seed_root, reqs, rng, types, min_storeys=min_storeys,
adjacency_aware=seed_adjacency_aware, adjacency_aware=seed_adjacency_aware,
proportion_aware=seed_proportion_aware, proportion_aware=seed_proportion_aware,
circ_divisor=circ_divisor) circ_divisor=circ_divisor,
leaf_sharing=leaf_sharing, leaf_share_factor=leaf_share_factor)
return (topo, None, child_budget, {}, f"construct/{tag}") return (topo, None, child_budget, {}, f"construct/{tag}")
n = int(rng.integers(max(1, n_target - 1), n_target + 2)) n = int(rng.integers(max(1, n_target - 1), n_target + 2))
return (random_topology(seed_root, n, rng, types), None, child_budget, return (random_topology(seed_root, n, rng, types), None, child_budget,
@ -511,6 +514,8 @@ def search_staged(
feasibility_filter: bool = False, feasibility_filter: bool = False,
feasibility_max_shape_fails: int | None = None, feasibility_max_shape_fails: int | None = None,
circ_divisor: int = 3, circ_divisor: int = 3,
leaf_sharing: bool = False,
leaf_share_factor: int = 2,
) -> SearchResult: ) -> SearchResult:
"""Staged per-floor topology search (DESIGN.md §11.3, ``homemaker-py-c4c.3``). """Staged per-floor topology search (DESIGN.md §11.3, ``homemaker-py-c4c.3``).
@ -558,7 +563,9 @@ def search_staged(
enable_reassociate=enable_reassociate, enable_reassociate=enable_reassociate,
feasibility_filter=feasibility_filter, feasibility_filter=feasibility_filter,
feasibility_max_shape_fails=feasibility_max_shape_fails, feasibility_max_shape_fails=feasibility_max_shape_fails,
circ_divisor=circ_divisor) circ_divisor=circ_divisor,
leaf_sharing=leaf_sharing,
leaf_share_factor=leaf_share_factor)
if types is None: if types is None:
types = sorted(reqs) + ["C", "O"] types = sorted(reqs) + ["C", "O"]
@ -588,6 +595,8 @@ def search_staged(
feasibility_filter=feasibility_filter, feasibility_filter=feasibility_filter,
feasibility_max_shape_fails=feasibility_max_shape_fails, feasibility_max_shape_fails=feasibility_max_shape_fails,
circ_divisor=circ_divisor, circ_divisor=circ_divisor,
leaf_sharing=leaf_sharing,
leaf_share_factor=leaf_share_factor,
) )
best_base = r1.best.root best_base = r1.best.root
_log(f"[staged] stage 1 done: base {r1.best.fitness:.6g} " _log(f"[staged] stage 1 done: base {r1.best.fitness:.6g} "
@ -605,7 +614,8 @@ def search_staged(
best_base, upper, rng2, types, reqs=reqs, best_base, upper, rng2, types, reqs=reqs,
adjacency_aware=seed_adjacency_aware, adjacency_aware=seed_adjacency_aware,
proportion_aware=seed_proportion_aware, proportion_aware=seed_proportion_aware,
circ_divisor=circ_divisor) circ_divisor=circ_divisor,
leaf_sharing=leaf_sharing, leaf_share_factor=leaf_share_factor)
_log(f"[staged] stage 2: upper floors as deltas, budget {b2}, base_p {base_p}") _log(f"[staged] stage 2: upper floors as deltas, budget {b2}, base_p {base_p}")
r2 = search( r2 = search(
@ -623,6 +633,8 @@ def search_staged(
feasibility_filter=feasibility_filter, feasibility_filter=feasibility_filter,
feasibility_max_shape_fails=feasibility_max_shape_fails, feasibility_max_shape_fails=feasibility_max_shape_fails,
circ_divisor=circ_divisor, circ_divisor=circ_divisor,
leaf_sharing=leaf_sharing,
leaf_share_factor=leaf_share_factor,
) )
# Stitch the two stages into one accounting (total evals, tagged history). # Stitch the two stages into one accounting (total evals, tagged history).

View file

@ -284,10 +284,12 @@ class Fitness:
target, sigma = params[0], params[1] target, sigma = params[0], params[1]
if self._leaf_sharing and t0 != "c" and target > 0: if self._leaf_sharing and t0 != "c" and target > 0:
# erc.3: a shared leaf holds k same-code rooms; centre the Gaussian on # erc.3: a shared leaf holds k same-code rooms; centre the Gaussian on
# k×target (k recovered from area, as in graph._leaf_share_mult) and # k×target (k = leaf's explicit, type-guarded share) and scale sigma by
# scale sigma by k so the *fractional* size tolerance is preserved. # k so the *fractional* size tolerance is preserved. An undersize
# shared leaf now lands a (light) size fail here instead of a (heavy)
# missing fail in the count check — the §13.3 leak fix.
from . import graph as _graph from . import graph as _graph
k = _graph._leaf_share_mult(geometry.area(leaf), target, self._max_share) k = _graph.leaf_share(leaf, self._max_share)
if k > 1: if k > 1:
target, sigma = target * k, sigma * k target, sigma = target * k, sigma * k
return gaussian(geometry.area(leaf), 1.0, target, sigma) return gaussian(geometry.area(leaf), 1.0, target, sigma)

View file

@ -427,17 +427,18 @@ def has_vertical_connection(leaf: Node, target_code: str, lvls: list[Node]) -> b
# Space-count detection + failure stacking (ProgrammeDriven.pm:154-215) # Space-count detection + failure stacking (ProgrammeDriven.pm:154-215)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _leaf_share_mult(area: float, target: float, max_share: int) -> int: def leaf_share(leaf: Node, max_share: int) -> int:
"""Recover how many same-code rooms a leaf of ``area`` covers (erc.3). """How many same-code rooms a leaf covers under leaf-sharing (erc.3, §13.3).
Leaf-sharing carries no explicit genome state: a shared leaf is just a Explicit per-leaf multiplicity: construction stamps ``leaf.share = k`` and
larger leaf of the code, sized by construction to ``k × target`` area. The ``leaf.share_type = code``; this is honoured only while ``leaf.type`` still
multiplicity is recovered here from area alone ``round(area/target)``, equals ``share_type``, so any retype/undivide silently invalidates a stale
clamped to ``[1, max_share]`` so the count check and ``quality_size`` share (a retyped small leaf cannot claim to cover rooms it does not provide).
agree on the same ``k`` (both use this helper / its rounding).""" Clamped to ``max_share``. Both the count check and ``quality_size`` read this
if target <= 0: one helper so they always agree on ``k``."""
if leaf.share > 1 and leaf.share_type == leaf.type:
return min(max_share, leaf.share)
return 1 return 1
return max(1, min(max_share, round(area / target)))
def check_space_counts( def check_space_counts(
@ -477,9 +478,8 @@ def check_space_counts(
leaves_of = count.get(code, []) leaves_of = count.get(code, [])
if leaf_sharing and req.size > 0: if leaf_sharing and req.size > 0:
# Coverage: sum the per-leaf multiplicity recovered from area. # Coverage: sum each leaf's explicit (type-guarded) share multiplicity.
actual = sum(_leaf_share_mult(geometry.area(lf), req.size, max_share) actual = sum(leaf_share(lf, max_share) for lf in leaves_of)
for lf in leaves_of)
else: else:
actual = len(leaves_of) actual = len(leaves_of)
expected = req.count expected = req.count

View file

@ -420,13 +420,16 @@ def _share_rooms(rooms: list[str], reqs,
def _leaf_mult_from_plan(lvl: dom.Node, plan: dict[str, list[int]]) -> dict: def _leaf_mult_from_plan(lvl: dom.Node, plan: dict[str, list[int]]) -> dict:
"""Map each typed leaf to its intended multiplicity from a ``_share_rooms`` """Stamp each typed leaf with its share multiplicity from a ``_share_rooms``
plan, so ``_size_divisions_from_targets`` sizes shared leaves to k×target. plan and return a leafmultiplicity map for sizing.
Bigger multiplicities go to whichever leaves already read largest, so the Sets ``leaf.share = k`` and ``leaf.share_type = leaf.type`` (the explicit,
proportional sizing pass has the least work to do. Defensive against a leaf type-guarded multiplicity the fitness reads, §13.3) on shared leaves, and
count that differs from the plan (assignment dropped/added a slot): extra returns ``{leaf: k}`` so ``_size_divisions_from_targets`` sizes them to
leaves default to multiplicity 1, surplus plan entries are ignored.""" k×target. Bigger multiplicities go to whichever leaves already read largest,
so the proportional sizing pass has the least work to do. Defensive against a
leaf count that differs from the plan (assignment dropped/added a slot):
extra leaves stay multiplicity 1, surplus plan entries are ignored."""
from . import geometry from . import geometry
by_code: dict[str, list[dom.Node]] = {} by_code: dict[str, list[dom.Node]] = {}
for lf in lvl.leaves(): for lf in lvl.leaves():
@ -438,6 +441,8 @@ def _leaf_mult_from_plan(lvl: dom.Node, plan: dict[str, list[int]]) -> dict:
for lf, m in zip(leaves, sorted(mults, reverse=True)): for lf, m in zip(leaves, sorted(mults, reverse=True)):
if m > 1: if m > 1:
leaf_mult[lf] = m leaf_mult[lf] = m
lf.share = m
lf.share_type = lf.type
return leaf_mult return leaf_mult
@ -717,7 +722,9 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
rng: np.random.Generator, types: list[str], rng: np.random.Generator, types: list[str],
reqs=None, adjacency_aware: bool = True, reqs=None, adjacency_aware: bool = True,
proportion_aware: bool = True, proportion_aware: bool = True,
circ_divisor: int = 3) -> dom.Node: circ_divisor: int = 3,
leaf_sharing: bool = False,
leaf_share_factor: int = 2) -> dom.Node:
"""Stack upper storeys onto an evolved single-storey base (DESIGN.md §11.3). """Stack upper storeys onto an evolved single-storey base (DESIGN.md §11.3).
Stage 2 seeder: the Stage-1 base is the credible ground floor and is left Stage 2 seeder: the Stage-1 base is the credible ground floor and is left
@ -748,6 +755,11 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
core_node = dup.by_id(core_path) if core_path is not None else None core_node = dup.by_id(core_path) if core_path is not None else None
rooms = [code for code, cnt in bucket.items() for _ in range(cnt)] rooms = [code for code, cnt in bucket.items() for _ in range(cnt)]
# erc.3: collapse same-code rooms into fewer shared leaves on this storey
# too (§13.3), so upper floors get the same per-leaf-tax saving.
share_plan: dict[str, list[int]] = {}
if leaf_sharing:
rooms, share_plan = _share_rooms(rooms, reqs, leaf_share_factor)
def _free() -> list[dom.Node]: def _free() -> list[dom.Node]:
return [lf for lf in dup.leaves() if lf is not core_node] return [lf for lf in dup.leaves() if lf is not core_node]
@ -798,7 +810,8 @@ def lift_base_to_storeys(base_root: dom.Node, upper_buckets: list[dict[str, int]
# the base via below-links are no-ops here — their geometry is fixed # the base via below-links are no-ops here — their geometry is fixed
# below — so this best-effort sizes the floor's own new divisions.) # below — so this best-effort sizes the floor's own new divisions.)
dom._link(child) dom._link(child)
_size_divisions_from_targets(dup, reqs) _size_divisions_from_targets(
dup, reqs, leaf_mult=_leaf_mult_from_plan(dup, share_plan))
prev = dup prev = dup

View file

@ -124,15 +124,18 @@ def test_constructive_topology_has_no_missing_spaces():
canonical(root) canonical(root)
def test_leaf_share_mult_recovery(): def test_leaf_share_explicit_and_type_guarded():
# erc.3 §13.3: multiplicity recovered from area = round(area/target), clamped. # erc.3 §13.3: explicit multiplicity, honoured only while type==share_type so
from homemaker_layout.graph import _leaf_share_mult # a retype silently invalidates a stale share (no operator reset needed).
from homemaker_layout.graph import leaf_share
assert _leaf_share_mult(10.0, 10.0, 4) == 1 leaf = dom.Node(type="n", share=3, share_type="n")
assert _leaf_share_mult(19.0, 10.0, 4) == 2 # ~2x target → covers 2 assert leaf_share(leaf, 4) == 3
assert _leaf_share_mult(100.0, 10.0, 4) == 4 # clamped at max_share assert leaf_share(leaf, 2) == 2 # clamped at max_share
assert _leaf_share_mult(3.0, 10.0, 4) == 1 # never below 1 leaf.type = "ba" # retyped → share no longer matches
assert _leaf_share_mult(10.0, 0.0, 4) == 1 # no target → 1 assert leaf_share(leaf, 4) == 1
plain = dom.Node(type="n") # default share 1
assert leaf_share(plain, 4) == 1
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available") @pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")