§39.9: level-not-connected is destroyed by the resize, not by the search

Answers homemaker-py-yql. §39.8 established the search is not PAID to sever
circulation; this establishes where connectivity actually goes.

CONSTRUCTED, THEN LOST -- at construction time, in the resize.
_assign_adjacency_aware picks circulation as a CONNECTED dominating set and
succeeds every time. _size_divisions_from_targets then moves every wall to hit
the programme's area targets and destroys it.

Measured over 20 constructed seeds per programme, fully-connected seeds:
harbor-house 1/20, health-centre 1/20, maple-court 0/20. The control -- same
seeds with proportion_aware=False, i.e. no resize -- is 100% connected on all
three. Mechanism confirmed on health-centre: 41 of 49 circulation-to-circulation
edges destroyed by the resize, surviving shared walls squeezed to 0.54-1.11 m
against door_width=1.2, so they stop counting as edges. This is the failure mode
§37.7 recorded for CP-SAT assignment, never looked for in connectivity, where it
costs 35-95 points.

§39.7 COST CHECK: zero. Identical rates under prefix-inferred vs declared
usages -- has_circulation never trims C-C edges, so last commit's usage change
could not and did not make connectivity harder to achieve.

REPAIR MEASURED NEGATIVE. operators.repair_circulation_settled applies §37.7's
own alternating-minimisation fix (re-connect against the settled geometry by
retyping the cheapest bridging leaves to C). It restores 100% connectivity on
all three programmes -- and is still the wrong trade: connectivity fails fall
0.8-1.7 per seed while missing-room fails rise 5.0-8.5, because every retyped
leaf displaces a required room at a 3-5 fail cascade (§38.5). Kept default off
with the write-up, per house style for a null lever, plus a byte-identical
default test and a test asserting it does reconnect every storey.

NEXT LEVER, FILED: preserve the connection during the resize (constrain
_size_divisions_from_targets so a shared C-C boundary cannot fall below
door_width) rather than rebuild it afterwards at the programme's expense --
a constraint on an existing solve, not a new repair pass. solver.py's existing
min_width_generic is the same idea applied to leaf width rather than to a shared
boundary, so it may belong beside it.

Adds experiments/diag_connectivity_yql.py (construct / cost / survive reports).
355 passed (+2 new), 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
This commit is contained in:
Claude 2026-08-26 14:39:07 +00:00
parent 3aee813ccd
commit 7c41069226
No known key found for this signature in database
5 changed files with 392 additions and 2 deletions

File diff suppressed because one or more lines are too long

View file

@ -5494,3 +5494,86 @@ question (`homemaker-py-yql`) — the evidence now says it is a reachability pro
(connected topologies are hard to construct and hold onto), not an incentive
one. It is newly measurable: §39.7 made the fails fire on constructed seeds
instead of being hidden by routes through store cupboards.
### 39.9 Why `level N not connected` persists: the resize destroys it (`homemaker-py-yql`)
§39.8 closed `2v1` NULL — severing circulation is already punished, so the fail
is not something the search is paid to create. That left the real question: is a
connected layout **rarely constructed**, or **constructed and then lost**?
**Answer: constructed, then lost — at construction time, in the resize.**
`level N not connected` fires from `graph.connected_circulation`, which keeps
only the generic `C`/`S` leaves and asks whether *they* form one component.
Measured over 20 constructed seeds per programme
(`experiments/diag_connectivity_yql.py`):
| programme | levels connected | seeds fully connected |
|---|---|---|
| harbor-house | 21/40 (52%) | **1/20** |
| health-centre | 1/20 (5%) | **1/20** |
| maple-court | 39/60 (65%) | **0/20** |
Then the decisive control — the same seeds with `proportion_aware=False`, i.e.
skipping `_size_divisions_from_targets`:
| programme | with resize | **without resize** |
|---|---|---|
| harbor-house | 52% | **100%** |
| health-centre | 5% | **100%** |
| maple-court | 65% | **100%** |
`_assign_adjacency_aware` picks circulation as a **connected** dominating set —
and it succeeds every time. The resize then moves every wall to hit the
programme's area targets, and the shared boundaries the dominating set relied on
shrink or vanish. On health-centre, **41 of 49 circulation-to-circulation edges
are destroyed by the resize**, and surviving shared walls are squeezed to
0.541.11 m against a 1.2 m `door_width`, so they stop counting as edges at all.
This is exactly the failure mode §37.7 recorded for CP-SAT room assignment —
"resizing can shrink a shared-wall segment below the door-width adjacency
threshold, silently invalidating an edge the exact solve relied on" — but nobody
had looked for it in **circulation connectivity**, where it costs 3595 points.
**§39.7 cost check: zero.** The same measurement under prefix-inferred vs
declared usages is identical (52/5/65% both ways). `has_circulation` never trims
`C``C` edges, so the usage change could not and did not make connectivity
harder to achieve.
#### The obvious repair is a net loss — measured
`operators.repair_circulation_settled` applies §37.7's own alternating-
minimisation fix: after the geometry settles, re-connect circulation by retyping
the cheapest bridging leaves to `C` (preferring generic outside, then
unassigned, crossing a required room last — `mutate_bridge_circulation`'s cost
model). It works, completely:
| programme | levels connected, repair OFF | repair ON |
|---|---|---|
| harbor-house | 52% (1/20 seeds full) | **100% (20/20)** |
| health-centre | 5% (1/20) | **100% (20/20)** |
| maple-court | 65% (0/20) | **100% (20/20)** |
And it is still the wrong trade. Mean fails per constructed seed, 12 seeds:
| programme | total | hard | connectivity | missing-room |
|---|---|---|---|---|
| harbor-house | 96.6 → **108.9** | 46.0 → 56.2 | 3.7 → 2.0 | 14.2 → **19.2** |
| health-centre | 62.8 → **84.2** | 24.2 → 46.8 | 2.9 → 2.2 | 2.0 → **10.5** |
| maple-court | 141.8 → **156.6** | 57.4 → 69.2 | 4.7 → 3.5 | 14.8 → **19.8** |
Connectivity failures fall by 0.81.7; missing-room failures rise by 5.08.5,
because every leaf retyped to `C` displaces a required room and each displacement
costs a 35 fail cascade (§38.5). **Robbing Peter to pay Paul.** Kept default
off with this write-up, per house style for a measured-null lever.
**The lever is upstream, not downstream.** The repair is treating a symptom: the
connection should never be destroyed in the first place. The named next move is
to *preserve* it during the resize — constrain `_size_divisions_from_targets` so
a shared boundary between two circulation leaves cannot fall below `door_width`
— rather than to rebuild it afterwards at the cost of the programme. That is a
constraint on an existing solve rather than a new repair pass. Filed as
`homemaker-py-3z0`. Worth noting while there: `solver.py` already carries
`min_width_generic` (default 1.2) to stop generic leaves collapsing to slivers
— the same idea applied to a leaf's WIDTH rather than to a shared BOUNDARY
between two specific leaves, so the new constraint may belong beside it.

View file

@ -0,0 +1,167 @@
"""Why does `level N not connected` persist? (`homemaker-py-yql`, DESIGN.md §39.9)
`homemaker-py-2v1` closed NULL: severing a level's circulation is already
punished, so the fail is not something the search is paid to create. This asks
the follow-on question is a connected layout **rarely constructed**, or
**constructed and then lost**?
`level N not connected` fires from `graph.connected_circulation`, which keeps
only the generic circulation leaves (`C`/`S`) and asks whether *they* form one
connected component. It runs on `graph_circ`, i.e. AFTER
`graph.has_circulation` has trimmed edges, so §39.7's usage change can in
principle reach it report (b) measures whether it did.
Three reports:
construct what fraction of constructed seeds start connected, per level
cost the same, prefix-inferred usages vs declared (the §39.7 cost)
survive from a CONNECTED layout, how often does one mutation break
connectivity, and would the outer comparator keep the mutant
Usage::
python experiments/diag_connectivity_yql.py construct
python experiments/diag_connectivity_yql.py cost
python experiments/diag_connectivity_yql.py survive --seeds 40
"""
from __future__ import annotations
import argparse
import copy
from pathlib import Path
import numpy as np
from homemaker_layout import dom as dom_mod
from homemaker_layout import driver, fitness, geometry
from homemaker_layout import graph as graph_mod
from homemaker_layout import operators, programme
CORPUS = ["examples/harbor-house", "examples/health-centre", "examples/maple-court"]
LEGACY_PREFIX = {"b": "bedroom", "t": "toilet", "l": "living", "k": "kitchen"}
def make_fitness(progdir: str) -> fitness.Fitness:
overrides = driver._overrides_for(
leaf_sharing=True, superpose=False, max_share=None, conn_grade=False,
collapse_insearch=True, multi_use=False)
conf, cost = fitness.load_config(progdir, overrides=dict(overrides or {}))
return fitness.Fitness(conf, cost)
def constructed_seed(progdir: str, seed: int) -> dom_mod.Node:
reqs = programme.load_programme_dir(progdir)
return operators.constructive_topology(
dom_mod.load(f"{progdir}/init.dom"), reqs, np.random.default_rng(seed),
sorted(reqs) + ["C", "O"],
min_storeys=programme.storey_minimum(progdir),
adjacency_aware=True, proportion_aware=True, circ_divisor=3,
leaf_sharing=True, leaf_share_factor=3, depth_balanced=True,
interior_outside=True, outside_divisor=3)
def connectivity(root: dom_mod.Node, usages: dict[str, str]) -> tuple[int, int]:
"""``(levels_connected, levels_total)`` for one tree.
Mirrors the scorer: build the circ graphs, then ask
``connected_circulation`` per level on a copy, exactly as
``process_storey`` does.
"""
tree = copy.deepcopy(root)
geometry.clear_cache()
dom_mod.canonicalize_shares(tree)
_, circ = graph_mod.build_graphs_with_circ(tree, 1.2, lambda _f: None, usages)
connected = sum(1 for gc in circ
if graph_mod.connected_circulation(gc.copy()))
return connected, len(circ)
def report_construct(seeds: int) -> None:
print(f"how often does a CONSTRUCTED seed start connected? ({seeds} seeds)\n")
print(f" {'programme':<18}{'levels connected':<20}{'seeds fully connected'}")
print(" " + "-" * 62)
for progdir in CORPUS:
fit = make_fitness(progdir)
usages = fit.usages()
ok = tot = full = 0
for s in range(seeds):
c, n = connectivity(constructed_seed(progdir, s), usages)
ok += c
tot += n
full += (c == n)
print(f" {Path(progdir).name:<18}{f'{ok}/{tot} ({100*ok/max(tot,1):.0f}%)':<20}"
f"{full}/{seeds}")
def report_cost(seeds: int) -> None:
"""Did §39.7's usage change make level connectivity harder to achieve?"""
print("§39.7 cost check — prefix-inferred usages vs declared "
f"({seeds} seeds)\n")
print(f" {'programme':<18}{'prefix-inferred':<20}{'declared':<20}delta")
print(" " + "-" * 68)
for progdir in CORPUS:
reqs = programme.load_programme_dir(progdir)
declared = {c: r.usage for c, r in reqs.items()}
legacy = {c: LEGACY_PREFIX.get(c[:1].lower(), "none") for c in reqs}
res = {}
for label, usages in (("legacy", legacy), ("declared", declared)):
ok = tot = 0
for s in range(seeds):
c, n = connectivity(constructed_seed(progdir, s), usages)
ok += c
tot += n
res[label] = (ok, tot)
(a, ta), (b, tb) = res["legacy"], res["declared"]
delta = 100 * b / max(tb, 1) - 100 * a / max(ta, 1)
print(f" {Path(progdir).name:<18}"
f"{f'{a}/{ta} ({100*a/max(ta,1):.0f}%)':<20}"
f"{f'{b}/{tb} ({100*b/max(tb,1):.0f}%)':<20}{delta:+.0f} pts")
def report_survive(seeds: int) -> None:
"""From a CONNECTED level, how fragile is that connectivity under one
mutation and would the comparator keep the mutant anyway?"""
print(f"survival of connectivity under one mutation ({seeds} trials)\n")
print(f" {'programme':<18}{'started connected':<20}{'broken by mutation':<22}"
f"{'…and kept by comparator'}")
print(" " + "-" * 82)
for progdir in CORPUS:
fit = make_fitness(progdir)
usages = fit.usages()
reqs = programme.load_programme_dir(progdir)
types = sorted(reqs) + ["C", "O"]
started = broken = kept = 0
rng = np.random.default_rng(0)
for s in range(seeds):
root = constructed_seed(progdir, s)
c, n = connectivity(root, usages)
if c != n:
continue # only study layouts that ARE connected
started += 1
base_score, base_fails = fit.score_with_fails(copy.deepcopy(root))
child, _desc = operators.mutate(root, rng, types, reqs=reqs)
c2, n2 = connectivity(child, usages)
if c2 == n2:
continue
broken += 1
# would the outer loop admit it? lexicographic (-n_fails, fitness)
score, fails = fit.score_with_fails(copy.deepcopy(child))
if (-len(fails), score) > (-len(base_fails), base_score):
kept += 1
print(f" {Path(progdir).name:<18}{f'{started}/{seeds}':<20}"
f"{f'{broken}/{max(started,1)}':<22}{kept}")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("report", choices=("construct", "cost", "survive"))
ap.add_argument("--seeds", type=int, default=20)
args = ap.parse_args()
{"construct": report_construct, "cost": report_cost,
"survive": report_survive}[args.report](args.seeds)
if __name__ == "__main__":
main()

View file

@ -460,6 +460,80 @@ def mutate_bridge_circulation(root: dom.Node, rng: np.random.Generator,
return _finalise(child), f"bridge_circulation lvl{li}: {names} -> C"
def repair_circulation_settled(lvl: dom.Node, reqs, max_bridges: int = 8) -> int:
"""Reconnect a storey's circulation AFTER the geometry has settled.
homemaker-py-yql (DESIGN.md §39.9). ``_assign_adjacency_aware`` picks
circulation as a CONNECTED dominating set, but it does so against the
pre-resize geometry; ``_size_divisions_from_targets`` then moves every wall
to hit the programme's area targets and the shared boundaries the dominating
set relied on shrink below ``door_width`` or vanish outright. Measured on
health-centre: 41 of 49 circulation-to-circulation edges destroyed by the
resize, surviving shared walls squeezed to 0.54-1.11 m against a 1.2 m
threshold so only 5% of constructed seeds started connected, against 100%
with the resize disabled.
This is the same alternating-minimisation fix §37.7 applied to room
assignment (``_cpsat_relabel_settled``): re-run the step against the
geometry that actually resulted. Retypes the cheapest bridging leaves to
``C``, preferring generic outside, then unassigned, and crossing a required
room last the cost model ``mutate_bridge_circulation`` already uses.
Returns the number of leaves retyped. Idempotent once connected.
"""
import networkx as nx
from . import geometry as _geo, graph as _graph
def _cost(node: dom.Node) -> int:
if dom.is_circulation(node):
return 0
if not node.type:
return 1
if node.type in dom.GENERIC_OUTSIDE:
return 0
if reqs and node.type in reqs:
return 5
return 1
retyped = 0
for _ in range(max_bridges):
_geo.clear_cache()
G = _geo.leaf_graph(lvl, _graph.DOOR_WIDTH)
circ = [x for x in G.nodes() if dom.is_circulation(x)]
if not circ:
return retyped
comps = list(nx.connected_components(G.subgraph(circ)))
if len(comps) <= 1:
return retyped
weighted = G.copy()
for u, v, data in weighted.edges(data=True):
data["bridge_weight"] = (_cost(u) + _cost(v)) / 2.0
best_path = None
best_weight = None
for i in range(len(comps)):
for j in range(i + 1, len(comps)):
for a in comps[i]:
for b in comps[j]:
try:
path = nx.shortest_path(weighted, a, b,
weight="bridge_weight")
except nx.NetworkXNoPath:
continue
w = sum(_cost(x) for x in path[1:-1])
if best_weight is None or w < best_weight:
best_weight, best_path = w, path
if not best_path:
return retyped
middle = [x for x in best_path[1:-1] if not dom.is_circulation(x)]
if not middle:
return retyped # components already touch; nothing to retype
for leaf in middle:
leaf.type = "C"
retyped += 1
return retyped
def _shape_failing(leaf: dom.Node, fit) -> bool:
"""A named-room leaf whose width or proportion factor actually fails
(``< fitness.FAIL_THRESHOLD``) under ``fit``, the same Gaussian quality
@ -1207,7 +1281,8 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
outside_divisor: int = 3,
construction_beam_width: int = 1,
multi_use: bool = False,
assign_solver: str = "greedy") -> dom.Node:
assign_solver: str = "greedy",
repair_circulation: bool = False) -> dom.Node:
"""Build a seed that instantiates every required space by construction.
The §11.0 diagnosis: random divide+retype chains leave required programme
@ -1317,6 +1392,8 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
leaf_extra=leaf_extra)
if adjacency_aware and assign_solver == "cpsat":
_cpsat_relabel_settled(lvl, reqs)
if repair_circulation:
repair_circulation_settled(lvl, reqs)
return _finalise(child)

View file

@ -937,3 +937,65 @@ def test_assign_cpsat_beats_greedy_on_a_namespace_clean_programme():
return total
assert secondary_fails("cpsat") < secondary_fails("greedy")
# --------------------------------------------------------------------------- #
# homemaker-py-yql / DESIGN.md §39.9 — settled-geometry circulation repair
# --------------------------------------------------------------------------- #
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
def test_repair_circulation_default_off_reproduces_prior_seeds():
"""Default off must be byte-identical, like every other experimental flag."""
from homemaker_layout import programme
reqs = programme.load_programme_dir(str(HARBOR))
types = sorted(reqs) + ["C", "O"]
seed = dom.load(str(HARBOR / "init.dom"))
kw = dict(min_storeys=programme.storey_minimum(str(HARBOR)),
adjacency_aware=True, proportion_aware=True, circ_divisor=3)
def sig(**extra):
root = operators.constructive_topology(
seed, reqs, np.random.default_rng(3), types, **kw, **extra)
return tuple(lf.type for lvl in dom.levels(root) for lf in lvl.leaves())
assert sig() == sig(repair_circulation=False)
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
def test_repair_circulation_reconnects_every_storey():
"""§39.9: the constructed circulation dominating set is connected, but
_size_divisions_from_targets then moves every wall and the shared
boundaries it relied on drop below door_width. Repairing against the
SETTLED geometry restores connectivity measured 52% -> 100% of levels on
harbor-house. (Whether that is a net WIN is a different question: it is
not, see §39.9 it displaces required rooms. Hence default off.)
"""
import networkx as nx
from homemaker_layout import geometry, graph as graph_mod, programme
reqs = programme.load_programme_dir(str(HARBOR))
types = sorted(reqs) + ["C", "O"]
seed = dom.load(str(HARBOR / "init.dom"))
def levels_connected(repair: bool) -> tuple[int, int]:
ok = tot = 0
for s in range(6):
root = operators.constructive_topology(
seed, reqs, np.random.default_rng(s), types,
min_storeys=programme.storey_minimum(str(HARBOR)),
adjacency_aware=True, proportion_aware=True, circ_divisor=3,
repair_circulation=repair)
for lvl in dom.levels(root):
geometry.clear_cache()
G = geometry.leaf_graph(lvl, graph_mod.DOOR_WIDTH)
circ = [n for n in G.nodes() if dom.is_circulation(n)]
tot += 1
if circ and nx.is_connected(G.subgraph(circ)):
ok += 1
return ok, tot
off_ok, off_tot = levels_connected(False)
on_ok, on_tot = levels_connected(True)
assert on_ok == on_tot, f"repair left {on_tot - on_ok} storeys disconnected"
assert on_ok > off_ok, f"repair did not help: {off_ok}/{off_tot} -> {on_ok}/{on_tot}"