diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index 167974d..5909afd 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -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); diff --git a/tests/browser/workspace_inspector_fixture.py b/tests/browser/workspace_inspector_fixture.py index cfd8776..e42eb23 100644 --- a/tests/browser/workspace_inspector_fixture.py +++ b/tests/browser/workspace_inspector_fixture.py @@ -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", {}), diff --git a/tests/fixtures/inspection_parity/vectors.json b/tests/fixtures/inspection_parity/vectors.json index 18d6fb7..9e22beb 100644 --- a/tests/fixtures/inspection_parity/vectors.json +++ b/tests/fixtures/inspection_parity/vectors.json @@ -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" } } } diff --git a/tests/js/comfy-metadata.test.js b/tests/js/comfy-metadata.test.js index 402b977..5a687c0 100644 --- a/tests/js/comfy-metadata.test.js +++ b/tests/js/comfy-metadata.test.js @@ -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 } }; diff --git a/tests/js/inspection-semantics-sync.test.js b/tests/js/inspection-semantics-sync.test.js index d59a5b8..6d1cd5c 100644 --- a/tests/js/inspection-semantics-sync.test.js +++ b/tests/js/inspection-semantics-sync.test.js @@ -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()); +}); diff --git a/web/js/comfy-metadata.js b/web/js/comfy-metadata.js index 47105c3..4e5c525 100644 --- a/web/js/comfy-metadata.js +++ b/web/js/comfy-metadata.js @@ -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);