Skip to content
14 changes: 8 additions & 6 deletions comfy_cli/command/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -772,12 +772,14 @@ def path_cmd(
``not_searched`` — the individual reasons the claim was withheld, so a
caller can widen the right bound instead of guessing.
- ``not_searched`` / ``not_searched_reason`` — the walk declined the query
and never ran, so the empty result is an abstention, not an answer. Today
the only reason reachable from the CLI is ``"same_type"``: a query whose
FROM and TO are the same type is answered empty by construction, even
though real self-returning routes such as ``MODEL -> LoraLoader -> MODEL``
exist. Such a result reports ``exact: false`` and must not be read as a
proof of unreachability.
and never ran, so the empty result is an abstention, not an answer. No
reason is reachable from this command: the only shape ``search_paths``
declines is a bound below 1, which is rejected up front with
``path_bounds_invalid`` before the graph is even loaded. Same-type queries
(``MODEL MODEL``) are searched like any other and return the real
self-returning routes. The fields stay in the envelope so a caller can
never mistake an abstention the engine adds later for a proof of
unreachability — an abstention always reports ``exact: false``.
"""
renderer = get_renderer()

Expand Down
35 changes: 19 additions & 16 deletions comfy_cli/cql/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -670,10 +670,12 @@ def search_paths(

- ``not_searched`` — the walk declined the query outright and never ran,
so the empty result is an abstention rather than an answer.
``not_searched_reason`` says which: ``"same_type"`` (FROM and TO are
the same type — self-returning routes exist but this walker cannot
represent them) or ``"degenerate_bounds"`` (``max_depth`` or
``max_paths`` below 1, a bound no path can satisfy).
``not_searched_reason`` names the only shape that does this:
``"degenerate_bounds"`` (``max_depth`` or ``max_paths`` below 1, a
bound no path can satisfy). A same-type query is *answered*, not
declined — self-returning routes such as
``MODEL -> LoraLoaderModelOnly -> MODEL`` are real, and the no-op rule
below exempts the terminal hop so they are found like any other.
- ``truncated`` — the walk stopped early (``max_paths`` reached, or the
internal state budget exhausted), so paths exist that are not listed.
- ``depth_limited`` — the frontier was still expanding at ``max_depth``,
Expand All @@ -699,17 +701,9 @@ def search_paths(
"not_searched": False,
"not_searched_reason": None,
}
# Query shapes the walk declines outright. The empty result they yield is
# an abstention, not a proof, so it has to say so — otherwise it reads
# as "no route exists" with every limit flag reassuringly false.
if from_type == to_type:
# Self-returning routes are real (``MODEL -> LoraLoader -> MODEL``),
# but the walk cannot represent them: the no-op rule below drops any
# step whose output type equals its input type, and for a same-type
# query that is the terminal step. Declining is the honest option.
result["not_searched"] = True
result["not_searched_reason"] = "same_type"
return result
# The one query shape the walk declines outright. The empty result it
# yields is an abstention, not a proof, so it has to say so — otherwise
# it reads as "no route exists" with every limit flag reassuringly false.
if max_depth < 1 or max_paths < 1:
result["not_searched"] = True
result["not_searched_reason"] = "degenerate_bounds"
Expand Down Expand Up @@ -740,7 +734,16 @@ def search_paths(
# alone — the pruning loose path-finding has always used.
new_produced = produced | frozenset(outs) if exact else produced
for out_t in outs:
if out_t == cur_type:
# A step that hands back the type it consumed is a no-op
# hop — except when that type is the target, where it is
# the terminal step and the only one that can answer the
# query (``MODEL -> LoraLoaderModelOnly -> MODEL``). The
# exemption is confined to same-type queries: any state
# whose output matched ``to_type`` was recorded as a
# completed path and never queued, so ``cur_type ==
# to_type`` can only hold for the initial frontier item,
# i.e. exactly when ``from_type == to_type``.
if out_t == cur_type and out_t != to_type:
continue
step = {"node": consumer.id, "input_type": cur_type, "output_type": out_t}
new_steps = steps + [step]
Expand Down
42 changes: 27 additions & 15 deletions tests/comfy_cli/command/test_nodes_introspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,32 +562,44 @@ def test_smallest_valid_bounds_are_searched_not_refused(self, patched_loader, ca
assert data["not_searched"] is False
assert data["exact"] is False

def test_same_type_query_declines_rather_than_claiming_unreachability(self, patched_loader, capsys):
def test_same_type_query_lists_the_route_it_used_to_decline(self, patched_loader, capsys):
"""`MODEL -> MODEL` is a *reachable* query — the fixture carries
`LoraLoaderModelOnly`, a stock-shaped node taking a MODEL link input and
emitting MODEL. The walker cannot represent that route (its no-op rule
drops any step whose output type equals its input type, which for a
same-type query is the terminal step), so it declines the query outright.
emitting MODEL — and the CLI now answers it.

Declining is fine. Declining while reporting `exact: true, count: 0` —
the envelope's proof that no route exists — would not be, so the
abstention is declared and the exactness claim withheld.
It used to decline: the walker's no-op rule dropped any step whose
output type equalled its input type, which for a same-type query is the
terminal step, so the command returned `count: 0` with the abstention
declared. Declining was honest but useless — the route is real, so it is
listed.
"""
lora = _run(["show", "LoraLoaderModelOnly"], capsys)["data"]
assert "MODEL" in {o["type"] for o in lora["outputs"]}, "fixture must offer a real MODEL -> MODEL route"
assert "MODEL" in {i["type"] for i in lora["inputs"]}

data = _run(["path", "MODEL", "MODEL"], capsys)["data"]
assert data["count"] == 0
assert data["not_searched"] is True
assert data["not_searched_reason"] == "same_type"
# The point of the whole ticket: an empty answer that is not a proof
# must not be labelled exact.
assert data["exact"] is False
# ...and it is the abstention doing it, not a bound that happened to bite.
assert data["count"] >= 1
one_step = [p for p in data["paths"] if [s["node"] for s in p["steps"]] == ["LoraLoaderModelOnly"]]
assert one_step, "the one-step MODEL -> MODEL route must be listed"
assert one_step[0]["steps"][0] == {
"node": "LoraLoaderModelOnly",
"from_type": "MODEL",
"to_type": "MODEL",
}
# The walk ran to completion: no abstention, no bound bit it.
assert data["not_searched"] is False
assert data["not_searched_reason"] is None
assert data["truncated"] is False
assert data["depth_limited"] is False
assert data["collapsed"] is False
# `exact` is still withheld here, and for the ordinary reason rather
# than a leftover of the old refusal: reaching MODEL ends a path, so the
# walk keeps expanding the branches that do not (KSampler -> LATENT),
# and there both decoders land on the same (IMAGE, {IMAGE, LATENT})
# state — a collapse. It costs no MODEL -> MODEL route (nothing in this
# catalog routes IMAGE back to MODEL), but the flag errs toward true by
# design, so the claim is withheld rather than forged.
assert data["collapsed"] is True
assert data["exact"] is False

def test_loose_mode_never_claims_exactness(self, patched_loader, capsys):
env = _run(["path", "MODEL", "IMAGE", "--loose", "--max-depth", "4"], capsys)["data"]
Expand Down
64 changes: 55 additions & 9 deletions tests/comfy_cli/cql/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,8 +366,19 @@ def test_exact_paths_model_to_image(self, graph: Graph):
for step in p["steps"]:
assert graph.node(step["node"]) is not None

def test_find_paths_same_type_returns_empty(self, graph: Graph):
def test_find_paths_same_type_is_searched_not_declined(self, graph: Graph):
"""Same-type queries used to be refused outright; they are now walked
like any other. This small catalog happens to hold no route back to
MODEL — nothing here consumes MODEL and emits it — so the empty result
is a fact about the catalog rather than an abstention. The catalog that
*does* carry one (`LoraLoaderModelOnly`) is the `graph_path` fixture,
pinned by `test_same_type_query_finds_the_route` below.
"""
assert graph.find_paths("MODEL", "MODEL") == []
result = graph.search_paths("MODEL", "MODEL", exact=False, max_depth=4)
assert result["paths"] == []
assert result["not_searched"] is False
assert result["not_searched_reason"] is None

def test_find_paths_unreachable_returns_empty(self, graph: Graph):
# No node consumes IMAGE and produces MODEL in this fixture
Expand Down Expand Up @@ -556,14 +567,16 @@ def test_state_budget_is_reported_as_truncation(self, graph_path: Graph):
assert result["truncated_by"] == "max_states"

def test_degenerate_bounds_return_nothing(self, graph_path: Graph):
assert graph_path.search_paths("MODEL", "MODEL")["paths"] == []
# `MODEL -> MODEL` used to sit here as a third degenerate case. It is no
# longer degenerate — a same-type query is a real question with a real
# answer (see `test_same_type_query_finds_the_route`), so only the
# bounds no path can satisfy remain.
assert graph_path.search_paths("MODEL", "IMAGE", max_depth=0)["paths"] == []
assert graph_path.search_paths("MODEL", "IMAGE", max_paths=0)["paths"] == []

@pytest.mark.parametrize(
("kwargs", "reason"),
[
({"from_type": "MODEL", "to_type": "MODEL"}, "same_type"),
({"from_type": "MODEL", "to_type": "IMAGE", "max_depth": 0}, "degenerate_bounds"),
({"from_type": "MODEL", "to_type": "IMAGE", "max_paths": 0}, "degenerate_bounds"),
],
Expand All @@ -586,17 +599,50 @@ def test_declined_queries_declare_the_abstention(self, graph_path: Graph, kwargs
assert result["depth_limited"] is False
assert result["collapsed"] is False

def test_same_type_query_is_declined_even_though_a_route_exists(self, graph_path: Graph):
def test_same_type_query_finds_the_route(self, graph_path: Graph):
"""`LoraLoaderModelOnly` in the fixture takes a MODEL link input and
emits MODEL, so `MODEL -> MODEL` is genuinely routable. The walker still
declines it — the no-op rule (`out_t == cur_type`) drops that step — so
the empty result must be flagged as an abstention, never as proof."""
emits MODEL, so `MODEL -> MODEL` is genuinely routable — and is now
answered rather than declined. The walker used to refuse the query
outright and report the empty result as an abstention; the no-op rule
(`out_t == cur_type`) no longer drops the hop that answers it.
"""
lora = graph_path.node("LoraLoaderModelOnly")
assert lora is not None and "MODEL" in lora.output_types()
assert lora.has_input("MODEL")

result = graph_path.search_paths("MODEL", "MODEL")
assert result["paths"] == []
assert result["not_searched"] is True
assert ("LoraLoaderModelOnly",) in {_node_chain(p) for p in result["paths"]}
# A real walk, not an abstention — and the one-step route is a genuine
# MODEL-in/MODEL-out hop, not a mislabelled edge.
assert result["not_searched"] is False
assert result["not_searched_reason"] is None
one_step = next(p for p in result["paths"] if _node_chain(p) == ("LoraLoaderModelOnly",))
assert one_step["from"] == "MODEL" and one_step["to"] == "MODEL"
assert one_step["steps"] == [{"node": "LoraLoaderModelOnly", "input_type": "MODEL", "output_type": "MODEL"}]

def test_no_op_hops_are_still_dropped(self, graph_path: Graph):
"""The exemption is scoped to the hop that answers a same-type query,
and to nothing else — a step that hands back the type it consumed is
still a no-op everywhere it is not the terminal step.

For a FROM != TO query that means *no* step may do it at all: a step
whose output equals the target ends the path, so a no-op-looking step
requires the incoming type to already be the target, which only the
first frontier item can satisfy.
"""
for from_type in ("MODEL", "LATENT", "CLIP", "CONDITIONING"):
for p in graph_path.exact_paths(from_type, "IMAGE", max_depth=6):
assert all(s["input_type"] != s["output_type"] for s in p["steps"]), (
f"no-op hop in {from_type} -> IMAGE via {_node_chain(p)}"
)
# And within a same-type query it is the terminal hop only.
same_type = graph_path.exact_paths("MODEL", "MODEL", max_depth=6)
assert same_type, "fixture must offer at least one MODEL -> MODEL route"
for p in same_type:
for i, step in enumerate(p["steps"]):
if step["input_type"] == step["output_type"]:
assert i == len(p["steps"]) - 1, f"no-op mid-path in {_node_chain(p)}"
assert step["output_type"] == "MODEL"

def test_completed_walks_are_not_marked_as_declined(self, graph_path: Graph):
"""The abstention flag must stay off for searches that actually ran,
Expand Down
Loading