94g: public-access pin + keep-better wrapper + CLI/finish-hook wiring
Public-access term (preserve_public_access, default on): when the building's only street access is an l/k ROOM neighbour of a public outside leaf (no circulation fallback — an existential building-level check the per-leaf objective can't see), that leaf is pinned (kept, its demand slot decremented) so the collapse can't drop "no outside public access". Best layout 15→13 becomes 15→12 with zero new fails; sweep total 172→171, still monotone. collapse_finish(root, **kw) -> (tree, base, coll, applied): keep-better wrapper, scores on throwaway copies (score_with_fails merges in place), returns the collapse only if fails don't increase. Wiring: driver.collapse_best updates result.best (lineage +collapse, canonical re-score); evolve.py runs it after the sharing polish behind --collapse/ --no-collapse (default on). New homemaker-collapse CLI (collapse_cmd.py) applies it to an existing .dom, writing <stem>.collapsed.dom. tests/test_collapse_global.py: demand-set relabel, level hard constraint, c/o/s exclusion, no-op safety, keep-better/unmerged. 267 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
This commit is contained in:
parent
da18ef744e
commit
880a214d96
7 changed files with 390 additions and 25 deletions
File diff suppressed because one or more lines are too long
|
|
@ -15,6 +15,7 @@ dependencies = [
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
homemaker-evolve = "homemaker_layout.evolve:main"
|
homemaker-evolve = "homemaker_layout.evolve:main"
|
||||||
homemaker-fitness = "homemaker_layout.fitness_cmd:main"
|
homemaker-fitness = "homemaker_layout.fitness_cmd:main"
|
||||||
|
homemaker-collapse = "homemaker_layout.collapse_cmd:main"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest>=8.0", "ruff>=0.5"]
|
dev = ["pytest>=8.0", "ruff>=0.5"]
|
||||||
|
|
|
||||||
97
src/homemaker_layout/collapse_cmd.py
Normal file
97
src/homemaker_layout/collapse_cmd.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
"""homemaker-collapse — finish-time global cell→room collapse on a .dom file.
|
||||||
|
|
||||||
|
Relabels a layout's room cells to the programme rooms they fit best via one
|
||||||
|
optimal assignment (hard level constraint, adjacency relaxation, public-access
|
||||||
|
pinning — see fitness.Fitness.collapse_global), keeping the result only if the
|
||||||
|
fail count does not increase. Labels only — geometry is never touched, so
|
||||||
|
shape-intrinsic fails (long-thin cells, crinkliness) are unaffected by design.
|
||||||
|
|
||||||
|
Like homemaker-fitness you MUST cd to the directory holding the .dom so that
|
||||||
|
patterns.config / costs.config resolve.
|
||||||
|
|
||||||
|
Usage (module):
|
||||||
|
python -m homemaker_layout.collapse_cmd file.dom [file2.dom ...] [-o OUT.dom]
|
||||||
|
|
||||||
|
When installed via pip install -e .:
|
||||||
|
homemaker-collapse file.dom [...]
|
||||||
|
|
||||||
|
Writes the collapsed layout to <stem>.collapsed.dom (or -o OUT for a single
|
||||||
|
input; - for stdout) and prints "base → collapsed fails" per file to stderr.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import dom as dom_mod
|
||||||
|
from .fitness import Fitness, load_config
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args(argv):
|
||||||
|
p = argparse.ArgumentParser(prog="homemaker-collapse", description=__doc__)
|
||||||
|
p.add_argument("dom", type=Path, nargs="+", help="input .dom file(s)")
|
||||||
|
p.add_argument("-o", "--output", type=Path, default=None, metavar="PATH",
|
||||||
|
help="output path (single input only; - for stdout). Default: "
|
||||||
|
"<stem>.collapsed.dom beside each input")
|
||||||
|
p.add_argument("--adjacency", action=argparse.BooleanOptionalAction, default=True,
|
||||||
|
help="enforce required room↔room adjacency (default: on)")
|
||||||
|
p.add_argument("--public-access", dest="public_access",
|
||||||
|
action=argparse.BooleanOptionalAction, default=True,
|
||||||
|
help="pin the sole street-access provider (default: on)")
|
||||||
|
p.add_argument("--objective", choices=("threshold", "quality"),
|
||||||
|
default="threshold",
|
||||||
|
help="threshold = minimise fail count; quality = maximise "
|
||||||
|
"continuous fit (default: threshold)")
|
||||||
|
p.add_argument("--keep-better", dest="keep_better",
|
||||||
|
action=argparse.BooleanOptionalAction, default=True,
|
||||||
|
help="revert if the collapse increases the fail count "
|
||||||
|
"(default: on)")
|
||||||
|
return p.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
args = _parse_args(argv or sys.argv[1:])
|
||||||
|
if args.output is not None and len(args.dom) != 1:
|
||||||
|
print("error: -o/--output requires exactly one input", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
conf, cost = load_config(Path.cwd())
|
||||||
|
fit = Fitness(conf, cost)
|
||||||
|
kw = dict(
|
||||||
|
adjacency=args.adjacency,
|
||||||
|
objective=args.objective,
|
||||||
|
preserve_public_access=args.public_access,
|
||||||
|
)
|
||||||
|
|
||||||
|
rc = 0
|
||||||
|
for dom_path in args.dom:
|
||||||
|
if not dom_path.exists():
|
||||||
|
print(f"not found, skipping: {dom_path}", file=sys.stderr)
|
||||||
|
rc = 1
|
||||||
|
continue
|
||||||
|
root = dom_mod.load(str(dom_path))
|
||||||
|
if args.keep_better:
|
||||||
|
tree, base_f, coll_f, applied = fit.collapse_finish(root, **kw)
|
||||||
|
else:
|
||||||
|
import copy
|
||||||
|
base_f = len(fit.score_with_fails(copy.deepcopy(root))[1])
|
||||||
|
fit.collapse_global(root, **kw)
|
||||||
|
coll_f = len(fit.score_with_fails(copy.deepcopy(root))[1])
|
||||||
|
tree, applied = root, True
|
||||||
|
verb = "applied" if applied else "reverted"
|
||||||
|
print(f"{dom_path.name}: {base_f} → {coll_f} fails ({verb})", file=sys.stderr)
|
||||||
|
|
||||||
|
if str(args.output) == "-":
|
||||||
|
sys.stdout.write(dom_mod.dumps(tree))
|
||||||
|
else:
|
||||||
|
out = args.output or dom_path.with_suffix(".collapsed.dom")
|
||||||
|
dom_mod.dump(tree, str(out))
|
||||||
|
print(f"written: {out}", file=sys.stderr)
|
||||||
|
|
||||||
|
return rc
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -645,6 +645,50 @@ def polish_finish(
|
||||||
return r2
|
return r2
|
||||||
|
|
||||||
|
|
||||||
|
def collapse_best(
|
||||||
|
result: SearchResult,
|
||||||
|
programme_dir: str | Path,
|
||||||
|
*,
|
||||||
|
leaf_sharing: bool = False,
|
||||||
|
superpose: bool = False,
|
||||||
|
log=None,
|
||||||
|
**collapse_kw,
|
||||||
|
) -> SearchResult:
|
||||||
|
"""homemaker-py-94g: finish-time global cell→room collapse on the best layout.
|
||||||
|
|
||||||
|
Relabels the best tree's room cells to the programme rooms they fit best via
|
||||||
|
one optimal assignment (hard level constraint, adjacency relaxation, and
|
||||||
|
public-access pinning — see :meth:`fitness.Fitness.collapse_global`), keeping
|
||||||
|
the result only if the fail count does not increase (:meth:`collapse_finish`).
|
||||||
|
A strictly monotone finish-time polish that searches only labels, not
|
||||||
|
geometry, so it cannot touch shape-intrinsic fails (long-thin cells, etc.).
|
||||||
|
|
||||||
|
Updates ``result.best`` in place with the canonically re-scored relabelling
|
||||||
|
when it helps; otherwise leaves the result untouched."""
|
||||||
|
if result.best is None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
fit = _fitness_for(str(programme_dir), leaf_sharing, superpose)
|
||||||
|
tree, base_fails, coll_fails, applied = fit.collapse_finish(
|
||||||
|
result.best.root, **collapse_kw
|
||||||
|
)
|
||||||
|
if log:
|
||||||
|
verb = "applied" if applied else "reverted — no improvement"
|
||||||
|
log(f"[finish] collapse: {base_fails} → {coll_fails} fails ({verb})")
|
||||||
|
if applied:
|
||||||
|
score, fails, grade = fit.score_with_grade(copy.deepcopy(tree))
|
||||||
|
result.best = Individual(
|
||||||
|
root=tree,
|
||||||
|
fitness=score,
|
||||||
|
n_fails=len(fails),
|
||||||
|
ratios=result.best.ratios,
|
||||||
|
lineage=result.best.lineage + "+collapse",
|
||||||
|
grade=grade,
|
||||||
|
sig=result.best.sig,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def search_annealed(
|
def search_annealed(
|
||||||
seed_root: dom.Node,
|
seed_root: dom.Node,
|
||||||
programme_dir: str | Path,
|
programme_dir: str | Path,
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,13 @@ def _parse_args(argv=None) -> argparse.Namespace:
|
||||||
"canonical scorer. -1 = auto (budget//2); 0 = unfold + "
|
"canonical scorer. -1 = auto (budget//2); 0 = unfold + "
|
||||||
"rescore only, no polish. Ignored with --no-leaf-sharing "
|
"rescore only, no polish. Ignored with --no-leaf-sharing "
|
||||||
"(default: -1)")
|
"(default: -1)")
|
||||||
|
p.add_argument("--collapse", action=argparse.BooleanOptionalAction,
|
||||||
|
default=True,
|
||||||
|
help="homemaker-py-94g: finish-time global cell→room collapse — "
|
||||||
|
"relabel the best layout's room cells to the programme "
|
||||||
|
"rooms they fit best (level + adjacency + public-access "
|
||||||
|
"constrained), kept only if it does not increase the fail "
|
||||||
|
"count. Labels only, never geometry (default: on)")
|
||||||
p.add_argument("--output", type=Path, default=None, metavar="PATH",
|
p.add_argument("--output", type=Path, default=None, metavar="PATH",
|
||||||
help="output .dom path (- for stdout)")
|
help="output .dom path (- for stdout)")
|
||||||
return p.parse_args(argv)
|
return p.parse_args(argv)
|
||||||
|
|
@ -234,6 +241,20 @@ def main(argv=None) -> int:
|
||||||
log=lambda m: print(m, file=sys.stderr, flush=True),
|
log=lambda m: print(m, file=sys.stderr, flush=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# homemaker-py-94g: finish-time global cell→room collapse. Relabels the best
|
||||||
|
# layout's room cells to the programme rooms they fit best (label search only,
|
||||||
|
# no geometry change), kept only if it does not increase the fail count. Runs
|
||||||
|
# after the sharing polish so it acts on the canonical (materialised) best.
|
||||||
|
if args.collapse and r.best is not None:
|
||||||
|
print(file=sys.stderr)
|
||||||
|
print("--- collapse (homemaker-py-94g): finish-time cell→room relabel ---",
|
||||||
|
file=sys.stderr, flush=True)
|
||||||
|
r = driver.collapse_best(
|
||||||
|
r, programme_dir,
|
||||||
|
superpose=args.superpose,
|
||||||
|
log=lambda m: print(m, file=sys.stderr, flush=True),
|
||||||
|
)
|
||||||
|
|
||||||
elapsed = time.perf_counter() - t0
|
elapsed = time.perf_counter() - t0
|
||||||
|
|
||||||
print(file=sys.stderr)
|
print(file=sys.stderr)
|
||||||
|
|
|
||||||
|
|
@ -338,6 +338,7 @@ class Fitness:
|
||||||
root: Node,
|
root: Node,
|
||||||
adjacency: bool = True,
|
adjacency: bool = True,
|
||||||
objective: str = "threshold",
|
objective: str = "threshold",
|
||||||
|
preserve_public_access: bool = True,
|
||||||
iters: int = 6,
|
iters: int = 6,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Finish-time GLOBAL cell->room collapse (homemaker-py-94g): relabel
|
"""Finish-time GLOBAL cell->room collapse (homemaker-py-94g): relabel
|
||||||
|
|
@ -375,7 +376,16 @@ class Fitness:
|
||||||
same weight (_COLLAPSE_FAIL_W = one avoided fail), so the collapse
|
same weight (_COLLAPSE_FAIL_W = one avoided fail), so the collapse
|
||||||
minimises (adjacency + size/width/proportion) fails jointly.
|
minimises (adjacency + size/width/proportion) fails jointly.
|
||||||
|
|
||||||
|
PRESERVE_PUBLIC_ACCESS pins the room leaf that solely provides the
|
||||||
|
building's street access (an l/k neighbour of a public outside leaf, with
|
||||||
|
no circulation fallback) so the collapse cannot drop the building-level
|
||||||
|
"no outside public access" check — the one recurring regression the
|
||||||
|
per-leaf objective cannot see (it is existential and building-scoped).
|
||||||
|
|
||||||
One-shot finish-time pass on a committed layout, not a per-eval re-type."""
|
One-shot finish-time pass on a committed layout, not a per-eval re-type."""
|
||||||
|
from collections import Counter
|
||||||
|
from . import graph as graph_mod
|
||||||
|
|
||||||
prog = self._programme or {}
|
prog = self._programme or {}
|
||||||
if not prog:
|
if not prog:
|
||||||
return
|
return
|
||||||
|
|
@ -383,12 +393,34 @@ class Fitness:
|
||||||
if not room_codes:
|
if not room_codes:
|
||||||
return
|
return
|
||||||
lvls = dom_mod.levels(root)
|
lvls = dom_mod.levels(root)
|
||||||
supply = [lf for lvl in lvls for lf in lvl.leaves() if lf.type in room_codes]
|
graphs = (
|
||||||
|
graph_mod.build_graphs(root, self.conf("door_width") or 1.2)
|
||||||
|
if (adjacency or preserve_public_access)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
pinned = (
|
||||||
|
self._public_access_pins(root, graphs, lvls, room_codes)
|
||||||
|
if (preserve_public_access and graphs is not None)
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
supply = [
|
||||||
|
lf
|
||||||
|
for lvl in lvls
|
||||||
|
for lf in lvl.leaves()
|
||||||
|
if lf.type in room_codes and id(lf) not in pinned
|
||||||
|
]
|
||||||
if not supply:
|
if not supply:
|
||||||
return
|
return
|
||||||
slots: list[str] = []
|
# Demand = room-code counts, minus one slot per pinned leaf (its instance
|
||||||
for code in sorted(room_codes):
|
# is already met by the pin, so it must not be demanded of another leaf).
|
||||||
slots.extend([code] * max(0, prog[code].count))
|
slot_counts = Counter({c: max(0, prog[c].count) for c in room_codes})
|
||||||
|
if pinned:
|
||||||
|
for lvl in lvls:
|
||||||
|
for lf in lvl.leaves():
|
||||||
|
if id(lf) in pinned and slot_counts.get(lf.type, 0) > 0:
|
||||||
|
slot_counts[lf.type] -= 1
|
||||||
|
slots = [c for c in sorted(slot_counts) for _ in range(slot_counts[c])]
|
||||||
if not slots:
|
if not slots:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -430,12 +462,9 @@ class Fitness:
|
||||||
supply[r].type = slots[c]
|
supply[r].type = slots[c]
|
||||||
return
|
return
|
||||||
|
|
||||||
# Adjacency relaxation. Build the pre-merge base graph once (fixed
|
# Adjacency relaxation on the pre-merge base graph (built above, fixed
|
||||||
# geometry). A satisfied adjacency is worth fail_w — one avoided fail,
|
# geometry). A satisfied adjacency is worth fail_w — one avoided fail,
|
||||||
# the same unit as a passing factor — so both are minimised jointly.
|
# the same unit as a passing factor — so both are minimised jointly.
|
||||||
from . import graph as graph_mod
|
|
||||||
|
|
||||||
graphs = graph_mod.build_graphs(root, self.conf("door_width") or 1.2)
|
|
||||||
code_adj = {code: prog[code].adjacency for code in set(slots)}
|
code_adj = {code: prog[code].adjacency for code in set(slots)}
|
||||||
|
|
||||||
prev_labels: list[str | None] = None # type: ignore[assignment]
|
prev_labels: list[str | None] = None # type: ignore[assignment]
|
||||||
|
|
@ -464,6 +493,58 @@ class Fitness:
|
||||||
break
|
break
|
||||||
prev_labels = new_labels
|
prev_labels = new_labels
|
||||||
|
|
||||||
|
def _public_access_pins(
|
||||||
|
self, root: Node, graphs: list, lvls: list, room_codes: set
|
||||||
|
) -> set[int]:
|
||||||
|
"""id()s of room leaves to hold fixed so the building keeps street access
|
||||||
|
across a collapse. If a ground circulation leaf already gives public
|
||||||
|
access it is invariant (circulation is never relabelled) — return empty.
|
||||||
|
Otherwise, for each outside leaf that provides public access solely via an
|
||||||
|
l/k ROOM neighbour (no circulation fallback), pin one such neighbour."""
|
||||||
|
for lvl in lvls:
|
||||||
|
for lf in lvl.leaves():
|
||||||
|
if (
|
||||||
|
lf.type
|
||||||
|
and lf.type[0].lower() == "c"
|
||||||
|
and self._public_access(lf, root) is not None
|
||||||
|
):
|
||||||
|
return set()
|
||||||
|
pins: set[int] = set()
|
||||||
|
for li, lvl in enumerate(lvls):
|
||||||
|
G = graphs[li]
|
||||||
|
for lf in lvl.leaves():
|
||||||
|
if not G.has_node(lf):
|
||||||
|
continue
|
||||||
|
if not self._public_access_outside(lf, G, root):
|
||||||
|
continue
|
||||||
|
nbs = list(G.neighbors(lf))
|
||||||
|
if any(nb.type and nb.type[0].lower() == "c" for nb in nbs):
|
||||||
|
continue # circulation neighbour keeps access invariant
|
||||||
|
for nb in nbs:
|
||||||
|
if nb.type in room_codes and nb.type[0].lower() in ("l", "k"):
|
||||||
|
pins.add(id(nb))
|
||||||
|
break
|
||||||
|
return pins
|
||||||
|
|
||||||
|
def collapse_finish(self, root: Node, **kw) -> tuple[Node, int, int, bool]:
|
||||||
|
"""Keep-better finish-time collapse: apply :meth:`collapse_global` to a
|
||||||
|
copy and return it only if it does not INCREASE the fail count, else the
|
||||||
|
original — a strictly monotone polish (safety belt; collapse_global is
|
||||||
|
already monotone on the harbor-house set but not proven so in general).
|
||||||
|
|
||||||
|
Returns ``(tree, base_fails, collapsed_fails, applied)``. Both the input
|
||||||
|
and returned trees are UNMERGED — scoring is done on throwaway deepcopies
|
||||||
|
because ``score_with_fails`` merges the tree in place."""
|
||||||
|
import copy
|
||||||
|
|
||||||
|
base_fails = len(self.score_with_fails(copy.deepcopy(root))[1])
|
||||||
|
cand = copy.deepcopy(root)
|
||||||
|
self.collapse_global(cand, **kw)
|
||||||
|
cand_fails = len(self.score_with_fails(copy.deepcopy(cand))[1])
|
||||||
|
if cand_fails <= base_fails:
|
||||||
|
return cand, base_fails, cand_fails, True
|
||||||
|
return copy.deepcopy(root), base_fails, cand_fails, False
|
||||||
|
|
||||||
def conf(self, key: str):
|
def conf(self, key: str):
|
||||||
v = self._conf.get(key)
|
v = self._conf.get(key)
|
||||||
if v is not None:
|
if v is not None:
|
||||||
|
|
|
||||||
120
tests/test_collapse_global.py
Normal file
120
tests/test_collapse_global.py
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
"""Tests for the finish-time global cell→room collapse (homemaker-py-94g).
|
||||||
|
|
||||||
|
Covers the contracts of Fitness.collapse_global / collapse_finish:
|
||||||
|
- global relabel to the demand set (larger cell → larger target)
|
||||||
|
- hard level constraint (never introduce a wrong-level fail)
|
||||||
|
- c/o/s partition exclusion (circulation/structure cells are never relabelled)
|
||||||
|
- no-op safety (no programme) and the keep-better wrapper
|
||||||
|
"""
|
||||||
|
|
||||||
|
from homemaker_layout import geometry
|
||||||
|
from homemaker_layout.dom import Node, _link_subtree
|
||||||
|
from homemaker_layout.fitness import Fitness
|
||||||
|
|
||||||
|
|
||||||
|
def _two_leaf_root(t_left: str, t_right: str, side: float = 6.0, div: float = 0.4):
|
||||||
|
geometry.clear_cache()
|
||||||
|
root = Node(
|
||||||
|
node=[[0, 0], [side, 0], [side, side], [0, side]],
|
||||||
|
rotation=0, division=[div, div],
|
||||||
|
left=Node(type=t_left), right=Node(type=t_right),
|
||||||
|
)
|
||||||
|
_link_subtree(root, None, "")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _conf(spaces, **extra):
|
||||||
|
return {"spaces": spaces, **extra}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Global relabel
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_relabels_to_demand_set():
|
||||||
|
# two leaves both typed b1; demand {b1, b2} — collapse spreads them so the
|
||||||
|
# larger cell takes the larger target (b1=16) and the smaller takes b2=12.
|
||||||
|
conf = _conf({
|
||||||
|
"b1": {"size": [16.0, 4.0], "width": [4.0, 1.0], "proportion": [1.5, 0.5]},
|
||||||
|
"b2": {"size": [12.0, 3.0], "width": [3.5, 0.8], "proportion": [1.5, 0.5]},
|
||||||
|
})
|
||||||
|
fit = Fitness(conf=conf)
|
||||||
|
root = _two_leaf_root("b1", "b1")
|
||||||
|
left, right = root.leaves()
|
||||||
|
assert geometry.area(right) > geometry.area(left)
|
||||||
|
|
||||||
|
fit.collapse_global(root)
|
||||||
|
|
||||||
|
assert sorted(lf.type for lf in root.leaves()) == ["b1", "b2"]
|
||||||
|
assert right.type == "b1"
|
||||||
|
assert left.type == "b2"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Hard level constraint
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_level_constraint_never_assigns_wrong_level():
|
||||||
|
# b2 requires level 1; a single-storey tree is all level 0, so no leaf may
|
||||||
|
# take b2 — both stay b1 rather than gaining a wrong-level fail.
|
||||||
|
conf = _conf({
|
||||||
|
"b1": {"size": [16.0, 4.0]},
|
||||||
|
"b2": {"size": [12.0, 3.0], "level": 1},
|
||||||
|
})
|
||||||
|
fit = Fitness(conf=conf)
|
||||||
|
root = _two_leaf_root("b1", "b1")
|
||||||
|
fit.collapse_global(root)
|
||||||
|
assert all(lf.type == "b1" for lf in root.leaves())
|
||||||
|
assert "b2" not in {lf.type for lf in root.leaves()}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# c/o/s partition exclusion
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_cos_prefixed_cells_are_not_relabelled():
|
||||||
|
# cr1 collides with the c* (circulation) convention the scorer counts against,
|
||||||
|
# so it is skeleton — never relabelled and never a demand slot.
|
||||||
|
conf = _conf({
|
||||||
|
"cr1": {"size": [20.0, 4.0]},
|
||||||
|
"b1": {"size": [16.0, 4.0]},
|
||||||
|
})
|
||||||
|
fit = Fitness(conf=conf)
|
||||||
|
root = _two_leaf_root("cr1", "b1")
|
||||||
|
fit.collapse_global(root)
|
||||||
|
assert sorted(lf.type for lf in root.leaves()) == ["b1", "cr1"]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# No-op safety + defaults
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_no_programme_is_noop():
|
||||||
|
fit = Fitness(conf={})
|
||||||
|
root = _two_leaf_root("b1", "b1")
|
||||||
|
fit.collapse_global(root)
|
||||||
|
assert [lf.type for lf in root.leaves()] == ["b1", "b1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_code_is_noop():
|
||||||
|
# one assignable code, count 2 → demand == supply of the same code → no change
|
||||||
|
conf = _conf({"b1": {"size": [16.0, 4.0], "count": 2}})
|
||||||
|
fit = Fitness(conf=conf)
|
||||||
|
root = _two_leaf_root("b1", "b1")
|
||||||
|
fit.collapse_global(root)
|
||||||
|
assert [lf.type for lf in root.leaves()] == ["b1", "b1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_collapse_finish_is_keep_better_and_unmerged():
|
||||||
|
# collapse_finish returns (tree, base, collapsed, applied); the tree it hands
|
||||||
|
# back is unmerged (leaves still carry their divisions), and collapsed<=base.
|
||||||
|
conf = _conf({
|
||||||
|
"b1": {"size": [16.0, 4.0], "width": [4.0, 1.0], "proportion": [1.5, 0.5]},
|
||||||
|
"b2": {"size": [12.0, 3.0], "width": [3.5, 0.8], "proportion": [1.5, 0.5]},
|
||||||
|
})
|
||||||
|
fit = Fitness(conf=conf)
|
||||||
|
root = _two_leaf_root("b1", "b1")
|
||||||
|
tree, base_f, coll_f, applied = fit.collapse_finish(root)
|
||||||
|
assert coll_f <= base_f
|
||||||
|
assert applied == (coll_f < base_f) or coll_f == base_f
|
||||||
|
assert len(tree.leaves()) == 2 # unmerged: both room leaves intact
|
||||||
Loading…
Add table
Reference in a new issue