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 = {}
|
_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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
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