Compare commits

..

1 Commits

Author SHA1 Message Date
Ethanfel dd3e51301c Gate signature matches by feature intent 2026-07-03 21:52:21 +02:00
5 changed files with 242 additions and 12 deletions
+2 -2
View File
@@ -52,7 +52,7 @@ and each node's source module) and ranks candidates in three tiers:
|------|---------|---------------| |------|---------|---------------|
| **curated** | a hand-written rule in `mappings.json` / `user_mappings.json` | yes (verified) | | **curated** | a hand-written rule in `mappings.json` / `user_mappings.json` | yes (verified) |
| **exact** | identical input names+types and output types to a core/other-pack node | yes (verified) | | **exact** | identical input names+types and output types to a core/other-pack node | yes (verified) |
| **partial** | can structurally stand in (accepts all inputs, provides all outputs) but names/slots differ | suggestion only | | **partial** | can structurally and semantically stand in (compatible slots plus matching feature intent) but names/slots differ | suggestion only |
"Available" means core is preferred, and if there's no core match it will offer "Available" means core is preferred, and if there's no core match it will offer
an equivalent from a **different installed pack** as a fallback. an equivalent from a **different installed pack** as a fallback.
@@ -61,7 +61,7 @@ For an **uninstalled** node, UTFCN tries curated rules by name first, then any
bundled generated signature for that node type, then the serialized link bundled generated signature for that node type, then the serialized link
signature preserved in the workflow. Generated exact signatures can produce signature preserved in the workflow. Generated exact signatures can produce
verified exact matches, but name-only metadata never can; loose structural verified exact matches, but name-only metadata never can; loose structural
matches remain suggestions. matches are filtered by feature intent and remain suggestions.
## Shipped equivalences ## Shipped equivalences
+1 -1
View File
@@ -14,7 +14,7 @@ the *analysis*: it has the live node registry, so it computes — accurately, fr
real INPUT_TYPES / RETURN_TYPES — which custom nodes have safe equivalents. real INPUT_TYPES / RETURN_TYPES — which custom nodes have safe equivalents.
GET /utfcn/scan[?refresh=1] -> { sources, candidates, stats } GET /utfcn/scan[?refresh=1] -> { sources, candidates, stats }
POST /utfcn/match {nodes:[{type,inputs,outputs,output_names}]} -> { candidates } POST /utfcn/match {nodes:[{type,display,inputs,outputs,output_names}]} -> { candidates }
(for UNINSTALLED / missing nodes in a workflow) (for UNINSTALLED / missing nodes in a workflow)
Curated overrides live in mappings.json (shipped) and user_mappings.json (yours). Curated overrides live in mappings.json (shipped) and user_mappings.json (yours).
+101
View File
@@ -158,6 +158,30 @@ class GeneratedSignatureMatchingTests(unittest.TestCase):
"outputs": ["IMAGE"], "outputs": ["IMAGE"],
"output_names": ["image"], "output_names": ["image"],
}, },
"ImageBlur": {
"inputs": {"image": "IMAGE", "radius": "INT"},
"required": {"image", "radius"},
"outputs": ["IMAGE"],
"output_names": ["image"],
},
"ImageResize": {
"inputs": {"image": "IMAGE", "width": "INT", "height": "INT"},
"required": {"image", "width", "height"},
"outputs": ["IMAGE"],
"output_names": ["image"],
},
"PrimitiveString": {
"inputs": {"value": "STRING"},
"required": {"value"},
"outputs": ["STRING"],
"output_names": ["text"],
},
"TextTruncate": {
"inputs": {"text": "STRING", "max_length": "INT"},
"required": {"text", "max_length"},
"outputs": ["STRING"],
"output_names": ["text"],
},
"CuratedTarget": { "CuratedTarget": {
"inputs": {"image": "IMAGE"}, "inputs": {"image": "IMAGE"},
"required": {"image"}, "required": {"image"},
@@ -169,6 +193,10 @@ class GeneratedSignatureMatchingTests(unittest.TestCase):
"CoreImageSize": {"source": "core", "pack": "nodes", "display": "Core Image Size"}, "CoreImageSize": {"source": "core", "pack": "nodes", "display": "Core Image Size"},
"CoreMaskInvert": {"source": "core", "pack": "nodes", "display": "Core Mask Invert"}, "CoreMaskInvert": {"source": "core", "pack": "nodes", "display": "Core Mask Invert"},
"CoreImagePassthrough": {"source": "core", "pack": "nodes", "display": "Core Image Passthrough"}, "CoreImagePassthrough": {"source": "core", "pack": "nodes", "display": "Core Image Passthrough"},
"ImageBlur": {"source": "core", "pack": "nodes", "display": "Image Blur"},
"ImageResize": {"source": "core", "pack": "nodes", "display": "Image Resize"},
"PrimitiveString": {"source": "core", "pack": "nodes", "display": "String"},
"TextTruncate": {"source": "core", "pack": "nodes", "display": "Text Truncate"},
"CuratedTarget": {"source": "core", "pack": "nodes", "display": "Curated Target"}, "CuratedTarget": {"source": "core", "pack": "nodes", "display": "Curated Target"},
} }
by_out = defaultdict(list) by_out = defaultdict(list)
@@ -359,6 +387,79 @@ class GeneratedSignatureMatchingTests(unittest.TestCase):
self.assertEqual("partial", result["SerializedMaskInvert"][0]["tier"]) self.assertEqual("partial", result["SerializedMaskInvert"][0]["tier"])
self.assertFalse(result["SerializedMaskInvert"][0]["verified"]) self.assertFalse(result["SerializedMaskInvert"][0]["verified"])
def test_text_entry_node_does_not_match_text_transform_candidate(self):
result = utfcn_core.match(
self._ctx(),
[
{
"type": "SomeCustomTextBox",
"display": "Text Box",
"outputs": ["STRING"],
"output_names": ["text"],
}
],
)
self.assertEqual(["PrimitiveString"], [cand["to"] for cand in result["SomeCustomTextBox"]])
def test_generated_text_entry_display_does_not_match_text_transform_candidate(self):
generated = _empty_generated()
generated["sigs"]["OpaqueCustomNode"] = {
"inputs": {},
"required": set(),
"outputs": ["STRING"],
"output_names": ["text"],
}
generated["meta"]["OpaqueCustomNode"] = {
"source": "generated",
"pack": "text-pack",
"display": "Text Box",
"repository": "https://github.com/example/text-pack",
"confidence": "static_exact",
}
generated["by_out"]["STRING"].append("OpaqueCustomNode")
result = utfcn_core.match(
self._ctx(generated=generated),
[{"type": "OpaqueCustomNode"}],
)
self.assertEqual(["PrimitiveString"], [cand["to"] for cand in result["OpaqueCustomNode"]])
def test_text_transform_node_can_match_same_transform_feature(self):
result = utfcn_core.match(
self._ctx(),
[
{
"type": "LegacyTextTruncate",
"display": "Text Truncate",
"inputs": {"text": "STRING"},
"outputs": ["STRING"],
"output_names": ["text"],
}
],
)
self.assertEqual("TextTruncate", result["LegacyTextTruncate"][0]["to"])
self.assertEqual("partial", result["LegacyTextTruncate"][0]["tier"])
self.assertFalse(result["LegacyTextTruncate"][0]["verified"])
def test_image_transform_node_does_not_match_different_transform_feature(self):
result = utfcn_core.match(
self._ctx(),
[
{
"type": "LegacyImageBlur",
"display": "Image Blur",
"inputs": {"image": "IMAGE"},
"outputs": ["IMAGE"],
"output_names": ["image"],
}
],
)
self.assertEqual(["ImageBlur"], [cand["to"] for cand in result["LegacyImageBlur"]])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+130 -7
View File
@@ -17,9 +17,9 @@ We answer it in three tiers, from most to least trustworthy:
exact the candidate's signature (input name→type map + ordered output exact the candidate's signature (input name→type map + ordered output
types) is IDENTICAL to the source's. Safe to remap by name. types) is IDENTICAL to the source's. Safe to remap by name.
Verified. Verified.
partial the candidate can structurally accept every input the source has partial the candidate can structurally accept every input the source has,
and provides every output type the source has, but names / extra provides every output type the source has, and matches the same
slots differ. A *suggestion* only — never auto-applied. feature intent. A *suggestion* only — never auto-applied.
The frontend consumes the result: `verified` candidates power auto-replace, The frontend consumes the result: `verified` candidates power auto-replace,
`partial` ones are shown for the user to confirm. `partial` ones are shown for the user to confirm.
@@ -27,6 +27,7 @@ The frontend consumes the result: `verified` candidates power auto-replace,
import json import json
import os import os
import re
from collections import Counter, defaultdict from collections import Counter, defaultdict
# Top-level python modules we consider "core" (shipped with ComfyUI itself). # Top-level python modules we consider "core" (shipped with ComfyUI itself).
@@ -37,6 +38,48 @@ CORE_TOPLEVEL = ("nodes", "comfy_extras", "comfy_api_nodes", "comfy_api")
# so they matter for widget-value transfer but not for link compatibility. # so they matter for widget-value transfer but not for link compatibility.
WIDGET_TYPES = frozenset({"INT", "FLOAT", "STRING", "BOOLEAN", "COMBO"}) WIDGET_TYPES = frozenset({"INT", "FLOAT", "STRING", "BOOLEAN", "COMBO"})
_TEXT_TYPES = frozenset({"STRING", "STRING_LIST"})
_TEXT_NEUTRAL_TOKENS = frozenset(
{
"any",
"box",
"constant",
"input",
"literal",
"multi",
"multiline",
"note",
"primitive",
"prompt",
"string",
"text",
"textarea",
"value",
"widget",
}
)
_ACTION_GROUPS = (
("blur", frozenset({"blur", "smooth"})),
("crop", frozenset({"crop"})),
("geometry", frozenset({"downscale", "resize", "rescale", "scale", "upscale"})),
("invert", frozenset({"invert", "inversion"})),
("passthrough", frozenset({"identity", "pass", "passthrough", "reroute"})),
("preview", frozenset({"display", "preview", "show", "view"})),
("size", frozenset({"dimension", "dimensions", "height", "resolution", "size", "width"})),
("concat", frozenset({"append", "combine", "concat", "concatenate", "join", "merge"})),
("convert", frozenset({"cast", "convert", "float", "int", "number"})),
("encode", frozenset({"clip", "conditioning", "encode", "encoder", "tokenize", "tokenizer"})),
("extract", frozenset({"extract", "find", "parse", "regex", "regexp", "select"})),
("format", frozenset({"format", "template"})),
("io", frozenset({"file", "load", "path", "read", "save", "url", "write"})),
("replace", frozenset({"remove", "replace", "substitute"})),
("split", frozenset({"separate", "split", "splitter"})),
("strip", frozenset({"clean", "lstrip", "rstrip", "sanitize", "strip", "trim"})),
("translate", frozenset({"translate", "translator"})),
("truncate", frozenset({"chop", "slice", "substring", "truncate"})),
("case", frozenset({"case", "lower", "upper"})),
)
def _module_of(cls): def _module_of(cls):
return getattr(cls, "RELATIVE_PYTHON_MODULE", "nodes") or "nodes" return getattr(cls, "RELATIVE_PYTHON_MODULE", "nodes") or "nodes"
@@ -119,6 +162,81 @@ def _score(src, cand):
return min(1.0, base + name_bonus) return min(1.0, base + name_bonus)
def _semantic_tokens(*parts):
text = " ".join(str(part or "") for part in parts)
text = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", text)
text = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", text)
return {
token
for token in re.split(r"[^A-Za-z0-9]+", text.lower())
if token
}
def _identity_tokens(name, meta, sig):
if not isinstance(meta, dict):
meta = {}
terms = [name, meta.get("display")]
terms.extend(sig.get("inputs", {}).keys())
terms.extend(sig.get("output_names") or [])
return _semantic_tokens(*terms)
def _action_groups(tokens):
groups = {
group
for group, group_tokens in _ACTION_GROUPS
if tokens & group_tokens
}
if "to" in tokens and tokens & {"bool", "boolean", "float", "int", "number"}:
groups.add("convert")
return groups
def _text_signature_kind(sig):
values = set(sig.get("inputs", {}).values()) | set(sig.get("outputs", []))
return bool(values & _TEXT_TYPES)
def _text_value_like(tokens, sig):
outputs = sig.get("outputs", [])
inputs = sig.get("inputs", {})
if not outputs or not set(outputs) <= _TEXT_TYPES:
return False
if _action_groups(tokens):
return False
if len(inputs) > 1:
return False
if inputs:
name, typ = next(iter(inputs.items()))
if typ not in _TEXT_TYPES and typ != "COMBO":
return False
if not (_semantic_tokens(name) & _TEXT_NEUTRAL_TOKENS):
return False
return bool(tokens & _TEXT_NEUTRAL_TOKENS)
def _features_compatible(src_name, src_sig, src_meta, cand_name, cand_sig, cand_meta):
"""
Structural compatibility is too weak for primitive text nodes: a missing
text box serializes as STRING output only, which otherwise matches every
STRING utility. Gate text candidates by identity tokens so text-entry
sources do not suggest transforms such as truncate/split/replace.
"""
src_tokens = _identity_tokens(src_name, src_meta, src_sig)
cand_tokens = _identity_tokens(cand_name, cand_meta, cand_sig)
src_actions = _action_groups(src_tokens)
cand_actions = _action_groups(cand_tokens)
if _text_signature_kind(src_sig) and _text_signature_kind(cand_sig) and _text_value_like(src_tokens, src_sig):
return not cand_actions and _text_value_like(cand_tokens, cand_sig)
if src_actions or cand_actions:
return bool(src_actions & cand_actions)
return True
# score below which a partial match isn't worth surfacing # score below which a partial match isn't worth surfacing
_PARTIAL_THRESHOLD = 0.5 _PARTIAL_THRESHOLD = 0.5
# max candidates returned per source node # max candidates returned per source node
@@ -267,7 +385,7 @@ def build_context(rules, generated=None):
} }
def _candidates_for(src_name, src_sig, src_pack, ctx): def _candidates_for(src_name, src_sig, src_pack, ctx, src_meta=None):
""" """
Rank replacement candidates for one source node. Rank replacement candidates for one source node.
@@ -277,6 +395,8 @@ def _candidates_for(src_name, src_sig, src_pack, ctx):
`src_pack` is None for uninstalled/unknown sources (skips same-pack exclusion). `src_pack` is None for uninstalled/unknown sources (skips same-pack exclusion).
""" """
sources, sigs, by_out, rules = ctx["sources"], ctx["sigs"], ctx["by_out"], ctx["rules"] sources, sigs, by_out, rules = ctx["sources"], ctx["sigs"], ctx["by_out"], ctx["rules"]
if not isinstance(src_meta, dict):
src_meta = sources.get(src_name, {})
found, seen = [], set() found, seen = [], set()
# --- tier 1: curated rules (ordered preference; core-first is the author's job) --- # --- tier 1: curated rules (ordered preference; core-first is the author's job) ---
@@ -300,6 +420,8 @@ def _candidates_for(src_name, src_sig, src_pack, ctx):
cand_sig = sigs[cand_name] cand_sig = sigs[cand_name]
if not _feasible(src_sig, cand_sig): if not _feasible(src_sig, cand_sig):
continue continue
if not _features_compatible(src_name, src_sig, src_meta, cand_name, cand_sig, cand_meta):
continue
if _is_exact(src_sig, cand_sig): if _is_exact(src_sig, cand_sig):
ranked.append((cand_name, "exact", 1.0)) ranked.append((cand_name, "exact", 1.0))
else: else:
@@ -435,7 +557,7 @@ def match(ctx, items):
`items`: [ {"type": str, "inputs": {name: TYPE}, "outputs": [TYPE], "output_names": [..]} ]. `items`: [ {"type": str, "inputs": {name: TYPE}, "outputs": [TYPE], "output_names": [..]} ].
Serialized nodes only carry link slots (not widget values), so 'exact' rarely Serialized nodes only carry link slots (not widget values), so 'exact' rarely
fires; curated rules (by type name), bundled generated signatures, and fires; curated rules (by type name), bundled generated signatures, and
partial link-type matches do. feature-gated partial link-type matches do.
Returns a mapping from source node type to candidate list. Returns a mapping from source node type to candidate list.
""" """
@@ -464,12 +586,13 @@ def match(ctx, items):
if not isinstance(gen_meta, dict): if not isinstance(gen_meta, dict):
gen_meta = {} gen_meta = {}
gen_pack = gen_meta.get("pack") gen_pack = gen_meta.get("pack")
found = _candidates_for(t, gen_sig, gen_pack, ctx) found = _candidates_for(t, gen_sig, gen_pack, ctx, gen_meta)
if found: if found:
out[t] = found out[t] = found
continue continue
found = _candidates_for(t, sig, None, ctx) item_meta = {"display": it.get("display") or t}
found = _candidates_for(t, sig, None, ctx, item_meta)
if found: if found:
out[t] = found out[t] = found
return out return out
+8 -2
View File
@@ -6,7 +6,7 @@ import { app } from "../../scripts/app.js";
* The backend (/utfcn/scan) tells us, for every custom node type, which core (or * The backend (/utfcn/scan) tells us, for every custom node type, which core (or
* other-pack) nodes could stand in for it, split into: * other-pack) nodes could stand in for it, split into:
* verified — curated rule or an identical signature; safe to auto-apply. * verified — curated rule or an identical signature; safe to auto-apply.
* partial — structurally compatible but looser; a suggestion to confirm. * partial — structurally and semantically compatible but looser; confirm first.
* *
* This file turns that into three things: * This file turns that into three things:
* 1. a toast tip when you interactively drop a replaceable custom node; * 1. a toast tip when you interactively drop a replaceable custom node;
@@ -61,7 +61,13 @@ async function matchMissing() {
seen.add(t); seen.add(t);
const inputs = {}; const inputs = {};
(s.inputs || []).forEach((inp) => { if (inp?.name) inputs[inp.name] = inp.type; }); (s.inputs || []).forEach((inp) => { if (inp?.name) inputs[inp.name] = inp.type; });
items.push({ type: t, inputs, outputs: (s.outputs || []).map((o) => o.type), output_names: (s.outputs || []).map((o) => o.name) }); items.push({
type: t,
display: s.title || n.title || t,
inputs,
outputs: (s.outputs || []).map((o) => o.type),
output_names: (s.outputs || []).map((o) => o.name),
});
} }
if (!items.length) return; if (!items.length) return;
try { try {