docs: DESIGN.md §26/§27 — backfill 9o5/xi7/b3v (type superposition) and mi7 (bubble-diagram signal)
Two closed, substantive experiments were missing their DESIGN.md write-up despite being referenced as prior art by later sections: - 9o5/xi7/b3v (closed 2026-06-30/07-17): multi-use-leaf type superposition, a full feature build + real A/B validation (negative — OFF beats ON on both programme-house and harbor-house) + a veto-hatch follow-up for the one genuine false-positive interchange class found. §17 and §20 both cite its verdict directly but it never got its own section. - mi7 (closed 2026-07-25): 3D bubble-diagram / topological-hop-distance fitness signal prototype, tested against real evolved trajectories on two programmes, both formulations null. bubble.py was left in the tree uncommitted "as documented reference" by the closing session -- committing it now (with two trivial ruff fixes: unused import, ambiguous var name) so the reference this write-up makes to it is actually resolvable, plus a CLAUDE.md module-list entry. Numbered §26/§27 (appended, not inserted chronologically) to avoid renumbering every cross-reference in §14-§25. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
5c8a5a5e09
commit
9c6b1552eb
3 changed files with 537 additions and 0 deletions
|
|
@ -80,6 +80,7 @@ Key modules:
|
|||
- `driver.py` — memetic search outer loop
|
||||
- `evolve.py` — `homemaker-evolve` CLI entry point
|
||||
- `oracle.py` — legacy Perl shim, kept for validation only; do not use in new code
|
||||
- `bubble.py` — 3D bubble-diagram adjacency fitness-signal prototype (DESIGN.md §27, `mi7`); validated NULL, not wired into `fitness.py` — reference only, do not build on without a new formulation
|
||||
|
||||
## Conventions & Patterns
|
||||
|
||||
|
|
|
|||
135
DESIGN.md
135
DESIGN.md
|
|
@ -3000,3 +3000,138 @@ left off pending a broader sweep and the `evolve.py` wiring above (`homemaker-py
|
|||
standalone today via `homemaker-collapse --local-search` given the keep-better guard. Tests:
|
||||
`tests/test_collapse_global.py` gains `test_two_opt_polish_escapes_jacobi_plateau` (7 total in that file);
|
||||
298/298 pass project-wide.
|
||||
|
||||
## 26. Multi-use leaves / type superposition (`homemaker-py-9o5`/`xi7`/`b3v`) — DONE (negative), backfilled
|
||||
|
||||
*Closed 2026-06-30 (`9o5`, `xi7`) / 2026-07-17 (`b3v`); written up retroactively — this section was
|
||||
missing when §17/§20 above were written, even though both reference its verdict directly ("mirrors
|
||||
9o5", "the opposite of the 9o5/xi7 verdict"). Numbered at the end of the log rather than renumbering
|
||||
§14-§25 to preserve every existing cross-reference.*
|
||||
|
||||
**Motivation.** A leaf that legitimately serves several DIFFERENT compatible programme codes at once
|
||||
(study+guest bedroom, kitchen+dining — Stewart Brand's "loose-fit" long-life rooms), distinct from
|
||||
§13.3 leaf-sharing which aggregates *k* instances of the *same* code. Two readings were scoped: (a)
|
||||
superposition as a SEARCH RELAXATION — carry an uncommitted set of candidate types per leaf during
|
||||
search, collapse (argmax re-type) to specific usages only at scoring time, for a smoother landscape;
|
||||
(b) multi-use as the permanent DESIGN GOAL, surviving into the output with no collapse. Path (a) was
|
||||
built and validated (below); path (b) was never started.
|
||||
|
||||
**Mechanism (path a, built).** `programme.derive_interchange_classes`: codes form an equivalence class
|
||||
(connected component, size ≥ 2) under a symmetric `interchangeable()` relation — S1 both sized and
|
||||
non-generic (no `c`/`o`/`s`), S2 size/width/proportion targets within LOCKED ratio bounds (`R_SIZE=1.5`,
|
||||
`R_WIDTH=1.3`, `R_PROP=1.5`), S3 compatible level and service stack, S4 no direct required-adjacency
|
||||
edge between the two codes (adjacency pairs are coexisting rooms, not one substitutable leaf). Pure
|
||||
function of the parsed programme — classes are auto-derived, no hand-authored list needed on the happy
|
||||
path. `Fitness.collapse_superposition` re-types every superposed leaf to its best in-class usage each
|
||||
eval, before any check: per class, an optimal supply (leaves currently in the class) → demand (class
|
||||
codes × required count) matching, area-weighted usage quality as the objective (brute-force ≤`CLASS_CAP`
|
||||
= 4! permutations, else scipy Hungarian — the same `_best_assignment` §17/§25 later reuse at global
|
||||
scope). Runs on the UNMERGED tree, so counts/adjacency/quality downstream see the condensed types with
|
||||
**no changes needed** to `graph.py`/`dom.py`/`operators.py` — the key design realisation was that
|
||||
because collapse re-types at eval time, `Node` never needs a persisted class/serves field and no
|
||||
mutation operator needs a "retype within class" move; the genome can carry *any* in-class type and
|
||||
collapse fixes it. Gated behind `superpose` (default OFF, bit-identical when off — verified against
|
||||
233 pre-existing tests). `tests/test_superposition.py` (20): derivation (service/adjacency/level
|
||||
guards, the real programme-house programme), assignment (brute force + Hungarian + surplus supply/demand),
|
||||
end-to-end collapse re-typing, veto-hatch behaviour.
|
||||
|
||||
**A/B verdict (`xi7`, measured 2026-06-30) — NULL/NEGATIVE.** Equal-budget `--superpose` ON vs OFF,
|
||||
measuring the COLLAPSED (final) score:
|
||||
- **programme-house** (`init.dom`, budget 3000, 4 workers, seeds 1–5): OFF wins 4/5 (s1 8f>10f, s2
|
||||
11f>12f, s3 10f>12f, s4 10f>10f-tied-fitness — all OFF strictly better or equal fails), ON wins only
|
||||
s5 (10f→8f).
|
||||
- **harbor-house** (`init.dom`, budget 2500, seeds 1–3): OFF wins 2/3 (s2 33f<38f, s3 43f<48f); ON
|
||||
wins s1 alone (50f<51f).
|
||||
- Superposition does **not** reach better layouts; in most seeds ON has ≥ OFF fails — the per-eval
|
||||
collapse re-typing perturbs counts/adjacency rather than smoothing the search, the same failure
|
||||
mode later sections would call "landscape flattening."
|
||||
|
||||
**Relaxation-gap instrumentation (`xi7` §7.4) — ruled OUT as the cause.** Logged relaxed (unconstrained
|
||||
best-case usage-quality) vs collapsed value on the same matched leaves across the 5 programme-house ON
|
||||
runs: total `gap_ratio` 1.01–1.23 (per-class peaks up to 1.52) — small-to-moderate, not the large gap
|
||||
the original risk note feared. Because collapse is *per-eval*, there is no separate relaxed phase to
|
||||
diverge from — search already optimises the collapsed objective by construction. **Conclusion: path
|
||||
(a) underperforms not from a relaxation gap but because the geometry floor (§11–§13) dominates** — type
|
||||
labels are not the binding constraint on these programmes, so easing them buys nothing while the
|
||||
re-typing adds feasibility noise. This is the diagnosis §20 (`qpk`) later cites when arguing its own
|
||||
in-search collapse is a *different* mechanism (a hard-constraint-respecting global relabel, not a
|
||||
per-class relaxation over interchangeable-but-not-identical codes) and so isn't pre-falsified by this
|
||||
verdict.
|
||||
|
||||
**Veto hatch (`b3v`, closed 2026-07-17) — the one real false-positive found.** Harbor-house's programme
|
||||
auto-derives a **transitive 8-code chain** `{da1,ef1,k1,la1,m,me1,n,ws1}` spanning a 6× size range
|
||||
(Meeting 10 m² .. Dining/Neighbourhood 60 m²) — semantically nonsensical (Meeting↔Dining↔Kitchen↔
|
||||
Mechanical are not interchangeable) but sanctioned by the S1–S4 relation as written (each adjacent pair
|
||||
in the chain individually satisfies the ratio bounds; connectivity is transitive). `xi7`'s harbor-house
|
||||
losses show ON *adding* fails in both loss seeds (38→33 became 38 vs 33; 48→43 became 48 vs 43) —
|
||||
consistent with this misgroup actively hurting. Fix: `SpaceReq.interchange` (default `True`), settable
|
||||
`interchange: false` per code in `patterns.config`, honoured by `interchangeable()`'s S0 check — an
|
||||
architect veto for one code without disabling superposition globally. `superpose` itself stays default
|
||||
OFF regardless (the `xi7` verdict was null/negative overall), so the hatch only matters if/when
|
||||
superposition is deliberately enabled on a real config.
|
||||
|
||||
**Status.** `--superpose` stays default OFF; path (a) is not recommended without a fundamentally
|
||||
different mechanism (the geometry floor, not the labelling relaxation, is what needs to move — the
|
||||
same conclusion §11–§13's construction-quality work and §19's negative geometry-repair result both
|
||||
reach from other directions). Path (b) (multi-use as a permanent design goal, no collapse) was never
|
||||
attempted — remains open if revisited, but low priority given (a)'s outcome and the project's broader
|
||||
0-for-several record on search-machinery/fitness-shaping bets vs construction-quality bets (see `mi7`,
|
||||
§27, for the same pattern one experiment later).
|
||||
|
||||
## 27. 3D bubble-diagram adjacency fitness signal (`homemaker-py-mi7`) — DONE (negative)
|
||||
|
||||
*Closed 2026-07-25, the session immediately before §25's `9wi`. `bubble.py` was left in the repo
|
||||
**uncommitted** as a documented reference per the original close note; committed alongside this
|
||||
write-up so the reference this section makes to it is actually resolvable.*
|
||||
|
||||
**Motivation.** `graph.py`'s adjacency checks are binary (is X adjacent to Y, yes/no) and, like §18's
|
||||
connectivity fail, give the search no gradient toward a better overall spatial *arrangement* — only
|
||||
toward satisfying each declared pair. Idea: build the programme's required-space adjacency as a graph,
|
||||
relax it into a 3D "bubble diagram" (a spring/repulsion physics simulation, architecture's traditional
|
||||
adjacency-diagramming technique), then score a candidate layout by how well its real room-to-room
|
||||
distances correlate with a relaxed target's distances — an additional graded fitness term / search-
|
||||
guidance signal, in the spirit of §18's graded connectivity but for general spatial layout rather than
|
||||
circulation topology specifically.
|
||||
|
||||
**Mechanism (`bubble.py`, prototype only, never wired into `fitness.py`).**
|
||||
`requirement_graph`: one node per required room instance (`code`, or `code#i` for `count>1`), generic
|
||||
`c`/`o`/`s` adjacency targets collapsed to one shared hub node per code (per whole building, not per
|
||||
storey — a known simplification), edges to a multi-count code fan out to all its instances at reduced
|
||||
weight (satisfying adjacency needs only *one* matching neighbour). `generate_targets`: relax the
|
||||
requirement graph from `n_restarts` random 3D starts with a spring force (ideal edge length = sum of
|
||||
target-area-equivalent circle radii) plus overlap-only repulsion plus a level-height pull on the z axis;
|
||||
because relaxation is non-convex and multi-modal (different starts settle on e.g. opposite-handed but
|
||||
equally valid arrangements), keep up to `keep` distinct low-energy solutions (pairwise-distance-vector
|
||||
correlation ≥ `dedup_corr` = duplicate) rather than one canonical target. `similarity`: weighted Pearson
|
||||
correlation between an actual Dom layout's real weighted shortest-path distances and a target bubble's
|
||||
Euclidean distances, over matched non-generic room instances, weighted `1/hop_distance` in the
|
||||
requirement graph so the many hub-mediated "just wants to be near circulation" pairs (weak positional
|
||||
evidence) don't drown out the few directly-declared adjacencies (strong evidence). `best_similarity`
|
||||
takes the max across the kept alternative targets. `matched_leaves` maps anonymous multi-count codes to
|
||||
actual leaves by a fixed centroid-order rule — flagged in the module docstring as a known simplification,
|
||||
not a real assignment solver. `topological_similarity` is a cheaper no-embedding alternative: hop-distance
|
||||
correlation directly on graph topology (real multi-cell circulation network on both sides), skipping the
|
||||
physics simulation and multi-restart dedup entirely.
|
||||
|
||||
**Validation (measured 2026-07-25) — NULL on both formulations, both programmes.** Correlated each
|
||||
similarity metric against real evolved trajectories (not static examples) via `driver.search`:
|
||||
- **programme-house** (n=100 recorded individuals): `embedding` ρ≈0.05, `topological` ρ≈−0.06 — flat.
|
||||
This is the cleanest data point: programme-house has **zero** multi-count anonymous codes, so
|
||||
`matched_leaves`' fixed centroid-order heuristic cannot be confounding the result, and it's still flat.
|
||||
- **harbor-house** (budget 6000, n=75, fitness 3e-28→3.9e-17, fails 83→51 over the trajectory):
|
||||
`similarity()` (embedding) spearman=0.164, p=0.16 (n.s.); `topological_similarity()` spearman=−0.160,
|
||||
p=0.17 (n.s.) — noisier than programme-house (heavy anonymous-count codes: `n`×5, `m`×3, `t`×6, `r`×10,
|
||||
`of`×2, a real uncontrolled confound for the centroid-order matching there) but tells the same story.
|
||||
- **No statistically significant correlation anywhere**, across 2 independent formulations (spatial
|
||||
embedding vs pure topology) × 2 programmes, with real search trajectories rather than canned batches.
|
||||
|
||||
**Status.** Do not pursue graph-relaxation-derived or pure-topological adjacency-matching as a fitness
|
||||
signal for this project without a fundamentally different formulation. If revisited, the harbor-house
|
||||
anonymous-code confound would need a real assignment solver (Hungarian/brute-force, mirroring `9o5`'s
|
||||
`CLASS_CAP` pattern) before drawing any programme-specific conclusion there — but programme-house's
|
||||
clean, confound-free null already argues against the core idea regardless. `bubble.py` stays in the repo
|
||||
as a working, documented reference, not wired into `fitness.py`. Consistent with the project's broader
|
||||
pattern (§11.4/11.5, §12.3/12.4, §14, §16, §21, §22, §26 above): search-machinery / fitness-shaping
|
||||
changes have been null-to-negative essentially every time they've been tried; only construction/seeding
|
||||
quality and representation-relaxation changes (leaf-sharing §13.3, global collapse §17/§25) have moved
|
||||
the needle. This is another data point for that pattern, not an exception.
|
||||
|
|
|
|||
401
src/homemaker_layout/bubble.py
Normal file
401
src/homemaker_layout/bubble.py
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
"""3D bubble-diagram relaxation of programme adjacency (homemaker-py-mi7).
|
||||
|
||||
Prototype for a fitness signal that looks past the binary "is X adjacent to
|
||||
Y" checks in ``graph.py`` toward overall spatial arrangement: build the
|
||||
programme's required-space adjacency as a graph, relax it in 3D with a
|
||||
spring/repulsion physics simulation (a "bubble diagram"), then measure how
|
||||
well an actual Dom layout's real room-to-room distances correlate with a
|
||||
relaxed target's distances.
|
||||
|
||||
Relaxation is non-convex and multi-modal by nature — different random starts
|
||||
settle into different but equally-valid arrangements (e.g. which side of the
|
||||
hub a wing ends up on). ``generate_targets`` returns several distinct local
|
||||
minima; callers should score a candidate layout against each and take the
|
||||
best match rather than expect one canonical target.
|
||||
|
||||
KNOWN SIMPLIFICATIONS (first prototype, see homemaker-py-mi7):
|
||||
- Generic circulation/outside/stair codes (c/o/s) collapse to one shared
|
||||
hub node per code across the whole graph, not per storey. This matches
|
||||
how sparse most patterns.config adjacency is (almost everything just
|
||||
says "adjacent to c") but means the target is closer to a hub-and-spoke
|
||||
layout than a fully free embedding.
|
||||
- Anonymous multi-count codes (``count: N``) are matched to actual leaves
|
||||
by a fixed centroid-order rule (see ``matched_leaves``), not by the
|
||||
optimal assignment. Good enough to sanity-check correlation; a real
|
||||
fitness term would need a proper assignment (Hungarian / brute-force
|
||||
for small N, mirroring the ``CLASS_CAP`` pattern in programme.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
import networkx as nx
|
||||
import numpy as np
|
||||
|
||||
from . import dom, geometry, graph as graph_mod
|
||||
from .dom import Node, levels
|
||||
from .programme import SpaceReq
|
||||
|
||||
STOREY_HEIGHT = 3.0 # metres; only used to seed/soften the z axis
|
||||
DOOR_WIDTH = graph_mod.DOOR_WIDTH
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Requirement graph
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _radius(area: float) -> float:
|
||||
"""Bubble radius for a target floor area (equal-area circle)."""
|
||||
return math.sqrt(max(area, 1.0) / math.pi)
|
||||
|
||||
|
||||
def requirement_graph(reqs: dict[str, SpaceReq]) -> nx.Graph:
|
||||
"""Expand programme codes into per-instance nodes plus generic hubs.
|
||||
|
||||
One node per required room instance (``code`` if ``count == 1`` else
|
||||
``code#i``), node attrs ``area``/``level``/``generic``/``code``. Generic
|
||||
c/o/s adjacency targets collapse to one shared hub node per code
|
||||
(``__c__``, ``__o__``, ``__s__``). Edges to a multi-count non-generic
|
||||
code fan out to all its instances at reduced weight, since satisfying
|
||||
adjacency only requires ONE matching neighbour, not all of them.
|
||||
"""
|
||||
G = nx.Graph()
|
||||
hubs: dict[str, str] = {}
|
||||
|
||||
def hub(low: str) -> str:
|
||||
if low not in hubs:
|
||||
hub_id = f"__{low}__"
|
||||
hubs[low] = hub_id
|
||||
G.add_node(hub_id, area=4.0, level=None, generic=True, code=low)
|
||||
return hubs[low]
|
||||
|
||||
instances: dict[str, list[str]] = {}
|
||||
for code, req in reqs.items():
|
||||
if code[0].lower() in ("c", "o", "s"):
|
||||
continue
|
||||
ids = []
|
||||
for i in range(req.count):
|
||||
node_id = code if req.count == 1 else f"{code}#{i + 1}"
|
||||
G.add_node(node_id, area=req.size, level=req.level, generic=False, code=code)
|
||||
ids.append(node_id)
|
||||
instances[code] = ids
|
||||
|
||||
for code, req in reqs.items():
|
||||
if code[0].lower() in ("c", "o", "s") or code not in instances:
|
||||
continue
|
||||
for node_id in instances[code]:
|
||||
for adj_code in req.adjacency:
|
||||
low = adj_code[0].lower()
|
||||
if low in ("c", "o", "s"):
|
||||
G.add_edge(node_id, hub(low), weight=1.0)
|
||||
elif adj_code in instances:
|
||||
targets = instances[adj_code]
|
||||
w = 1.0 / len(targets)
|
||||
for t in targets:
|
||||
if not G.has_edge(node_id, t):
|
||||
G.add_edge(node_id, t, weight=w)
|
||||
return G
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Relaxation
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@dataclass
|
||||
class BubbleLayout:
|
||||
positions: dict[str, np.ndarray]
|
||||
radius: dict[str, float]
|
||||
level: dict[str, float | None]
|
||||
energy: float
|
||||
|
||||
|
||||
def _relax_once(
|
||||
G: nx.Graph, rng: np.random.Generator, iterations: int, storey_height: float,
|
||||
) -> BubbleLayout:
|
||||
nodes = list(G.nodes())
|
||||
idx = {n: i for i, n in enumerate(nodes)}
|
||||
n = len(nodes)
|
||||
radius = {v: _radius(d["area"]) for v, d in G.nodes(data=True)}
|
||||
level = {v: d.get("level") for v, d in G.nodes(data=True)}
|
||||
|
||||
pos = rng.normal(scale=5.0, size=(n, 3))
|
||||
for v, d in G.nodes(data=True):
|
||||
if d.get("level") is not None:
|
||||
pos[idx[v], 2] = d["level"] * storey_height
|
||||
|
||||
edges = [(idx[u], idx[v], d["weight"]) for u, v, d in G.edges(data=True)]
|
||||
leveled = [idx[v] for v, d in G.nodes(data=True) if d.get("level") is not None]
|
||||
target_z = np.zeros(n)
|
||||
for v, d in G.nodes(data=True):
|
||||
if d.get("level") is not None:
|
||||
target_z[idx[v]] = d["level"] * storey_height
|
||||
r = np.array([radius[v] for v in nodes])
|
||||
|
||||
lr = 0.2
|
||||
max_disp = 0.5 * float(np.mean(r)) # clamp step size — repulsion sums grow with n
|
||||
for step in range(iterations):
|
||||
force = np.zeros_like(pos)
|
||||
|
||||
for ui, vi, w in edges:
|
||||
d = pos[vi] - pos[ui]
|
||||
dist = np.linalg.norm(d) + 1e-9
|
||||
ideal = r[ui] + r[vi]
|
||||
f = w * (dist - ideal) * d / dist
|
||||
force[ui] += f
|
||||
force[vi] -= f
|
||||
|
||||
# overlap-only repulsion, O(n^2) — fine at programme scale
|
||||
diff = pos[:, None, :] - pos[None, :, :]
|
||||
dist = np.linalg.norm(diff, axis=2) + 1e-9
|
||||
min_sep = r[:, None] + r[None, :]
|
||||
overlap = np.maximum(0.0, min_sep - dist)
|
||||
np.fill_diagonal(overlap, 0.0)
|
||||
rep = (overlap / dist)[:, :, None] * diff
|
||||
force += rep.sum(axis=1)
|
||||
|
||||
if leveled:
|
||||
force[leveled, 2] += (target_z[leveled] - pos[leveled, 2]) * 0.5
|
||||
|
||||
disp = lr * force / (1.0 + 0.01 * step)
|
||||
disp_norm = np.linalg.norm(disp, axis=1, keepdims=True)
|
||||
scale = np.minimum(1.0, max_disp / (disp_norm + 1e-12))
|
||||
pos += disp * scale
|
||||
|
||||
total = 0.0
|
||||
for ui, vi, w in edges:
|
||||
dist = np.linalg.norm(pos[vi] - pos[ui])
|
||||
total += w * (dist - (r[ui] + r[vi])) ** 2
|
||||
|
||||
return BubbleLayout(
|
||||
positions={v: pos[idx[v]] for v in nodes},
|
||||
radius=radius,
|
||||
level=level,
|
||||
energy=total,
|
||||
)
|
||||
|
||||
|
||||
def _layout_signature(layout: BubbleLayout, nodes: list[str]) -> np.ndarray:
|
||||
"""Flattened, translation-free pairwise-distance vector for dedup."""
|
||||
pts = np.array([layout.positions[n] for n in nodes])
|
||||
d = np.linalg.norm(pts[:, None, :] - pts[None, :, :], axis=2)
|
||||
iu = np.triu_indices(len(nodes), k=1)
|
||||
return d[iu]
|
||||
|
||||
|
||||
def generate_targets(
|
||||
reqs: dict[str, SpaceReq],
|
||||
n_restarts: int = 8,
|
||||
iterations: int = 400,
|
||||
storey_height: float = STOREY_HEIGHT,
|
||||
seed: int = 0,
|
||||
keep: int = 4,
|
||||
dedup_corr: float = 0.98,
|
||||
) -> list[BubbleLayout]:
|
||||
"""Relax the requirement graph from ``n_restarts`` random starts, return up
|
||||
to ``keep`` distinct low-energy layouts (Pearson correlation of pairwise
|
||||
distance vectors >= ``dedup_corr`` is treated as a duplicate solution)."""
|
||||
G = requirement_graph(reqs)
|
||||
nodes = list(G.nodes())
|
||||
rng = np.random.default_rng(seed)
|
||||
|
||||
candidates = []
|
||||
for _ in range(n_restarts):
|
||||
layout = _relax_once(G, rng, iterations, storey_height)
|
||||
candidates.append(layout)
|
||||
candidates.sort(key=lambda layout: layout.energy)
|
||||
|
||||
kept: list[BubbleLayout] = []
|
||||
kept_sigs: list[np.ndarray] = []
|
||||
for c in candidates:
|
||||
sig = _layout_signature(c, nodes)
|
||||
is_dup = any(
|
||||
np.corrcoef(sig, ks)[0, 1] >= dedup_corr for ks in kept_sigs if len(ks) > 1
|
||||
)
|
||||
if not is_dup:
|
||||
kept.append(c)
|
||||
kept_sigs.append(sig)
|
||||
if len(kept) >= keep:
|
||||
break
|
||||
return kept
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Matching an actual Dom layout to requirement-graph node ids
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def matched_leaves(root: Node, reqs: dict[str, SpaceReq]) -> dict[str, Node]:
|
||||
"""Map requirement-graph instance ids to actual leaves.
|
||||
|
||||
Anonymous multi-count codes are matched by fixed centroid order (x then
|
||||
y) — a known simplification, see module docstring. Codes with fewer
|
||||
actual leaves than required are matched as far as they go; excess actual
|
||||
leaves of a code are left unmatched.
|
||||
"""
|
||||
by_code: dict[str, list[Node]] = {}
|
||||
for lvl in levels(root):
|
||||
for leaf in lvl.leaves():
|
||||
if not leaf.type or leaf.type[0].lower() in ("c", "o", "s"):
|
||||
continue
|
||||
by_code.setdefault(leaf.type, []).append(leaf)
|
||||
|
||||
for leaves in by_code.values():
|
||||
leaves.sort(key=lambda lf: tuple(geometry.centroid(lf)))
|
||||
|
||||
result: dict[str, Node] = {}
|
||||
for code, req in reqs.items():
|
||||
if code[0].lower() in ("c", "o", "s"):
|
||||
continue
|
||||
leaves = by_code.get(code, [])
|
||||
for i in range(min(req.count, len(leaves))):
|
||||
node_id = code if req.count == 1 else f"{code}#{i + 1}"
|
||||
result[node_id] = leaves[i]
|
||||
return result
|
||||
|
||||
|
||||
def actual_full_graph(root: Node, door_width: float = DOOR_WIDTH) -> nx.Graph:
|
||||
"""Union of each storey's leaf-adjacency graph, with synthetic edges
|
||||
linking every circulation leaf on adjacent storeys (approximates a
|
||||
stair/shared-core connection so cross-floor distances are defined)."""
|
||||
lvls = levels(root)
|
||||
per_level = [geometry.leaf_graph(lvl, door_width) for lvl in lvls]
|
||||
|
||||
G = nx.Graph()
|
||||
for g in per_level:
|
||||
G.add_nodes_from(g.nodes())
|
||||
G.add_edges_from(g.edges(data=True))
|
||||
|
||||
for i in range(len(per_level) - 1):
|
||||
below = [v for v in per_level[i].nodes() if dom.is_circulation(v)]
|
||||
above = [v for v in per_level[i + 1].nodes() if dom.is_circulation(v)]
|
||||
for a in below:
|
||||
for b in above:
|
||||
if not G.has_edge(a, b):
|
||||
G.add_edge(a, b, weight=geometry._dist(geometry.centroid(a), geometry.centroid(b)) or 1.0)
|
||||
return G
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Similarity scoring
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def similarity(
|
||||
root: Node, reqs: dict[str, SpaceReq], target: BubbleLayout,
|
||||
) -> float | None:
|
||||
"""Weighted Pearson correlation between actual weighted shortest-path
|
||||
distances and the target bubble's Euclidean distances, over pairs of
|
||||
matched non-generic room instances. None if fewer than 3 matched pairs.
|
||||
|
||||
Pairs are weighted by ``1 / hop_distance`` in the requirement graph — most
|
||||
programmes only declare a handful of direct room-to-room adjacencies
|
||||
(e.g. kitchen-dining), with everything else attached only via a shared
|
||||
generic circulation hub. Two rooms that both merely want "near
|
||||
circulation" carry much weaker positional evidence than a pair with a
|
||||
declared adjacency, so unweighted correlation is dominated by noise from
|
||||
the many hub-mediated pairs. Raw (not rank) distances are compared since
|
||||
hop-weighting requires an ordinary weighted covariance; both distances are
|
||||
already in comparable metre-scale units (bubble radius = sqrt(area/pi)).
|
||||
"""
|
||||
matched = matched_leaves(root, reqs)
|
||||
ids = [i for i in matched if i in target.positions]
|
||||
if len(ids) < 3:
|
||||
return None
|
||||
|
||||
G = actual_full_graph(root)
|
||||
req_graph = requirement_graph(reqs)
|
||||
hop = dict(nx.all_pairs_shortest_path_length(req_graph))
|
||||
leaf_of = matched
|
||||
|
||||
actual_d = []
|
||||
target_d = []
|
||||
weights = []
|
||||
for i in range(len(ids)):
|
||||
for j in range(i + 1, len(ids)):
|
||||
a, b = leaf_of[ids[i]], leaf_of[ids[j]]
|
||||
try:
|
||||
ad = nx.shortest_path_length(G, a, b, weight="weight")
|
||||
except nx.NetworkXNoPath:
|
||||
continue
|
||||
td = float(np.linalg.norm(target.positions[ids[i]] - target.positions[ids[j]]))
|
||||
h = hop.get(ids[i], {}).get(ids[j])
|
||||
if h is None or h == 0:
|
||||
continue
|
||||
actual_d.append(ad)
|
||||
target_d.append(td)
|
||||
weights.append(1.0 / h)
|
||||
|
||||
if len(actual_d) < 3:
|
||||
return None
|
||||
|
||||
return _weighted_corr(np.array(actual_d), np.array(target_d), np.array(weights))
|
||||
|
||||
|
||||
def _weighted_corr(a: np.ndarray, b: np.ndarray, w: np.ndarray) -> float:
|
||||
wsum = w.sum()
|
||||
mean_a = (w * a).sum() / wsum
|
||||
mean_b = (w * b).sum() / wsum
|
||||
da, db = a - mean_a, b - mean_b
|
||||
cov = (w * da * db).sum() / wsum
|
||||
var_a = (w * da * da).sum() / wsum
|
||||
var_b = (w * db * db).sum() / wsum
|
||||
denom = math.sqrt(var_a * var_b)
|
||||
return float(cov / denom) if denom > 0 else 0.0
|
||||
|
||||
|
||||
def best_similarity(
|
||||
root: Node, reqs: dict[str, SpaceReq], targets: list[BubbleLayout],
|
||||
) -> float | None:
|
||||
"""Best (max) similarity across a set of alternative relaxed targets."""
|
||||
scores = [s for t in targets if (s := similarity(root, reqs, t)) is not None]
|
||||
return max(scores) if scores else None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Topological (no-embedding) alternative
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def topological_similarity(
|
||||
root: Node, reqs: dict[str, SpaceReq],
|
||||
) -> float | None:
|
||||
"""Hop-weighted correlation between requirement-graph hop distance and
|
||||
actual-building hop distance — no spatial embedding/relaxation at all.
|
||||
|
||||
Where ``similarity`` relaxes the requirement graph into a 3D bubble
|
||||
diagram and compares Euclidean distances, this compares plain graph
|
||||
topology on both sides: shortest-path hop count in ``requirement_graph``
|
||||
(through the real, possibly-multi-cell circulation network on the actual
|
||||
side, not a single collapsed hub) versus shortest-path hop count in
|
||||
``actual_full_graph``. Cheaper (no physics simulation, no multi-restart
|
||||
dedup) and avoids the single-point-hub abstraction's mismatch with real
|
||||
layouts, which spread circulation across several physical cells.
|
||||
"""
|
||||
matched = matched_leaves(root, reqs)
|
||||
req_graph = requirement_graph(reqs)
|
||||
ids = [i for i in matched if i in req_graph.nodes]
|
||||
if len(ids) < 3:
|
||||
return None
|
||||
|
||||
req_hop = dict(nx.all_pairs_shortest_path_length(req_graph))
|
||||
actual_graph = actual_full_graph(root)
|
||||
actual_hop = dict(nx.all_pairs_shortest_path_length(actual_graph))
|
||||
|
||||
req_d, actual_d, weights = [], [], []
|
||||
for i in range(len(ids)):
|
||||
for j in range(i + 1, len(ids)):
|
||||
a, b = ids[i], ids[j]
|
||||
h = req_hop.get(a, {}).get(b)
|
||||
if h is None or h == 0:
|
||||
continue
|
||||
leaf_a, leaf_b = matched[a], matched[b]
|
||||
ah = actual_hop.get(leaf_a, {}).get(leaf_b)
|
||||
if ah is None:
|
||||
continue
|
||||
req_d.append(h)
|
||||
actual_d.append(ah)
|
||||
weights.append(1.0 / h)
|
||||
|
||||
if len(req_d) < 3:
|
||||
return None
|
||||
|
||||
return _weighted_corr(np.array(actual_d), np.array(req_d), np.array(weights))
|
||||
Loading…
Add table
Reference in a new issue