diff --git a/CLAUDE.md b/CLAUDE.md index 15089e0..89d391b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/DESIGN.md b/DESIGN.md index 18a97d4..4cfe638 100644 --- a/DESIGN.md +++ b/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. diff --git a/src/homemaker_layout/bubble.py b/src/homemaker_layout/bubble.py new file mode 100644 index 0000000..7803330 --- /dev/null +++ b/src/homemaker_layout/bubble.py @@ -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))