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:
parent
bf3ff43837
commit
e983229857
9 changed files with 189 additions and 79 deletions
90
DESIGN.md
90
DESIGN.md
|
|
@ -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.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
|
||||
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
|
||||
need not apply.
|
||||
|
||||
**Mechanism (no genome change).** A shared leaf carries no explicit state — it is
|
||||
just a larger leaf of the code, which construction sizes to `k × target` area.
|
||||
Its multiplicity `k` is *recovered from area* at scoring time,
|
||||
`k = clamp(round(area/target), 1, max_share)` (`graph._leaf_share_mult`), used in
|
||||
two places, both gated by a default-OFF `leaf_sharing` config key (controls
|
||||
reproduce the §12.2 baseline exactly — all 212 tests pass with it off):
|
||||
**Mechanism — explicit, type-guarded per-leaf multiplicity.** A construction
|
||||
stamps `leaf.share = k` and `leaf.share_type = code` on each shared leaf
|
||||
(`operators._share_rooms` groups a sized, multi-instance code into runs of ≤ `N`
|
||||
= `leaf_share_factor`; `_leaf_mult_from_plan` stamps the survivors and
|
||||
`_size_divisions_from_targets` sizes them to `k × target`). The fitness honours
|
||||
`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
|
||||
`req.count`, so one shared leaf satisfies several same-code rooms with no
|
||||
missing fail;
|
||||
- `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
|
||||
oversize. `quality_proportion`/`quality_width` need no change — a
|
||||
`k`). `quality_proportion`/`quality_width` need no change — a
|
||||
proportionally-scaled leaf keeps its aspect and only gets wider.
|
||||
|
||||
Construction (`operators._share_rooms`, `constructive_topology` with
|
||||
`leaf_sharing=True`, `leaf_share_factor=N`) groups each sized, multi-instance
|
||||
code into runs of ≤ N instances → one leaf per run, then `_size_divisions_from_
|
||||
targets` sizes that leaf to the run's `k × target`. Only same-code merges (rooms
|
||||
with identical adjacency/level reqs) so the spine assignment stays valid.
|
||||
*Design history:* the first cut recovered `k` from area
|
||||
(`round(area/target)`) to avoid genome state, but the §13.2 depth
|
||||
maldistribution left shared leaves below `k × target`, so `round` undercounted
|
||||
and **17–44 missing fails leaked back** (harbor `share3`+il: 87.3 total, 16.7
|
||||
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)
|
||||
— a cheap de-risk before the full A/B: build the §12.2 seed both ways, score at
|
||||
the seed geometry and again after `innerloop.optimise` (nm, budget 80) under the
|
||||
*same* objective. Averaged fails:
|
||||
— build the §12.2 seed both ways, score at the seed geometry and again after
|
||||
`innerloop.optimise` (nm, budget 80) under the *same* objective. Averaged fails:
|
||||
|
||||
| programme | mode | leaves | total | missing | size | crink |
|
||||
|-----------|-------------|-------:|------:|--------:|-----:|------:|
|
||||
| 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 | share3 +il | 25.7 | 87.3 | 16.7 | 10.0 | 18.0 |
|
||||
| harbor | share2 +il | 31.7 | 86.0 | 0.0 | 15.3 | 22.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 | share2 +il | 52.0 | 184.3 | 44.3 | 23.3 | 41.0 |
|
||||
| maple | share3 +il | 47.0 | 162.7 | 35.3 | 17.7 | 39.0 |
|
||||
| maple | share2 +il | 52.0 | 145.7 | 0.0 | 25.7 | 41.3 |
|
||||
| maple | share3 +il | 47.0 | 133.0 | 0.0 | 21.0 | 39.3 |
|
||||
|
||||
**The floor moves** — total fails drop **−27 % harbor / −16 % maple** at
|
||||
`share3`, and the drop is exactly where §13.1 predicted: the shape factors fall
|
||||
roughly with leaf count (harbor size 22→10, crinkliness 34→18 as leaves 45→26).
|
||||
The §13.1 linear-in-leaves model holds.
|
||||
**The floor moves and the leak is closed** — `share3` cuts the achievable floor
|
||||
**−39 % harbor (120.3 → 73.3) / −32 % maple (194.7 → 133.0)** with **zero missing
|
||||
fails**, and the missing did *not* re-emerge as size fails (size still falls,
|
||||
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.**
|
||||
Sharing buys back 17–44 *missing* fails, and `innerloop.optimise` barely moves
|
||||
them (harbor 17→17, maple 37→35). This is the §13.2 mechanism on the new axis:
|
||||
the area-derived `k` recovery is defeated by binary-tree **depth maldistribution**
|
||||
— a leaf "sized to `k × target`" lands at the wrong *absolute* area because
|
||||
ratios multiply down the ancestry, so `round(area/target) < k` and the uncovered
|
||||
rooms read as missing; the frozen-topology ratio DOF then cannot grow it back
|
||||
(0.5ⁿ cliff). Net is still positive because the shape savings outweigh the leak.
|
||||
|
||||
**Verdict so far — leaf-sharing is validated as a real floor-mover (−16…−27 %),
|
||||
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.
|
||||
**Verdict — leaf-sharing is the floor-mover §13.1/§13.2 called for: −32…−39 % on
|
||||
the achievable floor, no missing-fail leak.** The flag is threaded through the
|
||||
staged driver (`driver.search`/`search_staged` → `constructive_topology` /
|
||||
`lift_base_to_storeys`) and exposed for the A/B via `LEAFSHARE`/`LEAFSHAREFAC` in
|
||||
`run_staged_search.py` (which injects the objective into the inner-loop and
|
||||
final-score fitness, both arms on one programme dir). Smoke-tested end-to-end
|
||||
(harbor, staged, leaf_sharing+factor 3: re-score OK). **Remaining: the staged
|
||||
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,
|
||||
recorded here as the closing verdict.**
|
||||
|
|
|
|||
43
experiments/run_leafshare_ab.sh
Executable file
43
experiments/run_leafshare_ab.sh
Executable 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"
|
||||
|
|
@ -62,6 +62,23 @@ def main() -> int:
|
|||
_ms = os.environ.get("MAXSHAPE") # 9gp.1 prune threshold (shape-fail count)
|
||||
max_shape = int(_ms) if _ms else None
|
||||
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"seed : {seed_file.name}")
|
||||
|
|
@ -75,6 +92,7 @@ def main() -> int:
|
|||
print(f"reassoc : {reassoc}")
|
||||
print(f"feas_filt : {feas} (max_shape={max_shape})")
|
||||
print(f"circ_div : {circ_div}")
|
||||
print(f"leaf_share: {leaf_share} (factor={leaf_share_fac})")
|
||||
print(flush=True)
|
||||
|
||||
seed_root = dom.load(str(seed_file))
|
||||
|
|
@ -101,6 +119,8 @@ def main() -> int:
|
|||
feasibility_filter=feas,
|
||||
feasibility_max_shape_fails=max_shape,
|
||||
circ_divisor=circ_div,
|
||||
leaf_sharing=leaf_share,
|
||||
leaf_share_factor=leaf_share_fac,
|
||||
)
|
||||
|
||||
elapsed = time.perf_counter() - t0
|
||||
|
|
|
|||
|
|
@ -31,6 +31,14 @@ class Node:
|
|||
right: "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
|
||||
node: list[list[float]] | None = None # working corners (wall-inset)
|
||||
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)
|
||||
if d.get("type") is not None:
|
||||
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:
|
||||
n.node = [[float(p[0]), float(p[1])] for p in d["node"]]
|
||||
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)
|
||||
if n.type and not n.divided:
|
||||
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
|
||||
if n.divided:
|
||||
d["division"] = list(n.division)
|
||||
|
|
|
|||
|
|
@ -187,6 +187,8 @@ def search(
|
|||
feasibility_filter: bool = False,
|
||||
feasibility_max_shape_fails: int | None = None,
|
||||
circ_divisor: int = 3,
|
||||
leaf_sharing: bool = False,
|
||||
leaf_share_factor: int = 2,
|
||||
) -> SearchResult:
|
||||
"""Run the memetic loop from ``seed_root`` until ``budget`` oracle
|
||||
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,
|
||||
adjacency_aware=seed_adjacency_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}")
|
||||
n = int(rng.integers(max(1, n_target - 1), n_target + 2))
|
||||
return (random_topology(seed_root, n, rng, types), None, child_budget,
|
||||
|
|
@ -511,6 +514,8 @@ def search_staged(
|
|||
feasibility_filter: bool = False,
|
||||
feasibility_max_shape_fails: int | None = None,
|
||||
circ_divisor: int = 3,
|
||||
leaf_sharing: bool = False,
|
||||
leaf_share_factor: int = 2,
|
||||
) -> SearchResult:
|
||||
"""Staged per-floor topology search (DESIGN.md §11.3, ``homemaker-py-c4c.3``).
|
||||
|
||||
|
|
@ -558,7 +563,9 @@ def search_staged(
|
|||
enable_reassociate=enable_reassociate,
|
||||
feasibility_filter=feasibility_filter,
|
||||
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:
|
||||
types = sorted(reqs) + ["C", "O"]
|
||||
|
|
@ -588,6 +595,8 @@ def search_staged(
|
|||
feasibility_filter=feasibility_filter,
|
||||
feasibility_max_shape_fails=feasibility_max_shape_fails,
|
||||
circ_divisor=circ_divisor,
|
||||
leaf_sharing=leaf_sharing,
|
||||
leaf_share_factor=leaf_share_factor,
|
||||
)
|
||||
best_base = r1.best.root
|
||||
_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,
|
||||
adjacency_aware=seed_adjacency_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}")
|
||||
r2 = search(
|
||||
|
|
@ -623,6 +633,8 @@ def search_staged(
|
|||
feasibility_filter=feasibility_filter,
|
||||
feasibility_max_shape_fails=feasibility_max_shape_fails,
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -284,10 +284,12 @@ class Fitness:
|
|||
target, sigma = params[0], params[1]
|
||||
if self._leaf_sharing and t0 != "c" and target > 0:
|
||||
# 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
|
||||
# scale sigma by k so the *fractional* size tolerance is preserved.
|
||||
# k×target (k = leaf's explicit, type-guarded share) and scale sigma by
|
||||
# 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
|
||||
k = _graph._leaf_share_mult(geometry.area(leaf), target, self._max_share)
|
||||
k = _graph.leaf_share(leaf, self._max_share)
|
||||
if k > 1:
|
||||
target, sigma = target * k, sigma * k
|
||||
return gaussian(geometry.area(leaf), 1.0, target, sigma)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _leaf_share_mult(area: float, target: float, max_share: int) -> int:
|
||||
"""Recover how many same-code rooms a leaf of ``area`` covers (erc.3).
|
||||
def leaf_share(leaf: Node, max_share: int) -> int:
|
||||
"""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
|
||||
larger leaf of the code, sized by construction to ``k × target`` area. The
|
||||
multiplicity is recovered here from area alone — ``round(area/target)``,
|
||||
clamped to ``[1, max_share]`` — so the count check and ``quality_size``
|
||||
agree on the same ``k`` (both use this helper / its rounding)."""
|
||||
if target <= 0:
|
||||
Explicit per-leaf multiplicity: construction stamps ``leaf.share = k`` and
|
||||
``leaf.share_type = code``; this is honoured only while ``leaf.type`` still
|
||||
equals ``share_type``, so any retype/undivide silently invalidates a stale
|
||||
share (a retyped small leaf cannot claim to cover rooms it does not provide).
|
||||
Clamped to ``max_share``. Both the count check and ``quality_size`` read this
|
||||
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 max(1, min(max_share, round(area / target)))
|
||||
|
||||
|
||||
def check_space_counts(
|
||||
|
|
@ -477,9 +478,8 @@ def check_space_counts(
|
|||
|
||||
leaves_of = count.get(code, [])
|
||||
if leaf_sharing and req.size > 0:
|
||||
# Coverage: sum the per-leaf multiplicity recovered from area.
|
||||
actual = sum(_leaf_share_mult(geometry.area(lf), req.size, max_share)
|
||||
for lf in leaves_of)
|
||||
# Coverage: sum each leaf's explicit (type-guarded) share multiplicity.
|
||||
actual = sum(leaf_share(lf, max_share) for lf in leaves_of)
|
||||
else:
|
||||
actual = len(leaves_of)
|
||||
expected = req.count
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""Map each typed leaf to its intended multiplicity from a ``_share_rooms``
|
||||
plan, so ``_size_divisions_from_targets`` sizes shared leaves to k×target.
|
||||
"""Stamp each typed leaf with its share multiplicity from a ``_share_rooms``
|
||||
plan and return a leaf→multiplicity map for sizing.
|
||||
|
||||
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 default to multiplicity 1, surplus plan entries are ignored."""
|
||||
Sets ``leaf.share = k`` and ``leaf.share_type = leaf.type`` (the explicit,
|
||||
type-guarded multiplicity the fitness reads, §13.3) on shared leaves, and
|
||||
returns ``{leaf: k}`` so ``_size_divisions_from_targets`` sizes them to
|
||||
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
|
||||
by_code: dict[str, list[dom.Node]] = {}
|
||||
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)):
|
||||
if m > 1:
|
||||
leaf_mult[lf] = m
|
||||
lf.share = m
|
||||
lf.share_type = lf.type
|
||||
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],
|
||||
reqs=None, adjacency_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).
|
||||
|
||||
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
|
||||
|
||||
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]:
|
||||
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
|
||||
# below — so this best-effort sizes the floor's own new divisions.)
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -124,15 +124,18 @@ def test_constructive_topology_has_no_missing_spaces():
|
|||
canonical(root)
|
||||
|
||||
|
||||
def test_leaf_share_mult_recovery():
|
||||
# erc.3 §13.3: multiplicity recovered from area = round(area/target), clamped.
|
||||
from homemaker_layout.graph import _leaf_share_mult
|
||||
def test_leaf_share_explicit_and_type_guarded():
|
||||
# erc.3 §13.3: explicit multiplicity, honoured only while type==share_type so
|
||||
# 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
|
||||
assert _leaf_share_mult(19.0, 10.0, 4) == 2 # ~2x target → covers 2
|
||||
assert _leaf_share_mult(100.0, 10.0, 4) == 4 # clamped at max_share
|
||||
assert _leaf_share_mult(3.0, 10.0, 4) == 1 # never below 1
|
||||
assert _leaf_share_mult(10.0, 0.0, 4) == 1 # no target → 1
|
||||
leaf = dom.Node(type="n", share=3, share_type="n")
|
||||
assert leaf_share(leaf, 4) == 3
|
||||
assert leaf_share(leaf, 2) == 2 # clamped at max_share
|
||||
leaf.type = "ba" # retyped → share no longer matches
|
||||
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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue