fix(cql): answer same-type nodes path queries instead of declining them - #698
fix(cql): answer same-type nodes path queries instead of declining them#698mattmillerai wants to merge 7 commits into
nodes path queries instead of declining them#698Conversation
…ng exactness `comfy nodes path <FROM> <TO>` ignored the source-type constraint entirely and still labelled its answer `"exact": true`. The exact walker seeded an `available` set with `from_type` and then enumerated every node whose *required link inputs* were satisfiable — which is trivially true for any loader or text-to-X API node — so it really enumerated things that produce `TO`, alphabetically. `AUDIO -> IMAGE` returned byte-for-byte the same rows as `MODEL -> IMAGE`, every step carried `"from_type": ""`, and a node with a COMBO widget merely *named* `model` was routed through as if it consumed MODEL. Replace both walkers with one `Graph.search_paths`: - Traversal now walks the `_consumers` index, which is keyed on declared link inputs, so a step is only taken through an input whose *type* matches what the previous step produced. Widget-named lookalikes can never be routed through. - Each step reports the type it actually consumes, so `from_type` is populated. - `max_depth` bounds path length, and a frontier still expanding at the bound is reported as `depth_limited` rather than silently cut. - A node's *other* required inputs are satisfied from a fixpoint closure of types obtainable without wiring anything in, and reported per path under `support` instead of being spliced into `steps` as bogus hops. - The result carries `truncated` / `truncated_by` / `depth_limited` / `collapsed`, and `exact` is now the honest claim that the listing is the complete, type-constrained answer — withheld whenever any bound was hit. The `--exact/--loose` flag is echoed separately as `mode`. `collapsed` covers the subtler version of the same overclaim: the walk explores each intermediate state once, so a second node offering the same hop is not re-expanded and its chains never reach the output. That is a real gap in the listing, so it is reported rather than hidden behind `exact`. The empty `AUDIO -> IMAGE` result is a fact about the catalog, never a hard-coded denial — current ComfyUI ships `VAEEncodeAudio` (AUDIO + VAE -> LATENT), and with that node present the walker returns the real two-hop route through `VAEDecode`. Both directions are pinned by tests driven off a recorded `object_info` fixture.
… contract Addresses the two CodeRabbit findings on PR #695. `--max-depth 0` / `--max-paths 0` were accepted by Typer and swallowed by the engine's degenerate-bounds guard, which returns an empty result with every flag false. The command then published `exact: true, count: 0` — under this PR's own contract, a proof that no route exists. That proof came from a typo, not from a walk, which is precisely the overclaiming this PR set out to remove. Bounds below 1 are now refused at the command boundary with a registered `path_bounds_invalid` error, before any object_info I/O. Also documents the `nodes path` envelope on `path_cmd`: `mode` echoes the requested matching mode and says nothing about completeness, while `exact` is the exhaustiveness claim, withheld whenever the walk was truncated, depth-limited, or collapsed. The docstring records one honest exception — a same-type query (FROM == TO) is answered empty by construction, so its empty result is not a proof of unreachability. That short-circuit predates this PR and is covered by a base-branch test, so it is documented here and tracked separately rather than changed under this PR. The repo keeps no changelog file (releases are cut from PR titles), so the contract change is recorded in the code docs and the error-code registry, which is the surface agents actually read via `comfy discover`.
… unreachability Second round of CodeRabbit findings on PR #695. `search_paths` declines two query shapes without walking: FROM == TO, and a bound below 1. Both returned an empty result with every limit flag false, which is precisely this module's encoding of "proof that no path exists" — so the abstention was indistinguishable from an exhaustive negative answer, and the command published it as `exact: true, count: 0`. The same-type case is the one that bites: self-returning routes are real (`MODEL -> LoraLoader -> MODEL` on any stock catalog), but the walker cannot represent them, because the no-op rule drops any step whose output type equals its input type and for a same-type query that is the terminal step. So the command was reporting a reachable route as provably unreachable. The result now carries `not_searched` / `not_searched_reason` (`"same_type"` / `"degenerate_bounds"`), the envelope surfaces both, and `exact` is withheld whenever the walk was declined. Behaviour is otherwise unchanged: declined queries still return no paths, so `find_paths(T, T) == []` and the base-branch test asserting it hold as before. Actually *answering* same-type queries is a larger design change and is left as follow-up; this commit only stops the false claim. Also tightens the two tests from the previous commit per review: the invalid-bounds test now installs a `_get_graph` tripwire instead of the loader fixture, so it genuinely pins that validation precedes object_info I/O, and the lower-bound test exercises `--max-depth 1 --max-paths 1` rather than 2, matching what it claims to cover. Adds `LoraLoaderModelOnly` to the path fixture so the same-type regression test asserts against a route that really exists.
`nodes path MODEL MODEL` returned `count: 0` with `not_searched: "same_type"` — an honest abstention, but a useless one: the route is real. `LoraLoaderModelOnly` takes a MODEL link input and emits MODEL, and the same shape covers `LoraLoader`, `CLIPSetLastLayer`, `ConditioningSetArea` and most patcher nodes. Two blocks stood in the way. The early `from_type == to_type` guard declined before any walk started, and the BFS no-op rule (`out_t == cur_type`) dropped the very hop that answers a same-type query. The guard is gone and the no-op rule now exempts the terminal hop (`out_t == cur_type and out_t != to_type`). The exemption cannot leak into any other query: a step whose output matches `to_type` is recorded as a completed path and never queued, so `cur_type == to_type` holds only for the initial frontier item — i.e. exactly when `from_type == to_type`. Verified differentially over 1568 FROM/TO/mode/bound combinations across both catalog fixtures: every non-same-type result is byte-identical to before. `degenerate_bounds` is now the sole `not_searched_reason`, and it is unreachable from the CLI because `path_cmd` rejects those bounds up front.
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 12 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Every reviewer in the matrix failed to contribute — see the panel summary for which cells errored, and the run logs for the underlying cause.
Panel: 0/8 reviewers contributed findings.
Reviewers that did not contribute: claude-opus-5-thinking-max:adversarial (error), gemini-3.1-pro:adversarial (error), gpt-5.6-sol-max:adversarial (error), kimi-k3-max:adversarial (error), claude-opus-5-thinking-max:edge-case (error), gemini-3.1-pro:edge-case (error), gpt-5.6-sol-max:edge-case (error), kimi-k3-max:edge-case (error)
|
Independent re-verification of this branch (fresh worktree at
One pre-existing lint note, not from this diff: |
|
Needs a rebase onto Its base #695 landed as squash merge Both are test files where #695's cases and this PR's same-type cases were added in the same regions. The single commit that is genuinely unique here is Context from reviewing #695: I confirmed the abstention this PR removes is live on so the behaviour you're fixing is real and reachable. Worth keeping in mind for the rebased version: on a real 792-class catalog, hub types like IMAGE collapse or truncate almost immediately, so a same-type answer will realistically come back Ping me once it's rebased and I'll pick it up. |
…e-nodes-path # Conflicts: # comfy_cli/command/nodes.py # comfy_cli/cql/engine.py # tests/comfy_cli/command/test_nodes_introspect.py # tests/comfy_cli/cql/test_engine.py
|
@bigcat88 merged `origin/main` in (`86ed079`), resolving the conflicts in `comfy_cli/command/nodes.py`, `comfy_cli/cql/engine.py`, and both test files — kept both #695's source-type-constraint cases and this PR's same-type cases. The PR diff is now scoped back down to the 4 files / single logical change (`4b2e20f`); pushed and CI is re-running. Ready for your review whenever you get a chance. |
bigcat88
left a comment
There was a problem hiding this comment.
The same-type fix itself is correct and I verified it independently — but the branch also silently reverts #687, which is what your red build check is. One deletion to undo and this is good to go.
The blocker: report_usage_error is gone from nodes._get_graph
git diff origin/main... shows this branch removing the wrapper that #687 (BE-6660, merged yesterday) added:
- with report_usage_error(get_renderer()):
- host, port = resolve_host_port(host, port)
+ host, port = resolve_host_port(host, port)Nothing in the PR body mentions it, and it has nothing to do with same-type queries — it reads like a merge resolution that dropped the incoming side. git log -S confirms the wrapper's only author is #687.
Measured through the real CLI, after merging current origin/main into this branch:
comfy --json … --port 0 |
main |
this branch |
|---|---|---|
nodes ls |
rc=2, 396 B host_port_invalid envelope |
rc=2, 0 B on stdout |
nodes show KSampler |
rc=2, 396 B envelope | rc=2, 0 B |
nodes path MODEL IMAGE |
rc=2, 396 B envelope | rc=2, 0 B |
Also 0 bytes for the --host 1.2.3.4:0 form and in NDJSON mode. That is exactly the "machine consumer just sees the stream stop" failure BE-6660 was filed for, restored for every comfy nodes verb.
tests/comfy_cli/test_host_port.py::test_nodes_bad_port_terminates_with_envelope fails deterministically on the merged branch (-p no:randomly: 1 failed, 79 passed) — it is not the flake the single red check might suggest.
Fix: restore the two lines (and the report_usage_error import) in _get_graph. Nothing else in the diff needs to change.
What I verified about the actual change (all good)
The no-op exemption does not leak — independently reproduced, not taken on trust. I ran search_paths over every FROM/TO type pair × exact/loose × max_depth ∈ {1,2,3} on both trees:
| catalog | cross-type queries | changed |
|---|---|---|
| live server, 792 classes, 109 link types | 70,632 | 0 |
nodes_path_object_info.json |
252 | 0 |
sd15_object_info.json |
180 | 0 |
subgraph_object_info.json |
252 | 0 |
All 654 same-type queries on the real catalog changed, exactly as intended — 225 of them from an abstention to a list of real routes. Your reasoning for why cur_type == to_type can only hold on the initial frontier item holds up empirically at this scale.
Live CLI, against a real ComfyUI on :8189:
main: nodes path MODEL MODEL -> count=0 not_searched=true reason=same_type
branch: nodes path MODEL MODEL -> count=10 not_searched=false (APG, CFGNorm, CFGOverride, …)
nodes path LATENT LATENT -> count=3 (AddNoise, HunyuanRefinerLatent, …)
Real MODEL-patching nodes, which is the answer a user asking that question actually wants.
I also agree with the call in your "unmet acceptance criterion" note: reporting exact: false because collapsed is genuinely true is the right outcome. Forcing exact: true there would forge exactly the proof #695 exists to prevent. Leave it.
Two minor things while you're in here
…ain merge The merge of `origin/main` into this branch (86ed079) resolved `_get_graph` to the outgoing side, silently reverting #687 (BE-6660): the `report_usage_error(get_renderer())` context manager around `resolve_host_port` was deleted along with its comment and import. Effect: every `comfy nodes` verb went back to exit 2 with zero bytes on stdout for a rejected `--host`/`--port`, instead of the terminating `host_port_invalid` envelope — the "machine consumer just sees the stream stop" failure BE-6660 was filed to fix. Reproduced locally on the merged tree: `test_host_port.py::test_nodes_bad_port_terminates_with_envelope` fails deterministically (1 failed, 79 passed under `-p no:randomly`) and passes once the wrapper is restored. Restored verbatim from main. Nothing else changes: the only remaining `nodes.py` delta versus main is this PR's `path_cmd` docstring. Reported by @bigcat88 on #698.
|
Confirmed and fixed in 3b0bb2d — you were right on every detail, including the cause.
Reproduced before fixing, on the tree with current Restored the two lines verbatim from The only remaining Both minor points handled too: current Thanks for the differential run over 70,632 cross-type queries — that is a much stronger check on the exemption than my 1,568-combination fixture harness, and against a real 792-class catalog rather than fixtures. |
ELI-5
Ask the CLI "how do I get from a MODEL to a MODEL?" and it used to shrug: zero routes, plus a note saying it hadn't actually looked. But there is an obvious answer — a LoRA loader takes a MODEL and hands you back a MODEL. It does that all day. The tool just refused to say so.
Two things were in the way. A guard at the top bailed out whenever the start and end types matched, before any searching happened. And the search itself had a rule that throws away any step handing back the type it was given — a sensible rule for a step in the middle of a route (that step accomplishes nothing), but for a same-type question that is the only step that could answer it.
Now the guard is gone and the rule makes an exception for the final step.
comfy nodes path MODEL MODELlistsLoraLoaderModelOnly. Every other question you can ask gets exactly the same answer it did before.What changed
comfy_cli/cql/engine.py::Graph.search_paths— removed the earlyfrom_type == to_typeabstention; changed the no-op rule fromif out_t == cur_typetoif out_t == cur_type and out_t != to_type. Docstring updated:degenerate_boundsis now the solenot_searched_reason.comfy_cli/command/nodes.py::path_cmd— docstring no longer claimssame_typeis the reason reachable from the CLI. Nonot_searchedreason is reachable from the CLI at all now: the only shapesearch_pathsdeclines is a bound below 1, whichpath_cmdalready rejects up front withpath_bounds_invalid.Why the exemption cannot leak into other queries
A step whose output matches
to_typeis appended as a completed path andcontinued — it is never pushed onto the queue. Socur_type == to_typecan only hold for the initial frontier item, which means only whenfrom_type == to_type. Every other search is untouched.Verified rather than argued: a differential harness ran
search_pathson this branch and on the base commit over 1568 combinations — every FROM/TO pair of every type in both catalog fixtures (nodes_path_object_info.json,sd15_object_info.json) × exact/loose ×max_depth∈ {1,2,3,6} ×max_paths∈ {1,10}, excluding the same-type pairs that are the intended change. The serialized results are byte-identical.Empirical check through the real CLI
Against the
sd15fixture,LATENT -> LATENTreturnsKSamplerwithexact: true— a same-type answer that carries the full exhaustiveness claim.Judgment calls and one unmet acceptance criterion
1.
exact: trueonMODEL MODELis NOT met, deliberately. The route is listed andnot_searchedis false, but the envelope reportsexact: falsebecausecollapsedis true. That collapse is genuine and unrelated to this change: reaching MODEL ends a path, so the walk keeps expanding the branches that do not end (KSampler -> LATENT), and there bothVAEDecodeandVAEDecodeTiledland on the same(IMAGE, {IMAGE, LATENT})state. #695 deliberately madecollapsederr toward true, and forcingexact: truehere would mean suppressing that flag — i.e. forging exactly the proof #695 was written to stop forging. So the test assertscollapsed is True/exact is Falsewith the reason spelled out. The criterion holds wherever no unrelated collapse occurs, e.g.LATENT -> LATENTon the sd15 fixture above. Flagging it rather than hiding it: if reviewers wantexact: truefor same-type queries, that is a change tocollapsed's reachability semantics and belongs in its own ticket.2. The plan's fixture assumption was wrong for one test. It said to convert
test_find_paths_same_type_returns_emptyto assertgraph.find_paths("MODEL","MODEL")returns theLoraLoaderModelOnlyroute — but that test uses the small inlinegraphfixture, which has noLoraLoaderModelOnlyand no MODEL-consuming-MODEL-producing node at all. Onlygraph_pathcarries one. So that test now asserts the honest outcome for its own catalog (still empty, butnot_searched is False— a fact about the catalog, not an abstention), and the positive route assertion lives intest_same_type_query_finds_the_route, which usesgraph_path, exactly as the plan's item 3.4 specified.3. Added one test beyond the plan. Removing an existing guard risks over-broadening, so
test_no_op_hops_are_still_droppedpins the other half: for a FROM != TO query no step may hand back the type it consumed, and within a same-type query only the terminal hop may. Mutation-checked both ways — dropping the no-op rule entirely fails it, and restoring the old unconditional rule fails the capability tests.Verification
ruff check .andruff format --check .— clean.pytest) — 4374 passed, 36 skipped in 21m.pytest tests/comfy_cli/cql/test_engine.py tests/comfy_cli/command/test_nodes_introspect.py— 194 passed (re-run after the final test-only edits, which landed after the full-suite run started; all product code was final before it).Note on capability direction
The falsification discipline applies inverted here: this diff removes a capability denial rather than adding one. The route it now surfaces was confirmed live through the CLI before the denial was deleted, not merely asserted by a test.
Post-review fix (2026-08-11)
@bigcat88 caught that the
origin/mainmerge into this branch (86ed079) resolvednodes._get_graphto theoutgoing side, silently reverting #687 (BE-6660): the
report_usage_error(get_renderer())wrapper aroundresolve_host_portwas deleted. That made everycomfy nodesverb exit 2 with zero bytes on stdout for arejected
--host/--portinstead of emitting the terminatinghost_port_invalidenvelope — the exactstream-just-stops failure BE-6660 was filed for, and the red
buildcheck.Reproduced locally on the merged tree before fixing:
test_host_port.py::test_nodes_bad_port_terminates_with_envelopefailed deterministically(
-p no:randomly: 1 failed, 79 passed), and passes with the wrapper restored. Restored verbatim frommain;the only remaining
nodes.pydelta versusmainis this PR'spath_cmddocstring. Currentorigin/mainmerged in at the same time.