homemaker-layout/experiments/diag_connectivity_yql.py
Claude 7c41069226
§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
2026-08-26 14:39:07 +00:00

167 lines
6.8 KiB
Python

"""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()