2026-07-26 23:16:21 +01:00
|
|
|
"""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():
|
§39.4: tighten generic-type matching, reverting the harbor rename
Supersedes the previous commit's approach. Renaming harbor's four colliding
codes fixed one programme; tightening the matching rule fixes the rule, so a
room may be called anything. cr1/of/st1/st2 are restored and the examples are
byte-identical to their pre-§39 state -- which also means existing .dom
artefacts (evolved-3M*) stay valid, so migrate_ju3_rename.py is deleted.
The rule: Urb has exactly three GENERIC structural types (get_space_types:
qw/C O S/), the leaves the search creates. Measured across the corpus: 154 C,
110 O, 1 S, not one lowercase generic -- while every programme code is
lowercase, including single-character ones (r, t, m, n). Case is the
discriminator, not length. Every generic test was type[0].lower() in (...), a
case-insensitive PREFIX that swept up any programme code starting with those
letters; they now match the generic set exactly. 30 sites across dom, fitness,
graph, operators, programme, shapecurve and bubble.
NOT applied to the SEMANTIC prefixes: l/k/b/t classify programme codes by first
letter (graph.py builds bedroom<->toilet and kitchen<->living relations from
them) and stay prefix-based. Where the namespaces were mixed in one expression
they were split -- has_circulation's ("b","l","k","c") is three semantic
prefixes plus dom.is_circulation; access()'s ("l","c","s") is semantic l plus
the generic circulation set.
New: dom.GENERIC_{CIRCULATION,OUTSIDE,TYPES} + is_generic(); fitness.
_generic_class(), replacing the _t0 dispatch in quality_size/quality_width/
quality_proportion/value_rate -- the four terms that mattered most and that a
first sweep missed, since they dispatch through a t0 variable rather than an
inline test. graph._adjacency_target resolves a generic adjacency requirement
(programmes write "adjacency: [c, o]") to the generic set while every other
requirement keeps Perl's prefix semantics.
Two subtleties: S is in both generic sets but takes the OUTSIDE parameter
families -- a first translation tested circulation first and silently gave S
the circulation params, caught by test_get_space_params_sahn_proportion. And
validate_codes survives, narrowed to a code spelled exactly C/O/S, which is a
genuine ambiguity; merely starting with c/o/s is now fine.
Invariant asserted as a test: test_scoring_is_invariant_under_programme_code_
spelling relabels one tree and its config together and re-scores. Bit-identical
across 12 comparisons (6 seeds x collapse on/off).
Re-baseline (seed 1, 20k, original names): 58 fails (15 hard / 43 soft) against
the real 37-instance programme, with cr1 at 79.1 m2 vs declared 80 (was 32.9
and 17.1), of/st1/st2 all present and in band, and one fail naming any of them.
57 -> 58 on a 5-instance-harder programme is within noise: "did not regress".
Fallout (§39.5): 2g7.5's CP-SAT seeder win does not survive. Over 6 seeds --
harbor real 102/114 (cpsat loses), harbor old-effective 98/99 (tie, so the win
was already marginal), maple-court 156/144 (cpsat wins). maple is the control:
the solver did not regress, harbor's programme changed. Test xfail'd with that
reason plus a maple companion; both assign_solver flags stay default off.
Filed homemaker-py-w6x to re-check other narrow-margin harbor A/Bs.
345 passed, 1 xfailed, 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:45:28 +00:00
|
|
|
if dom.is_generic(code):
|
2026-07-26 23:16:21 +01:00
|
|
|
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():
|
§39.4: tighten generic-type matching, reverting the harbor rename
Supersedes the previous commit's approach. Renaming harbor's four colliding
codes fixed one programme; tightening the matching rule fixes the rule, so a
room may be called anything. cr1/of/st1/st2 are restored and the examples are
byte-identical to their pre-§39 state -- which also means existing .dom
artefacts (evolved-3M*) stay valid, so migrate_ju3_rename.py is deleted.
The rule: Urb has exactly three GENERIC structural types (get_space_types:
qw/C O S/), the leaves the search creates. Measured across the corpus: 154 C,
110 O, 1 S, not one lowercase generic -- while every programme code is
lowercase, including single-character ones (r, t, m, n). Case is the
discriminator, not length. Every generic test was type[0].lower() in (...), a
case-insensitive PREFIX that swept up any programme code starting with those
letters; they now match the generic set exactly. 30 sites across dom, fitness,
graph, operators, programme, shapecurve and bubble.
NOT applied to the SEMANTIC prefixes: l/k/b/t classify programme codes by first
letter (graph.py builds bedroom<->toilet and kitchen<->living relations from
them) and stay prefix-based. Where the namespaces were mixed in one expression
they were split -- has_circulation's ("b","l","k","c") is three semantic
prefixes plus dom.is_circulation; access()'s ("l","c","s") is semantic l plus
the generic circulation set.
New: dom.GENERIC_{CIRCULATION,OUTSIDE,TYPES} + is_generic(); fitness.
_generic_class(), replacing the _t0 dispatch in quality_size/quality_width/
quality_proportion/value_rate -- the four terms that mattered most and that a
first sweep missed, since they dispatch through a t0 variable rather than an
inline test. graph._adjacency_target resolves a generic adjacency requirement
(programmes write "adjacency: [c, o]") to the generic set while every other
requirement keeps Perl's prefix semantics.
Two subtleties: S is in both generic sets but takes the OUTSIDE parameter
families -- a first translation tested circulation first and silently gave S
the circulation params, caught by test_get_space_params_sahn_proportion. And
validate_codes survives, narrowed to a code spelled exactly C/O/S, which is a
genuine ambiguity; merely starting with c/o/s is now fine.
Invariant asserted as a test: test_scoring_is_invariant_under_programme_code_
spelling relabels one tree and its config together and re-scores. Bit-identical
across 12 comparisons (6 seeds x collapse on/off).
Re-baseline (seed 1, 20k, original names): 58 fails (15 hard / 43 soft) against
the real 37-instance programme, with cr1 at 79.1 m2 vs declared 80 (was 32.9
and 17.1), of/st1/st2 all present and in band, and one fail naming any of them.
57 -> 58 on a 5-instance-harder programme is within noise: "did not regress".
Fallout (§39.5): 2g7.5's CP-SAT seeder win does not survive. Over 6 seeds --
harbor real 102/114 (cpsat loses), harbor old-effective 98/99 (tie, so the win
was already marginal), maple-court 156/144 (cpsat wins). maple is the control:
the solver did not regress, harbor's programme changed. Test xfail'd with that
reason plus a maple companion; both assign_solver flags stay default off.
Filed homemaker-py-w6x to re-check other narrow-margin harbor A/Bs.
345 passed, 1 xfailed, 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:45:28 +00:00
|
|
|
if dom.is_generic(code) or code not in instances:
|
2026-07-26 23:16:21 +01:00
|
|
|
continue
|
|
|
|
|
for node_id in instances[code]:
|
|
|
|
|
for adj_code in req.adjacency:
|
§39.4 completion + §39.5 retraction + §39.6: the usage namespace is NOT clean
Answering "are we clean". Generic namespace: yes. Usage namespace: no.
FINISH §39.4. The first sweep missed sites, found by a full re-grep:
graph.py's free-area budget, operators.py host-preference / keep-type /
repair-candidate, fitness.py's ("l","c","k") public-access test, bubble.py's
generic adjacency reference, and -- the important one -- cpsat.py, which was
still matching adjacency by raw startswith. graph.code_matches_requirement is
now the single public answer to "does this leaf count as the thing the
programme asked to be next to", shared by has_adjacency, has_vertical_connection
and cpsat.
RETRACT §39.5. It concluded 2g7.5's CP-SAT seeder win did not survive the
correction. That was wrong. The cause was the missed cpsat matcher above: the
exact solver was optimising a different relation than the scorer checked, so a
failing test reporting an incomplete sweep was misread as a baseline shift.
Re-measured over 6 seeds, cpsat now wins on both programmes (harbor 102/92,
maple 156/154). xfail removed.
REAL BUG UNDERNEATH: CP-SAT was never deterministic despite
num_search_workers=1 and a comment claiming it. neighbors[slot] is a set of
dom.Node, which hashes by id() -- a memory address -- so raw iteration made the
model-build order vary and CP-SAT returned a different equally-optimal
assignment each run (measured 194/180/171/182 over four identical aggregates).
sorted() on the slot indices fixes it. Also paired the wall-clock cap with
max_deterministic_time (solves run ~124ms against a 2s cap, so nothing was
timing out -- latent hazard, not the cause). solve_room_labels is now
reproducible on every captured instance; constructive_topology on the cpsat
path still is not, filed as homemaker-py-fdp (plausible contributor to b8g).
§39.6 THE SECOND NAMESPACE. Usage prefixes b/t/l/k (bedroom/toilet/living/
kitchen) classify programme codes by first letter and stay prefix-based by
design, but they are not inert: has_circulation deletes graph edges from them.
Four corpus rooms are misclassified by spelling -- la1 "Laundry Room" and li1
"Library Corner" as living, br1 "Staff Room" as bedroom, tr1 "Treatment Room"
as toilet. Measured on a health-centre seed: tr1 loses its edge to the adjacent
O, br1 loses its edge to t10 "Staff WC" -- both feed the connectivity fails §38
found persisting. Filed homemaker-py-sel; an explicit usage: key is the fix,
but it changes fitness for correctly-spelled programmes too so it needs its own
A/B.
DOCS. README gains a "Room codes and reserved names" section; CLAUDE.md and
AGENTS.md gain the same summary for agents. audit_programme_config.py now
reports the usage class each code picks up alongside the namespace and
satisfiability checks. DESIGN §37.2's note calling the c/o/s quirk "existing
product behaviour, not a bug" is annotated as superseded.
Corpus audit: zero generic-namespace violations across all ten example
programmes. 346 passed, same 7 pre-existing fixture failures, lint unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 10:09:14 +00:00
|
|
|
low = adj_code.lower()
|
|
|
|
|
if low.upper() in dom.GENERIC_TYPES:
|
2026-07-26 23:16:21 +01:00
|
|
|
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():
|
§39.4: tighten generic-type matching, reverting the harbor rename
Supersedes the previous commit's approach. Renaming harbor's four colliding
codes fixed one programme; tightening the matching rule fixes the rule, so a
room may be called anything. cr1/of/st1/st2 are restored and the examples are
byte-identical to their pre-§39 state -- which also means existing .dom
artefacts (evolved-3M*) stay valid, so migrate_ju3_rename.py is deleted.
The rule: Urb has exactly three GENERIC structural types (get_space_types:
qw/C O S/), the leaves the search creates. Measured across the corpus: 154 C,
110 O, 1 S, not one lowercase generic -- while every programme code is
lowercase, including single-character ones (r, t, m, n). Case is the
discriminator, not length. Every generic test was type[0].lower() in (...), a
case-insensitive PREFIX that swept up any programme code starting with those
letters; they now match the generic set exactly. 30 sites across dom, fitness,
graph, operators, programme, shapecurve and bubble.
NOT applied to the SEMANTIC prefixes: l/k/b/t classify programme codes by first
letter (graph.py builds bedroom<->toilet and kitchen<->living relations from
them) and stay prefix-based. Where the namespaces were mixed in one expression
they were split -- has_circulation's ("b","l","k","c") is three semantic
prefixes plus dom.is_circulation; access()'s ("l","c","s") is semantic l plus
the generic circulation set.
New: dom.GENERIC_{CIRCULATION,OUTSIDE,TYPES} + is_generic(); fitness.
_generic_class(), replacing the _t0 dispatch in quality_size/quality_width/
quality_proportion/value_rate -- the four terms that mattered most and that a
first sweep missed, since they dispatch through a t0 variable rather than an
inline test. graph._adjacency_target resolves a generic adjacency requirement
(programmes write "adjacency: [c, o]") to the generic set while every other
requirement keeps Perl's prefix semantics.
Two subtleties: S is in both generic sets but takes the OUTSIDE parameter
families -- a first translation tested circulation first and silently gave S
the circulation params, caught by test_get_space_params_sahn_proportion. And
validate_codes survives, narrowed to a code spelled exactly C/O/S, which is a
genuine ambiguity; merely starting with c/o/s is now fine.
Invariant asserted as a test: test_scoring_is_invariant_under_programme_code_
spelling relabels one tree and its config together and re-scores. Bit-identical
across 12 comparisons (6 seeds x collapse on/off).
Re-baseline (seed 1, 20k, original names): 58 fails (15 hard / 43 soft) against
the real 37-instance programme, with cr1 at 79.1 m2 vs declared 80 (was 32.9
and 17.1), of/st1/st2 all present and in band, and one fail naming any of them.
57 -> 58 on a 5-instance-harder programme is within noise: "did not regress".
Fallout (§39.5): 2g7.5's CP-SAT seeder win does not survive. Over 6 seeds --
harbor real 102/114 (cpsat loses), harbor old-effective 98/99 (tie, so the win
was already marginal), maple-court 156/144 (cpsat wins). maple is the control:
the solver did not regress, harbor's programme changed. Test xfail'd with that
reason plus a maple companion; both assign_solver flags stay default off.
Filed homemaker-py-w6x to re-check other narrow-margin harbor A/Bs.
345 passed, 1 xfailed, 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:45:28 +00:00
|
|
|
if not leaf.type or dom.is_generic(leaf.type):
|
2026-07-26 23:16:21 +01:00
|
|
|
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():
|
§39.4: tighten generic-type matching, reverting the harbor rename
Supersedes the previous commit's approach. Renaming harbor's four colliding
codes fixed one programme; tightening the matching rule fixes the rule, so a
room may be called anything. cr1/of/st1/st2 are restored and the examples are
byte-identical to their pre-§39 state -- which also means existing .dom
artefacts (evolved-3M*) stay valid, so migrate_ju3_rename.py is deleted.
The rule: Urb has exactly three GENERIC structural types (get_space_types:
qw/C O S/), the leaves the search creates. Measured across the corpus: 154 C,
110 O, 1 S, not one lowercase generic -- while every programme code is
lowercase, including single-character ones (r, t, m, n). Case is the
discriminator, not length. Every generic test was type[0].lower() in (...), a
case-insensitive PREFIX that swept up any programme code starting with those
letters; they now match the generic set exactly. 30 sites across dom, fitness,
graph, operators, programme, shapecurve and bubble.
NOT applied to the SEMANTIC prefixes: l/k/b/t classify programme codes by first
letter (graph.py builds bedroom<->toilet and kitchen<->living relations from
them) and stay prefix-based. Where the namespaces were mixed in one expression
they were split -- has_circulation's ("b","l","k","c") is three semantic
prefixes plus dom.is_circulation; access()'s ("l","c","s") is semantic l plus
the generic circulation set.
New: dom.GENERIC_{CIRCULATION,OUTSIDE,TYPES} + is_generic(); fitness.
_generic_class(), replacing the _t0 dispatch in quality_size/quality_width/
quality_proportion/value_rate -- the four terms that mattered most and that a
first sweep missed, since they dispatch through a t0 variable rather than an
inline test. graph._adjacency_target resolves a generic adjacency requirement
(programmes write "adjacency: [c, o]") to the generic set while every other
requirement keeps Perl's prefix semantics.
Two subtleties: S is in both generic sets but takes the OUTSIDE parameter
families -- a first translation tested circulation first and silently gave S
the circulation params, caught by test_get_space_params_sahn_proportion. And
validate_codes survives, narrowed to a code spelled exactly C/O/S, which is a
genuine ambiguity; merely starting with c/o/s is now fine.
Invariant asserted as a test: test_scoring_is_invariant_under_programme_code_
spelling relabels one tree and its config together and re-scores. Bit-identical
across 12 comparisons (6 seeds x collapse on/off).
Re-baseline (seed 1, 20k, original names): 58 fails (15 hard / 43 soft) against
the real 37-instance programme, with cr1 at 79.1 m2 vs declared 80 (was 32.9
and 17.1), of/st1/st2 all present and in band, and one fail naming any of them.
57 -> 58 on a 5-instance-harder programme is within noise: "did not regress".
Fallout (§39.5): 2g7.5's CP-SAT seeder win does not survive. Over 6 seeds --
harbor real 102/114 (cpsat loses), harbor old-effective 98/99 (tie, so the win
was already marginal), maple-court 156/144 (cpsat wins). maple is the control:
the solver did not regress, harbor's programme changed. Test xfail'd with that
reason plus a maple companion; both assign_solver flags stay default off.
Filed homemaker-py-w6x to re-check other narrow-margin harbor A/Bs.
345 passed, 1 xfailed, 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:45:28 +00:00
|
|
|
if dom.is_generic(code):
|
2026-07-26 23:16:21 +01:00
|
|
|
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))
|