Gate signature matches by feature intent
This commit is contained in:
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user