§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:
parent
7c41069226
commit
024311a2c2
5 changed files with 224 additions and 6 deletions
File diff suppressed because one or more lines are too long
67
DESIGN.md
67
DESIGN.md
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,9 +1511,10 @@ 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)
|
||||||
leaf_extra=leaf_extra)
|
_resize(lvl, reqs, leaf_mult=_leaf_mult_from_plan(lvl, share_plan),
|
||||||
|
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)
|
||||||
if repair_circulation:
|
if repair_circulation:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue