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:
parent
87d309771e
commit
c3635634e8
6 changed files with 472 additions and 24 deletions
File diff suppressed because one or more lines are too long
|
|
@ -39,8 +39,19 @@ from . import dom, fitness, genome, innerloop, operators, programme
|
|||
_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)
|
||||
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
|
||||
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.
|
||||
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)
|
||||
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,
|
||||
feasibility_max_shape_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
|
||||
# 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
|
||||
|
|
@ -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
|
||||
# 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.
|
||||
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):
|
||||
pred = operators.predicted_shape_fails(
|
||||
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:
|
||||
ind = Individual(root=root, fitness=0.0, n_fails=pred, ratios={},
|
||||
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.
|
||||
grade = 0.0
|
||||
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))
|
||||
ind = Individual(root=root, fitness=r.fitness, n_fails=r.n_fails,
|
||||
ratios=innerloop.ratio_map(root), lineage=lineage,
|
||||
|
|
@ -199,6 +212,7 @@ def search(
|
|||
circ_divisor: int = 3,
|
||||
leaf_sharing: bool = True,
|
||||
leaf_share_factor: int = 3,
|
||||
superpose: bool = False,
|
||||
depth_balanced: bool = True,
|
||||
interior_outside: bool = True,
|
||||
outside_divisor: int = 3,
|
||||
|
|
@ -371,7 +385,7 @@ def search(
|
|||
best_nf = result.best.n_fails if result.best is not None else None
|
||||
full = [
|
||||
(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
|
||||
]
|
||||
if _pool is not None:
|
||||
|
|
@ -442,7 +456,8 @@ def search(
|
|||
x0=None, budget=seed_budget,
|
||||
inner_kw={}, lineage="seed",
|
||||
want_grade=use_grade,
|
||||
leaf_sharing=leaf_sharing)
|
||||
leaf_sharing=leaf_sharing,
|
||||
superpose=superpose)
|
||||
n_evals += used
|
||||
admit(seed_ind, pop)
|
||||
|
||||
|
|
@ -551,6 +566,7 @@ def search_staged(
|
|||
circ_divisor: int = 3,
|
||||
leaf_sharing: bool = True,
|
||||
leaf_share_factor: int = 3,
|
||||
superpose: bool = False,
|
||||
depth_balanced: bool = True,
|
||||
interior_outside: bool = True,
|
||||
outside_divisor: int = 3,
|
||||
|
|
@ -605,6 +621,7 @@ def search_staged(
|
|||
circ_divisor=circ_divisor,
|
||||
leaf_sharing=leaf_sharing,
|
||||
leaf_share_factor=leaf_share_factor,
|
||||
superpose=superpose,
|
||||
depth_balanced=depth_balanced,
|
||||
interior_outside=interior_outside,
|
||||
outside_divisor=outside_divisor)
|
||||
|
|
@ -640,6 +657,7 @@ def search_staged(
|
|||
circ_divisor=circ_divisor,
|
||||
leaf_sharing=leaf_sharing,
|
||||
leaf_share_factor=leaf_share_factor,
|
||||
superpose=superpose,
|
||||
depth_balanced=depth_balanced,
|
||||
interior_outside=interior_outside,
|
||||
outside_divisor=outside_divisor,
|
||||
|
|
@ -684,6 +702,7 @@ def search_staged(
|
|||
circ_divisor=circ_divisor,
|
||||
leaf_sharing=leaf_sharing,
|
||||
leaf_share_factor=leaf_share_factor,
|
||||
superpose=superpose,
|
||||
depth_balanced=depth_balanced,
|
||||
interior_outside=interior_outside,
|
||||
outside_divisor=outside_divisor,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,12 @@ def _parse_args(argv=None) -> argparse.Namespace:
|
|||
"code iff its programme entry sets 'share: N>=2'); N>=2 = "
|
||||
"share every sized code at grain N, with a code's explicit "
|
||||
"'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",
|
||||
help="output .dom path (- for stdout)")
|
||||
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"leaf sharing : {args.leaf_sharing} (factor={args.leaf_share_factor})",
|
||||
file=sys.stderr)
|
||||
print(f"superpose : {args.superpose}", file=sys.stderr)
|
||||
print(f"output : {out or 'stdout'}", file=sys.stderr, flush=True)
|
||||
|
||||
seed_root = dom.load(str(seed_file))
|
||||
|
|
@ -140,6 +147,7 @@ def main(argv=None) -> int:
|
|||
n_workers=args.workers,
|
||||
leaf_sharing=args.leaf_sharing,
|
||||
leaf_share_factor=args.leaf_share_factor,
|
||||
superpose=args.superpose,
|
||||
log=lambda m: print(m, file=sys.stderr, flush=True),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -208,6 +208,120 @@ class Fitness:
|
|||
# share_edge_cap=False still reproduces the pre-flip control arm.
|
||||
cap = self.conf("share_edge_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):
|
||||
v = self._conf.get(key)
|
||||
|
|
@ -1072,6 +1186,11 @@ class Fitness:
|
|||
|
||||
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 ---
|
||||
check_fails, missing = graph_mod.check_space_counts(
|
||||
root, programme, self._leaf_sharing, self._max_share)
|
||||
|
|
|
|||
|
|
@ -83,6 +83,91 @@ def load_programme(path: str) -> dict[str, SpaceReq]:
|
|||
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:
|
||||
"""Number of storeys the programme implies, from the highest ``level:`` key.
|
||||
|
||||
|
|
|
|||
216
tests/test_superposition.py
Normal file
216
tests/test_superposition.py
Normal 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"]
|
||||
Loading…
Add table
Reference in a new issue