check_space_counts emitted, per missing room instance, two base failures plus one placeholder for each optional key the author happened to type -- has_size/has_width/has_proportion are literally "size" in c from the YAML. So a missing room cost 3, 4 or 5 fails depending on nothing but how verbosely its space was written, and under value *= 0.5 ** len(failures) that is a 4x difference in penalty between two single rooms. The tiered comparator inherits it directly, since n_hard is dominated by these cascades -- the search's primary key was partly a measure of config style. The two paths disagreed about the same room. A PRESENT room is checked on all three qualities regardless of declaration: get_space_params fills width and proportion from defaults, deriving width from size when absent, so programme-house's t2 declares size: alone and still gets a real width target of 1.633 it can fail on. Missing, it emitted one placeholder where b1 emitted three. The cascade stands in for the checks that could not run, and it stood in for the wrong number of them. Fix: emit all three placeholders always -- a fixed 5 per missing instance, mirroring the present-room path. 36 of 67 corpus codes were under-counted. Max weight ratio between two single rooms 4x -> 1x (programme-house), 2x -> 1x (harbor, maple). This makes fail counts LARGER and that is the point; it is a correctness fix, not an improvement. harbor evolved-3M-nols-3 82 -> 84, generated 155 -> 174, evolved-3M 131 -> 144; maple generated unchanged (no missing instances). NOT taken: 1i8's other option, one fail per instance with the placeholders informational. It fixes the verbosity dependence too but silently rescales a missing room from 1/32 to 1/2, the same weight as one crinkliness fail. Whether it SHOULD cost 1/32 is a real and separate question; bundling it here would change the objective's priorities under cover of a bug fix. Magnitude left exactly where it was, filed as homemaker-py-3i3. Every historical corpus fail count is invalidated again, on top of 39.4 and 38.10/38.11 -- which is why the cold-start re-baseline belongs after the objective work, not before it. Closes homemaker-py-1i8. Lint at parity (46); tests 379 passed (3 new), 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
818 lines
32 KiB
Python
818 lines
32 KiB
Python
"""Leaf-adjacency graph build and pre-merge checks for programme-driven fitness.
|
|
|
|
TWO-PHASE PATTERN (ProgrammeDriven.pm:83-103):
|
|
1. ``build_graphs(root)`` on the UNMERGED tree → graph_base
|
|
2. Run adjacency / level / vertical checks using graph_base and the unmerged tree.
|
|
3. ``dom.merge_divided(root)`` — mutates the tree in place.
|
|
4. ``build_graphs(root)`` again on the MERGED tree for storey processing.
|
|
|
|
FIDELITY DECISION — ``has_vertical_connection`` (DESIGN.md §8.1):
|
|
Ported faithfully including the no-spatial-overlap stub from
|
|
ProgrammeDriven.pm:399-423. Any leaf of the target type on the level below
|
|
counts as "connected", regardless of spatial overlap. This is a known
|
|
simplification in the Perl; it is preserved here for oracle parity.
|
|
Reshape in Phase 4 if needed.
|
|
|
|
PERL CLONE QUIRK — ``has_circulation`` (Base.pm:228-241):
|
|
Perl's ``Graph::clone()`` only copies vertices that are part of at least one
|
|
edge. Isolated vertices (single-leaf levels or unconnected nodes) are lost.
|
|
An empty graph returns ``is_connected = False``. ``has_circulation`` replicates
|
|
this by removing isolated vertices before the usability/edge-type filtering.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import networkx as nx
|
|
|
|
from . import dom, geometry
|
|
from .dom import Node, is_generic, levels
|
|
from . import programme as _pr
|
|
from .programme import SpaceReq
|
|
|
|
DOOR_WIDTH = 1.2 # Urb::Dom::Fitness::Base default_params door_width
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Graph build
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def build_graphs(root: Node, door_width: float = DOOR_WIDTH) -> list[nx.Graph]:
|
|
"""Return one ``nx.Graph`` per storey (lowest first); mirrors
|
|
``setup_storey_graphs`` in ``Urb::Dom::Fitness::Base``.
|
|
|
|
This is called twice in the two-phase pattern: once before
|
|
``dom.merge_divided`` for adjacency/level/vertical checks, and once after
|
|
for storey processing.
|
|
"""
|
|
return [geometry.leaf_graph(lvl, door_width) for lvl in levels(root)]
|
|
|
|
|
|
def build_graphs_with_circ(
|
|
root: Node,
|
|
door_width: float,
|
|
fail,
|
|
usages: dict[str, str],
|
|
) -> tuple[list[nx.Graph], list[nx.Graph]]:
|
|
"""Build ``(graph_base, graph_circ)`` pairs; mirrors ``setup_storey_graphs``
|
|
in ``Base.pm:217-241``.
|
|
|
|
``graph_base[i]`` is the unfiltered adjacency graph for level i.
|
|
``graph_circ[i]`` is a copy filtered by ``has_circulation``; emits
|
|
"N inaccessible usable space" via ``fail`` if a level is disconnected after
|
|
filtering.
|
|
|
|
Perl clone quirk: ``has_circulation`` removes isolated vertices first, so a
|
|
level with no adjacency edges always fires the "inaccessible" failure.
|
|
"""
|
|
lvls = levels(root)
|
|
graph_base: list[nx.Graph] = []
|
|
graph_circ: list[nx.Graph] = []
|
|
for i, lvl in enumerate(lvls):
|
|
g = geometry.leaf_graph(lvl, door_width)
|
|
graph_base.append(g)
|
|
gc = g.copy()
|
|
if not has_circulation(gc, usages):
|
|
fail(f"{i} inaccessible usable space")
|
|
graph_circ.append(gc)
|
|
return graph_base, graph_circ
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Has_Circulation (Base.pm:487-594)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def _avg_path_len_from(G: nx.Graph, node: Node) -> float:
|
|
"""Average weighted shortest-path length from node to all reachable other nodes.
|
|
|
|
Mirrors Perl's ``$graph->average_path_length($node, undef)`` which uses
|
|
Dijkstra with edge weights (centroid-to-centroid distances, stored as 'weight').
|
|
"""
|
|
try:
|
|
lengths = dict(nx.single_source_dijkstra_path_length(G, node, weight="weight"))
|
|
vals = [v for v in lengths.values() if v > 0]
|
|
return sum(vals) / len(vals) if vals else 0.0
|
|
except Exception:
|
|
return 0.0
|
|
|
|
|
|
def has_circulation(G: nx.Graph, usages: dict[str, str]) -> bool:
|
|
"""Port of ``Urb::Dom::Has_Circulation`` (modifies G in place).
|
|
|
|
``usages`` maps room code -> access-requirement class (homemaker-py-sel,
|
|
DESIGN.md §39.7); it decides which edges are trimmed. It used to be inferred
|
|
from a leaf type's first character, so `la1` "Laundry Room" was trimmed as a
|
|
living room and `tr1` "Treatment Room" as a toilet. Codes absent from the
|
|
map (the generic ``C``/``O``/``S``) have no usage and are never trimmed.
|
|
|
|
Replicates the Perl clone quirk: isolated vertices (degree 0) are removed
|
|
first since Perl's ``Graph::clone`` only copies vertices in edges. After
|
|
that, removes non-usable nodes, trims bedroom/toilet cross-connections, then
|
|
trims excess circulation and outdoor connections using centrality ordering.
|
|
Returns True iff the remaining graph is connected.
|
|
"""
|
|
# Perl clone loses isolated vertices → remove them first
|
|
isolated = [v for v in list(G.nodes()) if G.degree(v) == 0]
|
|
G.remove_nodes_from(isolated)
|
|
|
|
# Remove non-usable nodes (outside above outside etc.)
|
|
non_usable = [v for v in list(G.nodes()) if not dom.is_usable(v)]
|
|
G.remove_nodes_from(non_usable)
|
|
|
|
def _usage(v: Node) -> str:
|
|
return usages.get(v.type, "")
|
|
|
|
# A TERMINAL room (bedroom/utility) is reachable from circulation or outside
|
|
# only — never a route through. Trim its edges to every other room.
|
|
for v in list(G.nodes()):
|
|
if _usage(v) not in _pr.PRIVATE_USAGES:
|
|
continue
|
|
to_remove = [nb for nb in list(G.neighbors(v))
|
|
if _usage(nb) in _pr.PRIVATE_STRIPS]
|
|
G.remove_edges_from((v, nb) for nb in to_remove)
|
|
|
|
# A toilet keeps its edge to a terminal room (the Brand adjacency, §39.6)
|
|
# and loses outside/living/kitchen/toilet.
|
|
for v in list(G.nodes()):
|
|
if _usage(v) != "toilet":
|
|
continue
|
|
to_remove = [nb for nb in list(G.neighbors(v))
|
|
if nb.type in dom.GENERIC_OUTSIDE
|
|
or _usage(nb) in _pr.TOILET_STRIPS]
|
|
G.remove_edges_from((v, nb) for nb in to_remove)
|
|
|
|
# Any classified room keeps only one circulation neighbour.
|
|
for v in list(G.nodes()):
|
|
if _usage(v) not in _pr.PRIVATE_USAGES + ("toilet",) + _pr.SOCIABLE_USAGES:
|
|
continue
|
|
circ_nbs = [nb for nb in list(G.neighbors(v)) if dom.is_circulation(nb)]
|
|
if len(circ_nbs) <= 1:
|
|
continue
|
|
circ_nbs.sort(key=lambda nb: _avg_path_len_from(G, nb))
|
|
# terminal rooms and toilets keep their LEAST central circulation
|
|
# neighbour (privacy); sociable rooms keep their MOST central one.
|
|
sociable = _usage(v) in _pr.SOCIABLE_USAGES
|
|
while len(circ_nbs) > 1:
|
|
if not sociable:
|
|
G.remove_edge(v, circ_nbs.pop(0))
|
|
else:
|
|
G.remove_edge(v, circ_nbs.pop())
|
|
|
|
# Clone the current state and run Connected_Outside to get outdoor components
|
|
outside_graph = G.copy()
|
|
_connected_outside_inplace(outside_graph)
|
|
outside_components = list(nx.connected_components(outside_graph)) if len(outside_graph.nodes()) > 0 else []
|
|
|
|
# blkc nodes: keep only one outdoor neighbour per outdoor component
|
|
for v in list(G.nodes()):
|
|
# terminal rooms, sociable rooms, and generic circulation
|
|
if not (_usage(v) in _pr.PRIVATE_USAGES + _pr.SOCIABLE_USAGES
|
|
or dom.is_circulation(v)):
|
|
continue
|
|
out_nbs = [
|
|
nb for nb in list(G.neighbors(v))
|
|
if dom.is_outside(nb) and dom.is_usable(nb)
|
|
]
|
|
if len(out_nbs) <= 1:
|
|
continue
|
|
out_nbs.sort(key=lambda nb: _avg_path_len_from(G, nb))
|
|
|
|
for component in outside_components:
|
|
component_nbs = [nb for nb in out_nbs if nb in component]
|
|
if len(component_nbs) <= 1:
|
|
continue
|
|
terminal = _usage(v) in _pr.PRIVATE_USAGES
|
|
while len(component_nbs) > 1:
|
|
if terminal:
|
|
nb = component_nbs.pop(0)
|
|
else:
|
|
nb = component_nbs.pop()
|
|
if G.has_edge(v, nb):
|
|
G.remove_edge(v, nb)
|
|
|
|
if len(G.nodes()) == 0:
|
|
return False
|
|
return nx.is_connected(G)
|
|
|
|
|
|
def _connected_outside_inplace(G: nx.Graph) -> None:
|
|
"""Remove all non-outside/non-usable vertices; mirrors ``Connected_Outside``."""
|
|
to_remove = [v for v in list(G.nodes()) if not (dom.is_outside(v) and dom.is_usable(v))]
|
|
G.remove_nodes_from(to_remove)
|
|
|
|
|
|
def circulation_connectivity(G: nx.Graph) -> float:
|
|
"""Fraction of circulation cells in the largest connected circulation
|
|
component — a continuous [0,1] proximity to a single connected circulation
|
|
spine (1.0 = fully connected, lower = more fragmented, 0.0 = no circulation).
|
|
|
|
Companion graded signal for the binary ``level N not connected`` fail
|
|
(``connected_circulation``, homemaker-py-qi6). That fail fires identically
|
|
whether a level's circulation is split into 2 components or 7, so it is FLAT
|
|
across fragmentation and gives the outer search no gradient to climb toward
|
|
connectivity. This proxy restores the gradient: among equally-failing
|
|
layouts, the one whose circulation is closer to a single component scores
|
|
higher. Measured on the same circ subgraph the fail uses (all non-circulation
|
|
vertices removed), so the two agree at the connected endpoint (proxy == 1.0
|
|
iff ``connected_circulation`` is True on a non-empty circ set).
|
|
"""
|
|
gc = G.copy()
|
|
gc.remove_nodes_from(
|
|
[v for v in list(gc.nodes()) if not dom.is_circulation(v)]
|
|
)
|
|
n = gc.number_of_nodes()
|
|
if n == 0:
|
|
return 0.0
|
|
largest = max((len(c) for c in nx.connected_components(gc)), default=0)
|
|
return largest / n
|
|
|
|
|
|
def connected_circulation(G: nx.Graph) -> bool:
|
|
"""True iff circulation nodes are non-empty and connected; mirrors
|
|
``Urb::Dom::Connected_Circulation`` (Storey.pm:106).
|
|
|
|
Removes all non-circulation vertices from G in place before checking.
|
|
Perl's ``Graph::is_connected`` returns False for an empty graph — replicated
|
|
here so "level N not connected" fires when there are no circulation nodes.
|
|
"""
|
|
to_remove = [v for v in list(G.nodes()) if not dom.is_circulation(v)]
|
|
G.remove_nodes_from(to_remove)
|
|
if len(G.nodes()) == 0:
|
|
return False # Perl Graph::is_connected returns false for empty graph
|
|
return nx.is_connected(G)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Stair-corner detection (Quad.pm:1490-1544, Dom.pm:648-668)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def _corners_of(leaf: Node) -> list[list[float] | None]:
|
|
"""4 corner coordinates of leaf (index 0-3), with None at index 4 to
|
|
replicate Perl's undef-array-element behaviour in ``Corners_In_Use``."""
|
|
return [geometry.coordinate(leaf, i) for i in range(4)] + [None]
|
|
|
|
|
|
def corners_in_use(
|
|
leaf: Node, G: nx.Graph, neighbors: list[Node]
|
|
) -> list[int]:
|
|
"""Return the minimum set of consecutive corner indices needed to contain
|
|
all shared walls; mirrors ``Urb::Quad::Corners_In_Use`` (Quad.pm:1490).
|
|
|
|
Returns raw consecutive indices — may include values > 3 (e.g. [3,4] or
|
|
[3,4,5]); the caller (``stack_corners_in_use``) normalises with % 4 after
|
|
rotation remapping.
|
|
|
|
Perl's ``corners[4]`` is undef. ``is_between_2d(point, undef, undef)``
|
|
always returns True in Perl (distance_2d(undef,x)=0 so abs(0-0-0)<1e-6).
|
|
This means the triple check at idx=3 always succeeds in Perl, so [3,4,5]
|
|
is always returned when no shorter span works — equivalent to [0,1,3] after
|
|
rotation-normalisation. ``_ib`` replicates that: both-None → True,
|
|
one-None → False.
|
|
"""
|
|
walls: list[list] = []
|
|
for nb in neighbors:
|
|
if G.has_edge(leaf, nb):
|
|
coords = G[leaf][nb].get("coordinates")
|
|
if coords is not None:
|
|
walls.append(coords)
|
|
|
|
corners = _corners_of(leaf) # len==5, corners[4]=None
|
|
|
|
ib = geometry.is_between_2d # (point, pa, pb) — handles point=None
|
|
|
|
def _ib(point, pa, pb):
|
|
if pa is None and pb is None:
|
|
return True # Perl: is_between_2d(point,undef,undef) always True
|
|
if pa is None or pb is None:
|
|
return False
|
|
return ib(point, pa, pb)
|
|
|
|
# Try single corner
|
|
for idx in range(4):
|
|
c = corners[idx]
|
|
if all(ib(c, w[0], w[1]) for w in walls):
|
|
return [idx]
|
|
|
|
# Try pair (idx, idx+1); corners[4]=None → _ib returns False
|
|
for idx in range(4):
|
|
c0, c1 = corners[idx], corners[idx + 1]
|
|
ok = True
|
|
for w in walls:
|
|
if ib(c0, w[0], w[1]):
|
|
continue
|
|
if ib(c1, w[0], w[1]):
|
|
continue
|
|
if _ib(w[0], c0, c1):
|
|
continue
|
|
if _ib(w[1], c0, c1):
|
|
continue
|
|
ok = False
|
|
break
|
|
if ok:
|
|
return [idx, idx + 1] # raw; may be [3,4]
|
|
|
|
# Try triple (idx, idx+1, idx+2); corners[4] and corners[5]=None → _ib False
|
|
for idx in range(4):
|
|
c0 = corners[idx]
|
|
c1 = corners[idx + 1]
|
|
c2 = corners[idx + 2] if idx + 2 < len(corners) else None
|
|
ok = True
|
|
for w in walls:
|
|
if ib(c0, w[0], w[1]):
|
|
continue
|
|
if ib(c1, w[0], w[1]):
|
|
continue
|
|
if ib(c2, w[0], w[1]):
|
|
continue
|
|
if _ib(w[0], c0, c1):
|
|
continue
|
|
if _ib(w[1], c0, c1):
|
|
continue
|
|
if _ib(w[0], c1, c2):
|
|
continue
|
|
if _ib(w[1], c1, c2):
|
|
continue
|
|
ok = False
|
|
break
|
|
if ok:
|
|
return [idx, idx + 1, idx + 2] # raw; may be [3,4,5]
|
|
|
|
return [0, 1, 2, 3]
|
|
|
|
|
|
def _stack_levels_above(leaf: Node) -> list[Node]:
|
|
"""Same-path nodes on all levels above leaf; mirrors ``Levels_Above`` on a leaf."""
|
|
result: list[Node] = []
|
|
n = leaf
|
|
while True:
|
|
above = dom._above_node(n)
|
|
if above is None:
|
|
break
|
|
result.append(above)
|
|
n = above
|
|
return result
|
|
|
|
|
|
def _ground_rotation(node: Node) -> int:
|
|
"""Rotation of the lowest below node; mirrors Perl's ``Rotation()`` method.
|
|
|
|
Perl's ``Rotation`` returns ``$self->Below->Rotation`` when Below is
|
|
defined, so upper-storey nodes always report the ground-floor rotation.
|
|
Using the raw ``node.rotation`` (stored per-level) would give wrong
|
|
rotation corrections in ``stack_corners_in_use``.
|
|
"""
|
|
while node.below is not None:
|
|
node = node.below
|
|
return node.rotation
|
|
|
|
|
|
def stack_corners_in_use(
|
|
leaf: Node,
|
|
graph_circ_list: list[nx.Graph],
|
|
all_levels: list[Node],
|
|
) -> list[int]:
|
|
"""Minimum set of corners in use for the vertical stair stack; mirrors
|
|
``Urb::Dom::Stack_Corners_In_Use``.
|
|
|
|
Returns [] if the stack does not span all levels above leaf, or if any
|
|
level's node is not circulation type.
|
|
"""
|
|
if leaf.type != "C":
|
|
return []
|
|
|
|
stack = [leaf] + _stack_levels_above(leaf)
|
|
|
|
# The stack must span ALL levels (leaf's level + all above)
|
|
li = _level_index(leaf, all_levels)
|
|
levels_above_count = len(all_levels) - li - 1
|
|
if len(stack) <= levels_above_count:
|
|
return []
|
|
|
|
# All stack nodes must be circulation
|
|
if not all(n.type == "C" for n in stack):
|
|
return []
|
|
|
|
leaf_rot = _ground_rotation(leaf)
|
|
all_corners: set[int] = set()
|
|
for level_offset, node in enumerate(stack):
|
|
level_idx = li + level_offset
|
|
if level_idx >= len(graph_circ_list):
|
|
break
|
|
G = graph_circ_list[level_idx]
|
|
nbs = list(G.neighbors(node)) if G.has_node(node) else []
|
|
in_use = corners_in_use(node, G, nbs)
|
|
# Map to ground-floor rotation frame using ground rotation (Perl
|
|
# Rotation() follows Below chain, so upper nodes use ground rotation)
|
|
node_rot = _ground_rotation(node)
|
|
for c in in_use:
|
|
all_corners.add((c - node_rot + leaf_rot) % 4)
|
|
|
|
return sorted(all_corners)
|
|
|
|
|
|
def _level_index(n: Node, lvls: list[Node]) -> int:
|
|
"""Index of n's storey in ``lvls`` (0 = ground floor)."""
|
|
lr = n
|
|
while lr.parent is not None:
|
|
lr = lr.parent
|
|
return lvls.index(lr)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Adjacency helpers
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def _codes_match_prefix(codes: list[str], tc) -> bool:
|
|
"""Match a neighbour's codes against an adjacency target.
|
|
|
|
``tc`` is either a lowercase prefix string (ordinary programme requirement,
|
|
Perl's ``^target_code`` semantics — a requirement ``t`` matches ``t1``,
|
|
``t2``, …) or, for a GENERIC requirement, the exact set of generic types it
|
|
names (see :func:`_adjacency_target`).
|
|
"""
|
|
if isinstance(tc, frozenset):
|
|
return any(c in tc for c in codes)
|
|
return any(c.lower().startswith(tc) for c in codes)
|
|
|
|
|
|
def code_matches_requirement(code: str, target_code: str) -> bool:
|
|
"""True if one room ``code`` satisfies an ``adjacency:`` requirement.
|
|
|
|
The single place that answers "does this leaf count as the thing the
|
|
programme asked to be next to". Shared with :mod:`homemaker_layout.cpsat`
|
|
so the exact solver optimises the same relation the scorer checks.
|
|
"""
|
|
return _codes_match_prefix([code], _adjacency_target(target_code))
|
|
|
|
|
|
def _adjacency_target(target_code: str):
|
|
"""Resolve one ``adjacency:`` entry to a matcher.
|
|
|
|
§39.4: programmes name the generic types in lowercase (``adjacency: [c, o]``
|
|
— every corpus programme does this), and a case-insensitive PREFIX match
|
|
then let any programme code beginning with that letter satisfy the
|
|
requirement: a room next to ``cr1`` "Common Room" counted as being next to
|
|
circulation. A generic requirement now matches only the generic types it
|
|
names; every other requirement keeps Perl's prefix semantics.
|
|
"""
|
|
tc = target_code.lower()
|
|
if tc == "c":
|
|
return frozenset(dom.GENERIC_CIRCULATION)
|
|
if tc == "o":
|
|
return frozenset(("O",))
|
|
if tc == "s":
|
|
return frozenset(("S",))
|
|
return tc
|
|
|
|
|
|
def has_adjacency(leaf: Node, target_code: str, G: nx.Graph,
|
|
colocate_pairs=(), multi_use: bool = False) -> bool:
|
|
"""True if ``leaf`` (or its nearest graphed ancestor) has a neighbour whose
|
|
type matches ``^target_code`` (case-insensitive prefix); mirrors
|
|
``ProgrammeDriven.pm::has_adjacency``.
|
|
|
|
Walking up to the nearest graphed ancestor handles merged nodes that no
|
|
longer appear as individual vertices in a post-merge graph. Under
|
|
``multi_use`` a neighbour's ``leaf_codes()`` (both type and any live
|
|
co_type) are checked, not just its scalar ``type``.
|
|
"""
|
|
node: Node | None = leaf
|
|
while node is not None and not G.has_node(node):
|
|
node = node.parent
|
|
if node is None:
|
|
return False
|
|
tc = _adjacency_target(target_code)
|
|
for nb in G.neighbors(node):
|
|
if _codes_match_prefix(leaf_codes(nb, colocate_pairs, multi_use), tc):
|
|
return True
|
|
# neighbour might be a merged branch — check its leaves
|
|
for nl in (nb.leaves() if nb.divided else []):
|
|
if _codes_match_prefix(leaf_codes(nl, colocate_pairs, multi_use), tc):
|
|
return True
|
|
return False
|
|
|
|
|
|
def has_vertical_connection(leaf: Node, target_code: str, lvls: list[Node],
|
|
colocate_pairs=(), multi_use: bool = False) -> bool:
|
|
"""True if any leaf on the level directly below has type matching
|
|
``^target_code`` (case-insensitive); mirrors
|
|
``ProgrammeDriven.pm::has_vertical_connection``.
|
|
|
|
FAITHFUL STUB — no spatial-overlap check (ProgrammeDriven.pm:399-423 bug).
|
|
See module docstring for the fidelity decision.
|
|
"""
|
|
li = _level_index(leaf, lvls)
|
|
if li == 0:
|
|
return False
|
|
below_root = lvls[li - 1]
|
|
tc = _adjacency_target(target_code)
|
|
return any(_codes_match_prefix(leaf_codes(bl, colocate_pairs, multi_use), tc)
|
|
for bl in below_root.leaves())
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Space-count detection + failure stacking (ProgrammeDriven.pm:154-215)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def leaf_share(leaf: Node, max_share: int) -> int:
|
|
"""How many same-code rooms a leaf covers under leaf-sharing (erc.3, §13.3).
|
|
|
|
Explicit per-leaf multiplicity: construction stamps ``leaf.share = k`` and
|
|
``leaf.share_type = code``; this is honoured only while ``leaf.type`` still
|
|
equals ``share_type``, so any retype/undivide silently invalidates a stale
|
|
share (a retyped small leaf cannot claim to cover rooms it does not provide).
|
|
Clamped to ``max_share``. Both the count check and ``quality_size`` read this
|
|
one helper so they always agree on ``k``."""
|
|
if leaf.share > 1 and leaf.share_type == leaf.type:
|
|
return min(max_share, leaf.share)
|
|
return 1
|
|
|
|
|
|
def leaf_codes(leaf: Node, colocate_pairs=(), multi_use: bool = False) -> list[str]:
|
|
"""Codes ``leaf`` counts as (homemaker-py-1s3, §26 path b).
|
|
|
|
Normally just ``[leaf.type]``. Under ``multi_use``, a leaf carrying a
|
|
``co_type`` counts as BOTH codes simultaneously — but only while
|
|
``{type, co_type}`` is still a valid declared co-location pair
|
|
(``colocate_pairs``, from ``programme.derive_colocate_pairs``); a generic
|
|
retype mutation that changes ``leaf.type`` out from under a stale
|
|
``co_type`` silently drops it, mirroring ``leaf_share``'s type-guard.
|
|
"""
|
|
if not leaf.type:
|
|
return []
|
|
if multi_use and leaf.co_type and frozenset((leaf.type, leaf.co_type)) in colocate_pairs:
|
|
return [leaf.type, leaf.co_type]
|
|
return [leaf.type]
|
|
|
|
|
|
def check_space_counts(
|
|
root: Node,
|
|
targets: dict[str, SpaceReq],
|
|
leaf_sharing: bool = False,
|
|
max_share: int = 4,
|
|
multi_use: bool = False,
|
|
colocate_pairs=(),
|
|
) -> tuple[list[str], list[str]]:
|
|
"""Check design has exactly the required spaces; mirrors
|
|
``check_space_counts`` in ``ProgrammeDriven.pm:156-215``.
|
|
|
|
Returns ``(failures, missing_ids)`` where:
|
|
- ``failures`` is the stacked failure list: per missing instance, 2 base
|
|
failures plus one placeholder for each of the three quality checks it
|
|
would have faced -- a FIXED 5, independent of how the programme was
|
|
spelled (homemaker-py-1i8); also "too many" for excess spaces.
|
|
- ``missing_ids`` is the list of virtual space ids used to suppress false
|
|
adjacency/level/vertical failures for absent spaces.
|
|
|
|
With ``leaf_sharing`` (erc.3, DESIGN.md §13.3) presence is counted by
|
|
*coverage* not leaf count: one sufficiently large leaf of a code covers
|
|
``round(area/target)`` required instances (capped at ``max_share``), so a
|
|
single shared leaf can satisfy several same-code rooms without a missing
|
|
fail. Default OFF reproduces the strict per-leaf count exactly.
|
|
"""
|
|
# Count spaces by type (case-sensitive, as in Perl exact-match for unique)
|
|
count: dict[str, list[Node]] = {}
|
|
for lvl in levels(root):
|
|
for leaf in lvl.leaves():
|
|
for code in leaf_codes(leaf, colocate_pairs, multi_use):
|
|
count.setdefault(code, []).append(leaf)
|
|
|
|
failures: list[str] = []
|
|
missing: list[str] = []
|
|
|
|
for code, req in targets.items():
|
|
# §39.4: skip only Urb's GENERIC structural types. This used to test
|
|
# code[0].lower(), which silently dropped any programme code beginning
|
|
# with c/o/s from the required set -- 14% of harbor-house. Generic types
|
|
# are never declared in ``spaces`` anyway, so this is now a no-op guard
|
|
# kept for intent rather than a filter that discards real requirements.
|
|
if is_generic(code):
|
|
continue
|
|
|
|
leaves_of = count.get(code, [])
|
|
if leaf_sharing and req.size > 0:
|
|
# Coverage: sum each leaf's explicit (type-guarded) share multiplicity.
|
|
actual = sum(leaf_share(lf, max_share) for lf in leaves_of)
|
|
else:
|
|
actual = len(leaves_of)
|
|
expected = req.count
|
|
|
|
if actual < expected:
|
|
n_missing = expected - actual
|
|
for i in range(1, n_missing + 1):
|
|
mid = code if expected == 1 else f"{code}#{i}"
|
|
# 2 base failures
|
|
failures.append(f"missing required space: {mid}")
|
|
failures.append(f"missing required space: {mid} (critical)")
|
|
missing.append(mid)
|
|
# One placeholder per quality check the missing room WOULD have
|
|
# faced -- always all three (homemaker-py-1i8, DESIGN.md §38.12).
|
|
#
|
|
# These used to be gated on req.has_size/has_width/has_proportion,
|
|
# which record only whether the author TYPED the key in
|
|
# patterns.config, not whether the requirement exists. It always
|
|
# exists: `get_space_params` fills width and proportion from
|
|
# defaults (or derives width from size), so a PRESENT room is
|
|
# checked on all three however its config was spelled --
|
|
# programme-house's `t2` declares `size:` alone and still gets a
|
|
# real width target of 1.633 that it can fail on.
|
|
#
|
|
# So the missing path must mirror the present path. Gating it
|
|
# made one missing room cost 3 fails and another 5, and under
|
|
# `value *= 0.5 ** len(failures)` that is a 4x difference in
|
|
# penalty between two single rooms decided by YAML verbosity --
|
|
# inherited by the tiered comparator, whose primary key n_hard
|
|
# is dominated by these cascades.
|
|
for check in ("size", "width", "proportion"):
|
|
failures.append(f"missing {mid}: would need {check} check")
|
|
|
|
elif actual > expected:
|
|
failures.append(
|
|
f"too many spaces: {code} (found {actual}, expected {expected})"
|
|
)
|
|
|
|
return failures, missing
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Pre-merge checks
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def check_adjacency(
|
|
root: Node,
|
|
targets: dict[str, SpaceReq],
|
|
graph_base: list[nx.Graph],
|
|
missing: list[str],
|
|
multi_use: bool = False,
|
|
colocate_pairs=(),
|
|
) -> list[str]:
|
|
"""Adjacency check failures; mirrors
|
|
``check_adjacency_requirements`` in ``ProgrammeDriven.pm:218-278``.
|
|
|
|
Run on the UNMERGED tree with the pre-merge ``graph_base``.
|
|
"""
|
|
lvls = levels(root)
|
|
missing_set = set(missing)
|
|
failures: list[str] = []
|
|
seen: set[tuple] = set() # dedup per (leaf.id, code, adj_code) like Perl
|
|
|
|
for code, req in targets.items():
|
|
if not req.adjacency:
|
|
continue
|
|
any_missing = any(m == code or m.startswith(f"{code}#") for m in missing_set)
|
|
if any_missing:
|
|
for adj_code in req.adjacency:
|
|
failures.append(f"missing {code}: would need adjacency to {adj_code}")
|
|
continue
|
|
|
|
for lvl in lvls:
|
|
li = lvls.index(lvl)
|
|
for leaf in lvl.leaves():
|
|
if code not in leaf_codes(leaf, colocate_pairs, multi_use):
|
|
continue
|
|
G = graph_base[li]
|
|
for adj_code in req.adjacency:
|
|
key = (leaf.id, *sorted((code, adj_code)))
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
if not has_adjacency(leaf, adj_code, G, colocate_pairs, multi_use):
|
|
failures.append(
|
|
f"{li}/{leaf.id} ({code}) not adjacent to {adj_code}"
|
|
)
|
|
return failures
|
|
|
|
|
|
def check_level_constraints(
|
|
root: Node,
|
|
targets: dict[str, SpaceReq],
|
|
missing: list[str],
|
|
multi_use: bool = False,
|
|
colocate_pairs=(),
|
|
) -> list[str]:
|
|
"""Level constraint failures; mirrors
|
|
``check_level_constraints`` in ``ProgrammeDriven.pm:319-358``.
|
|
"""
|
|
lvls = levels(root)
|
|
missing_set = set(missing)
|
|
failures: list[str] = []
|
|
|
|
for code, req in targets.items():
|
|
if req.level is None:
|
|
continue
|
|
any_missing = any(m == code or m.startswith(f"{code}#") for m in missing_set)
|
|
if any_missing:
|
|
failures.append(f"missing {code}: would need to be on level {req.level}")
|
|
continue
|
|
|
|
for lvl in lvls:
|
|
li = lvls.index(lvl)
|
|
for leaf in lvl.leaves():
|
|
if code not in leaf_codes(leaf, colocate_pairs, multi_use):
|
|
continue
|
|
if li != req.level:
|
|
failures.append(
|
|
f"{code} on wrong level (level {li}, expected {req.level})"
|
|
)
|
|
return failures
|
|
|
|
|
|
def check_vertical_connectivity(
|
|
root: Node,
|
|
targets: dict[str, SpaceReq],
|
|
missing: list[str],
|
|
multi_use: bool = False,
|
|
colocate_pairs=(),
|
|
) -> list[str]:
|
|
"""Vertical connectivity failures; mirrors
|
|
``check_vertical_connectivity_requirements`` in ``ProgrammeDriven.pm:360-397``.
|
|
|
|
Uses the faithful no-overlap stub; see ``has_vertical_connection``.
|
|
"""
|
|
lvls = levels(root)
|
|
missing_set = set(missing)
|
|
failures: list[str] = []
|
|
|
|
for code, req in targets.items():
|
|
if req.requires_below is None:
|
|
continue
|
|
any_missing = any(m == code or m.startswith(f"{code}#") for m in missing_set)
|
|
if any_missing:
|
|
failures.append(
|
|
f"missing {code}: would need connection to {req.requires_below} below"
|
|
)
|
|
continue
|
|
|
|
for lvl in lvls:
|
|
for leaf in lvl.leaves():
|
|
if code not in leaf_codes(leaf, colocate_pairs, multi_use):
|
|
continue
|
|
if not has_vertical_connection(leaf, req.requires_below, lvls,
|
|
colocate_pairs, multi_use):
|
|
failures.append(
|
|
f"{code} not connected to {req.requires_below} below"
|
|
)
|
|
return failures
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Substrate readiness (DESIGN.md §11.3 Stage 1 objective)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
STAIR_MIN_AREA = 6.0 # a C leaf must be at least this big to count as a core
|
|
|
|
|
|
def substrate_readiness(
|
|
base_root: Node,
|
|
reqs: dict[str, SpaceReq],
|
|
n_storeys: int,
|
|
) -> float:
|
|
"""Score in [0,1] of how well a single-storey base can HOST upper floors.
|
|
|
|
Stage 1 must optimise the base as a *substrate*, not merely as a ground floor
|
|
(the §4.2 partial-objective / bungalow trap). Two structural proxies from the
|
|
bead:
|
|
|
|
- **Reserved core**: a vertically-alignable circulation core must already
|
|
exist, so Stage 2 keeps it rather than carving one from scratch. Full credit
|
|
when at least one base ``C`` leaf is at least ``STAIR_MIN_AREA``; otherwise a
|
|
small floor (0.25) so the term still rewards adding/enlarging a core.
|
|
- **Capacity**: enough divisible base footprint to carve the upper-floor room
|
|
set above. ``min(1, usable_base_area / required_upper_area)`` where the
|
|
reserved core area is excluded from the usable footprint.
|
|
|
|
Returns ``core_factor * capacity`` (both in [0,1]).
|
|
"""
|
|
# Parallel staged runs (n_workers>1) score children in pool workers, so this
|
|
# parent-process read is never preceded by the score_with_fails clear that
|
|
# normally keeps geometry._cache cold; without this, evicted trees' freed
|
|
# addresses can alias into freshly unpickled ones (homemaker-py-cvw).
|
|
geometry.clear_cache()
|
|
base_lvl = levels(base_root)[0]
|
|
base_leaves = base_lvl.leaves()
|
|
total_base_area = sum(geometry.area(lf) for lf in base_leaves)
|
|
|
|
core_leaves = [
|
|
lf for lf in base_leaves
|
|
if lf.type == "C" and geometry.area(lf) >= STAIR_MIN_AREA
|
|
]
|
|
core_factor = 1.0 if core_leaves else 0.25
|
|
core_area = max((geometry.area(lf) for lf in core_leaves), default=0.0)
|
|
|
|
# Required floor area on storeys >= 1: level-constrained upper rooms plus the
|
|
# expected share of level-free rooms distributed to upper storeys.
|
|
upper_levels = sum(
|
|
req.size * req.count
|
|
for req in reqs.values()
|
|
if req.level is not None and req.level >= 1
|
|
)
|
|
free_area = sum(
|
|
req.size * req.count
|
|
for code, req in reqs.items()
|
|
if not is_generic(code) and req.level is None
|
|
)
|
|
upper_free = free_area * (n_storeys - 1) / n_storeys if n_storeys > 0 else 0.0
|
|
required_upper_area = upper_levels + upper_free
|
|
|
|
usable = max(0.0, total_base_area - core_area)
|
|
capacity = 1.0 if required_upper_area <= 0 else min(1.0, usable / required_upper_area)
|
|
return core_factor * capacity
|