fix(inspector): keep recorded pair text, string-only seed rule, role inputs and unique candidates (#381)

Review of the pass-through: a walk through an unknown node never overrides a
PromptManagerPair's execution record (any outcome but work-limit is
unverifiable while the record is checked); the run-time seed rule reads only
string outputs; a node with positive/negative inputs follows only the role of
the requested slot, nothing when it is unlinked; one text offered twice is one
candidate.
This commit is contained in:
Vito Sansevero
2026-10-27 06:36:40 -07:00
parent a842a3c256
commit 4f927549fd
8 changed files with 247 additions and 33 deletions
+17 -10
View File
@@ -752,16 +752,23 @@ semantics are unavailable") and ingestion is unaffected, because it never import
Unknown nodes pass through (#381, maintainer-approved 2026-10-27; no per-node
inventory): an output whose class the table, its `facts`, `lineage` and the code's
adapters never name is read through, not refused. A `text` input is its text (the
legacy encoder rule, so RegexReplace or LoRAfromText show their input text). Else
output slot 0 with a linked `positive` (slot 1, `negative`) follows only that input,
else a linked `conditioning` alone, else every linked input except the table's
`pass_through.skip_inputs` (core type inputs: model, clip, vae, image, latent, ...),
each traced with kind `any`. A followed input with no text adds nothing; limits and
selector choices propagate; another failure (`runtime-value`, `composite`,
`malformed`) blocks, unless a PromptManager node was reached: this repository's own
nodes are authoritative, and then only their texts count. One distinct text
resolves; several make the sampler `ambiguous` "Several prompts reach the sampler"
with one candidate per text, in input order (at most 32, else `work-limit`). No text
legacy encoder rule, so RegexReplace or LoRAfromText show their input text), except
that a string output of a node with a seed input (`seed`, `noise_seed`,
`sampling_mode.seed`) and no linked `conditioning`/`positive`/`negative` input is
generated at run time (a language model such as TextGenerate, a wildcard):
`runtime-value` naming it. Else, at output slot 0 or 1 of a node with a `positive` or
`negative` input, only `positive` (slot 1, `negative`) is followed, nothing when it
is not linked; else a linked `conditioning` alone; else every linked input except
the table's `pass_through.skip_inputs` (core type inputs: model, clip, vae, image,
latent, ...), each traced with kind `any`. A followed input with no text adds
nothing; limits and selector choices propagate; another failure (`runtime-value`,
`composite`, `malformed`) blocks, unless a PromptManager node was reached: this
repository's own nodes are authoritative, and then only their texts count. One
distinct text resolves; several make the sampler `ambiguous` "Several prompts reach
the sampler" with one candidate per distinct outcome, in input order (at most 32,
else `work-limit`). A PromptManagerPair's execution record is what ran: while its
inputs are checked against it, a walk through an unknown node that fails, diverges
or passes text through is unverifiable and never overrides the record. No text
at all is `unsupported-node` naming the outermost unknown node. An unlisted output
of a listed class (a sampler, a loader, a lineage node) stays `unsupported-node`, so
the walk never reads another stage's prompt. `INSPECTION_RESOLVER_VERSION` is 2 for
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -913,7 +913,10 @@ def golden_cases():
import unknown_node_graphs
for name, graph in unknown_node_graphs.shapes().items():
add(f"pass-through-{name}", graph)
for name, records in unknown_node_graphs.recorded_shapes().items():
for name, records in {
**unknown_node_graphs.recorded_shapes(),
**unknown_node_graphs.review_shapes(),
}.items():
cases[f"pass-through-{name}"] = records
cases["parameters-only"] = {
"parameters": "a fox\nNegative prompt: blur\nSteps: 20, Sampler: euler"
@@ -301,3 +301,91 @@ class TestGeneratedAndTransformedText(unittest.TestCase):
},
)
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, ""))
class TestReviewFindings(unittest.TestCase):
"""Shapes from the independent review of the first #381 commit."""
def _recorded(self, text_source: dict) -> dict:
graph = any_switch_graph()
graph["10"]["inputs"]["text"] = ["30", 0]
graph.update(text_source)
metadata = {
"prompt": graph,
"comfyassets_prompt_pairs": graphs.execution("10", graphs.GENERATED),
}
return resolve_prompt_pair(metadata, profile=resolver.INSPECTION_PROFILE)
def test_a_failing_walk_never_overrides_the_recorded_text(self):
sources = {
"missing": {"30": {"class_type": "XLLM", "inputs": {"src": ["99", 0]}}},
"composite": {
"30": {"class_type": "XLLM", "inputs": {"src": ["31", 0]}},
"31": {
"class_type": "ConditioningCombine",
"inputs": {
"conditioning_1": ["32", 0],
"conditioning_2": ["33", 0],
},
},
"32": _encode(POSITIVE),
"33": _encode(OTHER),
},
"several": {
"30": {
"class_type": "XLLM",
"inputs": {"a": ["31", 0], "b": ["32", 0]},
},
"31": {"class_type": "PrimitiveString", "inputs": {"value": POSITIVE}},
"32": {"class_type": "PrimitiveString", "inputs": {"value": OTHER}},
},
"list-text": {"30": {"class_type": "XLLM", "inputs": {"text": [1, 2, 3]}}},
}
for name, source in sources.items():
with self.subTest(source=name):
result = self._recorded(source)
self.assertEqual(result["state"], "resolved", result["reason"])
self.assertEqual(_texts(result)[0], graphs.GENERATED)
def test_the_seed_rule_reads_only_string_outputs(self):
graph = _with(
any_switch_graph(any_01=["20", 0]),
**{
"20": {"class_type": "XNoise", "inputs": {"conditioning": ["21", 0]}},
"21": {
"class_type": "XSeedEncode",
"inputs": {"clip": ["7", 0], "text": OTHER, "seed": 3},
},
},
)
self.assertEqual(_texts(_inspect(graph))[0], OTHER)
def test_an_unlinked_role_input_adds_nothing(self):
graph = any_switch_graph()
graph["20"] = {
"class_type": "XApply",
"inputs": {"positive": ["21", 0], "negative_opt": ""},
}
graph["21"] = _encode(POSITIVE)
graph["17"]["inputs"]["positive"] = ["20", 0]
graph["17"]["inputs"]["negative"] = ["20", 1]
result = _inspect(graph)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(result["node"], {"id": "20", "class_type": "XApply"})
def test_one_text_offered_twice_is_one_candidate(self):
graph = _with(
any_switch_graph(any_01=["20", 0], any_02=["23", 0]),
**{
"20": {"class_type": "XB", "inputs": {"p": ["21", 0], "q": ["22", 0]}},
"21": _encode(POSITIVE),
"22": _encode(OTHER),
"23": _encode(POSITIVE),
},
)
result = _inspect(graph)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(
[_texts(item)[0] for item in result["candidates"]], [POSITIVE, OTHER]
)
+62
View File
@@ -257,3 +257,65 @@ def shapes():
},
),
}
def review_shapes():
"""Record sets for the review findings on the first #381 commit."""
def recorded(source):
graph = any_switch_graph()
graph["10"]["inputs"]["text"] = ["30", 0]
graph.update(source)
return {
"prompt": json.dumps(graph),
"comfyassets_prompt_pairs": execution("10", GENERATED),
}
seeded = with_nodes(
any_switch_graph(any_01=["20", 0]),
**{
"20": {"class_type": "XNoise", "inputs": {"conditioning": ["21", 0]}},
"21": {
"class_type": "XSeedEncode",
"inputs": {"clip": ["7", 0], "text": OTHER, "seed": 3},
},
},
)
unlinked = any_switch_graph()
unlinked["20"] = {
"class_type": "XApply",
"inputs": {"positive": ["21", 0], "negative_opt": ""},
}
unlinked["21"] = encode(POSITIVE)
unlinked["17"]["inputs"]["positive"] = ["20", 0]
unlinked["17"]["inputs"]["negative"] = ["20", 1]
twice = with_nodes(
any_switch_graph(any_01=["20", 0], any_02=["23", 0]),
**{
"20": {"class_type": "XB", "inputs": {"p": ["21", 0], "q": ["22", 0]}},
"21": encode(POSITIVE),
"22": encode(OTHER),
"23": encode(POSITIVE),
},
)
return {
"recorded-missing": recorded(
{"30": {"class_type": "XLLM", "inputs": {"src": ["99", 0]}}}
),
"recorded-several": recorded(
{
"30": {
"class_type": "XLLM",
"inputs": {"a": ["31", 0], "b": ["32", 0]},
},
"31": {"class_type": "PrimitiveString", "inputs": {"value": POSITIVE}},
"32": {"class_type": "PrimitiveString", "inputs": {"value": OTHER}},
}
),
"recorded-list-text": recorded(
{"30": {"class_type": "XLLM", "inputs": {"text": [1, 2, 3]}}}
),
"seeded-encoder-behind-noise": {"prompt": json.dumps(seeded)},
"unlinked-role-input": {"prompt": json.dumps(unlinked)},
"one-text-twice": {"prompt": json.dumps(twice)},
}
+42 -12
View File
@@ -216,6 +216,7 @@ SEVERAL_SOURCES_REASON = "Several prompts reach the sampler"
SELECTOR_BRANCHES_REASON = "Selector branches have different prompts"
# Failures that bound the work: never skipped by a pass-through walk.
_LIMIT_CODES = frozenset({"work-limit", "depth-limit", "text-limit"})
_PASSED_UNVERIFIABLE = "Text passed through an unknown node is not verifiable"
# Inputs that make an unknown node a conditioning transform, not a generator.
_CONDITIONING_INPUTS = ("conditioning", "positive", "negative")
# The first item of a pass-through divergence's 3-tuple choice key.
@@ -476,6 +477,8 @@ class _GraphResolver:
self.latent_charged: set = set()
# ``semantics`` (inspection) names the samplers and guiders it follows.
self.semantics = semantics
# Nesting depth of execution-record checks (``passed``).
self.verifying = 0
if semantics is None:
self.sampler_classes = self.stage_classes = _SAMPLERS
else:
@@ -880,10 +883,31 @@ class _GraphResolver:
def passed(
self, node_id: str, slot: int, kind: str, inputs: dict, depth: int
) -> Tuple[str, list, str]:
"""``read_through``, except while checking an execution record.
A pair node's recorded text is what ran. While its inputs are checked
against the record (``execution_record``), any outcome of a walk through
an unknown node but running out of visits only means "cannot be read
statically" (``unsupported-node``, unverifiable), as before #381.
"""
if not self.verifying:
return self.read_through(node_id, slot, kind, inputs, depth)
try:
return self.read_through(node_id, slot, kind, inputs, depth)
except _Diverging:
raise self.unsupported(_PASSED_UNVERIFIABLE, node_id) from None
except _Unresolved as error:
if error.code == "work-limit":
raise
raise self.unsupported(_PASSED_UNVERIFIABLE, node_id) from None
def read_through(
self, node_id: str, slot: int, kind: str, inputs: dict, depth: int
) -> Tuple[str, list, str]:
"""An output no rule describes passes its text through (#381).
A text output (kind ``string`` or ``any``) of a node with a seed input
A string output (kind ``string``) of a node with a seed input
(``seed``, ``noise_seed``, ``sampling_mode.seed``) and no linked
conditioning input is generated at run time (a language model, a
wildcard or random line): ``runtime-value``, never its inputs. With a
@@ -898,7 +922,7 @@ class _GraphResolver:
are a runtime choice with one candidate per text, in input order.
"""
if (
kind != "conditioning"
kind == "string"
and any(name.endswith("seed") for name in inputs)
and not any(_link(inputs.get(name)) for name in _CONDITIONING_INPUTS)
):
@@ -939,15 +963,16 @@ class _GraphResolver:
def pass_inputs(self, slot: int, inputs: dict) -> list:
"""The linked inputs an unknown node's output is read through.
Output slot 0 with a linked ``positive`` (slot 1, ``negative``) reads
only that input: the paired-conditioning convention, which the legacy
parser followed by role. A linked ``conditioning`` is read alone (the
Output slot 0 of a node with a ``positive`` or ``negative`` input reads
only ``positive`` (slot 1, ``negative``), nothing when that one is not
linked: the paired-conditioning convention, which the legacy parser
followed by role. A linked ``conditioning`` is read alone (the
legacy order). Otherwise every linked input but the core type inputs
(``pass_through.skip_inputs``: model, clip, image, ...) is read.
"""
for wanted, name in ((0, "positive"), (1, "negative")):
if slot == wanted and _link(inputs.get(name)):
return [inputs[name]]
if slot in (0, 1) and ("positive" in inputs or "negative" in inputs):
value = inputs.get(("positive", "negative")[slot])
return [value] if _link(value) else []
if _link(inputs.get("conditioning")):
return [inputs["conditioning"]]
skipped = self.semantics.pass_through["skip_inputs"]
@@ -1160,12 +1185,9 @@ class _GraphResolver:
if record is None:
return None
for name in ("text", "negative_text", "prepend_text", "append_text"):
self.verifying += 1
try:
expected, followed, _ = self.text(inputs.get(name, ""), "string")
except _Diverging:
# Several texts passed through an unknown node (#381): the
# record is what ran, so it is not checked against them.
continue
except _Unresolved as error:
if self.unverifiable(error):
value = inputs.get(name)
@@ -1186,6 +1208,8 @@ class _GraphResolver:
raise
continue
raise
finally:
self.verifying -= 1
if self.semantics is not None and self.through_unknown(followed):
# A text passed through an unknown node may be transformed there.
continue
@@ -1736,6 +1760,12 @@ class _GraphResolver:
self.choices = {}
if all(item["state"] == "unresolved" for item in results):
return results[0]
if several:
# Chained unknown nodes can offer one text twice: list it once.
unique: dict = {}
for item in results:
unique.setdefault(_outcome_key(item), item)
results = list(unique.values())
if len({_outcome_key(item) for item in results}) == 1:
return results[0]
return _result(
@@ -35,6 +35,7 @@ export const SELECTOR_BRANCHES_REASON = 'Selector branches have different prompt
// Failures that bound the work: never skipped by a pass-through walk (`_LIMIT_CODES`).
export const PASS_LIMIT_CODES = new Set(['work-limit', 'depth-limit', 'text-limit']);
export const PASS_THROUGH = 'pass-through';
export const PASSED_UNVERIFIABLE = 'Text passed through an unknown node is not verifiable';
// Classes traced by code, not by the table (`_CODED_CLASSES`).
export const CODED_CLASSES = ['Reroute', 'StringConcatenate', 'Text Concatenate'];
export const IDENTICAL_BRANCHES_REASON = 'Every sampler branch uses the same prompt pair';
+32 -9
View File
@@ -30,6 +30,7 @@ import {
PASS_THROUGH,
SELECTOR_BRANCHES_REASON,
SEVERAL_SOURCES_REASON,
PASSED_UNVERIFIABLE,
OVERWRITTEN_PROMPT_REASON,
PROFILE,
ROLE_METADATA_KEYS,
@@ -82,6 +83,8 @@ class GraphResolver {
this.latentWalks = new Map();
this.latentCharged = new Set();
this.semantics = semantics;
// Nesting depth of execution-record checks (`passed`).
this.verifying = 0;
this.samplerClasses = new Set([...semantics.samplers].filter(([, entry]) => CANDIDATE_STRATEGIES.has(entry.strategy)).map(([name]) => name));
this.stageClasses = new Set(semantics.samplers.keys());
// `described`: an unlisted output of one of these stays `unsupported-node`; any other class passes through (#381).
@@ -317,11 +320,23 @@ class GraphResolver {
return this.merged(nodeId, 0, parts);
}
/** `passed` (#381): an output no rule describes passes its text through; PromptManager nodes are authoritative. */
/** `passed`: `readThrough`, except that while an execution record is checked any outcome but `work-limit` is unverifiable. */
passed(nodeId, slot, kind, inputs, depth) {
// A text output of a node with a seed input is generated at run time (a language model, a wildcard).
if (!this.verifying) return this.readThrough(nodeId, slot, kind, inputs, depth);
try {
return this.readThrough(nodeId, slot, kind, inputs, depth);
} catch (error) {
if (error instanceof Unresolved && error.code === 'work-limit') throw error;
if (error instanceof Unresolved || error instanceof Diverging) throw this.unsupported(PASSED_UNVERIFIABLE, nodeId);
throw error;
}
}
/** `read_through` (#381): an output no rule describes passes its text through; PromptManager nodes are authoritative. */
readThrough(nodeId, slot, kind, inputs, depth) {
// A string output of a node with a seed input is generated at run time (a language model, a wildcard).
// With a linked conditioning input it is a conditioning transform (noise) instead.
if (kind !== 'conditioning' && [...inputs.keys()].some(name => name.endsWith('seed')) &&
if (kind === 'string' && [...inputs.keys()].some(name => name.endsWith('seed')) &&
!['conditioning', 'positive', 'negative'].some(name => isLink(pyGet(inputs, name)))) throw this.named(RUNTIME_REASON, 'runtime-value', nodeId);
if (inputs.has('text')) {
const traced = this.text(inputs.get('text'), 'string', depth + 1);
@@ -354,8 +369,9 @@ class GraphResolver {
/** `pass_inputs`: the role input by output slot, else `conditioning`, else every linked non-type input. */
passInputs(slot, inputs) {
for (const [wanted, name] of [[0, 'positive'], [1, 'negative']]) {
if (slot === wanted && isLink(pyGet(inputs, name))) return [inputs.get(name)];
if ((slot === 0 || slot === 1) && (inputs.has('positive') || inputs.has('negative'))) {
const value = pyGet(inputs, slot === 0 ? 'positive' : 'negative');
return isLink(value) ? [value] : [];
}
if (isLink(pyGet(inputs, 'conditioning'))) return [inputs.get('conditioning')];
return [...inputs].filter(([name, value]) => isLink(value) && !this.semantics.skipInputs.has(name)).map(([, value]) => value);
@@ -489,11 +505,10 @@ class GraphResolver {
if (record === null) return null;
for (const name of ['text', 'negative_text', 'prepend_text', 'append_text']) {
let expected, followed;
this.verifying += 1;
try {
[expected, followed] = this.text(pyGet(inputs, name, ''), 'string');
} catch (error) {
// Several texts passed through an unknown node (#381): the record is what ran.
if (error instanceof Diverging) continue;
if (error instanceof Unresolved && this.unverifiable(error)) {
const value = pyGet(inputs, name);
if (isLink(value)) {
@@ -505,6 +520,8 @@ class GraphResolver {
continue;
}
throw error;
} finally {
this.verifying -= 1;
}
// A text passed through an unknown node may be transformed there.
if (this.throughUnknown(followed)) continue;
@@ -562,8 +579,8 @@ class GraphResolver {
}
branches(samplerId) {
const pending = [new Map()], results = [];
let several = false;
const pending = [new Map()];
let results = [], several = false;
try {
while (pending.length) {
this.choices = pending.shift();
@@ -583,6 +600,12 @@ class GraphResolver {
this.choices = new Map();
}
if (results.every(item => item.state === 'unresolved')) return results[0];
if (several) {
// Chained unknown nodes can offer one text twice: list it once.
const unique = new Map();
for (const item of results) if (!unique.has(outcomeKey(item))) unique.set(outcomeKey(item), item);
results = [...unique.values()];
}
if (new Set(results.map(outcomeKey)).size === 1) return results[0];
return result('ambiguous', { samplerId, reason: several ? SEVERAL_SOURCES_REASON : SELECTOR_BRANCHES_REASON, candidates: results });
}