9o5: type superposition + per-eval collapse (multi-use leaves)

Interchangeable codes (similar size/width/proportion, compatible level/stack,
no adjacency edge) form equivalence classes derived from the programme. With
--superpose (default off), each fitness eval COLLAPSES every superposed leaf to
its best in-class usage via an optimal supply->demand assignment (brute force
<=C! within cap C=4, scipy Hungarian beyond), then scores the condensed types.
Because collapse re-types on the unmerged tree before all checks, counts /
adjacency / quality are unchanged downstream -- no Node field, no graph/operator
changes -- and default OFF is bit-identical.

- programme.py: derive_interchange_classes + interchangeable (S1-S4, locked
  thresholds R_SIZE=1.5/R_WIDTH=1.3/R_PROP=1.5, CLASS_CAP=4)
- fitness.py: collapse_superposition, _best_assignment, _usage_quality;
  superpose/superpose_class_cap conf knobs; collapse hooked into _evaluate_full
- driver.py/evolve.py: superpose flag plumbed beside leaf_sharing; --superpose
- tests/test_superposition.py: 17 tests (derivation, assignment, end-to-end)

Closes homemaker-py-9o5 (build); validation A/B is homemaker-py-xi7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-06-30 07:08:46 +01:00
parent 87d309771e
commit c3635634e8
6 changed files with 472 additions and 24 deletions

File diff suppressed because one or more lines are too long

View file

@ -39,8 +39,19 @@ from . import dom, fitness, genome, innerloop, operators, programme
_CHILD_INNER_KW: dict = {} _CHILD_INNER_KW: dict = {}
def _overrides_for(leaf_sharing: bool, superpose: bool) -> dict | None:
"""Run-level conf overrides for the native evaluator (None when all off)."""
ov: dict = {}
if leaf_sharing:
ov["leaf_sharing"] = True
if superpose:
ov["superpose"] = True
return ov or None
@functools.lru_cache(maxsize=None) @functools.lru_cache(maxsize=None)
def _fitness_for(programme_dir: str, leaf_sharing: bool = False) -> "fitness.Fitness": def _fitness_for(programme_dir: str, leaf_sharing: bool = False,
superpose: bool = False) -> "fitness.Fitness":
"""Cached Fitness evaluator per (programme dir, leaf_sharing) (config load is """Cached Fitness evaluator per (programme dir, leaf_sharing) (config load is
the cost). the cost).
@ -51,7 +62,7 @@ def _fitness_for(programme_dir: str, leaf_sharing: bool = False) -> "fitness.Fit
inner loop instead of reading the on-disk (sharing-free) patterns.config. inner loop instead of reading the on-disk (sharing-free) patterns.config.
Cached per process workers fork their own copy. Cached per process workers fork their own copy.
""" """
overrides = {"leaf_sharing": True} if leaf_sharing else None overrides = _overrides_for(leaf_sharing, superpose)
conf, cost = fitness.load_config(programme_dir, overrides=overrides) conf, cost = fitness.load_config(programme_dir, overrides=overrides)
return fitness.Fitness(conf, cost) return fitness.Fitness(conf, cost)
@ -125,7 +136,8 @@ def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw,
lineage: str, want_grade: bool = False, lineage: str, want_grade: bool = False,
feasibility_max_shape_fails: int | None = None, feasibility_max_shape_fails: int | None = None,
best_n_fails: int | None = None, best_n_fails: int | None = None,
leaf_sharing: bool = False) -> tuple[Individual, int]: leaf_sharing: bool = False,
superpose: bool = False) -> tuple[Individual, int]:
# §12.3 shape-feasibility pre-filter (homemaker-py-9gp.1): if even the best # §12.3 shape-feasibility pre-filter (homemaker-py-9gp.1): if even the best
# achievable (proportion-aware) geometry of this topology already has at least # achievable (proportion-aware) geometry of this topology already has at least
# as many shape fails as the incumbent's TOTAL fails — and exceeds the tunable # as many shape fails as the incumbent's TOTAL fails — and exceeds the tunable
@ -133,11 +145,11 @@ def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw,
# eval instead of spending the full inner-loop budget. The best_n_fails guard # eval instead of spending the full inner-loop budget. The best_n_fails guard
# makes the proxy safe: a topology whose shape-fail floor is still below the # makes the proxy safe: a topology whose shape-fail floor is still below the
# incumbent is never discarded. Pruned individuals are tagged and never admitted. # incumbent is never discarded. Pruned individuals are tagged and never admitted.
overrides = {"leaf_sharing": True} if leaf_sharing else None overrides = _overrides_for(leaf_sharing, superpose)
if (feasibility_max_shape_fails is not None and best_n_fails is not None): if (feasibility_max_shape_fails is not None and best_n_fails is not None):
pred = operators.predicted_shape_fails( pred = operators.predicted_shape_fails(
root, _reqs_for(str(programme_dir)), root, _reqs_for(str(programme_dir)),
_fitness_for(str(programme_dir), leaf_sharing)) _fitness_for(str(programme_dir), leaf_sharing, superpose))
if pred > feasibility_max_shape_fails and pred >= best_n_fails: if pred > feasibility_max_shape_fails and pred >= best_n_fails:
ind = Individual(root=root, fitness=0.0, n_fails=pred, ratios={}, ind = Individual(root=root, fitness=0.0, n_fails=pred, ratios={},
lineage=f"pruned/{lineage}", grade=0.0, lineage=f"pruned/{lineage}", grade=0.0,
@ -151,7 +163,8 @@ def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw,
# native eval per child (~1/child_budget overhead); skipped unless requested. # native eval per child (~1/child_budget overhead); skipped unless requested.
grade = 0.0 grade = 0.0
if want_grade: if want_grade:
_, _, grade = _fitness_for(str(programme_dir), leaf_sharing).score_with_grade( _, _, grade = _fitness_for(
str(programme_dir), leaf_sharing, superpose).score_with_grade(
copy.deepcopy(root)) copy.deepcopy(root))
ind = Individual(root=root, fitness=r.fitness, n_fails=r.n_fails, ind = Individual(root=root, fitness=r.fitness, n_fails=r.n_fails,
ratios=innerloop.ratio_map(root), lineage=lineage, ratios=innerloop.ratio_map(root), lineage=lineage,
@ -199,6 +212,7 @@ def search(
circ_divisor: int = 3, circ_divisor: int = 3,
leaf_sharing: bool = True, leaf_sharing: bool = True,
leaf_share_factor: int = 3, leaf_share_factor: int = 3,
superpose: bool = False,
depth_balanced: bool = True, depth_balanced: bool = True,
interior_outside: bool = True, interior_outside: bool = True,
outside_divisor: int = 3, outside_divisor: int = 3,
@ -371,7 +385,7 @@ def search(
best_nf = result.best.n_fails if result.best is not None else None best_nf = result.best.n_fails if result.best is not None else None
full = [ full = [
(root, programme_dir, urb_root, x0, budget_, kw_, lin, use_grade, (root, programme_dir, urb_root, x0, budget_, kw_, lin, use_grade,
mx, best_nf, leaf_sharing) mx, best_nf, leaf_sharing, superpose)
for root, x0, budget_, kw_, lin in tasks for root, x0, budget_, kw_, lin in tasks
] ]
if _pool is not None: if _pool is not None:
@ -442,7 +456,8 @@ def search(
x0=None, budget=seed_budget, x0=None, budget=seed_budget,
inner_kw={}, lineage="seed", inner_kw={}, lineage="seed",
want_grade=use_grade, want_grade=use_grade,
leaf_sharing=leaf_sharing) leaf_sharing=leaf_sharing,
superpose=superpose)
n_evals += used n_evals += used
admit(seed_ind, pop) admit(seed_ind, pop)
@ -551,6 +566,7 @@ def search_staged(
circ_divisor: int = 3, circ_divisor: int = 3,
leaf_sharing: bool = True, leaf_sharing: bool = True,
leaf_share_factor: int = 3, leaf_share_factor: int = 3,
superpose: bool = False,
depth_balanced: bool = True, depth_balanced: bool = True,
interior_outside: bool = True, interior_outside: bool = True,
outside_divisor: int = 3, outside_divisor: int = 3,
@ -605,6 +621,7 @@ def search_staged(
circ_divisor=circ_divisor, circ_divisor=circ_divisor,
leaf_sharing=leaf_sharing, leaf_sharing=leaf_sharing,
leaf_share_factor=leaf_share_factor, leaf_share_factor=leaf_share_factor,
superpose=superpose,
depth_balanced=depth_balanced, depth_balanced=depth_balanced,
interior_outside=interior_outside, interior_outside=interior_outside,
outside_divisor=outside_divisor) outside_divisor=outside_divisor)
@ -640,6 +657,7 @@ def search_staged(
circ_divisor=circ_divisor, circ_divisor=circ_divisor,
leaf_sharing=leaf_sharing, leaf_sharing=leaf_sharing,
leaf_share_factor=leaf_share_factor, leaf_share_factor=leaf_share_factor,
superpose=superpose,
depth_balanced=depth_balanced, depth_balanced=depth_balanced,
interior_outside=interior_outside, interior_outside=interior_outside,
outside_divisor=outside_divisor, outside_divisor=outside_divisor,
@ -684,6 +702,7 @@ def search_staged(
circ_divisor=circ_divisor, circ_divisor=circ_divisor,
leaf_sharing=leaf_sharing, leaf_sharing=leaf_sharing,
leaf_share_factor=leaf_share_factor, leaf_share_factor=leaf_share_factor,
superpose=superpose,
depth_balanced=depth_balanced, depth_balanced=depth_balanced,
interior_outside=interior_outside, interior_outside=interior_outside,
outside_divisor=outside_divisor, outside_divisor=outside_divisor,

View file

@ -84,6 +84,12 @@ def _parse_args(argv=None) -> argparse.Namespace:
"code iff its programme entry sets 'share: N>=2'); N>=2 = " "code iff its programme entry sets 'share: N>=2'); N>=2 = "
"share every sized code at grain N, with a code's explicit " "share every sized code at grain N, with a code's explicit "
"'share' overriding (share:1 opts out) (default: 3)") "'share' overriding (share:1 opts out) (default: 3)")
p.add_argument("--superpose", action=argparse.BooleanOptionalAction,
default=_env_bool("HOMEMAKER_SUPERPOSE", False),
help="type superposition (9o5): interchangeable codes (similar "
"requirements) form equivalence classes and each candidate "
"collapses every superposed leaf to its best in-class usage "
"before scoring (default: off)")
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)
@ -121,6 +127,7 @@ def main(argv=None) -> int:
print(f"rng seed : {args.seed}", file=sys.stderr) print(f"rng seed : {args.seed}", file=sys.stderr)
print(f"leaf sharing : {args.leaf_sharing} (factor={args.leaf_share_factor})", print(f"leaf sharing : {args.leaf_sharing} (factor={args.leaf_share_factor})",
file=sys.stderr) file=sys.stderr)
print(f"superpose : {args.superpose}", file=sys.stderr)
print(f"output : {out or 'stdout'}", file=sys.stderr, flush=True) print(f"output : {out or 'stdout'}", file=sys.stderr, flush=True)
seed_root = dom.load(str(seed_file)) seed_root = dom.load(str(seed_file))
@ -140,6 +147,7 @@ def main(argv=None) -> int:
n_workers=args.workers, n_workers=args.workers,
leaf_sharing=args.leaf_sharing, leaf_sharing=args.leaf_sharing,
leaf_share_factor=args.leaf_share_factor, leaf_share_factor=args.leaf_share_factor,
superpose=args.superpose,
log=lambda m: print(m, file=sys.stderr, flush=True), log=lambda m: print(m, file=sys.stderr, flush=True),
) )

View file

@ -208,6 +208,120 @@ class Fitness:
# share_edge_cap=False still reproduces the pre-flip control arm. # share_edge_cap=False still reproduces the pre-flip control arm.
cap = self.conf("share_edge_cap") cap = self.conf("share_edge_cap")
self._share_edge_cap = self._leaf_sharing if cap is None else bool(cap) self._share_edge_cap = self._leaf_sharing if cap is None else bool(cap)
# 9o5 type superposition (DESIGN.md §13/homemaker-py-9o5): default OFF.
# When on, interchangeable codes (similar requirements) form equivalence
# classes; each candidate's fitness re-types (collapses) every superposed
# leaf to its best in-class usage before scoring, so search optimises the
# condensed objective directly and the relaxation gap is removed.
self._superpose = bool(self.conf("superpose"))
from .programme import CLASS_CAP as _CLASS_CAP
self._class_cap = int(self.conf("superpose_class_cap") or _CLASS_CAP)
self._interchange_classes: list | None = None # lazily derived
# ------------------------------------------------------------------ #
# Type superposition + collapse (homemaker-py-9o5)
# ------------------------------------------------------------------ #
def interchange_classes(self) -> list:
"""Interchange equivalence classes (size>=2), derived once from the
programme and cached. Empty list when superposition has nothing to act
on, in which case the collapse is a no-op and scoring matches baseline."""
if self._interchange_classes is None:
from . import programme as _pr
reqs = self._programme or {}
self._interchange_classes = (
_pr.derive_interchange_classes(reqs) if reqs else []
)
return self._interchange_classes
def _usage_quality(self, leaf: Node, usage: str) -> float:
"""The usage-DEPENDENT part of a leaf's quality (size x width x
proportion) as if it were typed ``usage``. The remaining factors
(perpendicular, crinkliness, access) and value rate are usage-invariant
within a class, so this is the separable per-leaf collapse objective."""
orig = leaf.type
leaf.type = usage
try:
return (
self.quality_size(leaf)
* self.quality_width(leaf)
* self.quality_proportion(leaf)
)
finally:
leaf.type = orig
def _best_assignment(self, quality: list[list[float]]) -> list[tuple[int, int]]:
"""Maximum-total-quality matching of ``min(rows, cols)`` leaf->slot
pairs. Brute-forces <= C! permutations when the smaller side is within
the class cap (exact and tiny); otherwise solves the equivalent
linear-sum assignment (Hungarian) both give the optimum because the
objective is separable per leaf (§3 cost note)."""
rows = len(quality)
cols = len(quality[0]) if rows else 0
if rows == 0 or cols == 0:
return []
if min(rows, cols) <= self._class_cap:
import itertools
best: list[tuple[int, int]] = []
best_score = float("-inf")
if rows <= cols:
for sel in itertools.permutations(range(cols), rows):
s = sum(quality[r][sel[r]] for r in range(rows))
if s > best_score:
best_score = s
best = [(r, sel[r]) for r in range(rows)]
else:
for sel in itertools.permutations(range(rows), cols):
s = sum(quality[sel[c]][c] for c in range(cols))
if s > best_score:
best_score = s
best = [(sel[c], c) for c in range(cols)]
return best
from scipy.optimize import linear_sum_assignment
import numpy as np
ri, ci = linear_sum_assignment(-np.array(quality))
return list(zip(ri.tolist(), ci.tolist()))
def collapse_superposition(self, root: Node) -> None:
"""Re-type each superposed leaf to its best in-class usage (the per-eval
COLLAPSE, homemaker-py-9o5 §1). Runs on the UNMERGED tree before any
check, so counts/adjacency/quality downstream see the condensed types.
Per class: SUPPLY = leaves currently typed into the class; DEMAND = the
class codes expanded by their required counts. The optimal supply->demand
matching assigns each demand slot to the leaf that fits it best; surplus
supply leaves keep their type (a genuine over-supply that scoring still
penalises), unmet demand slots stay absent (a genuine missing room)."""
classes = self.interchange_classes()
if not classes:
return
prog = self._programme or {}
by_type: dict[str, list[Node]] = {}
for lvl in dom_mod.levels(root):
for leaf in lvl.leaves():
if leaf.type:
by_type.setdefault(leaf.type, []).append(leaf)
for cls in classes:
supply = [lf for code in cls for lf in by_type.get(code, [])]
if not supply:
continue
slots: list[str] = []
for code in sorted(cls):
cnt = prog[code].count if code in prog else 0
slots.extend([code] * max(0, cnt))
if not slots:
continue
# Weight each leaf's usage quality by its area: the condensed value is
# sum(quality * value_rate * area), and value_rate is constant within a
# class (all in-class codes are inside rooms), so area is the per-leaf
# weight that makes the matching maximise value, not just mean quality.
quality = [
[self._usage_quality(lf, s) * geometry.area(lf) for s in slots]
for lf in supply
]
for r, c in self._best_assignment(quality):
supply[r].type = slots[c]
def conf(self, key: str): def conf(self, key: str):
v = self._conf.get(key) v = self._conf.get(key)
@ -1072,6 +1186,11 @@ class Fitness:
programme = self._programme or {} programme = self._programme or {}
# 9o5 COLLAPSE: re-type superposed leaves to their best in-class usage
# before any check (no-op unless superposition is on and a class exists).
if self._superpose:
self.collapse_superposition(root)
# --- Phase 1: UNMERGED tree checks --- # --- Phase 1: UNMERGED tree checks ---
check_fails, missing = graph_mod.check_space_counts( check_fails, missing = graph_mod.check_space_counts(
root, programme, self._leaf_sharing, self._max_share) root, programme, self._leaf_sharing, self._max_share)

View file

@ -83,6 +83,91 @@ def load_programme(path: str) -> dict[str, SpaceReq]:
return _parse_spaces(conf) return _parse_spaces(conf)
# --------------------------------------------------------------------------- #
# Interchange equivalence classes (homemaker-py-9o5, type superposition)
# --------------------------------------------------------------------------- #
#
# A maximal group of codes whose leaf requirements are SIMILAR enough that one
# leaf is genuinely substitutable for any in-class usage. Derived as a pure
# function of the parsed programme (no hand-authored list on the happy path).
# Used by the superposition+collapse search relaxation: a leaf typed to any
# in-class code is left uncommitted during search and re-assigned to its best
# in-class usage at scoring time (fitness.collapse_superposition).
#
# Thresholds are LOCKED defaults (Bruno 2026-06-29); conservative on purpose —
# a missed grouping is cheap, a wrong one corrupts the relaxation.
R_SIZE = 1.5 # larger area target <= 1.5x smaller
R_WIDTH = 1.3 # clear-width targets vary less than areas; tighter band
R_PROP = 1.5 # max length/width aspect targets within 1.5x
CLASS_CAP = 4 # brute-force collapse <= C! assignments; beyond this use Hungarian
def _ratio(x: float, y: float) -> float:
"""max/min of two positive magnitudes (inf if either is non-positive)."""
lo, hi = min(abs(x), abs(y)), max(abs(x), abs(y))
return hi / lo if lo > 0 else float("inf")
def interchangeable(a: SpaceReq, b: SpaceReq) -> bool:
"""True iff codes ``a`` and ``b`` satisfy the S1-S4 interchange relation
(homemaker-py-9o5 §2). Symmetric."""
# S1 — both sized; generic circulation/outside never participate.
if not (a.has_size and b.has_size) or a.size <= 0 or b.size <= 0:
return False
if a.code[0].lower() in ("c", "o", "s") or b.code[0].lower() in ("c", "o", "s"):
return False
# S2 — requirement similarity within bounded ratios (ALL three).
if _ratio(a.size, b.size) > R_SIZE:
return False
if _ratio(a.width, b.width) > R_WIDTH:
return False
if _ratio(a.proportion, b.proportion) > R_PROP:
return False
# S3 — compatible level (equal or one None) and matching service stack.
if a.level is not None and b.level is not None and a.level != b.level:
return False
if (a.requires_below or None) != (b.requires_below or None):
return False
# S4 — no direct adjacency edge (an adjacency pair are coexisting rooms).
if b.code in a.adjacency or a.code in b.adjacency:
return False
return True
def derive_interchange_classes(reqs: dict[str, SpaceReq]) -> list[frozenset[str]]:
"""Connected components of the interchange relation, size >= 2
(homemaker-py-9o5 §2). Each class is a set of mutually-substitutable codes.
"""
codes = [
c for c, r in reqs.items()
if r.has_size and r.size > 0 and c[0].lower() not in ("c", "o", "s")
]
edges: dict[str, set[str]] = {c: set() for c in codes}
for i, a in enumerate(codes):
for b in codes[i + 1:]:
if interchangeable(reqs[a], reqs[b]):
edges[a].add(b)
edges[b].add(a)
seen: set[str] = set()
classes: list[frozenset[str]] = []
for c in codes:
if c in seen:
continue
comp: set[str] = set()
stack = [c]
while stack:
x = stack.pop()
if x in comp:
continue
comp.add(x)
seen.add(x)
stack.extend(edges[x] - comp)
if len(comp) >= 2:
classes.append(frozenset(comp))
return classes
def n_storeys_required(reqs: dict[str, SpaceReq]) -> int: def n_storeys_required(reqs: dict[str, SpaceReq]) -> int:
"""Number of storeys the programme implies, from the highest ``level:`` key. """Number of storeys the programme implies, from the highest ``level:`` key.

216
tests/test_superposition.py Normal file
View file

@ -0,0 +1,216 @@
"""Tests for type superposition + collapse (homemaker-py-9o5).
Covers the three layers of the feature:
1. interchange-class derivation (programme.derive_interchange_classes)
2. the per-eval collapse assignment (Fitness._best_assignment)
3. end-to-end collapse re-typing on a built tree (collapse_superposition)
plus the default-OFF guarantee.
"""
import pytest
from homemaker_layout import dom, geometry, programme
from homemaker_layout.dom import Node, _link_subtree
from homemaker_layout.fitness import Fitness
from homemaker_layout.programme import (
SpaceReq,
derive_interchange_classes,
interchangeable,
)
def _req(code, size, width=4.0, proportion=1.5, level=None,
requires_below=None, adjacency=None, count=1):
return SpaceReq(
code=code, size=size, width=width, proportion=proportion,
level=level, requires_below=requires_below,
adjacency=list(adjacency or []), count=count, has_size=True,
has_width=True, has_proportion=True,
)
# --------------------------------------------------------------------------- #
# Derivation
# --------------------------------------------------------------------------- #
def test_similar_pair_is_grouped():
# codes are first-letter-classed; c/o/s are generic and never participate,
# so use plain non-generic codes for the study/guest analogue
reqs = {"den": _req("den", 9.0), "guest": _req("guest", 12.0)}
assert derive_interchange_classes(reqs) == [frozenset({"den", "guest"})]
def test_dissimilar_size_not_grouped():
# 60 / 10 = 6x area, far outside R_SIZE
reqs = {"hall": _req("hall", 60.0), "wc": _req("wc", 10.0)}
assert derive_interchange_classes(reqs) == []
def test_width_band_is_tighter_than_size():
# sizes within R_SIZE but widths 4.0 vs 2.5 (1.6x) exceed R_WIDTH
reqs = {"a": _req("a", 10.0, width=4.0), "b": _req("b", 12.0, width=2.5)}
assert derive_interchange_classes(reqs) == []
def test_adjacency_pair_not_grouped():
# genuinely-similar requirements, but a required adjacency means they are
# two coexisting rooms, not one interchangeable leaf (S4)
reqs = {
"x": _req("x", 10.0, adjacency=["y"]),
"y": _req("y", 11.0),
}
assert derive_interchange_classes(reqs) == []
def test_service_stack_not_grouped_with_non_service():
# a wet-stack code (requires_below) never groups with a dry room (S3)
reqs = {
"bath": _req("bath", 10.0, requires_below="bath"),
"den": _req("den", 11.0),
}
assert derive_interchange_classes(reqs) == []
# ... but two matching-stack services do group
reqs2 = {
"bath1": _req("bath1", 10.0, requires_below="bath"),
"bath2": _req("bath2", 11.0, requires_below="bath"),
}
assert interchangeable(reqs2["bath1"], reqs2["bath2"])
def test_incompatible_levels_not_grouped():
reqs = {"a": _req("a", 10.0, level=0), "b": _req("b", 11.0, level=1)}
assert derive_interchange_classes(reqs) == []
# one level None is still compatible
reqs2 = {"a": _req("a", 10.0, level=0), "b": _req("b", 11.0, level=None)}
assert derive_interchange_classes(reqs2) == [frozenset({"a", "b"})]
def test_generic_codes_never_participate():
reqs = {"c": _req("c", 10.0), "o1": _req("o1", 11.0), "room": _req("room", 11.0)}
# c/o are circulation/outside — excluded; only one real code left -> no class
assert derive_interchange_classes(reqs) == []
def test_real_programme_house():
reqs = programme.load_programme_dir("examples/programme-house")
classes = {frozenset(c) for c in derive_interchange_classes(reqs)}
assert frozenset({"b1", "b2"}) in classes
assert frozenset({"t2", "t3"}) in classes
# --------------------------------------------------------------------------- #
# Assignment (collapse core)
# --------------------------------------------------------------------------- #
def _fit_super():
return Fitness(conf={"superpose": True})
def test_best_assignment_picks_max_diagonal():
fit = _fit_super()
# best matching is the diagonal (sum 27); any off-diagonal is worse
q = [[9, 1, 1], [1, 9, 1], [1, 1, 9]]
got = sorted(fit._best_assignment(q))
assert got == [(0, 0), (1, 1), (2, 2)]
def test_best_assignment_enumerates_all_permutations():
fit = _fit_super()
# the optimum is the anti-diagonal (10+10+10) — exercises the 3!=6 search
q = [[1, 2, 10], [2, 10, 2], [10, 2, 1]]
got = sorted(fit._best_assignment(q))
assert got == [(0, 2), (1, 1), (2, 0)]
def test_best_assignment_surplus_supply():
fit = _fit_super()
# 3 leaves, 2 demand slots -> only 2 pairs, drop the worst-fitting leaf row
q = [[10, 1], [1, 10], [0, 0]]
got = sorted(fit._best_assignment(q))
assert got == [(0, 0), (1, 1)]
def test_best_assignment_surplus_demand():
fit = _fit_super()
# 2 leaves, 3 demand slots -> 2 pairs covering the two best columns
q = [[10, 1, 0], [1, 10, 0]]
got = sorted(fit._best_assignment(q))
assert got == [(0, 0), (1, 1)]
def test_best_assignment_falls_back_to_hungarian_beyond_cap():
fit = Fitness(conf={"superpose": True, "superpose_class_cap": 1})
q = [[9, 1, 1], [1, 9, 1], [1, 1, 9]] # min dim 3 > cap 1 -> scipy path
got = sorted(fit._best_assignment(q))
assert got == [(0, 0), (1, 1), (2, 2)]
# --------------------------------------------------------------------------- #
# End-to-end collapse on a built tree
# --------------------------------------------------------------------------- #
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 _bedroom_conf(superpose=True):
return {
"superpose": superpose,
"spaces": {
"b1": {"size": [16.0, 4.0], "width": [4.0, 1.0],
"proportion": [1.5, 0.5], "count": 1},
"b2": {"size": [12.0, 3.0], "width": [3.5, 0.8],
"proportion": [1.5, 0.5], "count": 1},
},
}
def test_collapse_relabels_to_demand_set():
fit = Fitness(conf=_bedroom_conf())
# both leaves typed b1; areas 14.4 (left) and 21.6 (right)
root = _two_leaf_root("b1", "b1")
left, right = root.leaves()
assert geometry.area(right) > geometry.area(left)
fit.collapse_superposition(root)
# the demand set {b1, b2} is now covered, not two b1's
assert sorted(lf.type for lf in root.leaves()) == ["b1", "b2"]
# the larger leaf takes the larger target (b1=16), the smaller takes b2=12
assert right.type == "b1"
assert left.type == "b2"
def test_collapse_is_noop_without_a_class():
# only one real code -> no interchange class -> collapse must not touch types
conf = {"superpose": True,
"spaces": {"b1": {"size": [16.0, 4.0], "count": 2}}}
fit = Fitness(conf=conf)
root = _two_leaf_root("b1", "b1")
fit.collapse_superposition(root)
assert [lf.type for lf in root.leaves()] == ["b1", "b1"]
def test_superpose_default_off():
assert Fitness(conf=_bedroom_conf(superpose=False))._superpose is False
assert Fitness()._superpose is False
def test_superpose_off_does_not_relabel():
# with the flag off, _evaluate_full must never call collapse: a two-b1 tree
# keeps both labels through scoring (proxy: collapse only fires when on)
fit = Fitness(conf=_bedroom_conf(superpose=False))
root = _two_leaf_root("b1", "b1")
# collapse_superposition is gated by self._superpose in _evaluate_full; call
# the gate directly to document the contract
if fit._superpose:
fit.collapse_superposition(root)
assert [lf.type for lf in root.leaves()] == ["b1", "b1"]