Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/local-resolver.mjs
T
Vito cbc53ee9f1 feat(resolver): trace multi-stage chains through pass-through nodes (#308)
* feat(resolver): trace multi-stage chains through pass-through nodes

The inspection resolver (server and local Inspect) now compares a sampler
with the earlier sampler its latent continues through image-space
pass-through nodes: decode, image filter, model upscale, scale and encode,
listed in a new validated `lineage` section of the semantics table. Equal
prompts resolve (hires-fix, refiner); different prompts are
multistage-differs. A later stage whose positive adds conditioning to the
shared base prompt through an additive composite (ConditioningCombine or
Concat) keeps the base pair, the text legacy linked (three-stage Flux).
Image-edit batches stay ambiguous and run-time text stays untraceable.
Ingestion keeps the default profile (D1).

A card no longer shows a seed when the inspector names a chain stage
whose seed differs from the parser's pick.

* feat(resolver): leave out run-time PromptManager parts, labelled

Maintainer option A: when a PromptManager prepend_text or append_text is
fed directly by a node whose output is decided at run time (LoraManager's
TriggerWord Toggle, now a runtime class, or PromptSearchList), the prompt
resolves to the node's own text, as legacy showed. The side's source is
runtime-parts-omitted and the resolved reason says that text added at run
time, such as trigger words, is not included; the prompt block shows that
line (D-NEW-R1). The toggle's output is never computed. Literal and
traced parts are combined as before; an indirect run-time part, a run-time
main text or an unknown feeder keep their outcome. The browser port
changes identically. Ingestion is unchanged.

* fix(resolver): keep run-time and added-conditioning labels honest

A text that left out a run-time PromptManager part is now labelled when
any node on its traced path omitted it, so the label survives encoders,
concatenations and chained PromptManagers. Comparisons include what a
text leaves out, so a partial prompt never equals a complete one: a
branch with trigger words and one without stay ambiguous, and such
stages are multistage-differs. Only classes listed as run time are left
out.

The added-conditioning floor labels its positive (D126, "Conditioning
added at a later stage is not included."); the run-time line is D125.

A card hides its seed only when the stage the inspector names reads a
different seed than the parser; an agreeing seed is kept, as on main.
The browser port moves its lineage, latent and part helpers into
local-resolver-stages.mjs, keeping parity.
2026-10-23 11:09:12 -07:00

770 lines
39 KiB
JavaScript

// The server's inspection resolver in this browser (#249 local Inspect, PR L3 Task 8, decision LI-1): a port of
// utils/prompt_inspector.py `inspect_prompt_evidence` with utils/prompt_pair_resolver.py (INSPECTION_PROFILE) and
// utils/pair_execution.py `read_pair_execution`, driven by web/js/inspection-semantics.json. Python is the oracle:
// tests/test_local_inspect_parity.py compares every answer with the server's on the same records. Values follow
// Python's model (py-json.mjs): dict order, int versus float, code-point lengths, `str.strip()`.
//
// Task 8 ported execution evidence and graph tracing for one sampler; Task 9 adds candidate selection among several
// samplers and detailer stages (local-resolver-stages.mjs) and A1111 `parameters` (local-parameters.mjs). No DOM.
import { PyValueError, isDict, isList, isInt, pyJsonLoads, pyLen, pySlice, pyStrip, pyStr, pyGet } from './py-json.mjs';
import {
ADAPTERS,
ADAPTER_SLOTS,
Budget,
CANDIDATE_STRATEGIES,
CLASS_NAME_LENGTH,
Diverging,
EMPTY_POSITIVE,
EMPTY_POSITIVE_REASON,
GUIDER_ABSENT_REASON,
INCOMPATIBLE_SLOT,
MALFORMED_EVIDENCE_REASON,
MAX_BRANCH_CANDIDATES,
MAX_JOIN_INPUTS,
MAX_SUBGRAPH_DEPTH,
MAX_WORKFLOW_NODES,
NO_SAMPLER_NAMES,
OVERWRITTEN_PROMPT_REASON,
PROFILE,
ROLE_METADATA_KEYS,
RUNTIME_REASON,
SAMPLER_LIKE_INPUTS,
SEMANTICS_UNAVAILABLE_REASON,
UNREADABLE_METADATA_REASON,
UNVERIFIABLE_CODES,
Unresolved,
ZEROED_SOURCE,
compileSemantics,
explained,
isLink,
nodeRef,
RUNTIME_PARTS_REASON,
RUNTIME_PARTS_SOURCE,
readPairExecution,
result,
sideOf,
slotKey,
} from './local-resolver-support.mjs';
import { finalStages, latentStages, outcomeKey, promptPart, sharedPair, stage } from './local-resolver-stages.mjs';
import { parameters } from './local-parameters.mjs';
export { SemanticsError, compileSemantics, readPairExecution, outcomeMessage, isLink } from './local-resolver-support.mjs';
class GraphResolver {
constructor(graph, metadata, semantics, overwritten) {
this.graph = graph;
this.overwritten = overwritten;
this.perCandidateVisits = PROFILE.perCandidateVisits;
this.budget = new Budget(PROFILE.totalVisits);
this.budget.reset(PROFILE.perCandidateVisits);
this.pathEntries = PROFILE.maxPathEntries;
this.builtChars = PROFILE.maxBuiltChars;
this.scanned = PROFILE.maxScannedUnits;
this.checked = new Map();
this.costs = new Map();
this.heights = new Map();
this.deepest = 0;
this.maxDepth = PROFILE.maxDepth;
this.maxTextLength = PROFILE.maxTextLength;
this.memo = new Map();
this.active = new Set();
this.metadata = metadata ?? new Map();
this.textSources = new Map();
this.omitting = new Set();
this.choices = new Map();
this.tainted = false;
this.latentWalks = new Map();
this.latentCharged = new Set();
this.semantics = semantics;
this.samplerClasses = new Set([...semantics.samplers].filter(([, entry]) => CANDIDATE_STRATEGIES.has(entry.strategy)).map(([name]) => name));
this.stageClasses = new Set(semantics.samplers.keys());
}
node(nodeId) {
if (!this.checked.has(nodeId)) {
try {
this.checked.set(nodeId, this.checkedNode(nodeId));
} catch (error) {
if (!(error instanceof Unresolved) || error.code === 'work-limit') throw error;
this.checked.set(nodeId, error);
}
}
const checked = this.checked.get(nodeId);
if (checked instanceof Unresolved) throw new Unresolved(checked.message, checked.code, checked.node);
return checked;
}
checkedNode(nodeId) {
const node = pyGet(this.graph, nodeId);
if (!isDict(node) || !isDict(pyGet(node, 'inputs'))) throw new Unresolved('Missing or malformed graph node', 'malformed');
const inputs = node.get('inputs');
this.scan(inputs.size);
return [pyStr(pyGet(node, 'class_type', '')), inputs];
}
scan(units) {
if (units > this.scanned) {
this.scanned = -1;
throw new Unresolved('Resolution work limit reached', 'work-limit');
}
this.scanned -= units;
}
unsupported(message, nodeId) { return this.named(message, 'unsupported-node', nodeId); }
named(message, code, nodeId) {
const node = pyGet(this.graph, nodeId);
const classType = isDict(node) ? pyGet(node, 'class_type') : null;
return new Unresolved(message, code, nodeRef(nodeId, classType));
}
bounded(text) {
if (pyLen(text) > this.maxTextLength) throw new Unresolved('Resolved text exceeds the length limit', 'text-limit');
return text;
}
built(text) {
this.builtChars -= pyLen(text);
if (this.builtChars < 0) throw new Unresolved('Resolution work limit reached', 'work-limit');
return text;
}
/** `text`: [text, path, storageText] for a string value or a typed link. */
text(value, kind, depth = 0) {
this.deepest = Math.max(this.deepest, depth);
if (depth >= this.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit');
if (typeof value === 'string' && kind === 'string') return [this.bounded(value), [], value];
if (!isLink(value)) throw new Unresolved('Expected a typed graph link or text input', 'malformed');
const nodeId = String(value[0]), slot = value[1];
const key = `${slotKey(nodeId, slot)}\u0000${kind}`;
if (this.active.has(key)) throw new Unresolved('Cyclic prompt graph', 'malformed');
if (this.memo.has(key)) {
const height = this.heights.get(key);
if (depth + height >= this.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit');
this.budget.charge(this.costs.get(key));
this.deepest = Math.max(this.deepest, depth + height);
return this.memo.get(key);
}
const allowance = this.budget.allowance, outerDeepest = this.deepest, outerTainted = this.tainted;
this.tainted = false;
this.deepest = depth;
try {
this.budget.spend();
const [name, inputs] = this.node(nodeId);
this.scan(inputs.size);
if (this.overwritten.has(nodeId)) throw new Unresolved(OVERWRITTEN_PROMPT_REASON, 'malformed');
this.active.add(key);
let text, path, storageText;
const adapted = ADAPTERS.get(slotKey(name, slot));
if (name === 'Reroute' && slot === 0) {
const keys = ['value', 'input', 'in'].filter(item => inputs.has(item));
if (keys.length !== 1) throw new Unresolved('Reroute input is not uniquely identifiable', 'malformed');
[text, path, storageText] = this.text(inputs.get(keys[0]), kind, depth + 1);
this.inheritSource(nodeId, slot, [path]);
} else if (adapted) {
if (adapted.kind !== kind) throw new Unresolved(INCOMPATIBLE_SLOT, 'malformed');
const evidence = name === 'PromptManagerPair' ? this.executionRecord(nodeId, inputs) : null;
if (evidence !== null) {
text = evidence[adapted.inputName === 'text' ? 'positive_text' : 'negative_effective_text'];
storageText = evidence[adapted.inputName];
path = [];
this.textSources.set(slotKey(nodeId, slot), 'execution');
} else if (adapted.comma || adapted.strip) {
[text, path, storageText] = this.historical(adapted, inputs, depth);
} else {
[text, path, storageText] = this.text(pyGet(inputs, adapted.inputName), 'string', depth + 1);
if (adapted.compose || adapted.storageInput) storageText = text;
if (adapted.compose) {
const omitted = [];
const [before, beforePath] = promptPart(this, pyGet(inputs, 'prepend_text', ''), depth, omitted);
const [after, afterPath] = promptPart(this, pyGet(inputs, 'append_text', ''), depth, omitted);
this.scan(pyLen(before) + pyLen(after) + beforePath.length + path.length + afterPath.length);
text = this.built(this.bounded([pyStrip(before), text, pyStrip(after)].filter(part => part).join(' ')));
path = [...beforePath, ...path, ...afterPath];
if (omitted.length) this.omitting.add(slotKey(nodeId, slot));
}
}
} else if ((name === 'StringConcatenate' || name === 'Text Concatenate') && slot === 0 && kind === 'string') {
[text, path] = this.concatenate(name, inputs, depth);
storageText = text;
} else if (this.follows(name, slot, kind)) {
[text, path, storageText] = this.semantic(nodeId, name, slot, kind, inputs, depth);
} else {
throw this.unsupported('Unsupported node semantics or output slot', nodeId);
}
this.scan(path.length + 1);
path = [{ node_id: nodeId, output_slot: slot }, ...path];
const pathLimit = this.maxDepth * Math.max(1, this.perCandidateVisits);
this.pathEntries -= path.length;
if (path.length > pathLimit || this.pathEntries < 0) throw new Unresolved('Provenance path limit reached', 'work-limit');
const traced = [text, path, storageText];
if (!this.tainted) {
this.memo.set(key, traced);
this.costs.set(key, allowance - this.budget.allowance);
this.heights.set(key, this.deepest - depth);
}
return traced;
} finally {
this.deepest = Math.max(outerDeepest, this.deepest);
this.tainted = outerTainted || this.tainted;
this.active.delete(key);
}
}
/** `StringConcatenate` / `Text Concatenate` (the `text` branch). */
concatenate(name, inputs, depth) {
let keys, delimiter, clean;
if (name === 'StringConcatenate') {
keys = ['string_a', 'string_b'];
delimiter = pyGet(inputs, 'delimiter', '');
clean = false;
} else {
keys = [...inputs.keys()].filter(item => /^text_[a-z]$/.test(item)).sort();
delimiter = pyGet(inputs, 'delimiter', ' ');
clean = pyStr(pyGet(inputs, 'clean_whitespace', 'true')).toLowerCase() === 'true';
}
if (!keys.length || typeof delimiter !== 'string') throw new Unresolved('Malformed concatenation', 'malformed');
const parts = [], path = [];
let size = 0;
for (const inputName of keys) {
let [part, followed] = this.text(pyGet(inputs, inputName, ''), 'string', depth + 1);
this.scan(pyLen(part) + followed.length);
if (clean) part = pyStrip(part);
path.push(...followed);
if (part || name === 'StringConcatenate') {
size += pyLen(part) + (parts.length ? pyLen(delimiter) : 0);
if (size > this.maxTextLength) throw new Unresolved('Resolved text exceeds the length limit', 'text-limit');
parts.push(part);
}
}
return [this.built(parts.join(delimiter)), path];
}
/** `prompt_manager_*.py` at 07a5af3: strip, then join with ", ". */
historical(adapted, inputs, depth) {
let [text, path] = this.text(pyGet(inputs, adapted.inputName, adapted.default), 'string', depth + 1);
this.scan(pyLen(text));
text = pyStrip(text);
const storageText = text;
if (adapted.comma) {
let [before, beforePath] = this.text(pyGet(inputs, 'prepend_text') ?? '', 'string', depth + 1);
let [after, afterPath] = this.text(pyGet(inputs, 'append_text') ?? '', 'string', depth + 1);
this.scan(pyLen(before) + pyLen(after) + beforePath.length + afterPath.length);
before = pyStrip(before); after = pyStrip(after);
if (before) text = `${before}${text && !before.endsWith(',') ? ', ' : ''}${text}`;
if (after) text = `${text}${text && !text.endsWith(',') ? ', ' : ''}${after}`;
text = this.built(this.bounded(text));
path = [...beforePath, ...path, ...afterPath];
}
return [text, path, storageText];
}
follows(name, slot, kind) {
const semantics = this.semantics, key = slotKey(name, slot);
if (semantics.runtime.has(name)) return true;
if (semantics.encoders.has(key) || semantics.strings.has(key)) return true;
if (slot === 0 && semantics.selectors.has(name)) return true;
return kind === 'conditioning' && (semantics.conditioning.has(key) || (slot === 0 && semantics.composites.has(name)));
}
semantic(nodeId, name, slot, kind, inputs, depth) {
const semantics = this.semantics, key = slotKey(name, slot);
if (semantics.runtime.has(name)) throw this.named(RUNTIME_REASON, 'runtime-value', nodeId);
for (const [table, expected, build] of [[semantics.encoders, 'conditioning', 'encoded'], [semantics.strings, 'string', 'stringed']]) {
const listed = table.get(key);
if (listed !== undefined) {
if (kind !== expected) throw new Unresolved(INCOMPATIBLE_SLOT, 'malformed');
return this[build](nodeId, listed, inputs, depth);
}
}
const entry = kind === 'conditioning' ? semantics.conditioning.get(key) : undefined;
if (entry !== undefined && entry.strategy === 'zeroed') {
this.textSources.set(slotKey(nodeId, slot), ZEROED_SOURCE);
return ['', [], ''];
}
if (entry !== undefined) {
const traced = this.text(pyGet(inputs, entry.input), kind, depth + 1);
this.inheritSource(nodeId, slot, [traced[1]]);
return traced;
}
if (semantics.selectors.has(name)) return this.select(nodeId, semantics.selectors.get(name), inputs, kind, depth);
const parts = semantics.composites.get(name).inputs.map(inputName => this.text(pyGet(inputs, inputName), kind, depth + 1));
if (new Set(parts.map(part => part[0])).size !== 1) throw this.named('Combined conditioning has different texts', 'composite', nodeId);
return this.merged(nodeId, slot, parts);
}
select(nodeId, entry, inputs, kind, depth) {
const condition = pyGet(inputs, entry.condition), branches = entry.branches;
let choice;
if (typeof condition === 'boolean') choice = condition;
else if (!isLink(condition)) throw new Unresolved('Malformed selector condition', 'malformed');
else if (this.choices.has(slotKey(String(condition[0]), condition[1]))) {
choice = this.choices.get(slotKey(String(condition[0]), condition[1]));
this.tainted = true;
} else choice = null;
if (choice !== null) {
const traced = this.text(pyGet(inputs, branches[choice ? 'true' : 'false']), kind, depth + 1);
this.inheritSource(nodeId, 0, [traced[1]]);
return traced;
}
const parts = ['true', 'false'].map(label => this.text(pyGet(inputs, branches[label]), kind, depth + 1));
if (parts[0][0] !== parts[1][0]) throw new Diverging(slotKey(String(condition[0]), condition[1]));
return this.merged(nodeId, 0, parts);
}
merged(nodeId, slot, parts) {
const traced = this.joinedPaths(parts, parts);
this.inheritSource(nodeId, slot, parts.map(part => part[1]));
return traced;
}
joinedPaths(chosen, parts) {
const text = chosen[0][0];
const storages = new Set(chosen.map(part => part[2]));
const storageText = storages.size === 1 ? chosen[0][2] : text;
const paths = parts.map(part => part[1]);
this.scan(paths.reduce((sum, path) => sum + path.length, 0));
const merged = [], seen = new Set();
for (const path of paths) {
for (const step of path) {
const identity = slotKey(step.node_id, step.output_slot);
if (!seen.has(identity)) { seen.add(identity); merged.push(step); }
}
}
return [text, merged, storageText];
}
encoded(nodeId, entry, inputs, depth) {
const names = entry.inputs;
if (entry.strategy === 'single') return this.text(pyGet(inputs, names[0]), 'string', depth + 1);
if (entry.strategy === 'flag-parts') return this.flagged(nodeId, entry, inputs, depth);
if (entry.strategy !== 'parts-equal') throw this.unsupported('Unsupported encoder strategy', nodeId);
const parts = names.map(name => this.text(pyGet(inputs, name), 'string', depth + 1));
this.scan(parts.reduce((sum, part) => sum + pyLen(part[0]), 0));
if (new Set(parts.map(part => part[0])).size === 1) return this.joinedPaths(parts, parts);
const filled = parts.filter(part => part[0] !== '');
if (filled.length === 1) return this.joinedPaths(filled, parts);
throw this.named('Encoder parts have different texts', 'composite', nodeId);
}
flagged(nodeId, entry, inputs, depth) {
const names = entry.inputs;
if (!this.setting(nodeId, inputs, entry.params.flag, 'bool')) return this.text(pyGet(inputs, names[0]), 'string', depth + 1);
const parts = names.map(name => this.text(pyGet(inputs, name), 'string', depth + 1));
this.scan(parts.reduce((sum, part) => sum + pyLen(part[0]), 0));
if (new Set(parts.map(part => part[0])).size === 1) return this.joinedPaths(parts, parts);
throw this.named(RUNTIME_REASON, 'runtime-value', nodeId);
}
stringed(nodeId, entry, inputs, depth) {
const strategy = entry.strategy, source = entry.inputs[0];
if (strategy === 'literal') return this.text(pyGet(inputs, source), 'string', depth + 1);
if (strategy === 'strip-newlines') {
const flag = this.setting(nodeId, inputs, entry.params.flag, 'bool');
const traced = this.text(pyGet(inputs, source), 'string', depth + 1);
if (!flag) return traced;
this.scan(pyLen(traced[0]));
const text = this.built(this.bounded(pyStrip(traced[0].replaceAll('\n', ''))));
return [text, traced[1], text];
}
if (strategy === 'join-multi') return this.joinMulti(nodeId, entry, inputs, depth);
throw this.unsupported('Unsupported string strategy', nodeId);
}
/** A literal node setting of exactly `type(value) is kind`; linked is `unsupported-node`, else malformed. */
setting(nodeId, inputs, name, kind) {
if (!inputs.has(name)) throw new Unresolved('Missing string node setting', 'malformed');
const value = inputs.get(name);
if (isLink(value)) throw this.unsupported('Linked string node setting', nodeId);
const typed = { bool: typeof value === 'boolean', int: isInt(value), str: typeof value === 'string' }[kind];
if (!typed) throw new Unresolved('Malformed string node setting', 'malformed');
return value;
}
joinMulti(nodeId, entry, inputs, depth) {
const params = entry.params, flag = params.list_flag;
if (!inputs.has(flag)) throw new Unresolved('Missing string node setting', 'malformed');
if (isLink(inputs.get(flag)) || inputs.get(flag) === true) throw this.named(RUNTIME_REASON, 'runtime-value', nodeId);
if (inputs.get(flag) !== false) throw new Unresolved('Malformed string node setting', 'malformed');
const count = this.setting(nodeId, inputs, params.count, 'int');
const delimiter = this.setting(nodeId, inputs, params.delimiter, 'str');
if (!(count >= 1 && count <= MAX_JOIN_INPUTS)) throw new Unresolved('Malformed string node setting', 'malformed');
const total = Number(count);
this.scan(total);
const names = [entry.inputs[0]];
for (let index = 2; index <= total; index++) names.push(`${params.input_prefix}${index}`);
const parts = [], path = [], seen = new Set();
let size = 0;
names.forEach((inputName, position) => {
if (position && !inputs.has(inputName)) return;
const [part, followed] = this.text(pyGet(inputs, inputName), 'string', depth + 1);
this.scan(pyLen(part) + followed.length);
for (const step of followed) {
const identity = slotKey(step.node_id, step.output_slot);
if (!seen.has(identity)) { seen.add(identity); path.push(step); }
}
if (position && !part) return;
size += pyLen(part) + (parts.length ? pyLen(delimiter) : 0);
if (size > this.maxTextLength) throw new Unresolved('Resolved text exceeds the length limit', 'text-limit');
parts.push(part);
});
const text = this.built(parts.join(delimiter));
return [text, path, text];
}
sourceOf(path) {
if (!path.length) return 'graph-inferred';
return this.textSources.get(slotKey(path[0].node_id, path[0].output_slot)) ?? 'graph-inferred';
}
inheritSource(nodeId, slot, paths) {
const sources = new Set(paths.map(path => this.sourceOf(path)));
if (sources.size === 1) this.textSources.set(slotKey(nodeId, slot), [...sources][0]);
else this.textSources.delete(slotKey(nodeId, slot));
}
unverifiable(error) { return UNVERIFIABLE_CODES.has(error.code); }
executionRecord(nodeId, inputs) {
const record = readPairExecution(this.metadata, nodeId);
if (record === null) return null;
for (const name of ['text', 'negative_text', 'prepend_text', 'append_text']) {
let expected;
try {
expected = this.text(pyGet(inputs, name, ''), 'string')[0];
} catch (error) {
if (error instanceof Unresolved && this.unverifiable(error)) {
const value = pyGet(inputs, name);
if (isLink(value)) {
const [sourceName] = this.node(String(value[0]));
const known = ADAPTER_SLOTS.get(sourceName);
if (known && !known.has(String(value[1]))) throw error;
if (['StringConcatenate', 'Text Concatenate', 'Reroute'].includes(sourceName) && value[1] !== 0) throw error;
}
continue;
}
throw error;
}
if (name === 'prepend_text' || name === 'append_text') expected = pyStrip(expected);
if (expected !== record[name]) throw new Unresolved('Execution evidence does not match recorded node inputs', 'malformed');
}
return record;
}
side(value, samplerId, guiderId = null, depth = 0) {
const [text, path, storageText] = this.text(value, 'conditioning', depth);
const origin = path[0];
const provenance = {
format: 'comfyui', sampler_id: samplerId, ...origin, path,
text_source: this.textSources.get(slotKey(origin.node_id, origin.output_slot)) ?? 'graph-inferred',
};
if (guiderId !== null) provenance.guider_id = guiderId;
const omitted = [];
for (const step of path) if (this.omitting.has(slotKey(step.node_id, step.output_slot)) && !omitted.includes(step.node_id)) omitted.push(step.node_id);
// Through encoders, concatenations and chained PromptManagers (D125).
if (omitted.length) { provenance.text_source = RUNTIME_PARTS_SOURCE; provenance.omitted_parts = omitted; }
return { text, storage_text: storageText, provenance };
}
guider(value) {
const seen = new Set();
let depth = 0;
for (;;) {
if (depth >= this.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit');
if (!isLink(value)) throw new Unresolved('Expected a guider link', 'malformed');
const nodeId = String(value[0]), slot = value[1];
if (seen.has(nodeId)) throw new Unresolved('Cyclic guider ancestry', 'malformed');
seen.add(nodeId);
this.budget.spend();
const [name, inputs] = this.node(nodeId);
this.scan(inputs.size);
depth += 1;
if (name === 'Reroute' && slot === 0) {
const keys = ['value', 'input', 'in'].filter(item => inputs.has(item));
if (keys.length !== 1) throw new Unresolved('Reroute input is not uniquely identifiable', 'malformed');
value = inputs.get(keys[0]);
continue;
}
const entry = this.semantics.guiders.get(name);
if (entry === undefined || slot !== 0) throw this.unsupported('Unsupported guider', nodeId);
return [nodeId, inputs, { positive: entry.positive, negative: entry.negative ?? null }, entry.absent_reason ?? GUIDER_ABSENT_REASON, depth];
}
}
pair(samplerId) {
this.budget.reset(this.perCandidateVisits);
this.latentCharged = new Set();
if (this.budget.total <= 0) return result('unresolved', { samplerId, reason: 'Resolution visit limit reached', reasonCode: 'work-limit' });
return this.branches(samplerId);
}
branches(samplerId) {
const pending = [new Map()], results = [];
try {
while (pending.length) {
this.choices = pending.shift();
try {
results.push(stage(this, samplerId, new Set()));
} catch (error) {
if (!(error instanceof Diverging)) throw error;
if (results.length + pending.length + 2 > MAX_BRANCH_CANDIDATES) throw new Unresolved('Too many selector branches', 'work-limit');
for (const choice of [true, false]) pending.push(new Map([...this.choices, [error.key, choice]]));
}
}
} catch (error) {
if (!(error instanceof Unresolved)) throw error;
return result('unresolved', { samplerId, reason: error.message, reasonCode: error.code, node: error.node });
} finally {
this.choices = new Map();
}
if (results.every(item => item.state === 'unresolved')) return results[0];
if (new Set(results.map(outcomeKey)).size === 1) return results[0];
return result('ambiguous', { samplerId, reason: 'Selector branches have different prompts', candidates: results });
}
sides(samplerId) {
let positive = null, negative = null;
try {
let [name, inputs] = this.node(samplerId);
const entry = this.semantics.samplers.get(name);
this.checkOverrides(samplerId, entry, inputs);
const latent = entry.latent;
if (latent !== undefined && latent !== null && inputs.has(latent)) this.latentChecked(samplerId, inputs.get(latent));
let guiderId = null, depth = 0, conditioning = entry.conditioning, absentReason = 'Negative input is absent';
if (entry.strategy === 'custom-advanced') {
let guiderReason;
[guiderId, inputs, conditioning, guiderReason, depth] = this.guider(pyGet(inputs, conditioning.guider));
if (conditioning.negative === null) absentReason = guiderReason;
}
positive = this.side(pyGet(inputs, conditioning.positive), samplerId, guiderId, depth);
if (!pyStrip(positive.text)) throw new Unresolved(EMPTY_POSITIVE_REASON);
const negativeName = conditioning.negative;
if (negativeName === null || negativeName === undefined || !inputs.has(negativeName)) {
return result('absent', { positive, samplerId, reason: absentReason });
}
negative = this.side(inputs.get(negativeName), samplerId, guiderId, depth);
const omitted = [positive, negative].some(side => side.provenance.text_source === RUNTIME_PARTS_SOURCE);
return result('resolved', { positive, negative, samplerId, reason: omitted ? RUNTIME_PARTS_REASON : '' });
} catch (error) {
if (!(error instanceof Unresolved)) throw error;
return result('unresolved', { positive, negative, samplerId, reason: error.message, reasonCode: error.code, node: error.node });
}
}
checkOverrides(samplerId, entry, inputs) {
for (const name of entry.prompt_overrides ?? []) {
const value = pyGet(inputs, name);
if (value === null || value === '') continue;
if (isLink(value)) throw this.unsupported('Unsupported prompt override', String(value[0]));
throw this.named(RUNTIME_REASON, 'runtime-value', samplerId);
}
}
/** `latent_checked`: `latent_stages` once per sampler; returns the continued earlier stage, or null. */
latentChecked(samplerId, value) {
let error, earlier = null;
if (this.latentWalks.has(samplerId)) {
const [walked, cost, found] = this.latentWalks.get(samplerId);
error = walked;
earlier = found;
if (!this.latentCharged.has(samplerId)) {
this.budget.charge(cost);
this.latentCharged.add(samplerId);
}
} else {
const before = this.budget.allowance;
try {
earlier = latentStages(this, samplerId, value);
error = null;
} catch (raised) {
if (!(raised instanceof Unresolved) || raised.code === 'work-limit') throw raised;
error = raised;
}
this.latentWalks.set(samplerId, [error, before - this.budget.allowance, earlier]);
this.latentCharged.add(samplerId);
}
if (error !== null) throw new Unresolved(error.message, error.code, error.node === null ? null : { ...error.node });
return earlier;
}
enqueue(queue, queued, inputs, depth) {
this.scan(inputs.size);
for (const item of inputs.values()) {
if (!isLink(item)) continue;
const key = String(item[0]);
if (!queued.has(key)) { queued.add(key); queue.push([key, depth]); }
}
}
stageShaped(name, inputs) {
if (this.semantics.allClasses.has(name)) return false;
const { shaped, withModel } = this.semantics.stageInputs;
if (shaped.some(key => isLink(pyGet(inputs, key)))) return true;
return isLink(pyGet(inputs, 'model')) && withModel.some(key => isLink(pyGet(inputs, key)));
}
}
/** `_sampler_like_classes`: distinct classes, in graph order, of nodes shaped like samplers or guiders. */
function samplerLikeClasses(graph) {
const names = [];
for (const node of graph.values()) {
if (!isDict(node) || !isDict(pyGet(node, 'inputs'))) continue;
const name = pyGet(node, 'class_type');
if (typeof name !== 'string' || !name || names.includes(name)) continue;
if (SAMPLER_LIKE_INPUTS.some(key => isLink(pyGet(node.get('inputs'), key)))) {
names.push(pySlice(name, CLASS_NAME_LENGTH));
if (names.length === NO_SAMPLER_NAMES) break;
}
}
return names;
}
function noSampler(graph) {
const seen = samplerLikeClasses(graph);
const reason = `No supported sampler pair found${seen.length ? ` (saw ${seen.join(', ')})` : ''}`;
return result('unresolved', { reason, reasonCode: 'no-sampler' });
}
/** `_prompt_manager_widgets`: API node ID to widget text for every PromptManager node that runs. */
function promptManagerWidgets(workflow) {
const nodes = isDict(workflow) ? pyGet(workflow, 'nodes') : null;
if (!isList(nodes)) return new Map();
const definitions = pyGet(workflow, 'definitions');
const listed = isDict(definitions) ? pyGet(definitions, 'subgraphs') : null;
const subgraphs = new Map();
for (const subgraph of isList(listed) ? listed : []) {
if (isDict(subgraph) && typeof pyGet(subgraph, 'id') === 'string' && isList(pyGet(subgraph, 'nodes'))) subgraphs.set(subgraph.get('id'), subgraph.get('nodes'));
}
const widgets = new Map(), stack = [[nodes, '', 0]];
let visited = 0;
while (stack.length) {
const [members, prefix, depth] = stack.pop();
for (const node of members) {
visited += 1;
if (visited > MAX_WORKFLOW_NODES) return widgets;
if (!isDict(node)) continue;
const kind = pyGet(node, 'type'), nodeId = pyGet(node, 'id');
if (!(typeof nodeId === 'string' || isInt(nodeId))) continue;
const apiId = `${prefix}${String(nodeId)}`;
// A list or object `type` is neither a subgraph instance nor a PromptManager node (#286).
if (isList(kind) || isDict(kind)) continue;
if (typeof kind === 'string' && subgraphs.has(kind) && depth < MAX_SUBGRAPH_DEPTH) {
stack.push([subgraphs.get(kind), `${apiId}:`, depth + 1]);
continue;
}
const values = pyGet(node, 'widgets_values');
const text = isList(values) && values.length && typeof values[0] === 'string' ? values[0] :
isDict(values) && typeof pyGet(values, 'text') === 'string' ? values.get('text') : null;
if (typeof kind === 'string' && kind !== 'PromptManagerPair' && kind.toLowerCase().includes('promptmanager') && text !== null) widgets.set(apiId, text);
}
}
return widgets;
}
/** `_overwritten_prompt_nodes` (R1). */
function overwrittenPromptNodes(workflow, graph) {
const widgets = promptManagerWidgets(workflow), byText = new Map();
for (const [nodeId] of widgets) {
const node = pyGet(graph, nodeId);
const inputs = isDict(node) ? pyGet(node, 'inputs') : null;
const text = isDict(inputs) ? pyGet(inputs, 'text') : null;
if (typeof text === 'string') {
if (!byText.has(text)) byText.set(text, []);
byText.get(text).push(nodeId);
}
}
const found = new Set();
for (const ids of byText.values()) {
if (ids.length > 1 && new Set(ids.map(id => widgets.get(id))).size > 1) ids.forEach(id => found.add(id));
}
return found;
}
/** `_resolve` for an unanchored inspection (output_node_id None). */
function resolve(metadata, semantics) {
if (!isDict(metadata) || !metadata.size) return result('absent', { reason: 'No parsed prompt metadata' });
let graph;
if (metadata.has('prompt')) graph = metadata.get('prompt');
else if (metadata.has('parameters')) return parameters(metadata.get('parameters'), PROFILE.maxTextLength);
else graph = metadata;
if (!isDict(graph) || !graph.size) return result('unresolved', { reason: 'API graph is missing or malformed', reasonCode: 'malformed' });
if (graph.size > PROFILE.nodeCeiling) return result('unresolved', { reason: 'Prompt graph exceeds the inspection node ceiling', reasonCode: 'graph-ceiling' });
if (semantics === null) return result('unresolved', { reason: SEMANTICS_UNAVAILABLE_REASON });
let overwritten = new Set();
if (metadata.has('prompt')) {
overwritten = overwrittenPromptNodes(pyGet(metadata, 'workflow'), graph);
}
const resolver = new GraphResolver(graph, metadata.has('prompt') ? metadata : null, semantics, overwritten);
const samplerIds = finalStages(resolver, [...graph].filter(([, node]) => isDict(node) && typeof pyGet(node, 'class_type') === 'string' &&
resolver.samplerClasses.has(node.get('class_type'))).map(([id]) => id));
if (!samplerIds.length) return noSampler(graph);
if (samplerIds.length > 1) {
const candidates = samplerIds.map(id => resolver.pair(id));
return sharedPair(candidates) ?? result('ambiguous', { reason: 'Multiple sampler branches', candidates });
}
return resolver.pair(samplerIds[0]);
}
/** `_may_hold_prompt_manager`. */
function mayHoldPromptManager(canonical) {
const graph = pyGet(canonical, 'prompt');
if (!isDict(graph)) return false;
for (const node of graph.values()) {
if (!isDict(node)) continue;
const name = pyGet(node, 'class_type'), inputs = pyGet(node, 'inputs');
if (typeof name === 'string' && name.toLowerCase().includes('promptmanager')) return true;
if (isDict(inputs) && inputs.has('search_text')) return true;
}
return false;
}
/** `_parsed_workflow`: the UI workflow as a dict, or null. */
function parsedWorkflow(value) {
if (typeof value === 'string') {
try { value = pyJsonLoads(value); } catch (error) {
if (error instanceof PyValueError) return null;
throw error;
}
}
return isDict(value) ? value : null;
}
/** `unreadable_evidence`: both roles when the file's metadata could not be read at all. */
export function unreadableEvidence() {
const resolution = { state: 'unresolved', sampler_id: null, reason: UNREADABLE_METADATA_REASON, reason_code: null, node: null };
return { prompts: explained({ positive: sideOf('unresolved'), negative: sideOf('unresolved') }, resolution), resolution };
}
/**
* `inspect_prompt_evidence(records, None, profile=INSPECTION_PROFILE)`: `{prompts, resolution}` for the records a
* local read found (`{prompt, workflow, parameters, comfyassets_prompt_pairs}` texts). `semantics` is
* `compileSemantics(...)`'s table, or null when it could not be loaded.
*/
export function resolveLocalEvidence(records, semantics) {
const source = records !== null && typeof records === 'object' ? records : {};
const metadata = new Map(Object.entries(source).filter(([key]) => ROLE_METADATA_KEYS.has(key)));
const malformedResolution = { state: 'unresolved', sampler_id: null, reason: MALFORMED_EVIDENCE_REASON, reason_code: 'malformed', node: null };
if (!metadata.size) {
return {
prompts: { positive: sideOf('absent'), negative: sideOf('absent') },
resolution: { state: 'absent', sampler_id: null, reason: 'No parsed prompt metadata', reason_code: null, node: null },
};
}
const malformed = { prompts: explained({ positive: sideOf('unresolved'), negative: sideOf('unresolved') }, malformedResolution), resolution: malformedResolution };
if (!metadata.has('prompt') && !metadata.has('parameters')) return malformed;
if (typeof metadata.get('prompt') === 'string') {
try { metadata.set('prompt', pyJsonLoads(metadata.get('prompt'))); } catch (error) {
if (error instanceof PyValueError) return malformed;
throw error;
}
}
const workflow = mayHoldPromptManager(metadata) ? parsedWorkflow(source.workflow ?? null) : null;
if (workflow !== null && metadata.has('prompt')) metadata.set('workflow', workflow);
const resolved = resolve(metadata, semantics);
const state = resolved.state, prompts = {};
for (const role of ['positive', 'negative']) {
const side = resolved[role];
const present = side !== null && typeof side === 'object';
if (state !== 'ambiguous' && present && (role === 'negative' || pyStrip(side.text))) prompts[role] = sideOf('resolved', side.text, side.provenance.text_source);
else if (state === 'resolved' && present) prompts[role] = { ...sideOf('unresolved'), message: EMPTY_POSITIVE };
else prompts[role] = sideOf(state !== 'resolved' ? state : 'unresolved');
}
const resolution = { state, sampler_id: resolved.sampler_id, reason: resolved.reason, reason_code: resolved.reason_code, node: resolved.node };
return { prompts: explained(prompts, resolution), resolution };
}