2026-06-10 21:49:31 +01:00
|
|
|
"""Parse a ``patterns.config`` programme into per-code space requirements.
|
|
|
|
|
|
|
|
|
|
Only the ``spaces:`` section is read here. Generic codes (c/o/s) carry no
|
|
|
|
|
explicit targets and are left unconstrained by the solver (they absorb slack).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
|
|
|
|
import yaml
|
|
|
|
|
|
|
|
|
|
# Urb::Dom::Fitness defaults for optional params (ProgrammeDriven.default_params).
|
|
|
|
|
_DEFAULT_WIDTH = (4.0, 1.0)
|
|
|
|
|
_DEFAULT_PROPORTION = (1.5, 0.5)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class SpaceReq:
|
|
|
|
|
code: str
|
|
|
|
|
name: str = ""
|
|
|
|
|
size: float = 0.0 # target floor area, m^2
|
|
|
|
|
size_sigma: float = 1.0
|
|
|
|
|
width: float = _DEFAULT_WIDTH[0]
|
|
|
|
|
width_sigma: float = _DEFAULT_WIDTH[1]
|
|
|
|
|
proportion: float = _DEFAULT_PROPORTION[0] # max length/width ratio
|
|
|
|
|
proportion_sigma: float = _DEFAULT_PROPORTION[1]
|
|
|
|
|
adjacency: list[str] = field(default_factory=list)
|
|
|
|
|
level: int | None = None
|
|
|
|
|
requires_below: str | None = None
|
|
|
|
|
count: int = 1
|
2026-06-28 22:04:35 +01:00
|
|
|
# erc.3 §13.3 leaf-sharing grain (homemaker-py-x3b): how many rooms of this
|
|
|
|
|
# code may collapse into one shared leaf. Default 1 = not shareable. Under the
|
|
|
|
|
# global ``leaf_share_factor`` selector an explicit value overrides the global
|
|
|
|
|
# grain (share:1 opts a code OUT, share:N>=2 sets that code's grain to N).
|
|
|
|
|
share: int = 1
|
2026-07-17 16:53:20 +01:00
|
|
|
# 9o5 §7.5 veto hatch (homemaker-py-b3v): opt a code OUT of interchange-class
|
|
|
|
|
# derivation. Default True = eligible. Set ``interchange: false`` in
|
|
|
|
|
# patterns.config to let the architect suppress a harmful auto-derived
|
|
|
|
|
# grouping (e.g. harbor-house's transitive 8-code chain) without disabling
|
|
|
|
|
# superposition globally.
|
|
|
|
|
interchange: bool = True
|
2026-07-31 00:16:12 +01:00
|
|
|
# 1s3 §26 path b: codes this one may permanently co-locate on (fuse onto)
|
|
|
|
|
# one leaf with. Explicit, architect-declared (unlike the auto-derived
|
|
|
|
|
# interchange classes above) — a pair is only ever honoured if it *also*
|
|
|
|
|
# passes interchangeable()'s S1-S4 bounds (see derive_colocate_pairs).
|
|
|
|
|
co_locate: list[str] = field(default_factory=list)
|
2026-06-13 20:55:25 +01:00
|
|
|
# Whether each quality param was explicitly in the config (not a default)
|
|
|
|
|
has_size: bool = False
|
|
|
|
|
has_width: bool = False
|
|
|
|
|
has_proportion: bool = False
|
2026-06-28 22:04:35 +01:00
|
|
|
has_share: bool = False
|
2026-06-10 21:49:31 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _pair(d: dict, key: str, default: tuple[float, float]) -> tuple[float, float]:
|
|
|
|
|
v = d.get(key)
|
|
|
|
|
if v is None:
|
|
|
|
|
return default
|
|
|
|
|
return float(v[0]), float(v[1])
|
|
|
|
|
|
|
|
|
|
|
ju3: reject programme codes colliding with the c/o/s generic type prefixes
Closes homemaker-py-ju3. DESIGN.md §39.3.
The class: key from the bead's design was deliberately NOT built. Auditing
every use of the prefix rule first showed it runs deeper than c/o/s -- l/k/b/t
carry real adjacency semantics (graph.py builds bedroom<->toilet and
kitchen<->living relations from first characters) -- so re-plumbing the type
system would invalidate the whole corpus and every baseline, for a problem
whose damage is the silence, not the convention. Two findings made the smaller
fix sufficient: no corpus programme has ever declared a bare c/o/s code, so
check_space_counts' skip only ever discarded declared rooms; and nothing
references harbor's four codes in any adjacency or co_locate list.
- programme.validate_codes raises on a reserved-prefix code, with the full
explanation. Called from BOTH parse paths (programme._parse_spaces and
fitness.Fitness._load_programme parse conf["spaces"] independently, so
validating one would leave the other door open). l/k/b/t stay unreserved.
- harbor-house and harbor-house-l0 renamed: cr1->fr1, of->ao, st1->gs1,
st2->gs2. New prefixes are unused in harbor and semantically neutral, and
the two storage codes still share a prefix, preserving the structure
evaluate_building's per-code plot-ratio term depends on. name: unchanged.
- experiments/migrate_ju3_rename.py migrates .dom files written before the
rename (--check dry-runs). Pre-rename artefacts, notably evolved-3M*.dom,
must be migrated or their leaves read as unmatched generics.
- test_collapse_global's c/o/s exclusion test now uses a generic C leaf, which
is what the exclusion is actually for; it previously relied on a programme
code colliding, which is no longer possible.
Re-baseline (seed 1, 20k evals, same settings as §38's run): 57 fails against
the 32-instance effective programme -> 55 against the real 37-instance one,
with all five previously-lost room instances now placed inside their declared
sigma bands (fr1 87.2 vs declared 80, was 32.9/17.1; ao/gs1/gs2 were absent
entirely) and no failure naming any of the four codes. At one seed each,
57 vs 55 is within noise -- the robust result is the room placement, not the
count. Historical harbor numbers are not comparable to post-ju3 ones; filed
homemaker-py-t3s to restate 2v1's acceptance figure once evolved-3M is
migrated.
346 passed (+10 new), same 7 pre-existing fixture failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 09:06:08 +00:00
|
|
|
# homemaker-py-ju3 (DESIGN.md §39.2): Urb's type system is prefix-based — a
|
|
|
|
|
# leaf type starting with "c" is circulation, "o"/"s" is outside — and
|
|
|
|
|
# programme room codes share that namespace. A code starting with one of these
|
|
|
|
|
# letters is silently reinterpreted as a generic type, with three unannounced
|
|
|
|
|
# consequences: ``graph.check_space_counts`` skips it entirely (so the room is
|
|
|
|
|
# never required), ``Fitness.get_space_params`` returns the generic
|
|
|
|
|
# ``*_circulation``/``*_outside`` parameters instead of the declared ones, and
|
|
|
|
|
# ``dom.is_circulation``/``is_outside`` flip (changing value rate, crinkliness
|
|
|
|
|
# treatment, and whether the leaf supplies daylight to neighbours).
|
|
|
|
|
#
|
|
|
|
|
# harbor-house shipped four such codes for years — ``cr1`` "Common Room with
|
|
|
|
|
# Fireplace" had its declared 80 m² read as circulation's 0-30 m², and 14% of
|
|
|
|
|
# the programme was silently optional. Refusing the code at load turns a silent
|
|
|
|
|
# misread into a loud one. The other prefixes with semantics (l/k/b/t) are NOT
|
|
|
|
|
# reserved: they only flavour adjacency heuristics and never discard a
|
|
|
|
|
# requirement, so programme codes may use them freely.
|
|
|
|
|
RESERVED_PREFIXES = ("c", "o", "s")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_codes(codes) -> None:
|
|
|
|
|
"""Raise ``ValueError`` if any programme code collides with a generic type.
|
|
|
|
|
|
|
|
|
|
Called from both parse paths (``_parse_spaces`` and
|
|
|
|
|
``fitness.Fitness._load_programme``) so a colliding code cannot enter the
|
|
|
|
|
system through either door.
|
|
|
|
|
"""
|
|
|
|
|
bad = sorted(c for c in codes if c and c[0].lower() in RESERVED_PREFIXES)
|
|
|
|
|
if not bad:
|
|
|
|
|
return
|
|
|
|
|
raise ValueError(
|
|
|
|
|
"programme code(s) collide with Urb's reserved generic type prefixes "
|
|
|
|
|
f"{RESERVED_PREFIXES} (c=circulation, o/s=outside): {bad}. "
|
|
|
|
|
"Such a code is silently treated as a generic type: it is dropped from "
|
|
|
|
|
"the required-space check, its declared size/width/proportion are "
|
|
|
|
|
"replaced by the generic circulation/outside parameters, and it is "
|
|
|
|
|
"valued at the circulation/outside rate. Rename the code to start with "
|
|
|
|
|
"another letter (the name: field is free text and need not change). "
|
|
|
|
|
"See DESIGN.md §39.2 / homemaker-py-ju3."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 07:50:39 +01:00
|
|
|
def _parse_spaces(conf: dict) -> dict[str, SpaceReq]:
|
2026-06-10 21:49:31 +01:00
|
|
|
spaces = conf.get("spaces") or {}
|
ju3: reject programme codes colliding with the c/o/s generic type prefixes
Closes homemaker-py-ju3. DESIGN.md §39.3.
The class: key from the bead's design was deliberately NOT built. Auditing
every use of the prefix rule first showed it runs deeper than c/o/s -- l/k/b/t
carry real adjacency semantics (graph.py builds bedroom<->toilet and
kitchen<->living relations from first characters) -- so re-plumbing the type
system would invalidate the whole corpus and every baseline, for a problem
whose damage is the silence, not the convention. Two findings made the smaller
fix sufficient: no corpus programme has ever declared a bare c/o/s code, so
check_space_counts' skip only ever discarded declared rooms; and nothing
references harbor's four codes in any adjacency or co_locate list.
- programme.validate_codes raises on a reserved-prefix code, with the full
explanation. Called from BOTH parse paths (programme._parse_spaces and
fitness.Fitness._load_programme parse conf["spaces"] independently, so
validating one would leave the other door open). l/k/b/t stay unreserved.
- harbor-house and harbor-house-l0 renamed: cr1->fr1, of->ao, st1->gs1,
st2->gs2. New prefixes are unused in harbor and semantically neutral, and
the two storage codes still share a prefix, preserving the structure
evaluate_building's per-code plot-ratio term depends on. name: unchanged.
- experiments/migrate_ju3_rename.py migrates .dom files written before the
rename (--check dry-runs). Pre-rename artefacts, notably evolved-3M*.dom,
must be migrated or their leaves read as unmatched generics.
- test_collapse_global's c/o/s exclusion test now uses a generic C leaf, which
is what the exclusion is actually for; it previously relied on a programme
code colliding, which is no longer possible.
Re-baseline (seed 1, 20k evals, same settings as §38's run): 57 fails against
the 32-instance effective programme -> 55 against the real 37-instance one,
with all five previously-lost room instances now placed inside their declared
sigma bands (fr1 87.2 vs declared 80, was 32.9/17.1; ao/gs1/gs2 were absent
entirely) and no failure naming any of the four codes. At one seed each,
57 vs 55 is within noise -- the robust result is the room placement, not the
count. Historical harbor numbers are not comparable to post-ju3 ones; filed
homemaker-py-t3s to restate 2v1's acceptance figure once evolved-3M is
migrated.
346 passed (+10 new), same 7 pre-existing fixture failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 09:06:08 +00:00
|
|
|
validate_codes(spaces)
|
2026-06-10 21:49:31 +01:00
|
|
|
out: dict[str, SpaceReq] = {}
|
|
|
|
|
for code, c in spaces.items():
|
|
|
|
|
size = _pair(c, "size", (0.0, 1.0))
|
|
|
|
|
width = _pair(c, "width", _DEFAULT_WIDTH)
|
|
|
|
|
prop = _pair(c, "proportion", _DEFAULT_PROPORTION)
|
|
|
|
|
out[code] = SpaceReq(
|
|
|
|
|
code=code,
|
|
|
|
|
name=c.get("name", ""),
|
|
|
|
|
size=size[0],
|
|
|
|
|
size_sigma=size[1],
|
|
|
|
|
width=width[0],
|
|
|
|
|
width_sigma=width[1],
|
|
|
|
|
proportion=prop[0],
|
|
|
|
|
proportion_sigma=prop[1],
|
|
|
|
|
adjacency=list(c.get("adjacency") or []),
|
|
|
|
|
level=c.get("level"),
|
|
|
|
|
requires_below=c.get("requires_below"),
|
|
|
|
|
count=int(c.get("count") or 1),
|
2026-06-28 22:04:35 +01:00
|
|
|
share=int(c.get("share") or 1),
|
2026-07-17 16:53:20 +01:00
|
|
|
interchange=bool(c.get("interchange", True)),
|
2026-07-31 00:16:12 +01:00
|
|
|
co_locate=list(c.get("co_locate") or []),
|
2026-06-13 20:55:25 +01:00
|
|
|
has_size="size" in c,
|
|
|
|
|
has_width="width" in c,
|
|
|
|
|
has_proportion="proportion" in c,
|
2026-06-28 22:04:35 +01:00
|
|
|
has_share="share" in c,
|
2026-06-10 21:49:31 +01:00
|
|
|
)
|
|
|
|
|
return out
|
2026-06-14 07:50:39 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_programme(path: str) -> dict[str, SpaceReq]:
|
|
|
|
|
with open(path) as fh:
|
|
|
|
|
conf = yaml.safe_load(fh)
|
|
|
|
|
return _parse_spaces(conf)
|
|
|
|
|
|
|
|
|
|
|
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>
2026-06-30 07:08:46 +01:00
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# 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."""
|
2026-07-17 16:53:20 +01:00
|
|
|
# S0 — architect veto (homemaker-py-b3v): either code opted out.
|
|
|
|
|
if not a.interchange or not b.interchange:
|
|
|
|
|
return False
|
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>
2026-06-30 07:08:46 +01:00
|
|
|
# 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()
|
2026-07-17 16:53:20 +01:00
|
|
|
if r.interchange
|
|
|
|
|
and r.has_size and r.size > 0 and c[0].lower() not in ("c", "o", "s")
|
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>
2026-06-30 07:08:46 +01:00
|
|
|
]
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 00:16:12 +01:00
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# Co-location pairs (homemaker-py-1s3, §26 path b: permanent multi-use leaves)
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
#
|
|
|
|
|
# Unlike interchange classes (auto-derived, soft substitution), fusing two
|
|
|
|
|
# codes onto one permanent leaf is architect-declared per-code (``co_locate``)
|
|
|
|
|
# — but a declared pair is only ever honoured if it ALSO passes the existing
|
|
|
|
|
# interchangeable() S1-S4 relation. This reuses the already-validated bounds
|
|
|
|
|
# (size/width/proportion similarity, compatible level/service stack, no direct
|
|
|
|
|
# adjacency edge) instead of inventing a second relation, and — because pairs
|
|
|
|
|
# are kept individually rather than folded into connected components — sidesteps
|
|
|
|
|
# the b3v transitive-chain failure mode (a nonsensical A-C fusion can never be
|
|
|
|
|
# smuggled in via a declared A-B, B-C chain the way interchange classes could).
|
|
|
|
|
|
|
|
|
|
def derive_colocate_pairs(reqs: dict[str, SpaceReq]) -> list[frozenset[str]]:
|
|
|
|
|
"""Valid co-location pairs: architect-declared AND interchangeable().
|
|
|
|
|
|
|
|
|
|
A code may declare ``co_locate: [other_code, ...]``; declaration is
|
|
|
|
|
symmetric (either side declaring is sufficient). Returns one frozenset per
|
|
|
|
|
valid pair (size always 2); an entry with no interchangeable() partner is
|
|
|
|
|
silently dropped, never merged into a larger group.
|
|
|
|
|
"""
|
|
|
|
|
pairs: list[frozenset[str]] = []
|
|
|
|
|
seen: set[frozenset[str]] = set()
|
|
|
|
|
for code, req in reqs.items():
|
|
|
|
|
for other in req.co_locate:
|
|
|
|
|
if other not in reqs or other == code:
|
|
|
|
|
continue
|
|
|
|
|
key = frozenset((code, other))
|
|
|
|
|
if key in seen:
|
|
|
|
|
continue
|
|
|
|
|
seen.add(key)
|
|
|
|
|
if interchangeable(reqs[code], reqs[other]):
|
|
|
|
|
pairs.append(key)
|
|
|
|
|
return pairs
|
|
|
|
|
|
|
|
|
|
|
Phase 6 §11.3: staged per-floor search (c4c.3)
Search the genome in causal dependency order. Stage 1 evolves a single-storey
base over the level-0 room set (programme auto-derived to a tempdir), ranked
with a substrate-readiness bonus (reserved core × divisible capacity) so the
base is selected as a good substrate, not just a good ground floor (anti-§4.2).
Stage 2 lifts the best base into a full multi-storey design — preserving the
inherited core, instantiating each upper storey's required set by construction —
and searches the deltas with the base mutable at low probability (base_p=0.15).
New: programme.{n_storeys_required,partition_rooms_by_storey,write_stage1_programme},
graph.substrate_readiness, operators.{lift_base_to_storeys,_pick_weighted_by_storey},
base_p threading, driver.search rank_bonus_fn/seed_factory/base_p hooks +
search_staged orchestrator, experiments/run_staged_search.py, tests/test_staging.py.
Result (harbor, 20000 evals, seed 0): staged 95 fails vs single-stage 105
(-10, -9.5%), gain in crinkliness 27->18 + edge 12->8. Anti-bungalow confirmed
(Stage-2 core moves all noop — core inherited, not carved). Programme-house
regression PASS (warmstart-2f4 still reaches whole-pop 1-fail).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 06:05:53 +01:00
|
|
|
def n_storeys_required(reqs: dict[str, SpaceReq]) -> int:
|
|
|
|
|
"""Number of storeys the programme implies, from the highest ``level:`` key.
|
|
|
|
|
|
|
|
|
|
Level-free rooms (no ``level``) do not force extra storeys — they are
|
|
|
|
|
distributed across whatever storeys the level-constrained rooms require.
|
|
|
|
|
"""
|
|
|
|
|
levels = [r.level for r in reqs.values() if r.level is not None]
|
|
|
|
|
return (max(levels) + 1) if levels else 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def partition_rooms_by_storey(
|
|
|
|
|
reqs: dict[str, SpaceReq], n_storeys: int, rng,
|
|
|
|
|
) -> list[dict[str, int]]:
|
|
|
|
|
"""Per-storey required-room multisets (DESIGN.md §11.3 staging).
|
|
|
|
|
|
|
|
|
|
Level-constrained rooms land on their required storey; level-free rooms are
|
|
|
|
|
distributed round-robin over a shuffled order across all storeys. Generic
|
|
|
|
|
circulation/outside/sahn codes are excluded (they are added per storey at
|
|
|
|
|
construction time). Mirrors the inline partition in
|
|
|
|
|
``operators.constructive_topology`` so Stage 1 (base) and Stage 2 (upper
|
|
|
|
|
deltas) draw from one consistent partition.
|
|
|
|
|
|
|
|
|
|
Returns a list of length ``n_storeys``; each entry maps room code -> count.
|
|
|
|
|
"""
|
|
|
|
|
buckets: list[dict[str, int]] = [{} for _ in range(n_storeys)]
|
|
|
|
|
|
|
|
|
|
def _add(li: int, code: str) -> None:
|
|
|
|
|
buckets[li][code] = buckets[li].get(code, 0) + 1
|
|
|
|
|
|
|
|
|
|
free: list[str] = []
|
|
|
|
|
for code, req in reqs.items():
|
|
|
|
|
if code[0].lower() in ("c", "o", "s"):
|
|
|
|
|
continue
|
|
|
|
|
for _ in range(req.count):
|
|
|
|
|
if req.level is not None and req.level < n_storeys:
|
|
|
|
|
_add(req.level, code)
|
|
|
|
|
else:
|
|
|
|
|
free.append(code)
|
|
|
|
|
free = [free[i] for i in rng.permutation(len(free))]
|
|
|
|
|
for i, code in enumerate(free):
|
|
|
|
|
_add(i % n_storeys, code)
|
|
|
|
|
return buckets
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_stage1_programme(
|
|
|
|
|
full_dir: str | Path, out_dir: str | Path, base_codes: dict[str, int],
|
|
|
|
|
) -> Path:
|
|
|
|
|
"""Derive a single-storey base-floor programme (DESIGN.md §11.3 Stage 1).
|
|
|
|
|
|
|
|
|
|
Filters the full merged ``patterns.config`` down to the rooms assigned to the
|
|
|
|
|
base floor (``base_codes``: code -> count), drops their ``level:`` keys,
|
|
|
|
|
prunes each kept space's ``adjacency`` to references that survive (retained
|
|
|
|
|
codes or generic c/o/s), and forces single-storey building constraints. The
|
|
|
|
|
result is written as a *self-contained* ``patterns.config`` in ``out_dir`` so
|
|
|
|
|
``fitness.load_config``'s parent-dir merge contributes nothing — keep
|
|
|
|
|
``out_dir`` outside the corpus tree (e.g. a tempdir).
|
|
|
|
|
|
|
|
|
|
Returns ``out_dir`` as a ``Path``.
|
|
|
|
|
"""
|
|
|
|
|
from pathlib import Path as _Path
|
|
|
|
|
|
|
|
|
|
from . import fitness as _fit
|
|
|
|
|
|
|
|
|
|
out_dir = _Path(out_dir)
|
|
|
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
conf, _cost = _fit.load_config(full_dir)
|
|
|
|
|
|
|
|
|
|
keep = set(base_codes)
|
|
|
|
|
src_spaces = conf.get("spaces") or {}
|
|
|
|
|
new_spaces: dict = {}
|
|
|
|
|
for code, count in base_codes.items():
|
|
|
|
|
if code not in src_spaces:
|
|
|
|
|
continue
|
|
|
|
|
spec = dict(src_spaces[code])
|
|
|
|
|
spec.pop("level", None)
|
|
|
|
|
spec["count"] = count
|
|
|
|
|
adj = spec.get("adjacency")
|
|
|
|
|
if adj is not None:
|
|
|
|
|
spec["adjacency"] = [
|
|
|
|
|
r for r in adj if r in keep or r[0].lower() in ("c", "o", "s")
|
|
|
|
|
]
|
|
|
|
|
new_spaces[code] = spec
|
|
|
|
|
|
|
|
|
|
new_conf = {k: v for k, v in conf.items() if k != "spaces"}
|
|
|
|
|
new_conf["spaces"] = new_spaces
|
|
|
|
|
new_conf.update(
|
|
|
|
|
storey_minimum=1, storey_limit=1, staircase_min=1, staircase_max=1,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with open(out_dir / "patterns.config", "w") as fh:
|
|
|
|
|
yaml.safe_dump(new_conf, fh, sort_keys=False, default_flow_style=False)
|
|
|
|
|
return out_dir
|
|
|
|
|
|
|
|
|
|
|
Phase 7 §12.2: proportion-aware constructive seeding + storey_minimum fix (leu.2, cq1)
Size each constructive-seed cut from leaf TARGET areas (division=[f,f] gives
left area-fraction f) and pick each cut's rotation for child squareness — both
derived from target dims, topology/type assignment untouched. Area-only
regressed (slivers); rotation choice is what makes it pay.
End-to-end (20000 evals, 3 seeds, staged): harbor 85.3->74.0 (-13%, best 69),
maple-court 151.7->136.0 (-10%, best 126). PROP=0 reproduces the §11.7/§12.1
baselines exactly. programme-house regresses at fixed budget (deeper local
optimum walls off the undivide restructuring path) but a budget sweep shows
it's convergence speed, not a worse asymptote (PROP=1 reaches 1 fail at 150k).
Default-on (seed_proportion_aware=True, env PROP=1).
cq1: n_storeys now honours storey_minimum, not just level: keys — programme-house
(storey_minimum:2, all rooms level:0) was seeded one storey short and fell
through to plain search. New programme.storey_minimum()/n_storeys_for();
driver.search passes min_storeys to the seeder; search_staged routes on the max.
No-op for harbor/maple; programme-house single-stage 8.0->5.0.
New maple-court best (126) saved as generated.dom. 204 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:04:42 +01:00
|
|
|
def _load_merged_conf(directory: "str | Path") -> dict:
|
|
|
|
|
"""Merge ``../patterns.config`` then the local one (mirrors load_config)."""
|
2026-06-14 07:50:39 +01:00
|
|
|
from pathlib import Path as _Path
|
|
|
|
|
directory = _Path(directory)
|
|
|
|
|
conf: dict = {}
|
|
|
|
|
for p in (directory.parent / "patterns.config", directory / "patterns.config"):
|
|
|
|
|
if p.is_file():
|
|
|
|
|
with open(p) as fh:
|
|
|
|
|
conf.update(yaml.safe_load(fh) or {})
|
Phase 7 §12.2: proportion-aware constructive seeding + storey_minimum fix (leu.2, cq1)
Size each constructive-seed cut from leaf TARGET areas (division=[f,f] gives
left area-fraction f) and pick each cut's rotation for child squareness — both
derived from target dims, topology/type assignment untouched. Area-only
regressed (slivers); rotation choice is what makes it pay.
End-to-end (20000 evals, 3 seeds, staged): harbor 85.3->74.0 (-13%, best 69),
maple-court 151.7->136.0 (-10%, best 126). PROP=0 reproduces the §11.7/§12.1
baselines exactly. programme-house regresses at fixed budget (deeper local
optimum walls off the undivide restructuring path) but a budget sweep shows
it's convergence speed, not a worse asymptote (PROP=1 reaches 1 fail at 150k).
Default-on (seed_proportion_aware=True, env PROP=1).
cq1: n_storeys now honours storey_minimum, not just level: keys — programme-house
(storey_minimum:2, all rooms level:0) was seeded one storey short and fell
through to plain search. New programme.storey_minimum()/n_storeys_for();
driver.search passes min_storeys to the seeder; search_staged routes on the max.
No-op for harbor/maple; programme-house single-stage 8.0->5.0.
New maple-court best (126) saved as generated.dom. 204 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:04:42 +01:00
|
|
|
return conf
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_programme_dir(directory: str | Path) -> dict[str, SpaceReq]:
|
|
|
|
|
"""Load programme from a directory, merging parent patterns.config as base.
|
|
|
|
|
|
|
|
|
|
Mirrors urb-evolve.pl: ../patterns.config loaded first, then the local
|
|
|
|
|
file's top-level keys override it (same shallow-merge as fitness.load_config).
|
|
|
|
|
"""
|
|
|
|
|
return _parse_spaces(_load_merged_conf(directory))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def storey_minimum(directory: str | Path) -> int:
|
|
|
|
|
"""Minimum storey count the programme requires (``storey_minimum`` key).
|
|
|
|
|
|
|
|
|
|
Independent of ``level:`` keys: a programme can demand N storeys via
|
|
|
|
|
``storey_minimum`` without pinning any room to an upper floor (e.g.
|
|
|
|
|
programme-house: ``storey_minimum: 2`` but all rooms ``level: 0``). The
|
|
|
|
|
constructive seeder and the staged/plain dispatch must honour it, else the
|
|
|
|
|
seed is built one storey short and fitness fires a ``storey minimum`` fail the
|
|
|
|
|
search has to repair structurally (DESIGN.md §12.2).
|
|
|
|
|
"""
|
|
|
|
|
return int(_load_merged_conf(directory).get("storey_minimum") or 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def n_storeys_for(directory: str | Path) -> int:
|
|
|
|
|
"""Storeys the programme implies: the max of level-derived and storey_minimum."""
|
|
|
|
|
reqs = load_programme_dir(directory)
|
|
|
|
|
return max(n_storeys_required(reqs), storey_minimum(directory))
|