diff --git a/DESIGN.md b/DESIGN.md index b384d1d..8bc43d2 100644 --- a/DESIGN.md +++ b/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.** diff --git a/experiments/run_leafshare_ab.sh b/experiments/run_leafshare_ab.sh new file mode 100755 index 0000000..61a5ca2 --- /dev/null +++ b/experiments/run_leafshare_ab.sh @@ -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" diff --git a/experiments/run_staged_search.py b/experiments/run_staged_search.py index d2d0fd1..b6a6a10 100644 --- a/experiments/run_staged_search.py +++ b/experiments/run_staged_search.py @@ -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 diff --git a/src/homemaker_layout/dom.py b/src/homemaker_layout/dom.py index a1dd526..f11530e 100644 --- a/src/homemaker_layout/dom.py +++ b/src/homemaker_layout/dom.py @@ -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) diff --git a/src/homemaker_layout/driver.py b/src/homemaker_layout/driver.py index 2b136ff..9f39d8e 100644 --- a/src/homemaker_layout/driver.py +++ b/src/homemaker_layout/driver.py @@ -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). diff --git a/src/homemaker_layout/fitness.py b/src/homemaker_layout/fitness.py index 7dae92d..b3c839b 100644 --- a/src/homemaker_layout/fitness.py +++ b/src/homemaker_layout/fitness.py @@ -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) diff --git a/src/homemaker_layout/graph.py b/src/homemaker_layout/graph.py index 6edf841..158f926 100644 --- a/src/homemaker_layout/graph.py +++ b/src/homemaker_layout/graph.py @@ -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: - return 1 - return max(1, min(max_share, round(area / target))) + 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 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 diff --git a/src/homemaker_layout/operators.py b/src/homemaker_layout/operators.py index 2454e62..8c0aed2 100644 --- a/src/homemaker_layout/operators.py +++ b/src/homemaker_layout/operators.py @@ -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 diff --git a/tests/test_operators.py b/tests/test_operators.py index 24512d6..b964433 100644 --- a/tests/test_operators.py +++ b/tests/test_operators.py @@ -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")