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:
Bruno Postle 2026-07-18 18:44:24 +01:00
parent 1ae8faac7c
commit 94d4223a55
7 changed files with 256 additions and 25 deletions

File diff suppressed because one or more lines are too long

View file

@ -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 **47
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 37 bridge cells, and every needed-room→circulation conversion triggers a
missing-room fail cascade (25 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.

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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
View 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