§39.10: preserving constructed connectivity is NULL — and it reframes §39.9

§39.9 named the upstream fix: keep circulation connected DURING the resize
rather than rebuilding it after. Built and measured. It does not help, and the
reason matters more than the lever.

Both halves of the re-cut do damage, in different proportions per programme.
Freezing rotations and letting only ratios move (% levels connected, 12 seeds):
harbor 100 -> 71 -> 50, health-centre 100 -> 8 -> 8, maple 100 -> 92 -> 67. So
health-centre is destroyed entirely by the ratio and maple mostly by the
rotation; a fix must be able to give back either.

operators._size_divisions_preserving_circulation snapshots every cut, resizes,
then reverts the cuts on the tree path between each circulation pair the resize
broke -- programme fully intact, no retyping, only geometry given back. It works
on connectivity (harbor 50->92%, maple 67->97%, health-centre 8->17%) and costs
area accuracy: constructed-seed fails harbor 96.6->141.5, maple 141.8->175.8,
size fails roughly double. (A greedy single-cut revert barely moved -- it stalls
where no ONE revert helps though two would. Targeting the broken pairs is what
made connectivity work.)

The obvious defence -- raw constructed seeds understate it, the resize is only a
warm start, the inner loop should recover -- was TESTED AND FAILS. Full search,
harbor-house, 12000 evals, seed 1:
  OFF  43 fails,  9 hard, 3 connectivity
  ON   65 fails, 26 hard, 4 connectivity
Worse on every axis, including connectivity itself.

REFRAMING: §39.9's fact stands (the resize destroys 41 of 49 circulation edges)
but is NOT ACTIONABLE, because construction-time connectivity does not determine
final connectivity. The search discards and rebuilds the seeder's circulation
either way, and constraining the seed only spends area quality the search cannot
recover. Together with §39.8 (not an incentive problem) that retires the framing
this thread inherited from §38: connectivity is neither a construction problem
nor an incentive one.

Both flags (repair_circulation, preserve_circulation) stay default off with the
numbers recorded, plus byte-identical-default tests. Do not revisit either
without a new formulation -- the standing this document gives bubble.py.

356 passed (+1 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 15:11:46 +00:00
parent 7c41069226
commit 024311a2c2
No known key found for this signature in database
5 changed files with 224 additions and 6 deletions

File diff suppressed because one or more lines are too long

View file

@ -5577,3 +5577,70 @@ constraint on an existing solve rather than a new repair pass. Filed as
`min_width_generic` (default 1.2) to stop generic leaves collapsing to slivers `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 — 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. between two specific leaves, so the new constraint may belong beside it.
### 39.10 Preserving constructed connectivity through the resize (`homemaker-py-3z0`) — NULL, and it reframes §39.9
§39.9 established that `_size_divisions_from_targets` destroys the connected
circulation the seeder builds, and named the upstream fix: keep the connection
*during* the resize rather than rebuilding it after. Built and measured. **It
does not help, and the reason matters more than the lever.**
**Both halves of the re-cut do damage, in different proportions per programme.**
The resize changes each node's ratio *and* re-picks its rotation. Freezing the
rotations and letting only the ratios move (12 seeds, % of levels connected):
| programme | no resize | ratio only | full resize |
|---|---|---|---|
| harbor-house | 100% | 71% | 50% |
| health-centre | 100% | **8%** | 8% |
| maple-court | 100% | 92% | 67% |
health-centre is destroyed entirely by the ratio; maple-court mostly by the
rotation. So any fix has to be able to give back either.
**`operators._size_divisions_preserving_circulation`** snapshots every cut,
resizes, then reverts the cuts on the tree path between each
circulation-to-circulation pair the resize broke. It keeps the programme
completely intact — no retyping, no displacement, only geometry given back —
and it works on connectivity:
| programme | connected, OFF | ON | fails OFF → ON | hard OFF → ON |
|---|---|---|---|---|
| harbor-house | 50% | **92%** | 96.6 → **141.5** | 46.0 → 65.1 |
| health-centre | 8% | 17% | 62.8 → **76.9** | 24.2 → 25.8 |
| maple-court | 67% | **97%** | 141.8 → **175.8** | 57.4 → 70.9 |
(A first attempt reverted greedily — whichever single cut most reduced the
component count — and barely moved: it stalls on the plateau where no *one*
revert helps though two would. Targeting the specific broken pairs is what
made connectivity work.)
Reverting a cut gives back that subtree's area accuracy, and size failures
roughly double on harbor-house (5.2 → 11.4). The obvious defence is that these
are raw constructed seeds and the inner loop has not run yet — the resize exists
to *warm-start* the ratio optimiser, so a worse warm start might cost nothing
once it converges. **Tested, and the defence fails.** Full search, harbor-house,
12 000 evals, seed 1, both arms:
| | fails | hard | soft | connectivity |
|---|---|---|---|---|
| `preserve_circulation` OFF | **43** | **9** | 34 | **3** |
| `preserve_circulation` ON | 65 | 26 | 39 | 4 |
Worse on every axis — including connectivity itself, the thing it was built to
fix.
**The reframing.** §39.9's finding stands as a fact (the resize really does
destroy 41 of 49 circulation edges) but is **not actionable, because
construction-time connectivity is not what determines final connectivity**. The
search reaches 43 fails with 3 connectivity fails starting from a 50%-connected
seed; forcing the seed to 92% connected yields 65 fails and 4 connectivity
fails. The seeder's circulation topology is not the bottleneck — the search
discards and rebuilds it either way, and constraining the seed only spends area
quality the search then cannot recover.
That also retires the framing this whole thread inherited from §38: connectivity
was never a construction problem *or* an incentive problem (§39.8). Both flags
(`repair_circulation`, `preserve_circulation`) stay default off with these
numbers recorded. **Do not revisit either without a new formulation** — the same
standing this document gives `bubble.py`.

View file

@ -316,6 +316,7 @@ def search(
shapecurve_prune: bool = False, shapecurve_prune: bool = False,
assign_solver: str = "greedy", assign_solver: str = "greedy",
enable_reassign: bool = False, enable_reassign: bool = False,
preserve_circulation: bool = False,
) -> SearchResult: ) -> SearchResult:
"""Run the memetic loop from ``seed_root`` until ``budget`` oracle """Run the memetic loop from ``seed_root`` until ``budget`` oracle
evaluations are consumed. Returns the best individual found; its ``root`` evaluations are consumed. Returns the best individual found; its ``root``
@ -630,7 +631,8 @@ def search(
depth_balanced=depth_balanced, depth_balanced=depth_balanced,
interior_outside=interior_outside, outside_divisor=outside_divisor, interior_outside=interior_outside, outside_divisor=outside_divisor,
construction_beam_width=construction_beam_width, construction_beam_width=construction_beam_width,
multi_use=multi_use, assign_solver=assign_solver) multi_use=multi_use, assign_solver=assign_solver,
preserve_circulation=preserve_circulation)
return (topo, None, child_budget, {}, f"construct/{tag}") return (topo, None, child_budget, {}, f"construct/{tag}")
n = int(rng.integers(max(1, n_target - 1), n_target + 2)) n = int(rng.integers(max(1, n_target - 1), n_target + 2))
return (random_topology(seed_root, n, rng, types), None, child_budget, return (random_topology(seed_root, n, rng, types), None, child_budget,

View file

@ -876,6 +876,129 @@ def _size_divisions_from_targets(lvl: dom.Node, reqs, fmin: float = 0.04,
geometry.clear_cache() geometry.clear_cache()
def _circ_components(lvl: dom.Node) -> int:
"""Number of connected components among this storey's circulation leaves."""
import networkx as nx
from . import geometry as _geo, graph as _graph
_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 0
return nx.number_connected_components(G.subgraph(circ))
def _circ_edges(lvl: dom.Node) -> list[tuple]:
"""Circulation-to-circulation adjacencies on this storey, as leaf pairs."""
from . import geometry as _geo, graph as _graph
_geo.clear_cache()
G = _geo.leaf_graph(lvl, _graph.DOOR_WIDTH)
return [(a, b) for a, b in G.edges()
if dom.is_circulation(a) and dom.is_circulation(b)]
def _circ_edge_absent(lvl: dom.Node, a: dom.Node, b: dom.Node) -> bool:
from . import geometry as _geo, graph as _graph
_geo.clear_cache()
G = _geo.leaf_graph(lvl, _graph.DOOR_WIDTH)
return not (G.has_node(a) and G.has_node(b) and G.has_edge(a, b))
def _size_divisions_preserving_circulation(lvl: dom.Node, reqs,
max_reverts: int = 12, **kw) -> int:
"""Resize toward the programme's area targets WITHOUT severing circulation.
homemaker-py-3z0 (DESIGN.md §39.10). ``_assign_adjacency_aware`` picks
circulation as a CONNECTED dominating set, then
``_size_divisions_from_targets`` re-cuts every node new ratio *and* new
rotation and the shared boundaries the dominating set relied on shrink
below ``door_width`` or vanish. Measured (§39.9): fully-connected constructed
seeds 1/20, 1/20, 0/20 across the corpus, against 100% with the resize
skipped entirely. Both halves of the re-cut do damage, in different
proportions per programme on health-centre it is entirely the ratio, on
maple-court mostly the rotation so a fix has to be able to give back
either.
Rather than rebuild the connection afterwards by retyping rooms to ``C``
(measured a net loss it displaces required rooms at a 3-5 fail cascade
each, §39.9), this gives back the *geometry* and keeps the programme intact:
snapshot every cut, resize, then greedily revert whichever single cut most
reduces the circulation component count until the storey is connected again.
Reverting a cut costs only the area-target accuracy of that one subtree, and
the inner loop optimises ratios anyway nothing is displaced and no label
changes.
Returns the number of cuts reverted.
"""
from . import geometry as _geo
nodes = []
def _walk(node: dom.Node) -> None:
if node.divided:
nodes.append(node)
_walk(node.left)
_walk(node.right)
_walk(lvl)
before = {id(x): (x.rotation, list(x.division) if x.division else None)
for x in nodes}
_pre_circ_edges = _circ_edges(lvl)
_size_divisions_from_targets(lvl, reqs, **kw)
if _circ_components(lvl) <= 1:
_geo.clear_cache()
return 0
# Which circulation pairs were adjacent BEFORE the re-cut and are not now?
# Those are the connections the resize broke, and the cuts that govern each
# are exactly the ones between the two leaves — so revert those, rather than
# hunting for a single cut that happens to reduce the component count. A
# plain greedy gets stuck: often no ONE revert helps even though two would.
def _paths_between(a: dom.Node, b: dom.Node) -> list[dom.Node]:
"""Divided nodes on the tree path joining two leaves (via their LCA)."""
def _chain(x: dom.Node) -> list[dom.Node]:
out = []
while x is not None:
out.append(x)
x = x.parent
return out
ca, cb = _chain(a), _chain(b)
common = set(map(id, cb))
lca = next((x for x in ca if id(x) in common), None)
if lca is None:
return []
seen, out = set(), []
for chain in (ca, cb):
for x in chain:
if x.divided and id(x) not in seen:
out.append(x)
seen.add(id(x))
if x is lca:
break
return out
broken = [(a, b) for a, b in _pre_circ_edges
if _circ_edge_absent(lvl, a, b)]
reverted = 0
for a, b in broken:
if _circ_components(lvl) <= 1 or reverted >= max_reverts:
break
for node in _paths_between(a, b):
rot, div = before.get(id(node), (None, None))
if div is None or (node.rotation == rot and node.division == div):
continue
node.rotation, node.division = rot, list(div)
reverted += 1
_geo.clear_cache()
return reverted
def _grow_balanced(node: dom.Node, code: str, k: int) -> None: def _grow_balanced(node: dom.Node, code: str, k: int) -> None:
"""Turn ``node`` (a leaf) into a balanced binary subtree of ``k`` leaves, all """Turn ``node`` (a leaf) into a balanced binary subtree of ``k`` leaves, all
typed ``code``. Split ratio/rotation are placeholders ([0.5,0.5], rot 0); typed ``code``. Split ratio/rotation are placeholders ([0.5,0.5], rot 0);
@ -1282,7 +1405,8 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
construction_beam_width: int = 1, construction_beam_width: int = 1,
multi_use: bool = False, multi_use: bool = False,
assign_solver: str = "greedy", assign_solver: str = "greedy",
repair_circulation: bool = False) -> dom.Node: repair_circulation: bool = False,
preserve_circulation: bool = False) -> dom.Node:
"""Build a seed that instantiates every required space by construction. """Build a seed that instantiates every required space by construction.
The §11.0 diagnosis: random divide+retype chains leave required programme The §11.0 diagnosis: random divide+retype chains leave required programme
@ -1387,8 +1511,9 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
leaf_co = _leaf_colocate_from_plan(lvl, colocate_plan, reqs) if multi_use else {} leaf_co = _leaf_colocate_from_plan(lvl, colocate_plan, reqs) if multi_use else {}
leaf_extra = {lf: reqs[co].size for lf, co in leaf_co.items() leaf_extra = {lf: reqs[co].size for lf, co in leaf_co.items()
if co in reqs and reqs[co].size > 0} if co in reqs and reqs[co].size > 0}
_size_divisions_from_targets( _resize = (_size_divisions_preserving_circulation if preserve_circulation
lvl, reqs, leaf_mult=_leaf_mult_from_plan(lvl, share_plan), else _size_divisions_from_targets)
_resize(lvl, reqs, leaf_mult=_leaf_mult_from_plan(lvl, share_plan),
leaf_extra=leaf_extra) leaf_extra=leaf_extra)
if adjacency_aware and assign_solver == "cpsat": if adjacency_aware and assign_solver == "cpsat":
_cpsat_relabel_settled(lvl, reqs) _cpsat_relabel_settled(lvl, reqs)

View file

@ -999,3 +999,27 @@ def test_repair_circulation_reconnects_every_storey():
on_ok, on_tot = levels_connected(True) on_ok, on_tot = levels_connected(True)
assert on_ok == on_tot, f"repair left {on_tot - on_ok} storeys disconnected" 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}" assert on_ok > off_ok, f"repair did not help: {off_ok}/{off_tot} -> {on_ok}/{on_tot}"
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
def test_preserve_circulation_default_off_reproduces_prior_seeds():
"""§39.10 measured NULL, so the default must stay byte-identical."""
from homemaker_layout import geometry, 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):
geometry.clear_cache()
root = operators.constructive_topology(
seed, reqs, np.random.default_rng(5), types, **kw, **extra)
geometry.clear_cache()
return tuple((lf.type, round(geometry.area(lf), 6))
for lvl in dom.levels(root) for lf in lvl.leaves())
assert sig() == sig(preserve_circulation=False)
# ...and it does change something when enabled, or the A/B measured nothing
assert sig() != sig(preserve_circulation=True)