Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/local-resolver.mjs
T
Vito Sansevero 564de9e3f1 feat(inspect): local candidate selection matching the inspector
The browser port of the inspection resolver now answers every record set
the server does, so no path is left to the server any more:

- several sampler candidates, with the identical-branch rule (#270): a
  compare grid whose branches all resolve to one pair (text and recipe)
  is resolved, any other mix is ambiguous;
- stages whose outputs reach only previews are dropped first;
- detailer stages are judged with the samplers in their image ancestry
  (multistage-differs, cycles, depth and the per-stage visit budgets),
  and a sampler that only feeds detailers is not a candidate of its own;
- A1111 `parameters`, with Python's multiline anchors, `\s` and Unicode
  `\d`, the bare footer and Template trailers, counted in code points.

The parity corpus gains the selection cases and a seeded fuzzer, and
every inspection vector now gives the server's prompts, resolution and
facts locally. 16 of 17 mutations are caught; the uncaught one (not
flattening an ambiguous upstream stage) cannot change any answer.
2026-10-22 17:43:33 -07:00

780 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,
MULTISTAGE_UNATTRIBUTED_REASON,
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,
readPairExecution,
result,
sideOf,
slotKey,
} from './local-resolver-support.mjs';
import { finalStages, outcomeKey, 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.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 [before, beforePath] = this.text(pyGet(inputs, 'prepend_text', ''), 'string', depth + 1);
const [after, afterPath] = this.text(pyGet(inputs, 'append_text', ''), 'string', depth + 1);
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];
}
}
} 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;
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);
return result('resolved', { positive, negative, samplerId });
} 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);
}
}
latentChecked(samplerId, value) {
let error;
if (this.latentWalks.has(samplerId)) {
const [walked, cost] = this.latentWalks.get(samplerId);
error = walked;
if (!this.latentCharged.has(samplerId)) {
this.budget.charge(cost);
this.latentCharged.add(samplerId);
}
} else {
const before = this.budget.allowance;
try {
this.latentStages(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]);
this.latentCharged.add(samplerId);
}
if (error !== null) throw new Unresolved(error.message, error.code, error.node === null ? null : { ...error.node });
}
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)));
}
latentStages(origin, value) {
if (!isLink(value)) throw new Unresolved('Unknown latent ancestry', 'malformed');
const start = String(value[0]), queue = [[start, 0]], queued = new Set([start]);
for (let head = 0; head < queue.length; head++) {
const [nodeId, depth] = queue[head];
if (nodeId === origin) throw new Unresolved('Cyclic latent ancestry', 'malformed');
if (depth >= this.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit');
this.budget.spend();
const [name, inputs] = this.node(nodeId);
if (this.stageClasses.has(name)) throw new Unresolved(MULTISTAGE_UNATTRIBUTED_REASON);
if (this.stageShaped(name, inputs)) throw this.unsupported('Unsupported latent ancestry', nodeId);
this.enqueue(queue, queued, inputs, depth + 1);
}
}
}
/** `_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' });
}
/** A workflow `type` that is a list or an object is malformed evidence on the server too (#286). */
function hashable(value) {
if (isList(value) || isDict(value)) throw new Unresolved(MALFORMED_EVIDENCE_REASON, 'malformed');
return value;
}
/** `_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)}`;
if (typeof hashable(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')) {
try { overwritten = overwrittenPromptNodes(pyGet(metadata, 'workflow'), graph); } catch (error) {
if (!(error instanceof Unresolved)) throw error;
return result('unresolved', { reason: error.message, reasonCode: error.code });
}
}
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 };
}