fix(inspector): check only the negative the legacy parse read for a zero-out

Review I1/M2/M3/M5 of the zero-out legacy-fill guard. The guard decided
over every node with a `negative` input, so a graph whose read sampler
encodes "blurry" lost that legacy-filled negative when another branch
zeroed its own (fewer than the legacy pages: below the parity floor).

- comfy-metadata.js gains `legacyNegativeSource`, which returns the graph
  and sampler `negative` link that `extractGenerationParams` reads from.
  It reuses the parser's own sampler pick (`selectSampler`, factored out
  of `fromPromptGraph` without changing any reading). The workspace
  checks only that link: a direct zero-out shows the zeroed empty
  negative, a zero-out further up the path leaves it unfilled, and
  anything else keeps the legacy fill.
- Workflow-only files go through the parser's own workflow conversion,
  so bypassed (mode 4) and muted (mode 2) zero-outs and unconnected ones
  behave as the legacy reading does. A subgraph instance whose definition
  runs a zero-out (directly or nested) leaves the negative unfilled.
- One bounded walk with an index pointer replaces a walk per negative
  input with queue.shift().
- An ambiguous fact that the legacy parse knows (a grid's per-branch
  model) is still filled, documented: the legacy pages show it and this
  page never shows less.

Tests: the reversed mixed graph shows "blurry"; the mixed graph whose
read sampler is zeroed, the zero-out-only grid and a direct workflow
zero-out show the zeroed empty negative; a combined zero-out and a
zero-out subgraph instance stay unfilled; bypassed and unconnected
workflow zero-outs keep the legacy reading. An API-graph Reroute in
front of a zero-out gives the legacy parse no negative, so the server
answer stays (that expectation changed from zeroed).
This commit is contained in:
Vito Sansevero
2026-10-21 10:25:48 -07:00
parent 38fc3ee236
commit 6da378442b
4 changed files with 141 additions and 50 deletions
+29 -4
View File
@@ -212,12 +212,17 @@
return undefined;
}
/** The sampler the legacy parse reads: a custom sampler's guider, else the first sampler. */
function selectSampler(ctx, nodes) {
const custom = nodes.find(isCustomSampler);
const [, guider] = custom ? enter(ctx, custom.inputs.guider, new Set()) : [];
return { custom, sampler: isSampler(guider) ? guider : nodes.find(isSampler) };
}
function fromPromptGraph(graph, workflow) {
const ctx = createContext(graph, workflow);
const nodes = Object.values(graph);
const custom = nodes.find(isCustomSampler);
const [, guider] = custom ? enter(ctx, custom.inputs.guider, new Set()) : [];
const sampler = isSampler(guider) ? guider : nodes.find(isSampler);
const { custom, sampler } = selectSampler(ctx, nodes);
if (!sampler) {
const loose = nodes.find((n) => n && n.inputs && /ksampler/i.test(n.class_type || ""));
const inputs = (loose && loose.inputs) || {};
@@ -402,6 +407,26 @@
return merged;
}
/**
* Where `extractGenerationParams` reads its negative from (#249): the graph it used
* (the API graph, else the workflow converted the same way) and the read sampler's
* `negative` link, or null when it found no negative. It changes no reading.
*/
function legacyNegativeSource(comfyData) {
const data = comfyData || {};
const sources = [];
if (data.prompt && typeof data.prompt === "object") sources.push([data.prompt, data.workflow]);
if (data.workflow) sources.push([workflowToGraph(data.workflow), null]);
for (const [graph, workflow] of sources) {
const ctx = createContext(graph, workflow);
const { sampler } = selectSampler(ctx, Object.values(graph));
if (sampler && hasValue(traceConditioning(ctx, sampler.inputs.negative, "negative"))) {
return { graph, link: sampler.inputs.negative };
}
}
return null;
}
const FACT_FIELDS = ["seed", "steps", "cfg", "sampler", "scheduler", "model"];
const WORKFLOW_PASSTHROUGH = Symbol("inspection workflow passthrough");
// These tables must equal inspection-semantics.json: SAMPLERS its `samplers` section
@@ -761,7 +786,7 @@
return factSet(metadataStatus === "available" || metadataStatus === "absent" ? "not-recorded" : "unresolved");
}
const api = { extractGenerationParams, extractGenerationFacts, PLACEHOLDERS };
const api = { extractGenerationParams, extractGenerationFacts, legacyNegativeSource, PLACEHOLDERS };
// For the semantics sync test only; not part of the legacy page contract.
Object.defineProperty(api, "_factsTables", {
value: Object.freeze({
+35 -24
View File
@@ -175,45 +175,52 @@ function comfyMetadata() {
}
// #249: the legacy parse follows ConditioningZeroOut back to its input, so it reads the
// positive text as the negative. The workspace shows the server's zeroed state instead
// when every sampler's negative is a zero-out, and never fills the negative from the
// legacy parse when a zero-out sits anywhere else in a negative path.
// positive text as the negative. Only the negative it actually read is checked: a direct
// zero-out shows the server's zeroed state; a zero-out elsewhere on
// that path, or a subgraph instance whose definition holds one, leaves the negative
// unfilled. Any other negative keeps the legacy fill, so this page never shows less.
const ZERO_OUT = 'ConditioningZeroOut';
const ZEROED_NEGATIVE = Object.freeze({ status: 'resolved', text: '', source: 'zeroed-conditioning' });
const MAX_NEGATIVE_VISITS = 2000;
const MODE_MUTED = 2, MODE_BYPASSED = 4;
const isLink = value => Array.isArray(value) && value.length === 2 && ['string', 'number'].includes(typeof value[0]);
/** 'zeroed', 'undecided' (do not fill), or null (no zero-out: the legacy fill applies). */
function zeroOutNegative(graph, workflow) {
if (!graph) return Array.isArray(workflow?.nodes) && workflow.nodes.some(node => node?.type === ZERO_OUT) ? 'undecided' : null;
const nodes = Object.values(graph).filter(object);
if (!nodes.some(node => node.class_type === ZERO_OUT)) return null;
const kinds = nodes.filter(node => object(node.inputs) && isLink(node.inputs.negative)).map(node => negativeKind(graph, node.inputs.negative));
if (kinds.length && kinds.every(kind => kind === 'zeroed')) return 'zeroed';
return kinds.every(kind => kind === 'plain') && kinds.length ? null : 'undecided';
/** Subgraph definition IDs that run a zero-out, directly or through a nested instance. */
function zeroOutSubgraphs(workflow) {
const definitions = Array.isArray(workflow?.definitions?.subgraphs) ? workflow.definitions.subgraphs.filter(object) : [];
const active = item => Array.isArray(item.nodes) ? item.nodes.filter(node => object(node) && ![MODE_MUTED, MODE_BYPASSED].includes(node.mode)) : [];
const found = new Set();
for (let changed = true; changed;) {
changed = false;
for (const item of definitions) {
if (found.has(item.id) || !active(item).some(node => node.type === ZERO_OUT || found.has(node.type))) continue;
found.add(item.id); changed = true;
}
}
return found;
}
function negativeKind(graph, link) {
let node = graph[String(link[0])];
for (let hops = 0; object(node) && node.class_type === 'Reroute' && hops < MAX_NEGATIVE_VISITS; hops++) {
const next = object(node.inputs) ? node.inputs.value : undefined;
node = isLink(next) ? graph[String(next[0])] : undefined;
}
/** 'zeroed', 'undecided' (do not fill), or null (the legacy fill applies). */
function zeroOutNegative(source, workflow) {
if (!source || !object(source.graph) || !isLink(source.link)) return null;
const { graph } = source, subgraphs = zeroOutSubgraphs(workflow);
const zeroing = node => node.class_type === ZERO_OUT || subgraphs.has(node.class_type);
if (!Object.values(graph).some(node => object(node) && zeroing(node))) return null;
const node = graph[String(source.link[0])];
if (!object(node)) return 'undecided';
if (node.class_type === ZERO_OUT) return 'zeroed';
// Any zero-out further up (combined, averaged, ...) leaves the negative undecided.
// One bounded walk upstream; an index pointer keeps it linear in the nodes visited.
const queue = [node], seen = new Set();
while (queue.length) {
const current = queue.shift();
if (current.class_type === ZERO_OUT) return 'undecided';
if (seen.size >= MAX_NEGATIVE_VISITS) return 'undecided';
for (let index = 0; index < queue.length; index++) {
const current = queue[index];
if (zeroing(current) || seen.size >= MAX_NEGATIVE_VISITS) return 'undecided';
for (const value of object(current.inputs) ? Object.values(current.inputs) : []) {
const id = isLink(value) ? String(value[0]) : null;
if (id === null || seen.has(id) || !object(graph[id])) continue;
seen.add(id); queue.push(graph[id]);
}
}
return 'plain';
return null;
}
function withWorkflowParams(record, parser = comfyMetadata()) {
@@ -226,7 +233,9 @@ function withWorkflowParams(record, parser = comfyMetadata()) {
const found = key => typeof params[key] === 'string' || typeof params[key] === 'number' ?
String(params[key]) !== '' && params[key] !== parser.PLACEHOLDERS?.[key] : false;
const prompts = { ...record.prompts };
const zeroed = prompts.negative?.status === 'resolved' ? null : zeroOutNegative(graph, workflow);
const read = prompts.negative?.status === 'resolved' || typeof parser.legacyNegativeSource !== 'function' ? null :
parser.legacyNegativeSource({ prompt: graph, workflow });
const zeroed = zeroOutNegative(read, workflow);
for (const [role, key] of [['positive', 'positivePrompt'], ['negative', 'negativePrompt']]) {
const current = prompts[role];
if (current?.status === 'resolved') continue;
@@ -240,6 +249,8 @@ function withWorkflowParams(record, parser = comfyMetadata()) {
record.prompts = prompts;
for (const [field, key] of LEGACY_FACTS) {
// Only open answers are filled: a known value or a definite "not recorded" stays.
// An ambiguous field (e.g. a compare grid's per-branch model) is filled too, on
// purpose: the legacy pages show this value, and this page never shows less.
if (!found(key) || !['unresolved', 'ambiguous'].includes(record.generation[field]?.status)) continue;
record.generation[field] = { status: 'known', value: String(params[key]), source: graph ? 'api-graph' : 'workflow', evidence: 'configuration', reason: null, sampler_id: null };
}