fix(inspect): merge facts across sampler branches when ambiguous

This commit is contained in:
Vito Sansevero
2026-10-19 15:00:33 -07:00
parent 8cd8ca55ac
commit 9180851e18
6 changed files with 128 additions and 32 deletions
+4 -1
View File
@@ -26,7 +26,10 @@ test('inspection facts survive actual API to browser transport', async ({ page,
const complete = result.complete;
expect(Object.values(complete.generation).map(fact => fact.value)).toEqual(['9007199254740993', '28', '6.5', 'dpmpp_2m', 'karras', 'fixture-model.safetensors']);
expect(Object.values(complete.generation).every(fact => fact.source === 'api-graph' && fact.evidence === 'configuration')).toBe(true);
expect(Object.values(result.ambiguous.generation).every(fact => fact.status === 'ambiguous' && fact.value === null)).toBe(true);
// Ambiguous branches merge per field: the differing seed is ambiguous, the shared fields known.
expect(Object.values(result.ambiguous.generation).map(fact => [fact.status, fact.value])).toEqual([
['ambiguous', null], ['known', '28'], ['known', '6.5'], ['known', 'dpmpp_2m'], ['known', 'karras'], ['known', 'fixture-model.safetensors']]);
expect(Object.values(result.ambiguous.generation).every(fact => fact.sampler_id === null)).toBe(true);
expect(result['workflow-only'].generation.seed).toMatchObject({ status: 'known', value: '9007199254740993', source: 'workflow', evidence: 'configuration' });
expect(result['workflow-only'].prompts.positive.source).toBe('saved-association');
expect(Object.values(result['control-heavy'].generation).every(fact => fact.status === 'unresolved' && fact.value === null && fact.reason === 'projection-limited')).toBe(true);
+3 -1
View File
@@ -89,7 +89,9 @@ def seed_workspace_inspector(
],
"links": [],
}
ambiguous = {**complete, "5": complete["3"]}
# Two branches that differ only in seed: facts merge per field (spec section 10).
branch = {**complete["3"], "inputs": {**complete["3"]["inputs"], "seed": 7}}
ambiguous = {**complete, "5": branch}
examples = (
("complete.png", {"prompt": complete, "workflow": workflow}),
("empty.png", {}),
+26 -26
View File
@@ -19,24 +19,24 @@
"value": null
},
"steps": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "20"
},
"cfg": {
"status": "ambiguous",
"status": "unresolved",
"value": null
},
"sampler": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "ambiguous",
"value": null
},
"model": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "flux-2-klein-9b-fp8.safetensors"
}
}
}
@@ -61,24 +61,24 @@
"value": null
},
"steps": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "20"
},
"cfg": {
"status": "ambiguous",
"status": "unresolved",
"value": null
},
"sampler": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "ambiguous",
"value": null
},
"model": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "flux-2-klein-9b-fp8.safetensors"
}
}
}
@@ -603,28 +603,28 @@
"candidate_count": 0,
"facts": {
"seed": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "42"
},
"steps": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "25"
},
"cfg": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "6.5"
},
"sampler": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "normal"
},
"model": {
"status": "ambiguous",
"value": null
"status": "known",
"value": "sdxl_base.safetensors"
}
}
}
+51 -1
View File
@@ -203,7 +203,7 @@ test("facts use resolution.sampler_id for detailer-stage nodes", () => {
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
});
// Without a named sampler, two samplers stay ambiguous (today's rules, now over the samplers table).
for (const resolution of [undefined, { state: "ambiguous", sampler_id: null }, { state: "unresolved", sampler_id: "d" }]) {
for (const resolution of [undefined, { state: "unresolved", sampler_id: "d" }]) {
assert.ok(Object.values(factsFor(detailerFactGraph(), { resolution })).every(fact => fact.status === "ambiguous"), JSON.stringify(resolution));
}
// A sampler_id the graph or the samplers table does not hold never falls back to a guess.
@@ -226,6 +226,56 @@ test("facts use resolution.sampler_id for detailer-stage nodes", () => {
assert.ok(Object.values(factsFor(wide, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"), "the stage walk is bounded by MAX_VISITS");
});
test("ambiguous results without a sampler_id merge facts across every listed stage", () => {
const ambiguous = { resolution: { state: "ambiguous", sampler_id: null, reason: "Multiple sampler branches" } };
// f01b's shape: an upstream sampler and a detailer whose settings partly differ.
assert.deepEqual(statusValues(factsFor(detailerFactGraph(), ambiguous)), {
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
});
assert.ok(Object.values(factsFor(detailerFactGraph(), ambiguous)).every(fact => fact.sampler_id === null));
// Equal independent branches agree on every field once the server says ambiguous;
// without a resolution they stay ambiguous (today's rules, reversed in PR3).
const equal = factGraph(); equal.other = structuredClone(equal.s);
assert.ok(Object.values(factsFor(equal, ambiguous)).every(fact => fact.status === "known"));
assert.ok(Object.values(factsFor(equal)).every(fact => fact.status === "ambiguous"));
// A walk that fails keeps every field unresolved; so does a graph with no listed sampler.
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
const broken = detailerFactGraph(); broken.d.inputs.image = ["gone", 0];
for (const graph of [cyclic, broken, { model: factGraph().model }]) {
assert.ok(Object.values(factsFor(graph, ambiguous)).every(fact => fact.status === "unresolved" && fact.value === null));
}
const many = factGraph();
for (let index = 0; index < 40; index++) many[`s${index}`] = structuredClone(many.s);
assert.ok(Object.values(factsFor(many, ambiguous)).every(fact => fact.status === "unresolved"), "more stages than the candidate cap");
});
test("stage walks fail on unlisted sampler-shaped nodes like the resolver", () => {
const shaped = [
{ positive: ["model", 0] }, { latent_image: ["model", 0] }, { guider: ["model", 0] }, { negative: ["model", 0] },
{ pipe: ["model", 0] }, { basic_pipe: ["model", 0] }, { detailer_pipe: ["model", 0] }, { sampler: ["model", 0] },
{ sigmas: ["model", 0] }, { noise: ["model", 0] }, { text_embeds: ["model", 0] }, { hyvid_embeds: ["model", 0] },
{ model: ["model", 0], conditioning: ["model", 0] }, { model: ["model", 0], neg_conditioning: ["model", 0] },
{ model: ["model", 0], samples: ["model", 0] }, { model: ["model", 0], latent: ["model", 0] },
];
for (const inputs of shaped) {
const graph = detailerFactGraph();
graph.efficient = { class_type: "KSampler (Efficient)", inputs: { ...inputs, image: ["decode", 0] } };
graph.d.inputs.image = ["efficient", 0];
for (const sampler_id of ["d", null]) {
const facts = factsFor(graph, { resolution: { state: "ambiguous", sampler_id } });
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved"), `${JSON.stringify(inputs)} ${sampler_id}`);
}
}
// Model patches (model alone, or with clip) and listed table classes are walked through.
for (const [class_type, inputs] of [["ModelPatch", { model: ["model", 0] }], ["LoraPatch", { model: ["model", 0], clip: ["model", 1] }], ["ImageScale", { samples: ["model", 0] }], ["CFGGuider", { model: ["model", 0], positive: ["model", 0] }]]) {
const graph = detailerFactGraph();
graph.hop = { class_type, inputs: { ...inputs, image: ["decode", 0] } };
graph.d.inputs.image = ["hop", 0];
assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", class_type);
}
});
test("Flux2Scheduler provides steps and scheduler is its node type", () => {
const graph = customFactGraph();
graph.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 4, width: 1024, height: 1024 } };
@@ -49,3 +49,12 @@ test("detailer-stage samplers, and only they, name an image stage", () => {
assert.equal(Object.hasOwn(metadata._samplerTable[name], "image_stage"), entry.strategy === "detailer-stage", name);
}
});
// The stage walk treats every table class as known (the resolver's `all_classes`).
test("stage walk table classes equal every class in the semantics table", () => {
const classes = new Set();
for (const category of ["samplers", "guiders", "selectors", "composites", "savers"]) Object.keys(semantics[category]).forEach(name => classes.add(name));
for (const category of ["conditioning", "encoders", "strings"]) semantics[category].forEach(entry => classes.add(entry.class));
for (const category of ["runtime", "non_file_outputs"]) semantics[category].forEach(name => classes.add(name));
assert.deepStrictEqual([...metadata._tableClasses].sort(), [...classes].sort());
});
+35 -3
View File
@@ -415,8 +415,26 @@
SamplerCustomAdvanced: { facts: "custom-advanced" },
FaceDetailer: { facts: "ksampler", image_stage: "image" },
});
// Stages merged for one ambiguous multistage result: the resolver's candidate cap.
// Stages merged for one ambiguous result: the resolver's candidate cap.
const MAX_STAGES = 32;
// Every class the semantics table lists (the resolver's `all_classes`): a stage walk
// passes these, and treats any other node shaped like a sampler as an unknown stage.
const TABLE_CLASSES = new Set([
"KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced", "FaceDetailer",
"CFGGuider", "BasicGuider", "easy ifElse", "ConditioningCombine", "ConditioningConcat",
"ConditioningAverage", "SaveImage", "SaveAnimatedWEBP", "SaveAnimatedPNG", "SaveVideo",
"SaveWEBM", "VHS_VideoCombine", "FluxGuidance", "ReferenceLatent", "ConditioningSetArea",
"ConditioningSetAreaPercentage", "ConditioningSetAreaStrength", "ConditioningSetMask", "ConditioningSetTimestepRange", "ControlNetApply",
"ControlNetApplyAdvanced", "ControlNetApplySD3", "InpaintModelConditioning", "WanImageToVideo", "ConditioningZeroOut",
"CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeFlux", "CLIPTextEncodeSD3", "TextEncodeQwenImageEdit",
"TextEncodeQwenImageEditPlus", "TextEncodeQwenImage21", "KikoTextEncode", "StringConstant", "StringConstantMultiline",
"JoinStringMulti", "PromptSearchList", "PreviewImage", "PreviewAny", "SaveImageWebsocket",
"Image Comparer (rgthree)",
]);
// The resolver's stage-shaped inputs (pipes, embeds, custom-sampler parts), and the
// ones that make a node with a linked `model` a stage rather than a model patch.
const STAGE_SHAPED_INPUTS = ["latent_image", "guider", "positive", "negative", "pipe", "basic_pipe", "detailer_pipe", "sampler", "sigmas", "noise", "text_embeds", "hyvid_embeds"];
const MODEL_STAGE_INPUTS = ["conditioning", "neg_conditioning", "samples", "latent"];
// Fixed output0 contracts from the pinned core node schemas, not input-name inference.
const SCALAR_OUTPUTS = Object.freeze({
PrimitiveInt: "number", PrimitiveFloat: "number",
@@ -487,6 +505,11 @@
const samplerEntry = (graph, id) => (Object.hasOwn(graph, id) && Object.hasOwn(SAMPLERS, graph[id].class_type) ? SAMPLERS[graph[id].class_type] : undefined);
const linked = (inputs, key) => Object.hasOwn(inputs, key) && isLink(inputs[key]);
/** An unlisted node that may itself be a sampler stage: the walk cannot judge it. */
const stageShaped = ({ class_type, inputs }) => !TABLE_CLASSES.has(class_type) &&
(STAGE_SHAPED_INPUTS.some(key => linked(inputs, key)) || (linked(inputs, "model") && MODEL_STAGE_INPUTS.some(key => linked(inputs, key))));
/** Samplers reached from a detailer's image input, breadth-first, stopping at listed samplers. */
function upstreamStages(graph, origin, value, budget) {
if (!isLink(value)) return null;
@@ -495,6 +518,7 @@
const id = queue[head];
if (id === origin || ++budget.visits > MAX_VISITS || !Object.hasOwn(graph, id)) return null;
if (samplerEntry(graph, id)) { found.push(id); continue; }
if (stageShaped(graph[id])) return null;
for (const input of Object.values(graph[id].inputs)) {
const next = isLink(input) ? String(input[0]) : null;
if (next !== null && !queued.has(next)) { queued.add(next); queue.push(next); }
@@ -573,8 +597,9 @@
* Facts follow the server's resolution. Its `sampler_id` drives them only when
* resolved, or for an ambiguous result naming the final stage of a multistage
* chain (merged per field across the stages, independent of any selection: D5).
* Otherwise (no candidates on the wire until PR3) exactly one listed sampler in the
* graph supplies them; several leave every field ambiguous.
* Ambiguous without a named sampler (no candidates on the wire until PR3) merges
* the same way across every listed sampler and its stages. Otherwise exactly one
* listed sampler in the graph supplies them; several leave every field ambiguous.
*/
function graphFacts(graph, source, resolution) {
if (!object(graph)) return factSet("unresolved", source);
@@ -593,6 +618,12 @@
return mergeFacts([...stages].map(id => samplerFacts(ctx, id, source)), source);
}
const candidates = entries.filter(([id]) => samplerEntry(graph, id));
if (state === "ambiguous") {
// The server's branches: every listed sampler, a detailer judged with its stages.
const stages = new Set();
if (!candidates.length || !candidates.every(([id]) => collectStages(graph, id, new Set(), stages, { visits: 0 }))) return factSet("unresolved", source);
return mergeFacts([...stages].map(id => samplerFacts(ctx, id, source)), source);
}
if (candidates.length !== 1) return factSet(candidates.length > 1 ? "ambiguous" : "unresolved", source);
return samplerFacts(ctx, candidates[0][0], source);
}
@@ -672,6 +703,7 @@
}),
});
Object.defineProperty(api, "_samplerTable", { value: SAMPLERS });
Object.defineProperty(api, "_tableClasses", { value: TABLE_CLASSES });
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.ComfyMetadata = api;
})(typeof window !== "undefined" ? window : globalThis);