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:
Bruno Postle 2026-07-26 23:16:21 +01:00
parent 5c8a5a5e09
commit 9c6b1552eb
3 changed files with 537 additions and 0 deletions

View file

@ -80,6 +80,7 @@ Key modules:
- `driver.py` — memetic search outer loop - `driver.py` — memetic search outer loop
- `evolve.py``homemaker-evolve` CLI entry point - `evolve.py``homemaker-evolve` CLI entry point
- `oracle.py` — legacy Perl shim, kept for validation only; do not use in new code - `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 ## Conventions & Patterns

135
DESIGN.md
View file

@ -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: 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); `tests/test_collapse_global.py` gains `test_two_opt_polish_escapes_jacobi_plateau` (7 total in that file);
298/298 pass project-wide. 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 15): 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 13): 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.011.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 S1S4 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.

View 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))