qi6: graded circulation-connectivity signal (§18)
The dominant post-collapse fail is the binary "level N not connected", which is flat across fragmentation (a 7-component storey scores the same as a 2-component one), so the outer search has no gradient toward connected circulation. A finish-time convert-to-circulation repair was prototyped and measured NEGATIVE (195->560 fails: bridging needed rooms costs more missing-room fails than the one binary fail it clears). Instead add graph.circulation_connectivity(G) = largest-circ-component fraction, summed over storeys onto the score_with_grade proximity channel (conf flag conn_grade; replaces the §11.4 leaf-grade there). It is a secondary comparator key only — scalar fitness and fail count stay byte-identical — restoring the gradient the binary fail lacks. Threaded through driver (_overrides_for/_fitness_for/_evaluate/search; enabling it implies the grade key) and evolve --conn-grade (default off). A/B on full-budget runs pending; short smoke run confirms plumbing. Tests: tests/test_conn_grade.py x9 (fraction contract, non-circ ignored, monotone under (dis)connection, score/fail invariance); 276 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8566xAxTnwtJTkpXjYNZm
This commit is contained in:
parent
1ae8faac7c
commit
94d4223a55
7 changed files with 256 additions and 25 deletions
File diff suppressed because one or more lines are too long
51
DESIGN.md
51
DESIGN.md
|
|
@ -2414,3 +2414,54 @@ the collapse *inside* search per-eval (rather than finish-time) is `homemaker-py
|
||||||
gated on the 9o5 landscape-flattening risk (§13 / `homemaker-py-xi7`) and its own A/B.
|
gated on the 9o5 landscape-flattening risk (§13 / `homemaker-py-xi7`) and its own A/B.
|
||||||
Tests: `tests/test_collapse_global.py` ×6 (demand-set relabel, level hard constraint, c/o/s
|
Tests: `tests/test_collapse_global.py` ×6 (demand-set relabel, level hard constraint, c/o/s
|
||||||
exclusion, no-op safety, keep-better/unmerged); 267 pass.
|
exclusion, no-op safety, keep-better/unmerged); 267 pass.
|
||||||
|
|
||||||
|
## 18. Graded circulation-connectivity signal (`homemaker-py-qi6`) — in progress
|
||||||
|
|
||||||
|
**Motivation — the binary fail is flat.** After the §17 collapse, the residual fails on the
|
||||||
|
harbor-house set are dominated by `level N not connected` (2 of the best layout's 12; also on
|
||||||
|
5 of the 6 sweep layouts). That fail comes from `connected_circulation` (`graph.py`): remove
|
||||||
|
every non-circulation vertex from a storey's adjacency graph and require the remaining
|
||||||
|
circulation cells (`C` stairs plus the `cr`/`st` room-codes that collide with the c/s prefix)
|
||||||
|
to form ONE connected component. On the evolved layouts they instead fragment into **4–7
|
||||||
|
components per storey**.
|
||||||
|
|
||||||
|
**Why finish-time repair fails (measured, negative).** The obvious §17-style companion — a
|
||||||
|
finish-time pass that re-types boundary cells to circulation to bridge the components, kept
|
||||||
|
only if the fail count does not rise — was prototyped (Steiner-MST bridge set per disconnected
|
||||||
|
storey, keep-better guard) and measured on the 6 layouts: **195 → 560 fails (+365)**. The
|
||||||
|
`not connected` fail is *binary* (one fail per storey regardless of fragmentation), but each
|
||||||
|
storey needs 3–7 bridge cells, and every needed-room→circulation conversion triggers a
|
||||||
|
missing-room fail cascade (2–5 fails) that dwarfs the single connectivity fail it clears.
|
||||||
|
Keep-better reverts every one → no-op. **Conclusion: connectivity cannot be bought at finish
|
||||||
|
time when every cell is a needed room; it must come from the outer search allocating connected
|
||||||
|
circulation topology.** But the binary fail gives the search *zero gradient* — a 7-component
|
||||||
|
storey scores identically (both in fail count and in the `0.5^n` scalar) to a 2-component one —
|
||||||
|
so the search cannot tell it is making progress.
|
||||||
|
|
||||||
|
**Mechanism — a graded proximity on the same channel §11.4 built.** `graph.circulation_connectivity(G)`
|
||||||
|
returns the fraction of circulation cells in the largest connected circulation component ∈
|
||||||
|
[0,1] (1.0 = a single connected spine, lower = more fragmented, 0.0 = no circulation), measured
|
||||||
|
on the same circ subgraph the fail uses so the two agree at the connected endpoint. Summed over
|
||||||
|
storeys it is the graded proximity scalar `Fitness.score_with_grade` already carries for the
|
||||||
|
outer comparator, gated by the `conn_grade` conf flag: when on it *replaces* the §11.4 leaf
|
||||||
|
quality-proximity on that channel (a distinct, better-motivated use — §11.4 was rejected because
|
||||||
|
within a fail-tier the `0.5^n` scalar is NOT flat there and grade merely displaced a working
|
||||||
|
signal; connectivity is the opposite case, genuinely flat under the binary fail). Like §11.4 it
|
||||||
|
leaves the scalar fitness and fail count **byte-identical** (verified) — it is only the secondary
|
||||||
|
key `(-n_fails, grade, fitness)` (driver `use_lex and use_grade`), strictly beneath fail-count so
|
||||||
|
the §6 missing-space hierarchy and the §5.4 inner-loop cliff are untouched. Among equally-failing
|
||||||
|
neighbours the search now prefers the one whose circulation is closer to one component, restoring
|
||||||
|
the gradient toward connected topologies.
|
||||||
|
|
||||||
|
**Wiring.** `conn_grade` threads through `_overrides_for`/`_fitness_for`/`_evaluate` and the
|
||||||
|
`search` signature; enabling it implies the grade key. `evolve.py` exposes `--conn-grade`
|
||||||
|
(env `HOMEMAKER_CONN_GRADE`, default OFF); the grade is read off the optimised tree, one extra
|
||||||
|
native eval per child.
|
||||||
|
|
||||||
|
**Status / next.** Signal, fitness wiring, CLI, and 9 tests landed (`tests/test_conn_grade.py`:
|
||||||
|
pure-graph fraction contract, non-circ cells ignored, monotone under (dis)connection, and the
|
||||||
|
score/fail-count-invariance of the flag). The A/B — does the gradient actually pull evolve runs
|
||||||
|
toward connected circulation and clear `not connected` fails — needs full-budget runs and is
|
||||||
|
pending (short 60-eval smoke run confirms the plumbing only). If the graded key alone is
|
||||||
|
insufficient, the follow-on is an insert/relocate-circulation mutation operator (mechanism (a),
|
||||||
|
still `homemaker-py-qi6`) that now has a gradient to climb. 276 tests pass.
|
||||||
|
|
|
||||||
|
|
@ -40,11 +40,14 @@ _CHILD_INNER_KW: dict = {}
|
||||||
|
|
||||||
|
|
||||||
def _overrides_for(leaf_sharing: bool, superpose: bool,
|
def _overrides_for(leaf_sharing: bool, superpose: bool,
|
||||||
max_share: int | None = None) -> dict | None:
|
max_share: int | None = None,
|
||||||
|
conn_grade: bool = False) -> dict | None:
|
||||||
"""Run-level conf overrides for the native evaluator (None when all off).
|
"""Run-level conf overrides for the native evaluator (None when all off).
|
||||||
|
|
||||||
``max_share`` (homemaker-py-kpu) overrides the evaluator's ``leaf_share_max``
|
``max_share`` (homemaker-py-kpu) overrides the evaluator's ``leaf_share_max``
|
||||||
grain cap for the in-run annealing ramp; ``None`` leaves the config default.
|
grain cap for the in-run annealing ramp; ``None`` leaves the config default.
|
||||||
|
``conn_grade`` (homemaker-py-qi6) turns the graded proximity scalar into the
|
||||||
|
circulation-connectivity signal (§18).
|
||||||
"""
|
"""
|
||||||
ov: dict = {}
|
ov: dict = {}
|
||||||
if leaf_sharing:
|
if leaf_sharing:
|
||||||
|
|
@ -53,13 +56,16 @@ def _overrides_for(leaf_sharing: bool, superpose: bool,
|
||||||
ov["superpose"] = True
|
ov["superpose"] = True
|
||||||
if max_share is not None:
|
if max_share is not None:
|
||||||
ov["leaf_share_max"] = int(max_share)
|
ov["leaf_share_max"] = int(max_share)
|
||||||
|
if conn_grade:
|
||||||
|
ov["conn_grade"] = True
|
||||||
return ov or None
|
return ov or None
|
||||||
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=None)
|
@functools.lru_cache(maxsize=None)
|
||||||
def _fitness_for(programme_dir: str, leaf_sharing: bool = False,
|
def _fitness_for(programme_dir: str, leaf_sharing: bool = False,
|
||||||
superpose: bool = False,
|
superpose: bool = False,
|
||||||
max_share: int | None = None) -> "fitness.Fitness":
|
max_share: int | None = None,
|
||||||
|
conn_grade: bool = False) -> "fitness.Fitness":
|
||||||
"""Cached Fitness evaluator per (programme dir, leaf_sharing) (config load is
|
"""Cached Fitness evaluator per (programme dir, leaf_sharing) (config load is
|
||||||
the cost).
|
the cost).
|
||||||
|
|
||||||
|
|
@ -70,7 +76,7 @@ def _fitness_for(programme_dir: str, leaf_sharing: bool = False,
|
||||||
inner loop instead of reading the on-disk (sharing-free) patterns.config.
|
inner loop instead of reading the on-disk (sharing-free) patterns.config.
|
||||||
Cached per process — workers fork their own copy.
|
Cached per process — workers fork their own copy.
|
||||||
"""
|
"""
|
||||||
overrides = _overrides_for(leaf_sharing, superpose, max_share)
|
overrides = _overrides_for(leaf_sharing, superpose, max_share, conn_grade)
|
||||||
conf, cost = fitness.load_config(programme_dir, overrides=overrides)
|
conf, cost = fitness.load_config(programme_dir, overrides=overrides)
|
||||||
return fitness.Fitness(conf, cost)
|
return fitness.Fitness(conf, cost)
|
||||||
|
|
||||||
|
|
@ -146,7 +152,8 @@ def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw,
|
||||||
best_n_fails: int | None = None,
|
best_n_fails: int | None = None,
|
||||||
leaf_sharing: bool = False,
|
leaf_sharing: bool = False,
|
||||||
superpose: bool = False,
|
superpose: bool = False,
|
||||||
max_share: int | None = None) -> tuple[Individual, int]:
|
max_share: int | None = None,
|
||||||
|
conn_grade: bool = False) -> tuple[Individual, int]:
|
||||||
# §12.3 shape-feasibility pre-filter (homemaker-py-9gp.1): if even the best
|
# §12.3 shape-feasibility pre-filter (homemaker-py-9gp.1): if even the best
|
||||||
# achievable (proportion-aware) geometry of this topology already has at least
|
# achievable (proportion-aware) geometry of this topology already has at least
|
||||||
# as many shape fails as the incumbent's TOTAL fails — and exceeds the tunable
|
# as many shape fails as the incumbent's TOTAL fails — and exceeds the tunable
|
||||||
|
|
@ -154,11 +161,12 @@ def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw,
|
||||||
# eval instead of spending the full inner-loop budget. The best_n_fails guard
|
# eval instead of spending the full inner-loop budget. The best_n_fails guard
|
||||||
# makes the proxy safe: a topology whose shape-fail floor is still below the
|
# makes the proxy safe: a topology whose shape-fail floor is still below the
|
||||||
# incumbent is never discarded. Pruned individuals are tagged and never admitted.
|
# incumbent is never discarded. Pruned individuals are tagged and never admitted.
|
||||||
overrides = _overrides_for(leaf_sharing, superpose, max_share)
|
overrides = _overrides_for(leaf_sharing, superpose, max_share, conn_grade)
|
||||||
if (feasibility_max_shape_fails is not None and best_n_fails is not None):
|
if (feasibility_max_shape_fails is not None and best_n_fails is not None):
|
||||||
pred = operators.predicted_shape_fails(
|
pred = operators.predicted_shape_fails(
|
||||||
root, _reqs_for(str(programme_dir)),
|
root, _reqs_for(str(programme_dir)),
|
||||||
_fitness_for(str(programme_dir), leaf_sharing, superpose, max_share))
|
_fitness_for(str(programme_dir), leaf_sharing, superpose, max_share,
|
||||||
|
conn_grade))
|
||||||
if pred > feasibility_max_shape_fails and pred >= best_n_fails:
|
if pred > feasibility_max_shape_fails and pred >= best_n_fails:
|
||||||
ind = Individual(root=root, fitness=0.0, n_fails=pred, ratios={},
|
ind = Individual(root=root, fitness=0.0, n_fails=pred, ratios={},
|
||||||
lineage=f"pruned/{lineage}", grade=0.0,
|
lineage=f"pruned/{lineage}", grade=0.0,
|
||||||
|
|
@ -173,7 +181,8 @@ def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw,
|
||||||
grade = 0.0
|
grade = 0.0
|
||||||
if want_grade:
|
if want_grade:
|
||||||
_, _, grade = _fitness_for(
|
_, _, grade = _fitness_for(
|
||||||
str(programme_dir), leaf_sharing, superpose, max_share).score_with_grade(
|
str(programme_dir), leaf_sharing, superpose, max_share,
|
||||||
|
conn_grade).score_with_grade(
|
||||||
copy.deepcopy(root))
|
copy.deepcopy(root))
|
||||||
ind = Individual(root=root, fitness=r.fitness, n_fails=r.n_fails,
|
ind = Individual(root=root, fitness=r.fitness, n_fails=r.n_fails,
|
||||||
ratios=innerloop.ratio_map(root), lineage=lineage,
|
ratios=innerloop.ratio_map(root), lineage=lineage,
|
||||||
|
|
@ -209,6 +218,7 @@ def search(
|
||||||
base_p: float = 1.0,
|
base_p: float = 1.0,
|
||||||
child_probe=None,
|
child_probe=None,
|
||||||
use_grade: bool = False,
|
use_grade: bool = False,
|
||||||
|
conn_grade: bool = False,
|
||||||
tournament_k: int = 2,
|
tournament_k: int = 2,
|
||||||
niche_by_signature: bool = False,
|
niche_by_signature: bool = False,
|
||||||
restart_patience: int | None = None,
|
restart_patience: int | None = None,
|
||||||
|
|
@ -300,6 +310,9 @@ def search(
|
||||||
# Kept default-off for reproducibility. Strictly beneath -n_fails ⇒ the
|
# Kept default-off for reproducibility. Strictly beneath -n_fails ⇒ the
|
||||||
# missing-space hierarchy (§6) is preserved and the inner-loop cliff (§5.4)
|
# missing-space hierarchy (§6) is preserved and the inner-loop cliff (§5.4)
|
||||||
# is untouched.
|
# is untouched.
|
||||||
|
# homemaker-py-qi6 §18: the connectivity signal rides the same grade channel,
|
||||||
|
# so enabling it enables the grade secondary key.
|
||||||
|
use_grade = use_grade or conn_grade
|
||||||
if use_lex and use_grade:
|
if use_lex and use_grade:
|
||||||
_key = lambda ind: (-ind.n_fails, ind.grade, _rank_fitness(ind))
|
_key = lambda ind: (-ind.n_fails, ind.grade, _rank_fitness(ind))
|
||||||
elif use_lex:
|
elif use_lex:
|
||||||
|
|
@ -402,7 +415,7 @@ def search(
|
||||||
best_nf = result.best.n_fails if result.best is not None else None
|
best_nf = result.best.n_fails if result.best is not None else None
|
||||||
full = [
|
full = [
|
||||||
(root, programme_dir, urb_root, x0, budget_, kw_, lin, use_grade,
|
(root, programme_dir, urb_root, x0, budget_, kw_, lin, use_grade,
|
||||||
mx, best_nf, leaf_sharing, superpose, max_share)
|
mx, best_nf, leaf_sharing, superpose, max_share, conn_grade)
|
||||||
for root, x0, budget_, kw_, lin in tasks
|
for root, x0, budget_, kw_, lin in tasks
|
||||||
]
|
]
|
||||||
if _pool is not None:
|
if _pool is not None:
|
||||||
|
|
@ -483,7 +496,8 @@ def search(
|
||||||
want_grade=use_grade,
|
want_grade=use_grade,
|
||||||
leaf_sharing=leaf_sharing,
|
leaf_sharing=leaf_sharing,
|
||||||
superpose=superpose,
|
superpose=superpose,
|
||||||
max_share=max_share)
|
max_share=max_share,
|
||||||
|
conn_grade=conn_grade)
|
||||||
n_evals += used
|
n_evals += used
|
||||||
admit(seed_ind, pop)
|
admit(seed_ind, pop)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,16 @@ def _parse_args(argv=None) -> argparse.Namespace:
|
||||||
"requirements) form equivalence classes and each candidate "
|
"requirements) form equivalence classes and each candidate "
|
||||||
"collapses every superposed leaf to its best in-class usage "
|
"collapses every superposed leaf to its best in-class usage "
|
||||||
"before scoring (default: off)")
|
"before scoring (default: off)")
|
||||||
|
p.add_argument("--conn-grade", dest="conn_grade",
|
||||||
|
action=argparse.BooleanOptionalAction,
|
||||||
|
default=_env_bool("HOMEMAKER_CONN_GRADE", False),
|
||||||
|
help="homemaker-py-qi6 (§18): graded circulation-connectivity "
|
||||||
|
"signal. Adds a secondary comparator key (beneath fail "
|
||||||
|
"count, above fitness) = per-level largest-circ-component "
|
||||||
|
"fraction, giving the search a gradient toward connected "
|
||||||
|
"circulation that the binary 'not connected' fail lacks. "
|
||||||
|
"Does not change the scalar fitness or fail count "
|
||||||
|
"(default: off)")
|
||||||
p.add_argument("--anneal-grain", type=str,
|
p.add_argument("--anneal-grain", type=str,
|
||||||
default=os.environ.get("HOMEMAKER_ANNEAL_GRAIN"),
|
default=os.environ.get("HOMEMAKER_ANNEAL_GRAIN"),
|
||||||
metavar="LADDER",
|
metavar="LADDER",
|
||||||
|
|
@ -161,6 +171,7 @@ def main(argv=None) -> int:
|
||||||
print(f"leaf sharing : {args.leaf_sharing} (factor={args.leaf_share_factor})",
|
print(f"leaf sharing : {args.leaf_sharing} (factor={args.leaf_share_factor})",
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
print(f"superpose : {args.superpose}", file=sys.stderr)
|
print(f"superpose : {args.superpose}", file=sys.stderr)
|
||||||
|
print(f"conn grade : {args.conn_grade}", file=sys.stderr)
|
||||||
print(f"output : {out or 'stdout'}", file=sys.stderr, flush=True)
|
print(f"output : {out or 'stdout'}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
anneal_ladder = None
|
anneal_ladder = None
|
||||||
|
|
@ -209,6 +220,7 @@ def main(argv=None) -> int:
|
||||||
leaf_sharing=args.leaf_sharing,
|
leaf_sharing=args.leaf_sharing,
|
||||||
leaf_share_factor=args.leaf_share_factor,
|
leaf_share_factor=args.leaf_share_factor,
|
||||||
superpose=args.superpose,
|
superpose=args.superpose,
|
||||||
|
conn_grade=args.conn_grade,
|
||||||
log=lambda m: print(m, file=sys.stderr, flush=True),
|
log=lambda m: print(m, file=sys.stderr, flush=True),
|
||||||
)
|
)
|
||||||
_finish_sharing = args.leaf_sharing
|
_finish_sharing = args.leaf_sharing
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,13 @@ class Fitness:
|
||||||
# leaf to its best in-class usage before scoring, so search optimises the
|
# leaf to its best in-class usage before scoring, so search optimises the
|
||||||
# condensed objective directly and the relaxation gap is removed.
|
# condensed objective directly and the relaxation gap is removed.
|
||||||
self._superpose = bool(self.conf("superpose"))
|
self._superpose = bool(self.conf("superpose"))
|
||||||
|
# homemaker-py-qi6 graded circulation-connectivity signal (DESIGN.md §18):
|
||||||
|
# default OFF. When on, the graded proximity scalar (want_grade) is the
|
||||||
|
# per-level largest-circ-component fraction instead of the §11.4 leaf
|
||||||
|
# quality-proximity — a secondary comparator key giving the outer search
|
||||||
|
# a gradient the binary "level N not connected" fail lacks. Leaves the
|
||||||
|
# scalar fitness and fail count untouched, exactly like §11.4.
|
||||||
|
self._conn_grade = bool(self.conf("conn_grade"))
|
||||||
from .programme import CLASS_CAP as _CLASS_CAP
|
from .programme import CLASS_CAP as _CLASS_CAP
|
||||||
self._class_cap = int(self.conf("superpose_class_cap") or _CLASS_CAP)
|
self._class_cap = int(self.conf("superpose_class_cap") or _CLASS_CAP)
|
||||||
self._interchange_classes: list | None = None # lazily derived
|
self._interchange_classes: list | None = None # lazily derived
|
||||||
|
|
@ -1453,10 +1460,18 @@ class Fitness:
|
||||||
)
|
)
|
||||||
cost += se.cost
|
cost += se.cost
|
||||||
value += se.value
|
value += se.value
|
||||||
if want_grade: # §11.4 outer-comparator signal only; off by default
|
if want_grade and not self._conn_grade: # §11.4 signal; off by default
|
||||||
for le in se.leaves:
|
for le in se.leaves:
|
||||||
grade += _leaf_grade(le.factors)
|
grade += _leaf_grade(le.factors)
|
||||||
|
|
||||||
|
# §18 (homemaker-py-qi6): repurpose the grade channel for the graded
|
||||||
|
# circulation-connectivity signal — sum of per-level largest-circ-component
|
||||||
|
# fractions, higher when circulation is closer to a single connected spine.
|
||||||
|
# Secondary comparator key only; score and fail count are untouched.
|
||||||
|
if want_grade and self._conn_grade:
|
||||||
|
for gc in graph_circ:
|
||||||
|
grade += graph_mod.circulation_connectivity(gc)
|
||||||
|
|
||||||
building_factor = self.evaluate_building(root, tracking)
|
building_factor = self.evaluate_building(root, tracking)
|
||||||
value *= building_factor
|
value *= building_factor
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,32 @@ def _connected_outside_inplace(G: nx.Graph) -> None:
|
||||||
G.remove_nodes_from(to_remove)
|
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:
|
def connected_circulation(G: nx.Graph) -> bool:
|
||||||
"""True iff circulation nodes are non-empty and connected; mirrors
|
"""True iff circulation nodes are non-empty and connected; mirrors
|
||||||
``Urb::Dom::Connected_Circulation`` (Storey.pm:106).
|
``Urb::Dom::Connected_Circulation`` (Storey.pm:106).
|
||||||
|
|
|
||||||
113
tests/test_conn_grade.py
Normal file
113
tests/test_conn_grade.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""Tests for the graded circulation-connectivity signal (homemaker-py-qi6, §18).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- graph.circulation_connectivity: largest-circ-component fraction, non-circ
|
||||||
|
cells ignored, empty → 0.0, monotone under (dis)connection.
|
||||||
|
- Fitness conn_grade wiring: repurposes the graded proximity scalar, leaves the
|
||||||
|
scalar fitness and fail count byte-identical (secondary comparator key only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import networkx as nx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homemaker_layout import dom as dom_mod
|
||||||
|
from homemaker_layout.dom import Node
|
||||||
|
from homemaker_layout.graph import circulation_connectivity
|
||||||
|
from homemaker_layout.fitness import Fitness, load_config
|
||||||
|
|
||||||
|
HARBOR = Path(__file__).parent.parent / "examples" / "harbor-house"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# circulation_connectivity — pure graph contract
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _circ(*ids):
|
||||||
|
# bare, unlinked nodes: level_of == 0 → is_usable → is_circulation for c/s
|
||||||
|
return [Node(type=t) for t in ids]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fully_connected_is_one():
|
||||||
|
a, b, c = _circ("C", "C", "S")
|
||||||
|
G = nx.Graph([(a, b), (b, c)])
|
||||||
|
assert circulation_connectivity(G) == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_components_is_half():
|
||||||
|
a, b, c, d = _circ("C", "C", "C", "C")
|
||||||
|
G = nx.Graph([(a, b), (c, d)]) # two disjoint pairs of 4 circ cells
|
||||||
|
assert circulation_connectivity(G) == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_circulation_cells_ignored():
|
||||||
|
# largest circ component is {a,b} of 3 circ cells → 2/3; the room cells r/s
|
||||||
|
# bridging them do NOT count as circulation, so the split stands.
|
||||||
|
a, b, lone = _circ("C", "C", "C")
|
||||||
|
r1, r2 = Node(type="b1"), Node(type="k1")
|
||||||
|
G = nx.Graph([(a, b), (a, r1), (r1, r2), (r2, lone)])
|
||||||
|
assert circulation_connectivity(G) == pytest.approx(2 / 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_circulation_is_zero():
|
||||||
|
r1, r2 = Node(type="b1"), Node(type="k1")
|
||||||
|
G = nx.Graph([(r1, r2)])
|
||||||
|
assert circulation_connectivity(G) == 0.0
|
||||||
|
assert circulation_connectivity(nx.Graph()) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_connecting_a_component_raises_the_grade():
|
||||||
|
a, b, c, d = _circ("C", "C", "C", "C")
|
||||||
|
split = nx.Graph([(a, b), (c, d)]) # 0.5
|
||||||
|
joined = nx.Graph([(a, b), (b, c), (c, d)]) # 1.0
|
||||||
|
assert circulation_connectivity(joined) > circulation_connectivity(split)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Fitness conn_grade wiring — must not perturb score or fail count
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house example absent")
|
||||||
|
@pytest.mark.parametrize("name", ["evolved-3M-nols-3.dom", "evolved-3M.dom"])
|
||||||
|
def test_conn_grade_leaves_score_and_fails_untouched(name):
|
||||||
|
conf, cost = load_config(HARBOR)
|
||||||
|
conf_cg, _ = load_config(HARBOR, overrides={"conn_grade": True})
|
||||||
|
fit, fit_cg = Fitness(conf, cost), Fitness(conf_cg, cost)
|
||||||
|
|
||||||
|
root = dom_mod.load(str(HARBOR / name))
|
||||||
|
s_base, f_base = fit.score_with_fails(copy.deepcopy(root))
|
||||||
|
s_cg, f_cg, grade = fit_cg.score_with_grade(copy.deepcopy(root))
|
||||||
|
|
||||||
|
assert s_cg == pytest.approx(s_base)
|
||||||
|
assert f_cg == f_base
|
||||||
|
# grade is the sum of per-level fractions ∈ [0, n_levels]; harbor layouts are
|
||||||
|
# partially disconnected, so it is strictly positive and below the level count.
|
||||||
|
n_levels = len(dom_mod.levels(dom_mod.load(str(HARBOR / name))))
|
||||||
|
assert 0.0 < grade <= n_levels
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house example absent")
|
||||||
|
def test_more_connected_layout_scores_higher_grade():
|
||||||
|
conf_cg, cost = load_config(HARBOR, overrides={"conn_grade": True})
|
||||||
|
fit = Fitness(conf_cg, cost)
|
||||||
|
|
||||||
|
def grade_of(name):
|
||||||
|
_, _, g = fit.score_with_grade(dom_mod.load(str(HARBOR / name)))
|
||||||
|
return g
|
||||||
|
|
||||||
|
# evolved-3M has one fully-connected storey; nols-3 is fragmented on both.
|
||||||
|
assert grade_of("evolved-3M.dom") > grade_of("evolved-3M-nols-3.dom")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house example absent")
|
||||||
|
def test_conn_grade_off_uses_leaf_grade_not_connectivity():
|
||||||
|
# With the flag off, the grade is the §11.4 leaf quality-proximity, which is a
|
||||||
|
# different (smaller, here) scalar — the two channels must not collide.
|
||||||
|
conf, cost = load_config(HARBOR)
|
||||||
|
conf_cg, _ = load_config(HARBOR, overrides={"conn_grade": True})
|
||||||
|
root = dom_mod.load(str(HARBOR / "evolved-3M-nols-3.dom"))
|
||||||
|
_, _, g_leaf = Fitness(conf, cost).score_with_grade(copy.deepcopy(root))
|
||||||
|
_, _, g_conn = Fitness(conf_cg, cost).score_with_grade(copy.deepcopy(root))
|
||||||
|
assert g_leaf != g_conn
|
||||||
Loading…
Add table
Reference in a new issue