Gate signature matches by feature intent

This commit is contained in:
2026-07-03 21:52:21 +02:00
parent 9000b5500b
commit dd3e51301c
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) |
| **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
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
signature preserved in the workflow. Generated exact signatures can produce
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
+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.
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)
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"],
"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": {
"inputs": {"image": "IMAGE"},
"required": {"image"},
@@ -169,6 +193,10 @@ class GeneratedSignatureMatchingTests(unittest.TestCase):
"CoreImageSize": {"source": "core", "pack": "nodes", "display": "Core Image Size"},
"CoreMaskInvert": {"source": "core", "pack": "nodes", "display": "Core Mask Invert"},
"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"},
}
by_out = defaultdict(list)
@@ -359,6 +387,79 @@ class GeneratedSignatureMatchingTests(unittest.TestCase):
self.assertEqual("partial", result["SerializedMaskInvert"][0]["tier"])
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__":
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
types) is IDENTICAL to the source's. Safe to remap by name.
Verified.
partial the candidate can structurally accept every input the source has
and provides every output type the source has, but names / extra
slots differ. A *suggestion* only — never auto-applied.
partial the candidate can structurally accept every input the source has,
provides every output type the source has, and matches the same
feature intent. A *suggestion* only — never auto-applied.
The frontend consumes the result: `verified` candidates power auto-replace,
`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 os
import re
from collections import Counter, defaultdict
# 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.
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):
return getattr(cls, "RELATIVE_PYTHON_MODULE", "nodes") or "nodes"
@@ -119,6 +162,81 @@ def _score(src, cand):
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
_PARTIAL_THRESHOLD = 0.5
# 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.
@@ -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).
"""
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()
# --- 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]
if not _feasible(src_sig, cand_sig):
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):
ranked.append((cand_name, "exact", 1.0))
else:
@@ -435,7 +557,7 @@ def match(ctx, items):
`items`: [ {"type": str, "inputs": {name: TYPE}, "outputs": [TYPE], "output_names": [..]} ].
Serialized nodes only carry link slots (not widget values), so 'exact' rarely
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.
"""
@@ -464,12 +586,13 @@ def match(ctx, items):
if not isinstance(gen_meta, dict):
gen_meta = {}
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:
out[t] = found
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:
out[t] = found
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
* other-pack) nodes could stand in for it, split into:
* 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:
* 1. a toast tip when you interactively drop a replaceable custom node;
@@ -61,7 +61,13 @@ async function matchMissing() {
seen.add(t);
const inputs = {};
(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;
try {