fix(inspect): merge facts across sampler branches when ambiguous
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user