feat(resolver): trace multi-stage chains through pass-through nodes (#308)
* feat(resolver): trace multi-stage chains through pass-through nodes The inspection resolver (server and local Inspect) now compares a sampler with the earlier sampler its latent continues through image-space pass-through nodes: decode, image filter, model upscale, scale and encode, listed in a new validated `lineage` section of the semantics table. Equal prompts resolve (hires-fix, refiner); different prompts are multistage-differs. A later stage whose positive adds conditioning to the shared base prompt through an additive composite (ConditioningCombine or Concat) keeps the base pair, the text legacy linked (three-stage Flux). Image-edit batches stay ambiguous and run-time text stays untraceable. Ingestion keeps the default profile (D1). A card no longer shows a seed when the inspector names a chain stage whose seed differs from the parser's pick. * feat(resolver): leave out run-time PromptManager parts, labelled Maintainer option A: when a PromptManager prepend_text or append_text is fed directly by a node whose output is decided at run time (LoraManager's TriggerWord Toggle, now a runtime class, or PromptSearchList), the prompt resolves to the node's own text, as legacy showed. The side's source is runtime-parts-omitted and the resolved reason says that text added at run time, such as trigger words, is not included; the prompt block shows that line (D-NEW-R1). The toggle's output is never computed. Literal and traced parts are combined as before; an indirect run-time part, a run-time main text or an unknown feeder keep their outcome. The browser port changes identically. Ingestion is unchanged. * fix(resolver): keep run-time and added-conditioning labels honest A text that left out a run-time PromptManager part is now labelled when any node on its traced path omitted it, so the label survives encoders, concatenations and chained PromptManagers. Comparisons include what a text leaves out, so a partial prompt never equals a complete one: a branch with trigger words and one without stay ambiguous, and such stages are multistage-differs. Only classes listed as run time are left out. The added-conditioning floor labels its positive (D126, "Conditioning added at a later stage is not included."); the run-time line is D125. A card hides its seed only when the stage the inspector names reads a different seed than the parser; an agreeing seed is kept, as on main. The browser port moves its lineage, latent and part helpers into local-resolver-stages.mjs, keeping parity.
This commit is contained in:
+95
-7
@@ -392,7 +392,8 @@ with source `zeroed-conditioning`. `easy ifElse` with a literal boolean follows
|
||||
branch only; with a linked boolean both branches are traced, equal texts resolve and
|
||||
different texts make the sampler `ambiguous` with one candidate per choice (at most
|
||||
32, else `work-limit`). ConditioningCombine/Concat/Average resolve only when every
|
||||
part has the same text, otherwise `unresolved` with `composite` naming the node.
|
||||
part has the same text, otherwise `unresolved` with `composite` naming the node
|
||||
(but see the added-conditioning floor below for a later stage).
|
||||
Results that depend on a forced choice are never memoized. A `FaceDetailer`
|
||||
(`detailer-stage`) is a sampler stage: the samplers reached through its `image`
|
||||
ancestry are compared with it. Equal texts resolve with the detailer as the sampler;
|
||||
@@ -403,9 +404,65 @@ A sampler's latent ancestry is walked through every link input of non-sampler
|
||||
nodes within the same visit and depth budgets: with no earlier sampler it is a
|
||||
single stage whatever the origin (VAEEncode, EmptyLatentBatch, EmptyFlux2LatentImage,
|
||||
EmptyHunyuanLatentVideo, SetLatentNoiseMask, InpaintModelConditioning or
|
||||
WanImageToVideo slot 2). An earlier sampler (img2img from a decode, hires fix) stays
|
||||
`unresolved` "Multistage sampler ancestry is ambiguous" until stage texts are
|
||||
compared; an unlisted node with sampler-shaped inputs (positive, negative, guider,
|
||||
WanImageToVideo slot 2). Pass-through lineage (#304 follow-up, maintainer option B,
|
||||
2026-10-23): the table's `lineage` section lists the outputs whose image or latent
|
||||
continues one input's (`VAEDecode`/`VAEDecodeTiled` `samples`, `VAEEncode`/
|
||||
`VAEEncodeTiled` `pixels`, `ImageUpscaleWithModel`, `ImageScale`, `ImageScaleBy`,
|
||||
`ImageScaleToTotalPixels` `image`, `LatentUpscale`/`LatentUpscaleBy` `samples`,
|
||||
cg-image-filter `Image Filter` slot 0 `images` and slot 1 `latents`; a selection of
|
||||
the same batch), plus Reroutes. Lineage describes no prompt, so its classes are not
|
||||
claimed by a prompt category. When the latent walk finds exactly one earlier sampler
|
||||
and the latent descends from that sampler's output slot 0 only through lineage
|
||||
nodes (a hires-fix or refiner chain, img2img from an in-graph decode, a direct
|
||||
latent link), the two stages are compared like a detailer and its upstream stage:
|
||||
equal role texts resolve with the later sampler named, different texts are
|
||||
`ambiguous` `multistage-differs` with one candidate per stage, and an unreadable
|
||||
earlier stage makes the chain `unresolved` with that stage's reason. Unanchored, the
|
||||
earlier sampler is judged inside the chain when its output reaches no other file
|
||||
output. A size or resolution node reading the same image beside the lineage (a
|
||||
`ResolutionCalculator` feeding `ImageScale` width and height) is allowed, since the
|
||||
walk then still finds only that sampler. Any other earlier sampler, or one reached
|
||||
only through an unlisted image node, stays `unresolved` "Multistage sampler ancestry
|
||||
is ambiguous". Added-conditioning floor: a later stage whose own positive fails only
|
||||
as `composite` keeps its earlier stages' shared pair ("A later stage adds
|
||||
conditioning to the base prompt", naming the earlier stage's sampler) when that
|
||||
positive reaches, through Reroutes and conditioning pass-throughs, a composite the
|
||||
table marks `additive` (ConditioningCombine, ConditioningConcat; never
|
||||
ConditioningAverage, which can weight a part away), one of its parts is exactly the
|
||||
earlier stages' positive text, and its negative text equals theirs. This is the
|
||||
three-stage Flux run whose UltimateSDUpscale combines the PromptManager prompt with
|
||||
a style-trigger encoder: legacy linked the PromptManager text and every stage is
|
||||
conditioned on it. The positive is always labelled (maintainer ruling): its source is
|
||||
`added-conditioning` (D126; with a D125 omission too,
|
||||
`runtime-parts-omitted-and-added-conditioning`) and its provenance names the
|
||||
composite (`added_by`), whatever the added text is; no length heuristic. A combine without the base text, a different negative, or
|
||||
different earlier stages stay as they were (`composite` or `multistage-differs`).
|
||||
An image-edit batch (several samplers with distinct prompts, one shared saver
|
||||
prefix) stays `ambiguous`, and text generated at run time stays untraceable
|
||||
(`tests/test_resolver_pass_through.py` on synthetic shapes from
|
||||
`tests/passthrough_graphs.py`; the browser port is compared on the named
|
||||
`select-*` chain cases and `passthrough_fuzz_cases`). Ingestion keeps the default
|
||||
profile's historical latent rule (D1), so the admin scan still reaches these files
|
||||
through its legacy-parser fallback. Run-time PromptManager parts (maintainer option
|
||||
A, 2026-10-23): LoraManager's `TriggerWord Toggle` is a `runtime` class (its output is
|
||||
the LoRA's trigger words as filtered at run time). A PromptManager (or
|
||||
PromptManagerPair) `prepend_text` or `append_text` linked directly to a `runtime`
|
||||
node is left out: the text is the node's own, combined with any other literal or
|
||||
traced part exactly as before, the side's source is `runtime-parts-omitted`, and a
|
||||
resolved pair's reason is "Text added at run time, such as trigger words, is not
|
||||
included". The prompt block shows that line under the text (D125; the evidence
|
||||
badge stays "Workflow inferred"). The label follows the text through encoders,
|
||||
concatenations and chained PromptManagers: a side is labelled when any node on its
|
||||
traced path left a part out, and its provenance lists those PromptManager nodes
|
||||
(`omitted_parts`). Comparisons include what a text leaves out, so a prompt that left
|
||||
out a part never equals a complete one (a branch with the toggle and one without stay
|
||||
ambiguous; such stages are `multistage-differs`). Only classes in the table's
|
||||
`runtime` list are left out, never another `runtime-value` source (a JoinStringMulti
|
||||
with a linked list flag stays `runtime-value`). The toggle's output is never computed. A run-time node further upstream (inside a
|
||||
concatenation), a run-time main `text`, or an unknown feeder keep their outcome
|
||||
(`runtime-value` or `unsupported-node`). This is what legacy linked for the June
|
||||
hires-fix runs (`TestRunTimePromptManagerParts`, the `select-runtime-*` parity
|
||||
cases); an unlisted node with sampler-shaped inputs (positive, negative, guider,
|
||||
latent_image, a pipe, video embeds, custom-sampler parts, or a `model` link together
|
||||
with conditioning, neg_conditioning, samples or latent) is `unsupported-node`
|
||||
naming it, here and in a detailer's image ancestry; a latent that is not a link or
|
||||
@@ -1899,7 +1956,7 @@ the connection's `total_changes` is 0 at close and the data reads back unchanged
|
||||
`-shm` bytes are not compared: every WAL reader rewrites its read marks there, so no
|
||||
reader can keep it byte-identical (`test_health_check_never_writes`).
|
||||
|
||||
Recorded deviations (plan D60–D65 renumbered D92–D97; D121, D122, D123 and D124 assigned):
|
||||
Recorded deviations (plan D60–D65 renumbered D92–D97; D121, D122, D123, D124, D125 and D126 assigned):
|
||||
|
||||
- **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No
|
||||
unsaved changes.", "Unsaved changes.", "Saved in this browser." / "Saved to the server
|
||||
@@ -1971,6 +2028,21 @@ Recorded deviations (plan D60–D65 renumbered D92–D97; D121, D122, D123 and D
|
||||
button instead of reading the file. The concept has no such state, so no concept
|
||||
comparison renders it: `metadata off shows an explicit load and loads once on request`
|
||||
(workspace-system.spec) pins it instead.
|
||||
- **D125** (prompt blocks, maintainer option A): a resolved prompt whose source is
|
||||
`runtime-parts-omitted` (a PromptManager `prepend_text` or `append_text` fed directly
|
||||
by a listed run-time node, such as LoraManager's TriggerWord Toggle, was left out)
|
||||
shows the muted line "Text added at run time, such as trigger words, is not
|
||||
included." under its text. The evidence badge stays "Workflow inferred"; the line is
|
||||
never sent or copied. The concept has no such state (`a prompt that left out run-time
|
||||
trigger words says so under its text (D125)` in `tests/js/prompt-blocks.test.js`).
|
||||
- **D126** (prompt blocks, maintainer ruling on the added-conditioning floor): a
|
||||
resolved positive whose source is `added-conditioning` (a later stage combined more
|
||||
conditioning with the base prompt, as UltimateSDUpscale's ConditioningCombine of the
|
||||
PromptManager and a style trigger) shows "Conditioning added at a later stage is not
|
||||
included." the same way. A prompt with both shows both lines, D125 first (source
|
||||
`runtime-parts-omitted-and-added-conditioning`). Always labelled, with no length or
|
||||
"short trigger" heuristic (`a prompt a later stage added conditioning to says so, and
|
||||
both lines show together (D126)`).
|
||||
|
||||
Appearance scope and states. The Default layout and Thumbnail size defaults start the
|
||||
Images page only (a new visit, not the current view or a saved view); the embedded
|
||||
@@ -2072,8 +2144,24 @@ and skip the parse.
|
||||
integers past 2**53 as their exact digits, so the card and the inspector read
|
||||
the same digits. The parse runs after the file handle closes. A record over
|
||||
2 MiB (UTF-8), a parser error, a recursion error, a placeholder, or a
|
||||
non-integral, boolean or non-finite seed gives null. A card never shows a seed
|
||||
that differs from the inspector's
|
||||
non-integral, boolean or non-finite seed gives null. When the prompt graph's
|
||||
sampler stages read different seed inputs and the inspector's resolution names
|
||||
one sampler for a resolved pair (a detailer or pass-through chain, whose facts
|
||||
are the named stage's, D19), the card compares that stage's seed (read by the
|
||||
same parser, the other samplers hidden from it) with its own and shows no seed
|
||||
only when they differ. A seed that agrees, or one the named stage does not make
|
||||
known, is kept, as on main (a saved sampler beside a preview-only one keeps its
|
||||
seed; an ambiguous chain, whose facts merge and fill from the parser, keeps it).
|
||||
Only such graphs are resolved for the card, with `INSPECTION_PROFILE`
|
||||
(`test_a_chain_the_inspector_names_one_stage_of_shows_no_differing_seed`,
|
||||
`test_a_seed_that_agrees_with_the_named_stage_is_kept`,
|
||||
`test_a_named_ambiguous_chain_keeps_the_parser_seed`; a literal named seed needs no
|
||||
second parse, and stages sharing one linked seed skip the check. Measured on a
|
||||
200-card page of about 54 KB graphs, half multi-stage with per-stage seeds: 618 ms
|
||||
cold against main's 387 ms, about 1.2 ms more per such card, paid once per file
|
||||
through the header-facts cache below;
|
||||
before this rule a KSampler then FaceDetailer chain with two seeds broke the
|
||||
contract below). A card never shows a seed that differs from the inspector's
|
||||
(`test_card_seed_equals_inspector_seed_when_both_present`, which `JSON.parse`s
|
||||
the listing in Node and compares `String(seed)` with the inspector's seed,
|
||||
including one past 2**53). The card shows no seed for A1111 `parameters` files
|
||||
|
||||
+4
-4
@@ -594,12 +594,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "ambiguous",
|
||||
"reason": "Multiple sampler branches",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -82,6 +82,32 @@ test('a zeroed conditioning negative shows as an explicit empty negative without
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('a prompt that left out run-time trigger words says so under its text (D125)',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
|
||||
const note='Text added at run time, such as trigger words, is not included.';
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:{status:'resolved',text:'own text',source:'runtime-parts-omitted'},negative:{status:'resolved',text:'blur',source:'graph-inferred'}}});
|
||||
const notes=f.walk(container,node=>node.textContent===note);
|
||||
assert.equal(notes.length,1,'one note, under the positive only');
|
||||
assert.equal(notes[0].parent.attributes['aria-label'],'Positive prompt');
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);
|
||||
assert.deepEqual(sent,[{positive:'own text',negative:'blur'}],'the note is never sent');
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('a prompt a later stage added conditioning to says so, and both lines show together (D126)',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
const added='Conditioning added at a later stage is not included.', runtime='Text added at run time, such as trigger words, is not included.';
|
||||
try{
|
||||
const view=module.mountPromptBlocks(container,{parentScope:scope,prompts:{positive:{status:'resolved',text:'base',source:'added-conditioning'}}});
|
||||
assert.deepEqual(f.walk(container,node=>node.className==='small muted').map(node=>node.textContent),[added]);
|
||||
view.update({positive:{status:'resolved',text:'base',source:'runtime-parts-omitted-and-added-conditioning'}});
|
||||
assert.deepEqual(f.walk(container,node=>node.className==='small muted').map(node=>node.textContent),[runtime,added]);
|
||||
view.update({positive:{status:'resolved',text:'base',source:'graph-inferred'}});
|
||||
assert.equal(f.walk(container,node=>node.className==='small muted').length,0);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
const unresolved=message=>({status:'unresolved',text:null,source:null,...(message===undefined?{}:{message})});
|
||||
const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text muted').map(node=>node.textContent);
|
||||
test('unsupported node status names the class',()=>{
|
||||
|
||||
@@ -136,6 +136,17 @@ test('an unusable semantics table is refused', () => {
|
||||
for (const doc of [null, {schema: 2}, {schema: 1, samplers: []}]) assert.throws(() => resolver.compileSemantics(doc), resolver.SemanticsError);
|
||||
});
|
||||
|
||||
test('the lineage section is compiled, and a bad one refuses the table', () => {
|
||||
const doc = JSON.parse(fs.readFileSync(new URL('../../web/js/inspection-semantics.json', import.meta.url), 'utf8'));
|
||||
assert.deepEqual(semantics.lineage.get('Image Filter\u00001'), { strategy: 'continue', input: 'latents' });
|
||||
// Lineage describes no prompt: its classes are not table classes for the stage walk.
|
||||
assert.equal(semantics.allClasses.has('VAEEncode'), false);
|
||||
const row = { class: 'VAEEncode', slot: 0, strategy: 'continue', input: 'pixels' };
|
||||
for (const lineage of [undefined, {}, [null], [{ ...row, slot: -1 }], [{ ...row, strategy: 'passthrough' }], [{ ...row, input: '' }], [row, row]]) {
|
||||
assert.throws(() => resolver.compileSemantics({ ...doc, lineage }), resolver.SemanticsError, JSON.stringify(lineage));
|
||||
}
|
||||
});
|
||||
|
||||
// The committed server answers (tests/fixtures/local-inspect/resolver-vectors.json, written by
|
||||
// `python3 -m tests.local_resolver_cases --write` and checked current by the Python suite).
|
||||
test('committed server answers equal the local evidence', () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ VECTORS_PATH = (
|
||||
# Committed record sets stay small; the large bound cases are built at test time.
|
||||
MAX_VECTOR_CHARS = 20_000
|
||||
COMMITTED_FUZZ = (0, 60)
|
||||
COMMITTED_PASSTHROUGH = (0, 40)
|
||||
|
||||
SPACES = ["", " ", "\t", "\n", "\x1c", "\x85", " ", "", "
"]
|
||||
WORDS = ["fox", "snow", "café", "猫", "\U0001f600", "a,", "detailed fur", ""]
|
||||
@@ -1380,6 +1381,7 @@ def committed_vectors():
|
||||
}
|
||||
cases.update(fuzz_cases(*COMMITTED_FUZZ))
|
||||
cases.update(selection.selection_fuzz_cases(*COMMITTED_FUZZ))
|
||||
cases.update(selection.passthrough_fuzz_cases(*COMMITTED_PASSTHROUGH))
|
||||
vectors = [
|
||||
{
|
||||
"name": name,
|
||||
|
||||
@@ -12,8 +12,10 @@ import json
|
||||
import random
|
||||
|
||||
try:
|
||||
from tests import passthrough_graphs as shapes
|
||||
from tests.local_resolver_cases import EXECUTION_KEY, SPACES, WORDS, _Builder
|
||||
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
|
||||
import passthrough_graphs as shapes
|
||||
from local_resolver_cases import EXECUTION_KEY, SPACES, WORDS, _Builder
|
||||
|
||||
|
||||
@@ -437,10 +439,213 @@ def _parameters():
|
||||
return cases
|
||||
|
||||
|
||||
def _passthrough():
|
||||
"""Chains joined through lineage nodes, and the floor for added conditioning."""
|
||||
cases = {
|
||||
"hires-fix": shapes.hires_fix(),
|
||||
"hires-fix-differs": shapes.hires_fix(second_positive=shapes.OTHER),
|
||||
"hires-fix-unlisted": shapes.hires_fix(filter_class="MysteryImageEffect"),
|
||||
"hires-fix-generated": shapes.generated_text_chain(),
|
||||
"flux-three-stage": shapes.flux_three_stage(),
|
||||
"flux-concat": shapes.flux_three_stage(composite="ConditioningConcat"),
|
||||
"flux-average": shapes.flux_three_stage(composite="ConditioningAverage"),
|
||||
"edit-batch": shapes.edit_batch(3),
|
||||
}
|
||||
graph = shapes.flux_three_stage(combined=(("37", 0), ("31", 0)))
|
||||
graph["37"] = shapes.encode(shapes.OTHER)
|
||||
cases["flux-without-base"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["38"] = shapes.encode("watermark")
|
||||
graph["60"]["inputs"]["negative"] = ["38", 0]
|
||||
cases["flux-other-negative"] = graph
|
||||
graph = shapes.hires_fix()
|
||||
graph["20"]["inputs"]["latent_image"] = ["10", 0]
|
||||
graph["10"]["inputs"]["latent_image"] = ["20", 0]
|
||||
cases["hires-fix-cycle"] = graph
|
||||
graph = shapes.hires_fix()
|
||||
graph["17"]["inputs"]["pixels"] = ["18", 0]
|
||||
graph["18"] = {"class_type": "Reroute", "inputs": {"value": ["16", 0]}}
|
||||
cases["hires-fix-reroute"] = graph
|
||||
graph = shapes.hires_fix()
|
||||
graph["24"] = {"class_type": "SaveImage", "inputs": {"images": ["11", 0]}}
|
||||
cases["hires-fix-both-saved"] = graph
|
||||
graph = shapes.hires_fix()
|
||||
graph["20"]["inputs"]["latent_image"] = ["99", 0]
|
||||
cases["hires-fix-missing-latent"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["38"] = {"class_type": "MysteryNegative", "inputs": {"seed": 1}}
|
||||
graph["60"]["inputs"]["negative"] = ["38", 0]
|
||||
cases["flux-negative-unsupported"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["39"] = {"class_type": "Reroute", "inputs": {"input": ["35", 0]}}
|
||||
graph["60"]["inputs"]["positive"] = ["39", 0]
|
||||
cases["flux-reroute-input"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["39"] = {
|
||||
"class_type": "CLIPTextEncodeSDXL",
|
||||
"inputs": {"text_g": shapes.POSITIVE, "text_l": shapes.STYLE},
|
||||
}
|
||||
graph["60"]["inputs"]["positive"] = ["39", 0]
|
||||
cases["flux-encoder-parts"] = graph
|
||||
cases.update(_passthrough_edges())
|
||||
cases.update(_runtime_parts())
|
||||
return cases
|
||||
|
||||
|
||||
def _runtime_parts():
|
||||
"""Option A: a PromptManager part fed by a run-time node is left out."""
|
||||
cases = {"trigger-prepend": shapes.hires_fix_with_trigger_words()}
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["2"]["inputs"].update(prepend_text="masterpiece", append_text=["8", 0])
|
||||
graph["20"]["inputs"]["latent_image"] = ["4", 0]
|
||||
cases["trigger-append-single"] = graph
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["8"] = {"class_type": "PrimitiveString", "inputs": {"value": "trig9word"}}
|
||||
cases["traced-prepend"] = graph
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["8"]["class_type"] = "MysteryWords"
|
||||
cases["unknown-prepend"] = graph
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["9"] = {
|
||||
"class_type": "StringConcatenate",
|
||||
"inputs": {"string_a": "kept", "string_b": ["8", 0], "delimiter": ""},
|
||||
}
|
||||
graph["2"]["inputs"]["prepend_text"] = ["9", 0]
|
||||
cases["indirect-prepend"] = graph
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["2"]["inputs"].update(prepend_text="", text=["8", 0])
|
||||
cases["runtime-main-text"] = graph
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["2"]["class_type"] = "PromptManagerPair"
|
||||
graph["2"]["inputs"]["negative_text"] = shapes.NEGATIVE
|
||||
graph["10"]["inputs"]["negative"] = ["2", 1]
|
||||
graph["20"]["inputs"]["negative"] = ["2", 1]
|
||||
cases["trigger-pair"] = graph
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["30"] = {"class_type": "PromptSearchList", "inputs": {"search": "x"}}
|
||||
graph["2"]["inputs"]["append_text"] = ["30", 0]
|
||||
cases["search-list-append"] = graph
|
||||
for shape in ("text-encode", "chained", "concat"):
|
||||
cases[f"through-{shape}"] = shapes.trigger_words_through(shape)
|
||||
cases["branches-with-and-without"] = (
|
||||
shapes.branches_with_and_without_trigger_words()
|
||||
)
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["9"] = shapes.prompt_manager(shapes.POSITIVE)
|
||||
graph["20"]["inputs"]["positive"] = ["9", 0]
|
||||
cases["stage-with-and-without"] = graph
|
||||
graph = shapes.hires_fix()
|
||||
graph["9"] = {
|
||||
"class_type": "JoinStringMulti",
|
||||
"inputs": {
|
||||
"inputcount": 2,
|
||||
"delimiter": " ",
|
||||
"return_list": ["98", 0],
|
||||
"string_1": "static words",
|
||||
"string_2": "more",
|
||||
},
|
||||
}
|
||||
graph["98"] = {"class_type": "PrimitiveBoolean", "inputs": {"value": False}}
|
||||
graph["2"]["inputs"]["prepend_text"] = ["9", 0]
|
||||
cases["join-multi-flag"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["31"] = shapes.encode(shapes.OTHER)
|
||||
cases["flux-other-full-prompt"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["7"] = {
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"inputs": {"model": ["1", 0], "clip": ["2", 0], "text": "x"},
|
||||
}
|
||||
graph["8"] = shapes.trigger_toggle()
|
||||
graph["30"]["inputs"]["prepend_text"] = ["8", 0]
|
||||
cases["flux-trigger"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["60"] = {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {"guider": ["63", 0], "latent_image": ["66", 0]},
|
||||
}
|
||||
graph["63"] = {
|
||||
"class_type": "CFGGuider",
|
||||
"inputs": {"positive": ["35", 0], "negative": ["33", 0]},
|
||||
}
|
||||
graph["66"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["51", 0]}}
|
||||
graph["67"] = {"class_type": "VAEDecode", "inputs": {"samples": ["60", 0]}}
|
||||
graph["61"]["inputs"]["images"] = ["67", 0]
|
||||
cases["flux-custom-advanced-chained"] = graph
|
||||
return {f"runtime-{name}": graph for name, graph in cases.items()}
|
||||
|
||||
|
||||
def _passthrough_edges():
|
||||
"""Lineage and floor edges: branching refiners, other slots, cycles, guiders."""
|
||||
cases = {}
|
||||
graph = shapes.hires_fix()
|
||||
graph["30"] = shapes.ksampler(["2", 0], ["3", 0], ["17", 0], denoise=0.4)
|
||||
graph["31"] = {"class_type": "SaveImage", "inputs": {"images": ["30", 0]}}
|
||||
cases["hires-fix-two-refiners"] = graph
|
||||
graph = shapes.hires_fix()
|
||||
graph["10"]["class_type"] = "SamplerCustom"
|
||||
graph["11"]["inputs"]["samples"] = ["10", 1]
|
||||
cases["hires-fix-denoised-slot"] = graph
|
||||
graph = shapes.hires_fix()
|
||||
graph["30"] = shapes.ksampler(["2", 0], ["3", 0], ["4", 0])
|
||||
graph["31"] = {"class_type": "VAEDecode", "inputs": {"samples": ["30", 0]}}
|
||||
graph["32"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["31", 0]}}
|
||||
graph["30"]["inputs"]["latent_image"] = ["32", 0]
|
||||
cases["hires-fix-self-latent"] = graph
|
||||
graph = shapes.hires_fix()
|
||||
graph["30"] = shapes.ksampler(["2", 0], ["3", 0], ["32", 0])
|
||||
graph["31"] = {"class_type": "VAEDecode", "inputs": {"samples": ["32", 0]}}
|
||||
graph["32"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["31", 0]}}
|
||||
graph["33"] = {"class_type": "SaveImage", "inputs": {"images": ["30", 0]}}
|
||||
cases["hires-fix-lineage-cycle"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["39"] = shapes.encode(shapes.OTHER)
|
||||
graph["40"]["inputs"]["positive"] = ["39", 0]
|
||||
cases["flux-base-stages-differ"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
del graph["60"]["inputs"]["negative"]
|
||||
cases["flux-no-last-negative"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["39"] = {"class_type": "MysteryPrompt", "inputs": {"seed": 1}}
|
||||
graph["40"]["inputs"]["positive"] = ["39", 0]
|
||||
graph["50"]["inputs"]["positive"] = ["39", 0]
|
||||
cases["flux-unreadable-base"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["39"] = {
|
||||
"class_type": "MysteryUpscaler",
|
||||
"inputs": {"image": ["51", 0], "model": ["1", 0], "positive": ["34", 0]},
|
||||
}
|
||||
graph["52"]["inputs"]["images"] = ["39", 0]
|
||||
cases["flux-unsupported-ancestry"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["60"] = {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {
|
||||
"noise": ["62", 0],
|
||||
"guider": ["63", 0],
|
||||
"sampler": ["64", 0],
|
||||
"sigmas": ["65", 0],
|
||||
"latent_image": ["66", 0],
|
||||
},
|
||||
}
|
||||
graph["62"] = {"class_type": "RandomNoise", "inputs": {"noise_seed": 1}}
|
||||
graph["63"] = {
|
||||
"class_type": "CFGGuider",
|
||||
"inputs": {"model": ["1", 0], "positive": ["35", 0], "negative": ["33", 0]},
|
||||
}
|
||||
graph["64"] = {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "euler"}}
|
||||
graph["65"] = {"class_type": "BasicScheduler", "inputs": {"steps": 4}}
|
||||
graph["66"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["52", 0]}}
|
||||
graph["67"] = {"class_type": "VAEDecode", "inputs": {"samples": ["60", 0]}}
|
||||
graph["61"]["inputs"]["images"] = ["67", 0]
|
||||
cases["flux-custom-advanced-last"] = graph
|
||||
return {f"edge-{name}": graph for name, graph in cases.items()}
|
||||
|
||||
|
||||
def selection_cases():
|
||||
"""Named record sets for every Task 9 path, each a ``read_inspection_file`` dict."""
|
||||
cases = {}
|
||||
for group in (_samplers(), _previews(), _detailers()):
|
||||
for group in (_samplers(), _previews(), _detailers(), _passthrough()):
|
||||
for name, graph in group.items():
|
||||
cases[f"select-{name}"] = {"prompt": json.dumps(graph, ensure_ascii=False)}
|
||||
cases.update(_parameters())
|
||||
@@ -572,3 +777,101 @@ def selection_fuzz_cases(seed, count):
|
||||
records.update(builder.records)
|
||||
cases[f"select-fuzz-{seed}-{index}"] = records
|
||||
return cases
|
||||
|
||||
|
||||
_LINEAGE_HOPS = (
|
||||
("VAEDecode", "samples"),
|
||||
("VAEDecodeTiled", "samples"),
|
||||
("Image Filter", "images"),
|
||||
("ImageUpscaleWithModel", "image"),
|
||||
("ImageScale", "image"),
|
||||
("ImageScaleBy", "image"),
|
||||
("VAEEncode", "pixels"),
|
||||
("LatentUpscaleBy", "samples"),
|
||||
("Reroute", "value"),
|
||||
("MysteryImageEffect", "image"),
|
||||
)
|
||||
|
||||
|
||||
def passthrough_fuzz_cases(seed, count):
|
||||
"""Seeded sampler chains joined by random lineage (and unlisted) hops.
|
||||
|
||||
Each later stage reads the earlier one's output through zero to four hops,
|
||||
with a shared, a different, an added-to (combine, concat, average) or a
|
||||
run-time positive, and a shared or different negative, under a saver or a
|
||||
preview. The server is the oracle; nothing states the expected answer.
|
||||
"""
|
||||
rng = random.Random(seed)
|
||||
cases = {}
|
||||
for index in range(count):
|
||||
graph = {
|
||||
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
|
||||
"2": shapes.prompt_manager(rng.choice([shapes.POSITIVE, "fox"])),
|
||||
"3": shapes.encode(shapes.NEGATIVE),
|
||||
"4": {"class_type": "EmptyLatentBatch", "inputs": {"batch_size": 2}},
|
||||
"5": shapes.encode(shapes.STYLE),
|
||||
"6": shapes.encode(shapes.OTHER),
|
||||
}
|
||||
previous, next_id = ["4", 0], 10
|
||||
for stage in range(rng.randint(2, 3)):
|
||||
positive = ["2", 0]
|
||||
choice = rng.randrange(8)
|
||||
if stage and choice == 1:
|
||||
positive = ["6", 0]
|
||||
elif stage and choice in (2, 3, 4):
|
||||
name = (
|
||||
"ConditioningCombine",
|
||||
"ConditioningConcat",
|
||||
"ConditioningAverage",
|
||||
)[choice - 2]
|
||||
names = shapes._COMPOSITE_INPUTS[name]
|
||||
parts = [["2", 0], rng.choice([["5", 0], ["6", 0]])]
|
||||
rng.shuffle(parts)
|
||||
graph[str(next_id)] = {
|
||||
"class_type": name,
|
||||
"inputs": dict(zip(names, parts)),
|
||||
}
|
||||
positive, next_id = [str(next_id), 0], next_id + 1
|
||||
elif stage and choice == 5:
|
||||
graph[str(next_id)] = {
|
||||
"class_type": "FluxGuidance",
|
||||
"inputs": {"guidance": 3.5, "conditioning": ["2", 0]},
|
||||
}
|
||||
positive, next_id = [str(next_id), 0], next_id + 1
|
||||
negative = ["6", 0] if stage and rng.random() < 0.15 else ["3", 0]
|
||||
latent = previous
|
||||
if stage:
|
||||
image = previous
|
||||
for _ in range(rng.randrange(5)):
|
||||
name, key = rng.choice(_LINEAGE_HOPS)
|
||||
graph[str(next_id)] = {"class_type": name, "inputs": {key: image}}
|
||||
image, next_id = [str(next_id), 0], next_id + 1
|
||||
latent = image
|
||||
sampler = shapes.ksampler(positive, negative, latent)
|
||||
if stage and rng.random() < 0.2:
|
||||
sampler = {
|
||||
"class_type": "UltimateSDUpscale",
|
||||
"inputs": {
|
||||
"image": latent,
|
||||
"model": ["1", 0],
|
||||
"positive": positive,
|
||||
"negative": negative,
|
||||
},
|
||||
}
|
||||
graph[str(next_id)] = sampler
|
||||
previous, next_id = [str(next_id), 0], next_id + 1
|
||||
if rng.random() < 0.3:
|
||||
saver = rng.choice(["SaveImage", "PreviewImage"])
|
||||
graph[str(next_id)] = {
|
||||
"class_type": saver,
|
||||
"inputs": {"images": previous},
|
||||
}
|
||||
next_id += 1
|
||||
graph[str(next_id)] = {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"images": previous},
|
||||
}
|
||||
cases[f"select-passthrough-fuzz-{seed}-{index}"] = {
|
||||
"prompt": json.dumps(graph, ensure_ascii=False)
|
||||
}
|
||||
return cases
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
"""Synthetic multi-stage graphs for the pass-through lineage rule.
|
||||
|
||||
Each builder reproduces one graph shape with neutral texts and generic names:
|
||||
a hires-fix or refiner chain (sampler, decode, image filter, model upscale,
|
||||
scale, encode, sampler), a three-stage Flux run whose last stage combines the
|
||||
base prompt with a style trigger, an image-edit batch of samplers with distinct
|
||||
prompts, and a chain whose prompt is generated at run time. They are shared by
|
||||
the resolver tests and the browser-port parity cases; no user file is involved.
|
||||
"""
|
||||
|
||||
import copy
|
||||
|
||||
POSITIVE = "a lighthouse on a cliff, golden hour"
|
||||
NEGATIVE = "blurry, lowres"
|
||||
OTHER = "a red bicycle in the rain"
|
||||
STYLE = "film grain, analog photo"
|
||||
|
||||
|
||||
def encode(text):
|
||||
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["1", 1]}}
|
||||
|
||||
|
||||
def prompt_manager(text, prepend=""):
|
||||
return {
|
||||
"class_type": "PromptManager",
|
||||
"inputs": {
|
||||
"text": text,
|
||||
"category": "",
|
||||
"tags": "",
|
||||
"search_text": "",
|
||||
"prepend_text": prepend,
|
||||
"append_text": "",
|
||||
"clip": ["1", 1],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def ksampler(positive, negative, latent, denoise=1.0):
|
||||
return {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": 7,
|
||||
"steps": 20,
|
||||
"cfg": 7.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": denoise,
|
||||
"model": ["1", 0],
|
||||
"positive": positive,
|
||||
"negative": negative,
|
||||
"latent_image": latent,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def hires_fix(second_positive=None, filter_class="Image Filter"):
|
||||
"""Two KSamplers joined in image space, like a hires-fix or refiner run.
|
||||
|
||||
Sampler 10 runs on a custom batch latent; its output goes through a tiled
|
||||
decode, an interactive image filter, a model upscale and a scale (sized by a
|
||||
resolution node reading the filtered image), then is encoded again for
|
||||
sampler 20 (denoise 0.25), which is decoded and saved.
|
||||
"""
|
||||
graph = {
|
||||
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
|
||||
"2": prompt_manager(POSITIVE),
|
||||
"3": encode(NEGATIVE),
|
||||
"4": {
|
||||
"class_type": "EmptyLatentBatch",
|
||||
"inputs": {"width": 1024, "height": 512, "batch_size": 4},
|
||||
},
|
||||
"10": ksampler(["2", 0], ["3", 0], ["4", 0]),
|
||||
"11": {
|
||||
"class_type": "VAEDecodeTiled",
|
||||
"inputs": {"tile_size": 512, "samples": ["10", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"12": {
|
||||
"class_type": filter_class,
|
||||
"inputs": {"timeout": 600, "ontimeout": "send none", "images": ["11", 0]},
|
||||
},
|
||||
"13": {"class_type": "UpscaleModelLoader", "inputs": {"model_name": "x4"}},
|
||||
"14": {
|
||||
"class_type": "ImageUpscaleWithModel",
|
||||
"inputs": {"upscale_model": ["13", 0], "image": ["12", 0]},
|
||||
},
|
||||
"15": {
|
||||
"class_type": "ResolutionCalculator",
|
||||
"inputs": {"scale_factor": 1.5, "image": ["12", 0]},
|
||||
},
|
||||
"16": {
|
||||
"class_type": "ImageScale",
|
||||
"inputs": {
|
||||
"upscale_method": "nearest-exact",
|
||||
"width": ["15", 0],
|
||||
"height": ["15", 1],
|
||||
"crop": "disabled",
|
||||
"image": ["14", 0],
|
||||
},
|
||||
},
|
||||
"17": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {"pixels": ["16", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"20": ksampler(["2", 0], ["3", 0], ["17", 0], denoise=0.25),
|
||||
"21": {
|
||||
"class_type": "VAEDecodeTiled",
|
||||
"inputs": {"tile_size": 512, "samples": ["20", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"22": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "hires", "images": ["21", 0]},
|
||||
},
|
||||
"23": {"class_type": "PlaySound|pysssss", "inputs": {"any": ["21", 0]}},
|
||||
}
|
||||
if second_positive is not None:
|
||||
graph["5"] = encode(second_positive)
|
||||
graph["20"]["inputs"]["positive"] = ["5", 0]
|
||||
return graph
|
||||
|
||||
|
||||
_COMPOSITE_INPUTS = {
|
||||
"ConditioningCombine": ("conditioning_1", "conditioning_2"),
|
||||
"ConditioningConcat": ("conditioning_to", "conditioning_from"),
|
||||
"ConditioningAverage": ("conditioning_to", "conditioning_from"),
|
||||
}
|
||||
|
||||
|
||||
def flux_three_stage(combined=(("30", 0), ("31", 0)), composite="ConditioningCombine"):
|
||||
"""FluxGuidance base, a refiner through an image filter, then a tiled upscale.
|
||||
|
||||
Samplers 40 and 50 share FluxGuidance(PromptManager 30) and a zeroed empty
|
||||
negative. Sampler 50 refines 40's filtered image (denoise 0.25). The final
|
||||
UltimateSDUpscale 60 reads 50's image, scaled down, and a positive that is
|
||||
``composite`` of ``combined`` (by default the base PromptManager and a
|
||||
style-trigger encoder 31).
|
||||
"""
|
||||
names = _COMPOSITE_INPUTS[composite]
|
||||
graph = {
|
||||
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": "flux"}},
|
||||
"2": {"class_type": "DualCLIPLoader", "inputs": {"type": "flux"}},
|
||||
"3": {"class_type": "VAELoader", "inputs": {"vae_name": "ae"}},
|
||||
"30": prompt_manager(POSITIVE),
|
||||
"31": encode(STYLE),
|
||||
"32": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "", "clip": ["2", 0]},
|
||||
},
|
||||
"33": {
|
||||
"class_type": "ConditioningZeroOut",
|
||||
"inputs": {"conditioning": ["32", 0]},
|
||||
},
|
||||
"34": {
|
||||
"class_type": "FluxGuidance",
|
||||
"inputs": {"guidance": 3.5, "conditioning": ["30", 0]},
|
||||
},
|
||||
"35": {
|
||||
"class_type": composite,
|
||||
"inputs": dict(zip(names, map(list, combined))),
|
||||
},
|
||||
"36": {
|
||||
"class_type": "EmptyLatentBatch",
|
||||
"inputs": {"width": 1080, "height": 1920, "batch_size": 4},
|
||||
},
|
||||
"40": ksampler(["34", 0], ["33", 0], ["36", 0]),
|
||||
"41": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["40", 0], "vae": ["3", 0]},
|
||||
},
|
||||
"42": {
|
||||
"class_type": "Image Filter",
|
||||
"inputs": {"timeout": 600, "images": ["41", 0]},
|
||||
},
|
||||
"43": {"class_type": "PreviewImage", "inputs": {"images": ["42", 0]}},
|
||||
"44": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {"pixels": ["42", 0], "vae": ["3", 0]},
|
||||
},
|
||||
"50": ksampler(["34", 0], ["33", 0], ["44", 0], denoise=0.25),
|
||||
"51": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["50", 0], "vae": ["3", 0]},
|
||||
},
|
||||
"52": {
|
||||
"class_type": "ImageScaleDownBy",
|
||||
"inputs": {"scale_by": 0.75, "images": ["51", 0]},
|
||||
},
|
||||
"53": {"class_type": "PreviewImage", "inputs": {"images": ["51", 0]}},
|
||||
"54": {
|
||||
"class_type": "Image Comparer (rgthree)",
|
||||
"inputs": {"image_a": ["51", 0], "image_b": ["42", 0]},
|
||||
},
|
||||
"60": {
|
||||
"class_type": "UltimateSDUpscale",
|
||||
"inputs": {
|
||||
"upscale_by": 2.0,
|
||||
"seed": 7,
|
||||
"steps": 4,
|
||||
"denoise": 0.35,
|
||||
"image": ["52", 0],
|
||||
"model": ["1", 0],
|
||||
"positive": ["35", 0],
|
||||
"negative": ["33", 0],
|
||||
"vae": ["3", 0],
|
||||
},
|
||||
},
|
||||
"61": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "flux", "images": ["60", 0]},
|
||||
},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
def edit_batch(count=3):
|
||||
"""Image-edit samplers with distinct prompts, all saved with one prefix."""
|
||||
graph = {
|
||||
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
|
||||
"2": {"class_type": "LoadImage", "inputs": {"image": "ref.png"}},
|
||||
"3": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {"pixels": ["2", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"4": {"class_type": "PrimitiveString", "inputs": {"value": "edit"}},
|
||||
}
|
||||
for index in range(count):
|
||||
base = 100 + index * 10
|
||||
graph[str(base)] = {
|
||||
"class_type": "TextEncodeQwenImageEdit",
|
||||
"inputs": {
|
||||
"prompt": f"edit number {index}: change the pose",
|
||||
"clip": ["1", 1],
|
||||
},
|
||||
}
|
||||
graph[str(base + 1)] = ksampler([str(base), 0], [str(base), 0], ["3", 0])
|
||||
graph[str(base + 2)] = {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": [str(base + 1), 0], "vae": ["1", 2]},
|
||||
}
|
||||
graph[str(base + 3)] = {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": ["4", 0], "images": [str(base + 2), 0]},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
def trigger_toggle(words="trig9word"):
|
||||
"""LoraManager's TriggerWord Toggle: its output is decided at run time."""
|
||||
return {
|
||||
"class_type": "TriggerWord Toggle (LoraManager)",
|
||||
"inputs": {
|
||||
"group_mode": True,
|
||||
"default_active": True,
|
||||
"toggle_trigger_words": {"__value__": [{"text": words, "active": True}]},
|
||||
"orinalMessage": words,
|
||||
"trigger_words": ["7", 2],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def hires_fix_with_trigger_words():
|
||||
"""``hires_fix`` whose PromptManager prepends LoRA trigger words (node 8)."""
|
||||
graph = hires_fix()
|
||||
graph["7"] = {
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"inputs": {"model": ["1", 0], "clip": ["1", 1], "text": "<lora:x:1>"},
|
||||
}
|
||||
graph["8"] = trigger_toggle()
|
||||
graph["2"]["inputs"]["prepend_text"] = ["8", 0]
|
||||
return graph
|
||||
|
||||
|
||||
def prompt_manager_text(text, prepend=""):
|
||||
"""PromptManagerText: a STRING PromptManager (prepend/main/append)."""
|
||||
return {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {
|
||||
"text": text,
|
||||
"category": "",
|
||||
"tags": "",
|
||||
"search_text": "",
|
||||
"prepend_text": prepend,
|
||||
"append_text": "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def trigger_words_through(shape):
|
||||
"""``hires_fix_with_trigger_words`` with nodes between the toggle and sampler.
|
||||
|
||||
``text-encode``: PromptManagerText (prepend <- toggle) -> CLIPTextEncode.
|
||||
``chained``: a PromptManager whose ``text`` reads that PromptManagerText.
|
||||
``concat``: PromptManagerText -> StringConcatenate(..., "extra") -> encoder.
|
||||
"""
|
||||
graph = hires_fix_with_trigger_words()
|
||||
graph["9"] = prompt_manager_text(POSITIVE, ["8", 0])
|
||||
if shape == "chained":
|
||||
graph["2"]["inputs"].update(prepend_text="", text=["9", 0])
|
||||
elif shape == "text-encode":
|
||||
graph["2"] = {"class_type": "CLIPTextEncode", "inputs": {"text": ["9", 0]}}
|
||||
else:
|
||||
graph["19"] = {
|
||||
"class_type": "StringConcatenate",
|
||||
"inputs": {"string_a": ["9", 0], "string_b": "extra", "delimiter": ", "},
|
||||
}
|
||||
graph["2"] = {"class_type": "CLIPTextEncode", "inputs": {"text": ["19", 0]}}
|
||||
return graph
|
||||
|
||||
|
||||
def branches_with_and_without_trigger_words():
|
||||
"""Two saved branches: one PromptManager prepends trigger words, one does not."""
|
||||
graph = hires_fix_with_trigger_words()
|
||||
graph["9"] = prompt_manager(POSITIVE)
|
||||
graph["20"]["inputs"].update(positive=["9", 0], latent_image=["4", 0])
|
||||
return graph
|
||||
|
||||
|
||||
def generated_text_chain():
|
||||
"""``hires_fix`` whose positive text comes from a run-time text generator."""
|
||||
graph = hires_fix()
|
||||
graph["2"] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": ["6", 0], "clip": ["1", 1]},
|
||||
}
|
||||
graph["6"] = {
|
||||
"class_type": "TextGenerate",
|
||||
"inputs": {"prompt": "write a prompt", "model": ["1", 0]},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
def copied(graph):
|
||||
return copy.deepcopy(graph)
|
||||
@@ -68,6 +68,55 @@ class CardSeed(unittest.TestCase):
|
||||
self.assertIn(expected, (111, 222))
|
||||
self.assertEqual(info_seed(info), str(expected))
|
||||
|
||||
def test_a_chain_the_inspector_names_one_stage_of_shows_no_differing_seed(self):
|
||||
# The inspector shows the named final stage's seed (D19, pass-through
|
||||
# chains); the parser shows another stage's. Never two seeds: none.
|
||||
extra = {"2": {"class_type": "EmptyLatentImage", "inputs": {}}}
|
||||
extra["20"] = {"class_type": "VAEDecode", "inputs": {"samples": ["10", 0]}}
|
||||
extra["21"] = {
|
||||
"class_type": "FaceDetailer",
|
||||
"inputs": {
|
||||
"seed": 333,
|
||||
"image": ["20", 0],
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
},
|
||||
}
|
||||
info = graph(seeded(111), extra=extra)
|
||||
self.assertEqual(extract_from_records(info["prompt"], None)["seed"], 111)
|
||||
self.assertIsNone(info_seed(info))
|
||||
# The same chain on one seed keeps it; so does a refiner over a decode.
|
||||
extra["21"]["inputs"]["seed"] = 111
|
||||
self.assertEqual(info_seed(graph(seeded(111), extra=extra)), "111")
|
||||
refiner = seeded(222, latent_image=["22", 0])
|
||||
extra["22"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["20", 0]}}
|
||||
del extra["21"]
|
||||
self.assertIsNone(info_seed(graph(seeded(111), refiner, extra=extra)))
|
||||
refiner["inputs"]["seed"] = 111
|
||||
self.assertEqual(info_seed(graph(seeded(111), refiner, extra=extra)), "111")
|
||||
|
||||
def test_a_seed_that_agrees_with_the_named_stage_is_kept(self):
|
||||
# Review I3: a saved sampler (seed 7) beside a preview-only sampler
|
||||
# (seed 5): the parser and the inspector both show 7, as on main.
|
||||
extra = {"2": {"class_type": "EmptyLatentImage", "inputs": {}}}
|
||||
extra["20"] = {"class_type": "VAEDecode", "inputs": {"samples": ["10", 0]}}
|
||||
extra["21"] = {"class_type": "SaveImage", "inputs": {"images": ["20", 0]}}
|
||||
extra["30"] = {"class_type": "VAEDecode", "inputs": {"samples": ["11", 0]}}
|
||||
extra["31"] = {"class_type": "PreviewImage", "inputs": {"images": ["30", 0]}}
|
||||
info = graph(seeded(7), seeded(5), extra=extra)
|
||||
self.assertEqual(info_seed(info), "7")
|
||||
|
||||
def test_a_named_ambiguous_chain_keeps_the_parser_seed(self):
|
||||
# Review M2: multistage-differs names a sampler, but its facts merge,
|
||||
# and the inspector fills the parser's seed: the card keeps it.
|
||||
extra = {"2": {"class_type": "EmptyLatentImage", "inputs": {}}}
|
||||
extra["8"] = encoder("another scene")
|
||||
extra["20"] = {"class_type": "VAEDecode", "inputs": {"samples": ["10", 0]}}
|
||||
extra["22"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["20", 0]}}
|
||||
refiner = ksampler(("8", 0), ("7", 0), seed=222, latent_image=["22", 0])
|
||||
self.assertEqual(info_seed(graph(seeded(111), refiner, extra=extra)), "111")
|
||||
|
||||
def test_card_seed_linked_input_follows_shared_parser(self):
|
||||
primitive = {"class_type": "PrimitiveNode", "inputs": {"value": 555}}
|
||||
info = graph(seeded(["20", 0]), extra={"20": primitive})
|
||||
@@ -207,6 +256,7 @@ class CardSeed(unittest.TestCase):
|
||||
self.assertIsNone(parsed_positive(unparsable))
|
||||
|
||||
def test_every_shared_case_reads_the_projected_graphs(self):
|
||||
withheld = []
|
||||
for name, case in CASES.items():
|
||||
with self.subTest(case=name):
|
||||
info = {k: v for k, v in case.items() if v is not None}
|
||||
@@ -225,11 +275,18 @@ class CardSeed(unittest.TestCase):
|
||||
except GenerationParamsError:
|
||||
self.assertIsNone(params)
|
||||
continue
|
||||
if name == "shared-prompt-pipeline":
|
||||
# The inspector names the detailer (seed 333); the parser
|
||||
# reads the base sampler (111): the card shows none.
|
||||
self.assertEqual(expected["seed"], 111)
|
||||
expected = {**expected, "seed": None}
|
||||
withheld.append(name)
|
||||
self.assertEqual(params, expected)
|
||||
# Prompt text is the same as from a plain parse of the records.
|
||||
if len(values) == len(info):
|
||||
raw = extract_from_records(case["prompt"], case["workflow"])
|
||||
self.assertEqual(params["positivePrompt"], raw["positivePrompt"])
|
||||
self.assertEqual(withheld, ["shared-prompt-pipeline"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -800,10 +800,15 @@ class TestUpstreamVerifiedNodeSemantics(unittest.TestCase):
|
||||
prompt["31"] = dict(prompt["3"], inputs=dict(prompt["3"]["inputs"]))
|
||||
prompt["31"]["inputs"]["latent_image"] = ["3", 0]
|
||||
prompt["8"]["inputs"]["samples"] = ["31", 0]
|
||||
# Unanchored, two listed samplers are two branches, as for KSampler.
|
||||
self.assertEqual(_inspect({"prompt": prompt})["state"], "ambiguous")
|
||||
# 31 continues 3's latent: the stages are compared, as for KSampler.
|
||||
for anchor in (None, "9"):
|
||||
result = _inspect({"prompt": prompt}, output_node_id=anchor)
|
||||
self.assertEqual(
|
||||
(result["state"], result["sampler_id"]), ("resolved", "31")
|
||||
)
|
||||
prompt["31"]["inputs"]["positive"] = ["7", 0]
|
||||
result = _inspect({"prompt": prompt}, output_node_id="9")
|
||||
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
|
||||
self.assertEqual(result["reason_code"], "multistage-differs")
|
||||
|
||||
def test_seed_variance_enhancer_passes_its_conditioning(self):
|
||||
prompt = _f02(
|
||||
|
||||
@@ -55,6 +55,9 @@ def _minimal():
|
||||
"runtime": [],
|
||||
"savers": {},
|
||||
"non_file_outputs": [],
|
||||
"lineage": [
|
||||
{"class": "VAEEncode", "slot": 0, "strategy": "continue", "input": "pixels"}
|
||||
],
|
||||
"stage_inputs": {"shaped": ["positive"], "with_model": ["samples"]},
|
||||
"facts": {
|
||||
"configuration_outputs": {},
|
||||
@@ -199,16 +202,26 @@ class SpecEntriesTest(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
# Combine and concat keep every part in effect (additive); an average
|
||||
# blends its parts, so a part may be weighted away.
|
||||
self.assertEqual(
|
||||
table.composites["ConditioningCombine"],
|
||||
{"strategy": "combine", "inputs": ("conditioning_1", "conditioning_2")},
|
||||
{
|
||||
"strategy": "combine",
|
||||
"inputs": ("conditioning_1", "conditioning_2"),
|
||||
"additive": True,
|
||||
},
|
||||
)
|
||||
for name in ("ConditioningConcat", "ConditioningAverage"):
|
||||
for name, additive in (
|
||||
("ConditioningConcat", {"additive": True}),
|
||||
("ConditioningAverage", {}),
|
||||
):
|
||||
self.assertEqual(
|
||||
table.composites[name],
|
||||
{
|
||||
"strategy": "combine",
|
||||
"inputs": ("conditioning_to", "conditioning_from"),
|
||||
**additive,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -257,7 +270,12 @@ class SpecEntriesTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
# D18: decided at run time.
|
||||
self.assertEqual(table.runtime, frozenset({"PromptSearchList"}))
|
||||
# LoraManager's TriggerWord Toggle outputs the LoRA's trigger words as
|
||||
# filtered at run time (option A leaves them out of a PromptManager).
|
||||
self.assertEqual(
|
||||
table.runtime,
|
||||
frozenset({"PromptSearchList", "TriggerWord Toggle (LoraManager)"}),
|
||||
)
|
||||
|
||||
comfy = "{name}_{counter:05}_.{ext}"
|
||||
for name, image in (
|
||||
@@ -464,6 +482,89 @@ class SpecEntriesTest(unittest.TestCase):
|
||||
self.assertIsInstance(table.composites["ConditioningCombine"]["inputs"], tuple)
|
||||
|
||||
|
||||
class LineageTableTest(_TableCase):
|
||||
"""``lineage``: outputs whose image or latent continues one input's."""
|
||||
|
||||
def test_repository_lineage_lists_the_pass_through_nodes(self):
|
||||
table = load_semantics()
|
||||
self.assertEqual(
|
||||
{key: dict(entry) for key, entry in table.lineage.items()},
|
||||
{
|
||||
("VAEDecode", 0): {"strategy": "continue", "input": "samples"},
|
||||
("VAEDecodeTiled", 0): {"strategy": "continue", "input": "samples"},
|
||||
("VAEEncode", 0): {"strategy": "continue", "input": "pixels"},
|
||||
("VAEEncodeTiled", 0): {"strategy": "continue", "input": "pixels"},
|
||||
("ImageUpscaleWithModel", 0): {
|
||||
"strategy": "continue",
|
||||
"input": "image",
|
||||
},
|
||||
("ImageScale", 0): {"strategy": "continue", "input": "image"},
|
||||
("ImageScaleBy", 0): {"strategy": "continue", "input": "image"},
|
||||
("ImageScaleToTotalPixels", 0): {
|
||||
"strategy": "continue",
|
||||
"input": "image",
|
||||
},
|
||||
("LatentUpscale", 0): {"strategy": "continue", "input": "samples"},
|
||||
("LatentUpscaleBy", 0): {"strategy": "continue", "input": "samples"},
|
||||
("Image Filter", 0): {"strategy": "continue", "input": "images"},
|
||||
("Image Filter", 1): {"strategy": "continue", "input": "latents"},
|
||||
},
|
||||
)
|
||||
# Lineage describes no prompt: its classes are not prompt categories.
|
||||
self.assertFalse({name for name, _ in table.lineage} & table.all_classes)
|
||||
|
||||
def test_lineage_rows_are_validated(self):
|
||||
def row(**changes):
|
||||
entry = {
|
||||
"class": "VAEEncode",
|
||||
"slot": 0,
|
||||
"strategy": "continue",
|
||||
"input": "pixels",
|
||||
}
|
||||
entry.update(changes)
|
||||
return entry
|
||||
|
||||
cases = (
|
||||
({"lineage": {}}, "must be a list"),
|
||||
({"lineage": ["VAEEncode"]}, "must be an object"),
|
||||
({"lineage": [row(strategy="passthrough")]}, "unknown strategy"),
|
||||
({"lineage": [row(slot=-1)]}, "slot"),
|
||||
({"lineage": [row(slot=True)]}, "slot"),
|
||||
({"lineage": [row(input="")]}, "input name"),
|
||||
({"lineage": [row(note="x")]}, "field"),
|
||||
({"lineage": [row(), row()]}, "duplicate"),
|
||||
({"lineage": [row(**{"class": ""})]}, "class"),
|
||||
)
|
||||
for index, (change, fragment) in enumerate(cases):
|
||||
with self.subTest(case=index):
|
||||
doc = _minimal()
|
||||
doc.update(change)
|
||||
self.assertRejected(doc, fragment)
|
||||
doc = _minimal()
|
||||
doc["lineage"].append(row(slot=1, input="latents"))
|
||||
self.assertEqual(len(self.load_doc(doc).lineage), 2)
|
||||
|
||||
def test_a_lineage_class_may_also_carry_conditioning(self):
|
||||
# InpaintModelConditioning-like: conditioning slots, a latent slot.
|
||||
doc = _minimal()
|
||||
doc["lineage"].append(
|
||||
{"class": "FluxGuidance", "slot": 1, "strategy": "continue", "input": "x"}
|
||||
)
|
||||
self.assertIn(("FluxGuidance", 1), self.load_doc(doc).lineage)
|
||||
|
||||
def test_composite_additive_must_be_a_boolean(self):
|
||||
doc = _minimal()
|
||||
doc["composites"]["ConditioningCombine"] = {
|
||||
"strategy": "combine",
|
||||
"inputs": ["conditioning_1", "conditioning_2"],
|
||||
"additive": "yes",
|
||||
}
|
||||
self.assertRejected(doc, "additive must be a boolean")
|
||||
doc["composites"]["ConditioningCombine"]["additive"] = True
|
||||
table = self.load_doc(doc)
|
||||
self.assertIs(table.composites["ConditioningCombine"]["additive"], True)
|
||||
|
||||
|
||||
class ValidationTest(_TableCase):
|
||||
def test_minimal_table_loads(self):
|
||||
table = self.load_doc(_minimal())
|
||||
@@ -501,6 +602,7 @@ class ValidationTest(_TableCase):
|
||||
{"literal", "strip-newlines", "join-multi", "concat"}
|
||||
),
|
||||
"savers": frozenset({"comfy-counter", "vhs-counter"}),
|
||||
"lineage": frozenset({"continue"}),
|
||||
},
|
||||
)
|
||||
with self.assertRaises(TypeError):
|
||||
|
||||
@@ -52,7 +52,7 @@ from utils.prompt_inspector import (
|
||||
)
|
||||
|
||||
from utils.prompt_inspector import inspect_prompt_evidence, unreadable_evidence
|
||||
from utils.prompt_pair_resolver import INSPECTION_PROFILE
|
||||
from utils.prompt_pair_resolver import ADDED_CONDITIONING_REASON, INSPECTION_PROFILE
|
||||
|
||||
try:
|
||||
from tests import inspection_parity as parity
|
||||
@@ -1293,6 +1293,33 @@ class LocalResolverParityTests(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
answers["select-preview-dropped"]["resolution"]["sampler_id"], "3"
|
||||
)
|
||||
# Pass-through lineage: the chain shapes reach every new outcome.
|
||||
self.assertEqual(
|
||||
{
|
||||
name: answers[f"select-{name}"]["resolution"]["reason"]
|
||||
for name in ("hires-fix", "flux-three-stage", "hires-fix-differs")
|
||||
},
|
||||
{
|
||||
"hires-fix": "",
|
||||
"flux-three-stage": ADDED_CONDITIONING_REASON,
|
||||
"hires-fix-differs": "Sampler stages have different prompts",
|
||||
},
|
||||
)
|
||||
|
||||
def test_local_roles_match_fuzzed_pass_through_chains(self):
|
||||
cases = {}
|
||||
for seed in range(4):
|
||||
cases.update(selection_cases.passthrough_fuzz_cases(seed, 120))
|
||||
answers = self._compare(cases)
|
||||
reasons = [answer["resolution"]["reason"] for answer in answers.values()]
|
||||
for reason in (
|
||||
"",
|
||||
"Sampler stages have different prompts",
|
||||
"A later stage adds conditioning to the base prompt",
|
||||
"Combined conditioning has different texts",
|
||||
"Multiple sampler branches",
|
||||
):
|
||||
self.assertGreater(reasons.count(reason), 5, reason)
|
||||
|
||||
def test_local_roles_match_fuzzed_selection_sets(self):
|
||||
cases = {}
|
||||
|
||||
@@ -1245,7 +1245,7 @@ class TestGuiderSamplers(unittest.TestCase):
|
||||
source["3"]["inputs"]["guider"] = [previous, 0]
|
||||
self.assertEqual(_inspect(source)["reason_code"], "depth-limit")
|
||||
|
||||
def test_custom_advanced_upstream_of_a_sampler_is_multistage(self):
|
||||
def test_custom_advanced_upstream_of_a_sampler_is_a_compared_stage(self):
|
||||
source = _guided()
|
||||
source["41"] = {
|
||||
"class_type": "KSampler",
|
||||
@@ -1256,15 +1256,18 @@ class TestGuiderSamplers(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
source["8"]["inputs"]["samples"] = ["41", 0]
|
||||
# 41 continues 3's latent and reads the same texts as 3's guider.
|
||||
for anchor in ("9", None):
|
||||
result = _inspect(source, output_node_id=anchor)
|
||||
self.assertEqual(
|
||||
(result["state"], result["sampler_id"]), ("resolved", "41")
|
||||
)
|
||||
source["41"]["inputs"]["negative"] = ["6", 0]
|
||||
result = _inspect(source, output_node_id="9")
|
||||
self.assertEqual(result["sampler_id"], "41")
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason"], "Multistage sampler ancestry is ambiguous")
|
||||
unanchored = _inspect(source)
|
||||
self.assertEqual(unanchored["state"], "ambiguous")
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(result["reason_code"], "multistage-differs")
|
||||
self.assertEqual(
|
||||
sorted(item["sampler_id"] for item in unanchored["candidates"]),
|
||||
["3", "41"],
|
||||
sorted(item["sampler_id"] for item in result["candidates"]), ["3", "41"]
|
||||
)
|
||||
|
||||
|
||||
@@ -2632,17 +2635,19 @@ class TestLatentAncestry(unittest.TestCase):
|
||||
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
|
||||
self.assertEqual(_inspect(source, output_node_id="9"), result)
|
||||
|
||||
def test_upstream_sampler_through_vaedecode_vaeencode_is_multistage(self):
|
||||
for positive in (OTHER, parity.POS):
|
||||
with self.subTest(positive=positive):
|
||||
result = _inspect(_image_to_image(positive), output_node_id="63")
|
||||
# Until PR3 compares stage texts, never one stage's prompt.
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["sampler_id"], "61")
|
||||
self.assertEqual(result["reason"], MULTISTAGE)
|
||||
self.assertIsNone(result["reason_code"])
|
||||
self.assertIsNone(result["positive"])
|
||||
self.assertIsNone(result["negative"])
|
||||
def test_upstream_sampler_through_vaedecode_vaeencode_is_compared(self):
|
||||
# The latent continues the first stage's through lineage nodes: the
|
||||
# stages are compared (spec 9.3), equal prompts resolve, others differ.
|
||||
result = _inspect(_image_to_image(parity.POS), output_node_id="63")
|
||||
self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "61"))
|
||||
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
|
||||
result = _inspect(_image_to_image(OTHER), output_node_id="63")
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(result["sampler_id"], "61")
|
||||
self.assertEqual(result["reason_code"], "multistage-differs")
|
||||
self.assertEqual(
|
||||
[item["sampler_id"] for item in result["candidates"]], ["61", "3"]
|
||||
)
|
||||
# An upstream sampler reached through a non-latent input is a stage too.
|
||||
source = _latent_from(
|
||||
{
|
||||
@@ -2959,9 +2964,15 @@ class TestLatentAncestry(unittest.TestCase):
|
||||
result = _inspect(source, output_node_id="9")
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(result["node"], {"id": "60", "class_type": "MysteryUpscaler"})
|
||||
# The listed stage is now an earlier sampler stage in the ancestry.
|
||||
# The listed stage is now an earlier sampler stage, continued through
|
||||
# VAEEncode: its prompt equals this stage's, so the chain resolves.
|
||||
source["60"]["class_type"] = "UltimateSDUpscale"
|
||||
result = _inspect(source, output_node_id="9")
|
||||
self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "3"))
|
||||
# Reached only through an unlisted image node, it stays unattributed.
|
||||
source["61"] = {"class_type": "MysteryFilter", "inputs": {"image": ["60", 0]}}
|
||||
source["5"]["inputs"]["pixels"] = ["61", 0]
|
||||
result = _inspect(source, output_node_id="9")
|
||||
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
|
||||
|
||||
def test_latent_walk_budget_exhaustion_is_work_limit(self):
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Multi-stage chains joined through image-space pass-through nodes.
|
||||
|
||||
A hires-fix or refiner run decodes sampler A's latent, filters, upscales and
|
||||
scales the image, then encodes it again for sampler B. The semantics table's
|
||||
``lineage`` section lists the nodes through which the latent or image carries
|
||||
on unchanged in kind. When B's latent descends from A only through those nodes
|
||||
and the stages share one pair, the chain resolves to it. A last stage that adds
|
||||
conditioning to the base prompt (ConditioningCombine of the base and a style
|
||||
trigger) keeps the base pair: the floor legacy linked. Everything else stays
|
||||
ambiguous or unresolved. Ingestion (the default profile) is unchanged (D1).
|
||||
"""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from utils import prompt_pair_resolver as resolver
|
||||
from utils.prompt_inspector import inspect_prompt_evidence
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
|
||||
try:
|
||||
from tests import passthrough_graphs as shapes
|
||||
except ImportError: # discovered with ``-s tests``
|
||||
import passthrough_graphs as shapes
|
||||
|
||||
|
||||
def _inspect(graph, **options):
|
||||
return resolve_prompt_pair(
|
||||
{"prompt": graph}, profile=resolver.INSPECTION_PROFILE, **options
|
||||
)
|
||||
|
||||
|
||||
def _texts(result):
|
||||
return tuple(
|
||||
result[role]["text"] if isinstance(result[role], dict) else None
|
||||
for role in ("positive", "negative")
|
||||
)
|
||||
|
||||
|
||||
class TestHiresFixChain(unittest.TestCase):
|
||||
def test_shared_pair_resolves_through_image_space_pass_through(self):
|
||||
graph = shapes.hires_fix()
|
||||
for anchor in (None, "22"):
|
||||
with self.subTest(anchor=anchor):
|
||||
result = _inspect(graph, output_node_id=anchor)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["sampler_id"], "20")
|
||||
self.assertEqual(_texts(result), (shapes.POSITIVE, shapes.NEGATIVE))
|
||||
self.assertIsNone(result["reason_code"])
|
||||
|
||||
def test_the_earlier_stage_is_judged_in_its_chain(self):
|
||||
# Sampler 10 only feeds sampler 20, so the file is 20's: one candidate.
|
||||
result = _inspect(shapes.hires_fix())
|
||||
self.assertEqual(result["candidates"], [])
|
||||
|
||||
def test_inspector_shows_the_shared_prompt(self):
|
||||
evidence = inspect_prompt_evidence({"prompt": json.dumps(shapes.hires_fix())})
|
||||
self.assertEqual(evidence["resolution"]["state"], "resolved")
|
||||
self.assertEqual(
|
||||
[evidence["prompts"][role]["text"] for role in ("positive", "negative")],
|
||||
[shapes.POSITIVE, shapes.NEGATIVE],
|
||||
)
|
||||
|
||||
def test_different_stage_prompts_are_multistage_ambiguous(self):
|
||||
result = _inspect(shapes.hires_fix(second_positive=shapes.OTHER))
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(result["reason_code"], "multistage-differs")
|
||||
self.assertEqual(
|
||||
sorted(_texts(item)[0] for item in result["candidates"]),
|
||||
sorted([shapes.POSITIVE, shapes.OTHER]),
|
||||
)
|
||||
|
||||
def test_an_unlisted_image_node_breaks_the_lineage(self):
|
||||
# The walk still finds the earlier sampler, but not through listed
|
||||
# pass-through nodes: the stages are never compared.
|
||||
result = _inspect(shapes.hires_fix(filter_class="MysteryImageEffect"))
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
later = next(
|
||||
item for item in result["candidates"] if item["sampler_id"] == "20"
|
||||
)
|
||||
self.assertEqual(later["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
|
||||
|
||||
def test_a_second_earlier_sampler_in_the_ancestry_stays_unattributed(self):
|
||||
graph = shapes.hires_fix()
|
||||
graph["30"] = shapes.ksampler(["2", 0], ["3", 0], ["4", 0])
|
||||
graph["31"] = {"class_type": "VAEDecode", "inputs": {"samples": ["30", 0]}}
|
||||
graph["32"] = {
|
||||
"class_type": "ImageBlend",
|
||||
"inputs": {"image1": ["14", 0], "image2": ["31", 0], "blend_factor": 0.5},
|
||||
}
|
||||
graph["16"]["inputs"]["image"] = ["32", 0]
|
||||
result = _inspect(graph, output_node_id="22")
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
|
||||
|
||||
def test_a_direct_latent_link_is_a_chain_too(self):
|
||||
graph = shapes.hires_fix()
|
||||
graph["20"]["inputs"]["latent_image"] = ["10", 0]
|
||||
result = _inspect(graph, output_node_id="22")
|
||||
self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "20"))
|
||||
|
||||
def test_an_unreadable_earlier_stage_blocks_the_chain(self):
|
||||
graph = shapes.hires_fix(second_positive=shapes.POSITIVE)
|
||||
graph["10"]["inputs"]["positive"] = ["6", 0]
|
||||
graph["6"] = {"class_type": "MysteryPrompt", "inputs": {"seed": 1}}
|
||||
result = _inspect(graph, output_node_id="22")
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(result["node"]["class_type"], "MysteryPrompt")
|
||||
|
||||
def test_generated_text_stays_untraceable(self):
|
||||
result = _inspect(shapes.generated_text_chain())
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(result["node"]["class_type"], "TextGenerate")
|
||||
|
||||
def test_ingestion_is_unchanged(self):
|
||||
# D1: the default profile keeps its historical latent rule.
|
||||
result = resolve_prompt_pair({"prompt": shapes.hires_fix()})
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(
|
||||
{item["reason"] for item in result["candidates"]},
|
||||
{"Unsupported latent ancestry"},
|
||||
)
|
||||
|
||||
|
||||
class TestFluxThreeStage(unittest.TestCase):
|
||||
def test_added_style_conditioning_keeps_the_base_pair(self):
|
||||
graph = shapes.flux_three_stage()
|
||||
for anchor in (None, "61"):
|
||||
with self.subTest(anchor=anchor):
|
||||
result = _inspect(graph, output_node_id=anchor)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result), (shapes.POSITIVE, ""))
|
||||
self.assertEqual(result["reason"], resolver.ADDED_CONDITIONING_REASON)
|
||||
self.assertEqual(result["sampler_id"], "50")
|
||||
# D126: the positive says a later stage added conditioning.
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["text_source"],
|
||||
resolver.ADDED_CONDITIONING_SOURCE,
|
||||
)
|
||||
self.assertEqual(
|
||||
result["negative"]["provenance"]["text_source"],
|
||||
"zeroed-conditioning",
|
||||
)
|
||||
|
||||
def test_any_added_conditioning_is_labelled_never_measured(self):
|
||||
# Ruling I2: a whole other prompt combined in is labelled the same way;
|
||||
# no length or "short trigger" heuristic.
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["31"] = shapes.encode(shapes.OTHER)
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result)[0], shapes.POSITIVE)
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["text_source"],
|
||||
resolver.ADDED_CONDITIONING_SOURCE,
|
||||
)
|
||||
|
||||
def test_trigger_words_and_added_conditioning_carry_both_labels(self):
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["7"] = {
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"inputs": {"model": ["1", 0], "clip": ["2", 0], "text": "x"},
|
||||
}
|
||||
graph["8"] = shapes.trigger_toggle()
|
||||
graph["30"]["inputs"]["prepend_text"] = ["8", 0]
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["text_source"],
|
||||
resolver.RUNTIME_AND_ADDED_SOURCE,
|
||||
)
|
||||
|
||||
def test_a_custom_advanced_last_stage_keeps_its_failure(self):
|
||||
# Review M2: a guider's combined positive is not floored.
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["60"] = {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {"guider": ["63", 0], "latent_image": ["66", 0]},
|
||||
}
|
||||
graph["63"] = {
|
||||
"class_type": "CFGGuider",
|
||||
"inputs": {"positive": ["35", 0], "negative": ["33", 0]},
|
||||
}
|
||||
graph["66"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["51", 0]}}
|
||||
graph["67"] = {"class_type": "VAEDecode", "inputs": {"samples": ["60", 0]}}
|
||||
graph["61"]["inputs"]["images"] = ["67", 0]
|
||||
result = _inspect(graph, output_node_id="61")
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason_code"]), ("unresolved", "composite")
|
||||
)
|
||||
|
||||
def test_concat_also_adds_to_the_base_prompt(self):
|
||||
result = _inspect(shapes.flux_three_stage(composite="ConditioningConcat"))
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result), (shapes.POSITIVE, ""))
|
||||
|
||||
def test_an_average_blends_the_base_away(self):
|
||||
result = _inspect(shapes.flux_three_stage(composite="ConditioningAverage"))
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason_code"]), ("unresolved", "composite")
|
||||
)
|
||||
|
||||
def test_a_combine_without_the_base_prompt_is_not_the_base_pair(self):
|
||||
graph = shapes.flux_three_stage(combined=(("37", 0), ("31", 0)))
|
||||
graph["37"] = shapes.encode(shapes.OTHER)
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason_code"]), ("unresolved", "composite")
|
||||
)
|
||||
|
||||
def test_a_different_last_negative_is_not_the_base_pair(self):
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["38"] = shapes.encode("watermark")
|
||||
graph["60"]["inputs"]["negative"] = ["38", 0]
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason_code"]), ("unresolved", "composite")
|
||||
)
|
||||
|
||||
def test_different_base_stages_are_never_floored(self):
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["39"] = shapes.encode(shapes.OTHER)
|
||||
graph["40"]["inputs"]["positive"] = ["39", 0]
|
||||
result = _inspect(graph)
|
||||
self.assertNotEqual(result["state"], "resolved")
|
||||
|
||||
def test_a_rerouted_combine_is_followed(self):
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["39"] = {"class_type": "Reroute", "inputs": {"input": ["35", 0]}}
|
||||
graph["60"]["inputs"]["positive"] = ["39", 0]
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["reason"], resolver.ADDED_CONDITIONING_REASON)
|
||||
|
||||
def test_an_unreadable_last_negative_or_encoder_parts_keep_the_failure(self):
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["38"] = {"class_type": "MysteryNegative", "inputs": {"seed": 1}}
|
||||
graph["60"]["inputs"]["negative"] = ["38", 0]
|
||||
self.assertEqual(_inspect(graph)["reason_code"], "composite")
|
||||
# Differing encoder parts are a composite too, but not an added stage.
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["39"] = {
|
||||
"class_type": "CLIPTextEncodeSDXL",
|
||||
"inputs": {"text_g": shapes.POSITIVE, "text_l": shapes.STYLE},
|
||||
}
|
||||
graph["60"]["inputs"]["positive"] = ["39", 0]
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason_code"]), ("unresolved", "composite")
|
||||
)
|
||||
self.assertEqual(result["node"]["class_type"], "CLIPTextEncodeSDXL")
|
||||
|
||||
def test_inspector_shows_the_base_prompt(self):
|
||||
evidence = inspect_prompt_evidence(
|
||||
{"prompt": json.dumps(shapes.flux_three_stage())}
|
||||
)
|
||||
self.assertEqual(evidence["resolution"]["state"], "resolved")
|
||||
self.assertEqual(evidence["prompts"]["positive"]["text"], shapes.POSITIVE)
|
||||
|
||||
|
||||
class TestRunTimePromptManagerParts(unittest.TestCase):
|
||||
"""Maintainer option A: a run-time prepend or append is left out, labelled."""
|
||||
|
||||
def test_trigger_words_prepend_resolves_to_the_prompt_manager_text(self):
|
||||
for anchor in (None, "22"):
|
||||
with self.subTest(anchor=anchor):
|
||||
result = _inspect(
|
||||
shapes.hires_fix_with_trigger_words(), output_node_id=anchor
|
||||
)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result), (shapes.POSITIVE, shapes.NEGATIVE))
|
||||
self.assertEqual(result["reason"], resolver.RUNTIME_PARTS_REASON)
|
||||
provenance = result["positive"]["provenance"]
|
||||
self.assertEqual(
|
||||
provenance["text_source"], resolver.RUNTIME_PARTS_SOURCE
|
||||
)
|
||||
self.assertEqual(result["positive"]["storage_text"], shapes.POSITIVE)
|
||||
# The negative is untouched by the positive's run-time part.
|
||||
self.assertEqual(
|
||||
result["negative"]["provenance"]["text_source"], "graph-inferred"
|
||||
)
|
||||
|
||||
def test_append_and_a_single_sampler_are_labelled_too(self):
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["2"]["inputs"]["prepend_text"] = "masterpiece"
|
||||
graph["2"]["inputs"]["append_text"] = ["8", 0]
|
||||
graph["20"]["inputs"]["latent_image"] = ["4", 0]
|
||||
result = _inspect(graph, output_node_id="22")
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
# The literal prepend is still combined exactly as before.
|
||||
self.assertEqual(_texts(result)[0], "masterpiece " + shapes.POSITIVE)
|
||||
self.assertEqual(result["reason"], resolver.RUNTIME_PARTS_REASON)
|
||||
|
||||
def test_the_inspector_shows_the_prompt_and_its_source(self):
|
||||
evidence = inspect_prompt_evidence(
|
||||
{"prompt": json.dumps(shapes.hires_fix_with_trigger_words())}
|
||||
)
|
||||
self.assertEqual(evidence["resolution"]["state"], "resolved")
|
||||
self.assertEqual(
|
||||
evidence["prompts"]["positive"],
|
||||
{
|
||||
"status": "resolved",
|
||||
"text": shapes.POSITIVE,
|
||||
"source": resolver.RUNTIME_PARTS_SOURCE,
|
||||
},
|
||||
)
|
||||
|
||||
def test_literal_and_traceable_parts_are_combined_as_before(self):
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["8"] = {"class_type": "PrimitiveString", "inputs": {"value": "trig9word"}}
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(_texts(result)[0], "trig9word " + shapes.POSITIVE)
|
||||
self.assertEqual(result["reason"], "")
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["text_source"], "graph-inferred"
|
||||
)
|
||||
|
||||
def test_only_a_run_time_feeder_is_left_out(self):
|
||||
# An unknown feeder stays unsupported, naming it.
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["8"]["class_type"] = "MysteryWords"
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(result["node"]["class_type"], "MysteryWords")
|
||||
# A run-time node deeper inside the part makes the whole part run time,
|
||||
# but it is not a direct feeder: never leave out a traced literal.
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["9"] = {
|
||||
"class_type": "StringConcatenate",
|
||||
"inputs": {"string_a": "kept", "string_b": ["8", 0], "delimiter": ""},
|
||||
}
|
||||
graph["2"]["inputs"]["prepend_text"] = ["9", 0]
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["reason_code"], "runtime-value")
|
||||
|
||||
def test_the_main_text_from_a_run_time_node_is_never_left_out(self):
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["2"]["inputs"]["prepend_text"] = ""
|
||||
graph["2"]["inputs"]["text"] = ["8", 0]
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["reason_code"], "runtime-value")
|
||||
|
||||
def test_the_label_survives_nodes_between_the_prompt_manager_and_sampler(self):
|
||||
# Review C1: encoders, chained PromptManagers and concatenations.
|
||||
expected = {
|
||||
"text-encode": shapes.POSITIVE,
|
||||
"chained": shapes.POSITIVE,
|
||||
"concat": shapes.POSITIVE + ", extra",
|
||||
}
|
||||
for shape, text in expected.items():
|
||||
with self.subTest(shape=shape):
|
||||
result = _inspect(shapes.trigger_words_through(shape))
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result)[0], text)
|
||||
self.assertEqual(result["reason"], resolver.RUNTIME_PARTS_REASON)
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["text_source"],
|
||||
resolver.RUNTIME_PARTS_SOURCE,
|
||||
)
|
||||
|
||||
def test_a_partial_prompt_never_equals_a_complete_one(self):
|
||||
# Review C2: never collapse a branch that left out trigger words with
|
||||
# one that did not, nor compare such stages as equal.
|
||||
result = _inspect(shapes.branches_with_and_without_trigger_words())
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason"]),
|
||||
("ambiguous", "Multiple sampler branches"),
|
||||
)
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["9"] = shapes.prompt_manager(shapes.POSITIVE)
|
||||
graph["20"]["inputs"]["positive"] = ["9", 0]
|
||||
result = _inspect(graph, output_node_id="22")
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason_code"]),
|
||||
("ambiguous", "multistage-differs"),
|
||||
)
|
||||
# Two PromptManagers leaving out two different toggles differ too.
|
||||
graph["8b"] = shapes.trigger_toggle("otherword")
|
||||
graph["9"]["inputs"]["prepend_text"] = ["8b", 0]
|
||||
result = _inspect(graph, output_node_id="22")
|
||||
self.assertEqual(result["reason_code"], "multistage-differs")
|
||||
|
||||
def test_only_listed_run_time_classes_are_left_out(self):
|
||||
# Review M1: a JoinStringMulti with a linked list flag is run time
|
||||
# too, but it is not a listed run-time node: never left out.
|
||||
graph = shapes.hires_fix()
|
||||
graph["9"] = {
|
||||
"class_type": "JoinStringMulti",
|
||||
"inputs": {
|
||||
"inputcount": 2,
|
||||
"delimiter": " ",
|
||||
"return_list": ["98", 0],
|
||||
"string_1": "static words",
|
||||
"string_2": "more",
|
||||
},
|
||||
}
|
||||
graph["98"] = {"class_type": "PrimitiveBoolean", "inputs": {"value": False}}
|
||||
graph["2"]["inputs"]["prepend_text"] = ["9", 0]
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason_code"]), ("unresolved", "runtime-value")
|
||||
)
|
||||
self.assertEqual(result["node"]["class_type"], "JoinStringMulti")
|
||||
|
||||
def test_ingestion_is_unchanged(self):
|
||||
result = resolve_prompt_pair({"prompt": shapes.hires_fix_with_trigger_words()})
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
|
||||
|
||||
class TestStillAmbiguous(unittest.TestCase):
|
||||
def test_edit_batch_with_distinct_prompts_stays_ambiguous(self):
|
||||
result = _inspect(shapes.edit_batch(4))
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(result["reason"], "Multiple sampler branches")
|
||||
self.assertEqual(len({_texts(item)[0] for item in result["candidates"]}), 4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+95
-3
@@ -6,19 +6,28 @@ the inspection projection (``project_graph``: strict JSON, the depth and node
|
||||
bounds, big integers and decimals as text), and only graphs it parsed, through
|
||||
the shared parser (``extract_generation_params``, the Python twin of the
|
||||
browser parser the inspector fills from since #251). No separate seed
|
||||
heuristic, no graph resolution and no extra file reads. One parse feeds both
|
||||
the seed and the positive that confirms a first-line card title.
|
||||
heuristic and no extra file reads. One parse feeds both the seed and the
|
||||
positive that confirms a first-line card title. One exception keeps the card
|
||||
from ever showing a seed the inspector does not: when sampler stages read
|
||||
different seed inputs and the inspector's resolution names one of them for a
|
||||
resolved pair (whose facts are that stage's), the card shows no seed when the
|
||||
named stage's seed differs from the parser's. Only such graphs are resolved,
|
||||
with the inspector's own profile.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
|
||||
from .generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
_is_sampler,
|
||||
extract_generation_params,
|
||||
)
|
||||
from .inspection_response import project_graph
|
||||
from .inspection_semantics import SemanticsError, get_semantics
|
||||
from .prompt_pair_resolver import INSPECTION_PROFILE, resolve_prompt_pair
|
||||
|
||||
__all__ = [
|
||||
"MAX_RECORD_BYTES",
|
||||
@@ -73,10 +82,93 @@ def header_params(info) -> dict | None:
|
||||
if not graphs:
|
||||
return None
|
||||
try:
|
||||
return extract_generation_params(graphs)
|
||||
params = extract_generation_params(graphs)
|
||||
except (GenerationParamsError, RecursionError):
|
||||
# Defence in depth: a parser failure gives no card facts, never a 500.
|
||||
return None
|
||||
if isinstance(params, dict) and _inspector_names_another_seed(
|
||||
graphs, card_seed(params)
|
||||
):
|
||||
return {**params, "seed": None}
|
||||
return params
|
||||
|
||||
|
||||
_SEED_INPUTS = ("seed", "noise_seed", "noise")
|
||||
|
||||
|
||||
def _stage_seed(graph: Mapping, node_id: str) -> str | None:
|
||||
"""The seed the parser reads for one sampler node, or None when unknown.
|
||||
|
||||
A literal seed input is that seed. A linked one is read by the parser
|
||||
itself when it treats the node as a sampler, with every other such node
|
||||
hidden from it (no ``positive`` input), so linked seeds resolve as they do
|
||||
on the card; any other linked seed is unknown.
|
||||
"""
|
||||
node = graph.get(node_id)
|
||||
if not isinstance(node, Mapping) or not isinstance(node.get("inputs"), Mapping):
|
||||
return None
|
||||
inputs = node["inputs"]
|
||||
literal = next((inputs[k] for k in _SEED_INPUTS if k in inputs), None)
|
||||
if not isinstance(literal, (list, tuple)):
|
||||
# A literal needs no second parse (the cost is measured in TESTING.md).
|
||||
return card_seed({"seed": literal})
|
||||
if _is_sampler(node):
|
||||
only = {
|
||||
key: (
|
||||
{**other, "inputs": {**other["inputs"], "positive": None}}
|
||||
if key != node_id and _is_sampler(other)
|
||||
else other
|
||||
)
|
||||
for key, other in graph.items()
|
||||
}
|
||||
try:
|
||||
return card_seed(extract_generation_params({"prompt": only}))
|
||||
except (GenerationParamsError, RecursionError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _inspector_names_another_seed(graphs: dict, parsed: str | None) -> bool:
|
||||
"""Whether the inspector shows another stage's seed than the parser's.
|
||||
|
||||
True when the prompt graph's sampler stages read different seed inputs, the
|
||||
inspection resolution names one sampler for a resolved pair (the
|
||||
inspector's facts are then that stage's, D19), and that stage's seed is
|
||||
known and differs from ``parsed``. A seed that agrees is kept.
|
||||
"""
|
||||
if parsed is None:
|
||||
return False
|
||||
graph = graphs.get("prompt")
|
||||
if not isinstance(graph, Mapping):
|
||||
return False
|
||||
try:
|
||||
samplers = get_semantics().samplers
|
||||
except SemanticsError:
|
||||
return False
|
||||
seeds = set()
|
||||
for node in graph.values():
|
||||
if not isinstance(node, Mapping) or not isinstance(node.get("inputs"), Mapping):
|
||||
continue
|
||||
if not isinstance(node.get("class_type"), str):
|
||||
continue
|
||||
if node["class_type"] not in samplers:
|
||||
continue
|
||||
inputs = node["inputs"]
|
||||
value = next((inputs[key] for key in _SEED_INPUTS if key in inputs), None)
|
||||
seeds.add(json.dumps(value, sort_keys=True, default=str))
|
||||
if len(seeds) < 2:
|
||||
return False
|
||||
try:
|
||||
resolution = resolve_prompt_pair(
|
||||
{"prompt": dict(graph)}, profile=INSPECTION_PROFILE
|
||||
)
|
||||
except RecursionError:
|
||||
return True
|
||||
named = resolution["sampler_id"]
|
||||
if resolution["state"] != "resolved" or named is None:
|
||||
return False
|
||||
seed = _stage_seed(graph, named)
|
||||
return seed is not None and seed != parsed
|
||||
|
||||
|
||||
def card_seed(params: dict | None) -> str | None:
|
||||
|
||||
@@ -12,8 +12,10 @@ valid result. Importing this module reads nothing, and the default (ingestion)
|
||||
profile never imports it, so a missing or bad table can never break ingestion.
|
||||
An invalid table raises ``SemanticsError`` so a bad edit fails loudly instead of
|
||||
silently changing what the inspector resolves. The file is bounded in size and
|
||||
nesting before it is validated. Every class appears in exactly one category;
|
||||
classes that are not listed stay ``unsupported-node``.
|
||||
nesting before it is validated. Every class appears in exactly one prompt
|
||||
category; classes that are not listed stay ``unsupported-node``. ``lineage``
|
||||
(image and latent pass-through outputs for multi-stage chains) describes no
|
||||
prompt, so its classes are not claimed by a category.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -44,6 +46,7 @@ STRATEGIES: Mapping[str, FrozenSet[str]] = MappingProxyType(
|
||||
"encoders": frozenset({"single", "parts-equal", "pm-compose", "flag-parts"}),
|
||||
"strings": frozenset({"literal", "strip-newlines", "join-multi", "concat"}),
|
||||
"savers": frozenset({"comfy-counter", "vhs-counter"}),
|
||||
"lineage": frozenset({"continue"}),
|
||||
}
|
||||
)
|
||||
# Shapes of the facts each sampler class provides (consumed by comfy-metadata.js).
|
||||
@@ -72,7 +75,7 @@ _CATEGORIES = frozenset(
|
||||
+ _CLASS_CATEGORIES
|
||||
+ _SLOT_CATEGORIES
|
||||
+ _SET_CATEGORIES
|
||||
+ ("stage_inputs", "facts")
|
||||
+ ("lineage", "stage_inputs", "facts")
|
||||
)
|
||||
# Input names that make an unlisted node a possible sampler stage in an ancestry
|
||||
# walk (``shaped``: pipes, video embeds and custom-sampler parts carry a prompt
|
||||
@@ -111,6 +114,7 @@ class Semantics:
|
||||
runtime: FrozenSet[str]
|
||||
savers: Mapping[str, Mapping[str, Any]]
|
||||
non_file_outputs: FrozenSet[str]
|
||||
lineage: Mapping[Tuple[str, int], Mapping[str, Any]]
|
||||
stage_inputs: Mapping[str, Tuple[str, ...]]
|
||||
facts: Mapping[str, Any]
|
||||
all_classes: FrozenSet[str]
|
||||
@@ -225,8 +229,12 @@ def _check_selector(entry: dict, strategy: str, where: str) -> None:
|
||||
|
||||
|
||||
def _check_composite(entry: dict, strategy: str, where: str) -> None:
|
||||
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset())
|
||||
# ``additive``: every part stays in effect (combine, concat), so a later
|
||||
# stage that adds parts to an earlier stage's prompt keeps that prompt.
|
||||
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset({"additive"}))
|
||||
_names(entry["inputs"], where, minimum=2)
|
||||
if "additive" in entry and not isinstance(entry["additive"], bool):
|
||||
raise SemanticsError(f"{where}: additive must be a boolean")
|
||||
|
||||
|
||||
def _check_saver(entry: dict, strategy: str, where: str) -> None:
|
||||
@@ -356,6 +364,35 @@ def _slot_category(doc: dict, category: str, owners: _Owners) -> Mapping:
|
||||
return MappingProxyType(result)
|
||||
|
||||
|
||||
def _lineage(doc: dict) -> Mapping:
|
||||
"""``(class, slot)`` outputs whose image or latent continues one input's.
|
||||
|
||||
These describe an output's image or latent, never its prompt, so they are
|
||||
not prompt categories: a class listed here is not claimed, and stays
|
||||
unsupported (or listed elsewhere) for text. Each output is listed once.
|
||||
"""
|
||||
rows = doc["lineage"]
|
||||
if not isinstance(rows, list):
|
||||
raise SemanticsError("category lineage must be a list")
|
||||
result = {}
|
||||
for index, row in enumerate(rows):
|
||||
where = f"lineage[{index}]"
|
||||
if not isinstance(row, dict):
|
||||
raise SemanticsError(f"{where}: entry must be an object")
|
||||
name = _name(row.get("class"), where, "class")
|
||||
slot = _slot(row, where)
|
||||
entry = {
|
||||
key: value for key, value in row.items() if key not in ("class", "slot")
|
||||
}
|
||||
_strategy(entry, "lineage", where)
|
||||
_fields(entry, where, frozenset({"strategy", "input"}), frozenset())
|
||||
_name(entry["input"], where)
|
||||
if (name, slot) in result:
|
||||
raise SemanticsError(f"lineage: duplicate output key {(name, slot)!r}")
|
||||
result[(name, slot)] = _freeze(entry)
|
||||
return MappingProxyType(result)
|
||||
|
||||
|
||||
def _set_category(doc: dict, category: str, owners: _Owners) -> FrozenSet[str]:
|
||||
rows = doc[category]
|
||||
if not isinstance(rows, list):
|
||||
@@ -452,6 +489,7 @@ def _validate(doc: Any) -> Semantics:
|
||||
for category in _SET_CATEGORIES:
|
||||
tables[category] = _set_category(doc, category, owners)
|
||||
return Semantics(
|
||||
lineage=_lineage(doc),
|
||||
stage_inputs=_stage_inputs(doc),
|
||||
facts=_facts(doc),
|
||||
all_classes=owners.classes(),
|
||||
|
||||
+282
-23
@@ -191,6 +191,18 @@ EMPTY_POSITIVE_REASON = "Positive prompt is empty"
|
||||
# Several sampler branches, every one resolved to the same pair (#249).
|
||||
IDENTICAL_BRANCHES_REASON = "Every sampler branch uses the same prompt pair"
|
||||
MULTISTAGE_UNATTRIBUTED_REASON = "Multistage sampler ancestry is ambiguous"
|
||||
# A later stage's positive combines the earlier stages' shared prompt with more
|
||||
# conditioning (a style trigger): that shared pair is the run's prompt (floor).
|
||||
ADDED_CONDITIONING_REASON = "A later stage adds conditioning to the base prompt"
|
||||
# Maintainer option A (2026-10-23): a PromptManager prepend or append fed
|
||||
# directly by a run-time node (LoRA trigger words) is left out, never guessed;
|
||||
# the text is the node's own, labelled by this source and reason (D125). A side
|
||||
# is labelled when any node on its traced path left a part out.
|
||||
RUNTIME_PARTS_SOURCE = "runtime-parts-omitted"
|
||||
RUNTIME_PARTS_REASON = "Text added at run time, such as trigger words, is not included"
|
||||
# The added-conditioning floor's positive (D126), and both labels at once.
|
||||
ADDED_CONDITIONING_SOURCE = "added-conditioning"
|
||||
RUNTIME_AND_ADDED_SOURCE = "runtime-parts-omitted-and-added-conditioning"
|
||||
_INCOMPATIBLE_SLOT = "Output slot has an incompatible type"
|
||||
# Before 3.2.3 a save patch overwrote every PromptManager node's saved ``text``
|
||||
# with the last-run node's prompt (comfy-metadata.js ``createContext``).
|
||||
@@ -310,15 +322,51 @@ def _role_text(side: Any) -> Optional[str]:
|
||||
return side["text"] if isinstance(side, dict) else None
|
||||
|
||||
|
||||
def _left_out(side: Any) -> tuple:
|
||||
"""What a side's text leaves out: the PromptManager nodes that omitted a
|
||||
run-time part, and the composite whose added conditioning is not included.
|
||||
|
||||
Empty for a complete text, so a partial prompt never equals a complete one.
|
||||
"""
|
||||
provenance = side.get("provenance") if isinstance(side, dict) else None
|
||||
if not isinstance(provenance, dict):
|
||||
return ()
|
||||
return (
|
||||
tuple(provenance.get("omitted_parts", ())),
|
||||
provenance.get("added_by"),
|
||||
)
|
||||
|
||||
|
||||
def _outcome_key(result: Dict[str, Any]) -> tuple:
|
||||
"""What a reader sees of a result: state and the two role texts."""
|
||||
"""What a reader sees of a result: state, the two role texts, and what
|
||||
each text leaves out."""
|
||||
return (
|
||||
result["state"],
|
||||
_role_text(result["positive"]),
|
||||
_role_text(result["negative"]),
|
||||
_left_out(result["positive"]),
|
||||
_left_out(result["negative"]),
|
||||
)
|
||||
|
||||
|
||||
def _reroute_value(inputs: dict) -> Any:
|
||||
"""A Reroute's one input (``value``, ``input`` or ``in``), else ``None``."""
|
||||
keys = [key for key in ("value", "input", "in") if key in inputs]
|
||||
return inputs[keys[0]] if len(keys) == 1 else None
|
||||
|
||||
|
||||
def _parts_reason(*sides: dict) -> str:
|
||||
"""The run-time parts reason when a side left out a PromptManager part."""
|
||||
if any(side["provenance"]["text_source"] == RUNTIME_PARTS_SOURCE for side in sides):
|
||||
return RUNTIME_PARTS_REASON
|
||||
return ""
|
||||
|
||||
|
||||
def _combined_positive(result: Dict[str, Any]) -> bool:
|
||||
"""Whether a stage failed only because its positive combines texts."""
|
||||
return result["reason_code"] == "composite" and result["positive"] is None
|
||||
|
||||
|
||||
def _legacy_result(result: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""The default profile's wire: today's keys plus ``reason_code: None``."""
|
||||
legacy = {key: value for key, value in result.items() if key != "node"}
|
||||
@@ -374,6 +422,8 @@ class _GraphResolver:
|
||||
self.active: set = set()
|
||||
self.metadata = metadata or {}
|
||||
self.text_sources: dict = {}
|
||||
# PromptManager outputs that left out a run-time part (option A).
|
||||
self.omitting: set = set()
|
||||
# Forced runtime selector choices (inspection); a result traced through
|
||||
# one is "tainted" and never memoized, since it depends on the choice.
|
||||
self.choices: dict = {}
|
||||
@@ -523,11 +573,12 @@ class _GraphResolver:
|
||||
if adapter.compose or adapter.storage_input:
|
||||
storage_text = text
|
||||
if adapter.compose:
|
||||
before, before_path, _ = self.text(
|
||||
inputs.get("prepend_text", ""), "string", depth + 1
|
||||
omitted: list = []
|
||||
before, before_path = self.part(
|
||||
inputs.get("prepend_text", ""), depth, omitted
|
||||
)
|
||||
after, after_path, _ = self.text(
|
||||
inputs.get("append_text", ""), "string", depth + 1
|
||||
after, after_path = self.part(
|
||||
inputs.get("append_text", ""), depth, omitted
|
||||
)
|
||||
self.scan(
|
||||
len(before)
|
||||
@@ -546,6 +597,8 @@ class _GraphResolver:
|
||||
)
|
||||
)
|
||||
path = before_path + path + after_path
|
||||
if omitted:
|
||||
self.omitting.add((node_id, slot))
|
||||
elif (
|
||||
name in ("StringConcatenate", "Text Concatenate")
|
||||
and slot == 0
|
||||
@@ -615,6 +668,30 @@ class _GraphResolver:
|
||||
self.tainted = outer_tainted or self.tainted
|
||||
self.active.discard(key)
|
||||
|
||||
def part(self, value: Any, depth: int, omitted: list) -> Tuple[str, list]:
|
||||
"""A PromptManager prepend or append: traced, or left out when run time.
|
||||
|
||||
Only with the semantics table (inspection), and only when the input is
|
||||
a link to a node the table lists as ``runtime`` (such as LoraManager's
|
||||
TriggerWord Toggle), which raises ``runtime-value`` at itself: that part
|
||||
is not in the file, so it is left out and recorded in ``omitted``.
|
||||
Anything else, including a run-time source further upstream or another
|
||||
``runtime-value`` node (a JoinStringMulti list), keeps its failure.
|
||||
"""
|
||||
try:
|
||||
text, path, _ = self.text(value, "string", depth + 1)
|
||||
except _Unresolved as error:
|
||||
if (
|
||||
self.semantics is None
|
||||
or error.code != "runtime-value"
|
||||
or not _link(value)
|
||||
or self.node(str(value[0]))[0] not in self.semantics.runtime
|
||||
):
|
||||
raise
|
||||
omitted.append(str(value[0]))
|
||||
return "", []
|
||||
return text, path
|
||||
|
||||
def historical(
|
||||
self, adapter: OutputAdapter, inputs: dict, depth: int
|
||||
) -> Tuple[str, list, str]:
|
||||
@@ -985,6 +1062,15 @@ class _GraphResolver:
|
||||
if guider_id is not None:
|
||||
# The conditioning path starts below the guider the sampler reads.
|
||||
provenance["guider_id"] = guider_id
|
||||
omitted: list = []
|
||||
for step in path:
|
||||
if (step["node_id"], step["output_slot"]) in self.omitting:
|
||||
if step["node_id"] not in omitted:
|
||||
omitted.append(step["node_id"])
|
||||
if omitted:
|
||||
# Through encoders, concatenations and chained PromptManagers (D125).
|
||||
provenance["text_source"] = RUNTIME_PARTS_SOURCE
|
||||
provenance["omitted_parts"] = omitted
|
||||
return {"text": text, "storage_text": storage_text, "provenance": provenance}
|
||||
|
||||
def guider(self, value: Any) -> Tuple[str, dict, Mapping, str, int]:
|
||||
@@ -1061,19 +1147,18 @@ class _GraphResolver:
|
||||
reason_code="depth-limit",
|
||||
)
|
||||
own = self.sides(sampler_id)
|
||||
if own["state"] == "unresolved":
|
||||
return own
|
||||
name, inputs = self.node(sampler_id)
|
||||
entry = self.semantics.samplers.get(name)
|
||||
if entry is None or entry["strategy"] != _DETAILER:
|
||||
# An unresolved stage is final, unless its positive combines texts: it
|
||||
# may then add conditioning to its earlier stages' prompt (the floor).
|
||||
combined = own["state"] == "unresolved" and _combined_positive(own)
|
||||
if own["state"] == "unresolved" and not combined:
|
||||
return own
|
||||
try:
|
||||
upstream = self.upstream_stages(
|
||||
sampler_id, inputs.get(entry["image_stage"])
|
||||
)
|
||||
upstream = self.earlier_stages(sampler_id)
|
||||
if any(node_id in later for node_id in upstream):
|
||||
raise _Unresolved("Cyclic sampler stage ancestry", "malformed")
|
||||
except _Unresolved as error:
|
||||
if combined:
|
||||
return own
|
||||
return _result(
|
||||
"unresolved",
|
||||
sampler_id=sampler_id,
|
||||
@@ -1086,6 +1171,10 @@ class _GraphResolver:
|
||||
later = later | {sampler_id}
|
||||
stages = [self.stage(node_id, later) for node_id in upstream]
|
||||
blocked = next((item for item in stages if item["state"] == "unresolved"), None)
|
||||
if combined:
|
||||
if blocked is not None:
|
||||
return own
|
||||
return self.added_conditioning(sampler_id, stages) or own
|
||||
if blocked is not None:
|
||||
# A stage that cannot be read cannot be compared: never pick one.
|
||||
return _result(
|
||||
@@ -1108,6 +1197,128 @@ class _GraphResolver:
|
||||
reason_code="multistage-differs",
|
||||
)
|
||||
|
||||
def earlier_stages(self, sampler_id: str) -> list:
|
||||
"""The stages a sampler continues: a detailer's image ancestry, or the
|
||||
one sampler its latent descends from through listed lineage nodes.
|
||||
|
||||
The latent walk already ran in ``sides`` (its outcome is cached); a
|
||||
sampler without a latent input, or whose walk found no earlier sampler,
|
||||
has none.
|
||||
"""
|
||||
name, inputs = self.node(sampler_id)
|
||||
entry = self.semantics.samplers[name]
|
||||
if entry["strategy"] == _DETAILER:
|
||||
return self.upstream_stages(sampler_id, inputs.get(entry["image_stage"]))
|
||||
walked = self.latent_walks.get(sampler_id)
|
||||
if walked is None or walked[2] is None:
|
||||
return []
|
||||
return [walked[2]]
|
||||
|
||||
def lineage_predecessor(self, value: Any) -> Optional[str]:
|
||||
"""The sampler whose output a latent input continues, or ``None``.
|
||||
|
||||
Follows only the table's ``lineage`` outputs (decode, image filter,
|
||||
upscale, scale, encode) and Reroutes, from the input back to a listed
|
||||
sampler's output slot 0. Any other node ends the lineage: its output is
|
||||
not known to continue one image or latent. At most ``max_depth`` hops;
|
||||
each node is read through ``node`` (one scan charge per node).
|
||||
"""
|
||||
seen: set = set()
|
||||
while _link(value) and len(seen) < self.max_depth:
|
||||
node_id, slot = str(value[0]), value[1]
|
||||
if node_id in seen:
|
||||
return None
|
||||
seen.add(node_id)
|
||||
name, inputs = self.node(node_id)
|
||||
if name in self.stage_classes:
|
||||
return node_id if slot == 0 else None
|
||||
if name == "Reroute" and slot == 0:
|
||||
value = _reroute_value(inputs)
|
||||
continue
|
||||
entry = self.semantics.lineage.get((name, slot))
|
||||
if entry is None:
|
||||
return None
|
||||
value = inputs.get(entry["input"])
|
||||
return None
|
||||
|
||||
def added_conditioning(self, sampler_id: str, stages: list) -> Optional[dict]:
|
||||
"""The earlier stages' shared pair when this stage only adds to it.
|
||||
|
||||
This stage's positive reaches (through Reroutes and conditioning
|
||||
pass-throughs) an ``additive`` composite, one of whose parts is exactly
|
||||
the earlier stages' positive text, and its negative is exactly theirs:
|
||||
every stage is conditioned on that prompt, the last one with more. Any
|
||||
other shape, or a part that cannot be traced, is ``None``.
|
||||
"""
|
||||
flat: list = []
|
||||
for item in stages:
|
||||
flat.extend(item["candidates"] if item["state"] == "ambiguous" else [item])
|
||||
base = flat[0]
|
||||
if base["state"] != "resolved" or len({_outcome_key(i) for i in flat}) != 1:
|
||||
return None
|
||||
name, inputs = self.node(sampler_id)
|
||||
entry = self.semantics.samplers[name]
|
||||
if entry["strategy"] == "custom-advanced":
|
||||
return None
|
||||
conditioning = entry["conditioning"]
|
||||
try:
|
||||
found = self.composite_of(inputs.get(conditioning["positive"]))
|
||||
if found is None:
|
||||
return None
|
||||
parts, depth, composite = found
|
||||
texts = [self.text(part, "conditioning", depth)[0] for part in parts]
|
||||
if base["positive"]["text"] not in texts:
|
||||
return None
|
||||
if conditioning["negative"] not in inputs:
|
||||
return None
|
||||
negative = self.side(inputs[conditioning["negative"]], sampler_id)
|
||||
except _Unresolved:
|
||||
return None
|
||||
if negative["text"] != base["negative"]["text"]:
|
||||
return None
|
||||
# D126: the positive does not include what the later stage added.
|
||||
provenance = dict(base["positive"]["provenance"])
|
||||
provenance["text_source"] = (
|
||||
RUNTIME_AND_ADDED_SOURCE
|
||||
if provenance["text_source"] == RUNTIME_PARTS_SOURCE
|
||||
else ADDED_CONDITIONING_SOURCE
|
||||
)
|
||||
provenance["added_by"] = composite
|
||||
positive = {**base["positive"], "provenance": provenance}
|
||||
return _result(
|
||||
"resolved",
|
||||
positive,
|
||||
base["negative"],
|
||||
base["sampler_id"],
|
||||
ADDED_CONDITIONING_REASON,
|
||||
)
|
||||
|
||||
def composite_of(self, value: Any) -> Optional[Tuple[list, int]]:
|
||||
"""An ``additive`` composite a conditioning input reaches, or ``None``.
|
||||
|
||||
Reroutes and conditioning pass-throughs (FluxGuidance and the like) are
|
||||
followed; returns the composite's part values, the depth reached and
|
||||
its node ID. ``text`` follows a composite only at output slot 0, so a
|
||||
stage whose positive failed as ``composite`` reached it there.
|
||||
"""
|
||||
depth = 0
|
||||
while _link(value) and depth < self.max_depth:
|
||||
node_id, slot = str(value[0]), value[1]
|
||||
name, inputs = self.node(node_id)
|
||||
depth += 1
|
||||
if name == "Reroute" and slot == 0:
|
||||
value = _reroute_value(inputs)
|
||||
continue
|
||||
forward = self.semantics.conditioning.get((name, slot))
|
||||
if forward is not None and forward["strategy"] == "passthrough":
|
||||
value = inputs.get(forward["input"])
|
||||
continue
|
||||
composite = self.semantics.composites.get(name)
|
||||
if composite is None or composite.get("additive") is not True:
|
||||
return None
|
||||
return [inputs.get(part) for part in composite["inputs"]], depth, node_id
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def walk_from(value: list) -> Tuple[deque, set]:
|
||||
"""A breadth-first queue holding one start link, and its queued IDs."""
|
||||
@@ -1183,7 +1394,10 @@ class _GraphResolver:
|
||||
if self.semantics.samplers[self.graph[node_id]["class_type"]]["strategy"]
|
||||
== _DETAILER
|
||||
]
|
||||
if not detailers:
|
||||
# A sampler whose latent continues another's (hires-fix, refiner) is
|
||||
# judged with it in ``stage``, like a detailer's upstream samplers.
|
||||
continued = self.continued_stages(sampler_ids) if len(sampler_ids) > 1 else []
|
||||
if not detailers and not continued:
|
||||
return sampler_ids
|
||||
try:
|
||||
consumers = self.consumers()
|
||||
@@ -1200,9 +1414,36 @@ class _GraphResolver:
|
||||
absorbed.add(node_id)
|
||||
except _Unresolved:
|
||||
continue
|
||||
for node_id in continued:
|
||||
self.budget.reset(self.per_candidate_visits)
|
||||
try:
|
||||
if not self.reaches_output(node_id, consumers):
|
||||
absorbed.add(node_id)
|
||||
except _Unresolved:
|
||||
continue
|
||||
kept = [node_id for node_id in sampler_ids if node_id not in absorbed]
|
||||
return kept or sampler_ids
|
||||
|
||||
def continued_stages(self, sampler_ids: list) -> list:
|
||||
"""Lineage predecessors of the non-detailer candidates, in order, once.
|
||||
|
||||
A predecessor equal to its own sampler (a cycle) is left out; a walk
|
||||
that cannot read a node names none.
|
||||
"""
|
||||
found: list = []
|
||||
for node_id in sampler_ids:
|
||||
entry = self.semantics.samplers[self.graph[node_id]["class_type"]]
|
||||
if entry["strategy"] == _DETAILER or entry.get("latent") is None:
|
||||
continue
|
||||
try:
|
||||
_, inputs = self.node(node_id)
|
||||
earlier = self.lineage_predecessor(inputs.get(entry["latent"]))
|
||||
except _Unresolved:
|
||||
continue
|
||||
if earlier is not None and earlier != node_id and earlier not in found:
|
||||
found.append(earlier)
|
||||
return found
|
||||
|
||||
def file_stages(self, sampler_ids: list) -> list:
|
||||
"""Stages not proven preview-only; all of them when every one is.
|
||||
|
||||
@@ -1381,7 +1622,13 @@ class _GraphResolver:
|
||||
reason=absent_reason,
|
||||
)
|
||||
negative = self.side(inputs[negative_name], sampler_id, guider_id, depth)
|
||||
return _result("resolved", positive, negative, sampler_id)
|
||||
return _result(
|
||||
"resolved",
|
||||
positive,
|
||||
negative,
|
||||
sampler_id,
|
||||
_parts_reason(positive, negative),
|
||||
)
|
||||
except _Unresolved as error:
|
||||
return _result(
|
||||
"unresolved",
|
||||
@@ -1432,7 +1679,7 @@ class _GraphResolver:
|
||||
return
|
||||
raise self.unsupported("Unsupported latent ancestry", node_id)
|
||||
|
||||
def latent_checked(self, sampler_id: str, value: Any) -> None:
|
||||
def latent_checked(self, sampler_id: str, value: Any) -> Optional[str]:
|
||||
"""``latent_stages`` once per sampler per resolution.
|
||||
|
||||
A candidate reusing another candidate's walk is charged its visit cost
|
||||
@@ -1441,26 +1688,29 @@ class _GraphResolver:
|
||||
out of work is never reused, since it depends on the remaining budget.
|
||||
"""
|
||||
if sampler_id in self.latent_walks:
|
||||
error, cost = self.latent_walks[sampler_id]
|
||||
error, cost, earlier = self.latent_walks[sampler_id]
|
||||
if sampler_id not in self.latent_charged:
|
||||
self.budget.charge(cost)
|
||||
self.latent_charged.add(sampler_id)
|
||||
else:
|
||||
before = self.budget.allowance
|
||||
earlier = None
|
||||
try:
|
||||
self.latent_stages(sampler_id, value)
|
||||
earlier = self.latent_stages(sampler_id, value)
|
||||
error = None
|
||||
except _Unresolved as raised:
|
||||
if raised.code == "work-limit":
|
||||
raise
|
||||
error = raised
|
||||
self.latent_walks[sampler_id] = (error, before - self.budget.allowance)
|
||||
cost = before - self.budget.allowance
|
||||
self.latent_walks[sampler_id] = (error, cost, earlier)
|
||||
self.latent_charged.add(sampler_id)
|
||||
if error is not None:
|
||||
node = dict(error.node) if error.node is not None else None
|
||||
raise _Unresolved(str(error), error.code, node)
|
||||
return earlier
|
||||
|
||||
def latent_stages(self, origin: str, value: Any) -> None:
|
||||
def latent_stages(self, origin: str, value: Any) -> Optional[str]:
|
||||
"""Semantics table: is there an earlier sampler in the latent ancestry?
|
||||
|
||||
Every link input of non-sampler nodes is walked breadth-first (encoders,
|
||||
@@ -1469,10 +1719,14 @@ class _GraphResolver:
|
||||
(VAEEncode, EmptyLatentBatch, WanImageToVideo slot 2, ...). An unlisted
|
||||
node with sampler-shaped inputs could itself be a stage, so it is
|
||||
``unsupported-node``. Budget or depth exhaustion is never single stage.
|
||||
One earlier sampler that the latent continues through ``lineage``
|
||||
nodes (``lineage_predecessor``) is returned, to be compared in
|
||||
``stage``; any other sampler found leaves the stages unattributed.
|
||||
"""
|
||||
if not _link(value):
|
||||
raise _Unresolved("Unknown latent ancestry", "malformed")
|
||||
queue, queued = self.walk_from(value)
|
||||
earlier, lineage, traced = None, None, False
|
||||
while queue:
|
||||
node_id, depth = queue.popleft()
|
||||
if node_id == origin:
|
||||
@@ -1482,12 +1736,17 @@ class _GraphResolver:
|
||||
self.budget.spend()
|
||||
name, inputs = self.node(node_id)
|
||||
if name in self.stage_classes:
|
||||
# ``multistage-differs`` is reserved for when stage texts are
|
||||
# compared (PR3); until then no stage's prompt is chosen.
|
||||
raise _Unresolved(MULTISTAGE_UNATTRIBUTED_REASON)
|
||||
if not traced:
|
||||
lineage, traced = self.lineage_predecessor(value), True
|
||||
if node_id != lineage:
|
||||
# Not the one continued stage: no stage's prompt is chosen.
|
||||
raise _Unresolved(MULTISTAGE_UNATTRIBUTED_REASON)
|
||||
earlier = node_id
|
||||
continue
|
||||
if self.stage_shaped(name, inputs):
|
||||
raise self.unsupported("Unsupported latent ancestry", node_id)
|
||||
self.enqueue(queue, queued, inputs, depth + 1)
|
||||
return earlier
|
||||
|
||||
def anchor(self, node_id: str) -> str:
|
||||
seen = set()
|
||||
|
||||
@@ -462,7 +462,7 @@
|
||||
"ControlNetApplyAdvanced", "ControlNetApplySD3", "InpaintModelConditioning", "WanImageToVideo", "ConditioningZeroOut",
|
||||
"CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeFlux", "CLIPTextEncodeSD3", "TextEncodeQwenImageEdit",
|
||||
"TextEncodeQwenImageEditPlus", "TextEncodeQwenImage21", "KikoTextEncode", "StringConstant", "StringConstantMultiline",
|
||||
"JoinStringMulti", "PromptSearchList", "PreviewImage", "PreviewAny", "SaveImageWebsocket",
|
||||
"JoinStringMulti", "PromptSearchList", "TriggerWord Toggle (LoraManager)", "PreviewImage", "PreviewAny", "SaveImageWebsocket",
|
||||
"Image Comparer (rgthree)", "DetailerForEach", "UltimateSDUpscale", "UltimateSDUpscaleNoUpscale",
|
||||
"ControlNetInpaintingAliMamaApply", "LTXVConditioning", "FluxKontextMultiReferenceLatentMethod", "Text to Conditioning",
|
||||
"TextInput", "DisplayText", "ClownsharKSampler_Beta", "KSampler (Efficient)", "ttN KSampler_v2",
|
||||
|
||||
@@ -124,8 +124,8 @@
|
||||
}
|
||||
},
|
||||
"composites": {
|
||||
"ConditioningCombine": { "strategy": "combine", "inputs": ["conditioning_1", "conditioning_2"] },
|
||||
"ConditioningConcat": { "strategy": "combine", "inputs": ["conditioning_to", "conditioning_from"] },
|
||||
"ConditioningCombine": { "strategy": "combine", "inputs": ["conditioning_1", "conditioning_2"], "additive": true },
|
||||
"ConditioningConcat": { "strategy": "combine", "inputs": ["conditioning_to", "conditioning_from"], "additive": true },
|
||||
"ConditioningAverage": { "strategy": "combine", "inputs": ["conditioning_to", "conditioning_from"] }
|
||||
},
|
||||
"encoders": [
|
||||
@@ -175,7 +175,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"runtime": ["PromptSearchList"],
|
||||
"runtime": ["PromptSearchList", "TriggerWord Toggle (LoraManager)"],
|
||||
"savers": {
|
||||
"SaveImage": {
|
||||
"strategy": "comfy-counter",
|
||||
@@ -215,6 +215,20 @@
|
||||
}
|
||||
},
|
||||
"non_file_outputs": ["PreviewImage", "PreviewAny", "SaveImageWebsocket", "Image Comparer (rgthree)"],
|
||||
"lineage": [
|
||||
{ "class": "VAEDecode", "slot": 0, "strategy": "continue", "input": "samples" },
|
||||
{ "class": "VAEDecodeTiled", "slot": 0, "strategy": "continue", "input": "samples" },
|
||||
{ "class": "VAEEncode", "slot": 0, "strategy": "continue", "input": "pixels" },
|
||||
{ "class": "VAEEncodeTiled", "slot": 0, "strategy": "continue", "input": "pixels" },
|
||||
{ "class": "ImageUpscaleWithModel", "slot": 0, "strategy": "continue", "input": "image" },
|
||||
{ "class": "ImageScale", "slot": 0, "strategy": "continue", "input": "image" },
|
||||
{ "class": "ImageScaleBy", "slot": 0, "strategy": "continue", "input": "image" },
|
||||
{ "class": "ImageScaleToTotalPixels", "slot": 0, "strategy": "continue", "input": "image" },
|
||||
{ "class": "LatentUpscale", "slot": 0, "strategy": "continue", "input": "samples" },
|
||||
{ "class": "LatentUpscaleBy", "slot": 0, "strategy": "continue", "input": "samples" },
|
||||
{ "class": "Image Filter", "slot": 0, "strategy": "continue", "input": "images" },
|
||||
{ "class": "Image Filter", "slot": 1, "strategy": "continue", "input": "latents" }
|
||||
],
|
||||
"stage_inputs": {
|
||||
"shaped": [
|
||||
"latent_image",
|
||||
|
||||
@@ -156,7 +156,10 @@ function generationFacts(record, listClass, noteClass) {
|
||||
return [facts, explanation];
|
||||
}
|
||||
// D21: the panel badge names the strongest resolved prompt source; any other source shows no badge.
|
||||
const EVIDENCE_BADGES = [['execution', 'Execution recorded'], ['graph-inferred', 'Workflow inferred'], ['embedded-parameters', 'Embedded parameters']];
|
||||
// A prompt that left out a run-time PromptManager part (D125) or a later stage's added conditioning (D126) is still
|
||||
// inferred from the workflow.
|
||||
const EVIDENCE_BADGES = [['execution', 'Execution recorded'], ['graph-inferred', 'Workflow inferred'], ['runtime-parts-omitted', 'Workflow inferred'],
|
||||
['added-conditioning', 'Workflow inferred'], ['runtime-parts-omitted-and-added-conditioning', 'Workflow inferred'], ['embedded-parameters', 'Embedded parameters']];
|
||||
function evidenceBadge(prompts) {
|
||||
const sources = new Set(Object.values(prompts).filter(role => role?.status === 'resolved').map(role => role.source));
|
||||
return EVIDENCE_BADGES.find(([source]) => sources.has(source))?.[1] ?? null;
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
// Candidate selection for local-resolver.mjs (#249 local Inspect, PR L3 Task 9): ports of
|
||||
// utils/prompt_pair_resolver.py `_GraphResolver` `stage`, `upstream_stages`, `final_stages`, `file_stages`,
|
||||
// `non_file_outputs`, `producers_of`, `consumers`, `reaches_output`, plus `_outcome_key`, `_recipe_key` and
|
||||
// `_shared_pair` (#270). Each function takes the resolver whose budgets and caches it spends, so the order of
|
||||
// work, and with it every limit, is the server's. No DOM.
|
||||
// utils/prompt_pair_resolver.py `_GraphResolver` `stage`, `earlier_stages`, `added_conditioning`, `composite_of`,
|
||||
// `upstream_stages`, `final_stages`, `continued_stages`, `file_stages`, `non_file_outputs`, `producers_of`,
|
||||
// `consumers`, `reaches_output`, `lineage_predecessor`, `latent_stages` and `part`, plus `_left_out`, `_outcome_key`,
|
||||
// `_recipe_key`, `_combined_positive` and `_shared_pair` (#270). Pass-through lineage: a sampler whose latent continues
|
||||
// another's through `lineage` nodes is compared with it. Each function takes the resolver whose budgets and caches it
|
||||
// spends, so the order of work, and with it every limit, is the server's. No DOM.
|
||||
import { isDict, pyGet, pyStr } from './py-json.mjs';
|
||||
import { DETAILER, IDENTICAL_BRANCHES_REASON, Unresolved, isLink, result } from './local-resolver-support.mjs';
|
||||
import {
|
||||
ADDED_CONDITIONING_REASON, ADDED_CONDITIONING_SOURCE, DETAILER, IDENTICAL_BRANCHES_REASON,
|
||||
MULTISTAGE_UNATTRIBUTED_REASON, RUNTIME_AND_ADDED_SOURCE, RUNTIME_PARTS_SOURCE, Unresolved, isLink, rerouteValue, result,
|
||||
slotKey,
|
||||
} from './local-resolver-support.mjs';
|
||||
|
||||
const roleText = side => side !== null && typeof side === 'object' ? side.text : null;
|
||||
const storageText = side => side !== null && typeof side === 'object' ? side.storage_text : null;
|
||||
/** `_outcome_key`: what a reader sees of a result, the state and the two role texts. */
|
||||
export const outcomeKey = item => JSON.stringify([item.state, roleText(item.positive), roleText(item.negative)]);
|
||||
/** `_left_out`: the omitting PromptManagers and the added-by composite, so a partial text never equals a complete one. */
|
||||
function leftOut(side) {
|
||||
const provenance = side !== null && typeof side === 'object' ? side.provenance : null;
|
||||
if (provenance === null || typeof provenance !== 'object') return [];
|
||||
return [provenance.omitted_parts ?? [], provenance.added_by ?? null];
|
||||
}
|
||||
/** `_outcome_key`: what a reader sees of a result, the state, the two role texts and what each leaves out. */
|
||||
const outcomeParts = item => [item.state, roleText(item.positive), roleText(item.negative), leftOut(item.positive), leftOut(item.negative)];
|
||||
export const outcomeKey = item => JSON.stringify(outcomeParts(item));
|
||||
/** `_recipe_key`: the outcome plus each side's stored recipe. */
|
||||
const recipeKey = item => JSON.stringify([item.state, roleText(item.positive), roleText(item.negative), storageText(item.positive), storageText(item.negative)]);
|
||||
const recipeKey = item => JSON.stringify([...outcomeParts(item), storageText(item.positive), storageText(item.negative)]);
|
||||
|
||||
/** `_shared_pair`: the one pair every sampler branch resolved to, or null (#270). */
|
||||
export function sharedPair(candidates) {
|
||||
@@ -21,26 +34,29 @@ export function sharedPair(candidates) {
|
||||
return result('resolved', { positive: first.positive, negative: first.negative, reason: IDENTICAL_BRANCHES_REASON, candidates });
|
||||
}
|
||||
|
||||
/** `stage`: one sampler's result; a detailer is compared with its upstream stages. */
|
||||
/** `_combined_positive`: whether a stage failed only because its positive combines texts. */
|
||||
const combinedPositive = item => item.reason_code === 'composite' && item.positive === null;
|
||||
|
||||
/** `stage`: one sampler's result, compared with the stages it continues (detailer image, latent lineage). */
|
||||
export function stage(resolver, samplerId, later) {
|
||||
if (later.size >= resolver.maxDepth) return result('unresolved', { samplerId, reason: 'Resolution depth limit reached', reasonCode: 'depth-limit' });
|
||||
const own = resolver.sides(samplerId);
|
||||
if (own.state === 'unresolved') return own;
|
||||
const [name, inputs] = resolver.node(samplerId);
|
||||
const entry = resolver.semantics.samplers.get(name);
|
||||
if (entry === undefined || entry.strategy !== DETAILER) return own;
|
||||
const combined = own.state === 'unresolved' && combinedPositive(own);
|
||||
if (own.state === 'unresolved' && !combined) return own;
|
||||
let upstream;
|
||||
try {
|
||||
upstream = upstreamStages(resolver, samplerId, pyGet(inputs, entry.image_stage));
|
||||
upstream = earlierStages(resolver, samplerId);
|
||||
if (upstream.some(nodeId => later.has(nodeId))) throw new Unresolved('Cyclic sampler stage ancestry', 'malformed');
|
||||
} catch (error) {
|
||||
if (!(error instanceof Unresolved)) throw error;
|
||||
if (combined) return own;
|
||||
return result('unresolved', { samplerId, reason: error.message, reasonCode: error.code, node: error.node });
|
||||
}
|
||||
if (!upstream.length) return own;
|
||||
const deeper = new Set([...later, samplerId]);
|
||||
const stages = upstream.map(nodeId => stage(resolver, nodeId, deeper));
|
||||
const blocked = stages.find(item => item.state === 'unresolved');
|
||||
if (combined) return blocked !== undefined ? own : addedConditioning(resolver, samplerId, stages) ?? own;
|
||||
// A stage that cannot be read cannot be compared: never pick one.
|
||||
if (blocked !== undefined) return result('unresolved', { samplerId, reason: blocked.reason, reasonCode: blocked.reason_code, node: blocked.node });
|
||||
const flat = [];
|
||||
@@ -49,6 +65,67 @@ export function stage(resolver, samplerId, later) {
|
||||
return result('ambiguous', { samplerId, reason: 'Sampler stages have different prompts', candidates: flat, reasonCode: 'multistage-differs' });
|
||||
}
|
||||
|
||||
/** `earlier_stages`: a detailer's upstream samplers, or the one sampler a latent continues (cached walk). */
|
||||
function earlierStages(resolver, samplerId) {
|
||||
const [name, inputs] = resolver.node(samplerId);
|
||||
const entry = resolver.semantics.samplers.get(name);
|
||||
if (entry.strategy === DETAILER) return upstreamStages(resolver, samplerId, pyGet(inputs, entry.image_stage));
|
||||
const walked = resolver.latentWalks.get(samplerId);
|
||||
if (walked === undefined || walked[2] === null) return [];
|
||||
return [walked[2]];
|
||||
}
|
||||
|
||||
/** `added_conditioning`: the earlier stages' shared pair when this stage's positive only adds to it. */
|
||||
function addedConditioning(resolver, samplerId, stages) {
|
||||
const flat = [];
|
||||
for (const item of stages) flat.push(...(item.state === 'ambiguous' ? item.candidates : [item]));
|
||||
const base = flat[0];
|
||||
if (base.state !== 'resolved' || new Set(flat.map(outcomeKey)).size !== 1) return null;
|
||||
const [name, inputs] = resolver.node(samplerId);
|
||||
const entry = resolver.semantics.samplers.get(name);
|
||||
if (entry.strategy === 'custom-advanced') return null;
|
||||
const conditioning = entry.conditioning;
|
||||
let negative, composite;
|
||||
try {
|
||||
const found = compositeOf(resolver, pyGet(inputs, conditioning.positive));
|
||||
if (found === null) return null;
|
||||
let parts, depth;
|
||||
[parts, depth, composite] = found;
|
||||
const texts = parts.map(part => resolver.text(part, 'conditioning', depth)[0]);
|
||||
if (!texts.includes(base.positive.text)) return null;
|
||||
if (!inputs.has(conditioning.negative)) return null;
|
||||
negative = resolver.side(inputs.get(conditioning.negative), samplerId);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Unresolved)) throw error;
|
||||
return null;
|
||||
}
|
||||
if (negative.text !== base.negative.text) return null;
|
||||
// D126: the positive does not include what the later stage added.
|
||||
const provenance = { ...base.positive.provenance };
|
||||
provenance.text_source = provenance.text_source === RUNTIME_PARTS_SOURCE ? RUNTIME_AND_ADDED_SOURCE : ADDED_CONDITIONING_SOURCE;
|
||||
provenance.added_by = composite;
|
||||
const positive = { ...base.positive, provenance };
|
||||
return result('resolved', { positive, negative: base.negative, samplerId: base.sampler_id, reason: ADDED_CONDITIONING_REASON });
|
||||
}
|
||||
|
||||
/** `composite_of`: [part values, depth, node ID] of the `additive` composite a conditioning input reaches, or null. */
|
||||
function compositeOf(resolver, value) {
|
||||
let depth = 0;
|
||||
while (isLink(value) && depth < resolver.maxDepth) {
|
||||
const nodeId = String(value[0]), slot = value[1];
|
||||
const [name, inputs] = resolver.node(nodeId);
|
||||
depth += 1;
|
||||
if (name === 'Reroute' && slot === 0) { value = rerouteValue(inputs); continue; }
|
||||
const forward = resolver.semantics.conditioning.get(slotKey(name, slot));
|
||||
if (forward !== undefined && forward.strategy === 'passthrough') { value = pyGet(inputs, forward.input); continue; }
|
||||
// `text` follows a composite only at output slot 0, so a failed `composite` positive reached it there.
|
||||
const composite = resolver.semantics.composites.get(name);
|
||||
if (composite === undefined || composite.additive !== true) return null;
|
||||
return [composite.inputs.map(part => pyGet(inputs, part)), depth, nodeId];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `upstream_stages`: samplers reached from a detailer's image input, breadth first; each node draws a visit. */
|
||||
function upstreamStages(resolver, origin, value) {
|
||||
if (!isLink(value)) throw new Unresolved('Expected an image link', 'malformed');
|
||||
@@ -72,7 +149,8 @@ const strategyOf = (resolver, nodeId) => resolver.semantics.samplers.get(resolve
|
||||
export function finalStages(resolver, samplerIds) {
|
||||
samplerIds = fileStages(resolver, samplerIds);
|
||||
const detailers = samplerIds.filter(nodeId => strategyOf(resolver, nodeId) === DETAILER);
|
||||
if (!detailers.length) return samplerIds;
|
||||
const continued = samplerIds.length > 1 ? continuedStages(resolver, samplerIds) : [];
|
||||
if (!detailers.length && !continued.length) return samplerIds;
|
||||
let reverse;
|
||||
try { reverse = consumers(resolver); } catch (error) {
|
||||
if (!(error instanceof Unresolved)) throw error;
|
||||
@@ -91,10 +169,37 @@ export function finalStages(resolver, samplerIds) {
|
||||
if (!(error instanceof Unresolved)) throw error;
|
||||
}
|
||||
}
|
||||
for (const nodeId of continued) {
|
||||
resolver.budget.reset(resolver.perCandidateVisits);
|
||||
try {
|
||||
if (!reachesOutput(resolver, nodeId, reverse)) absorbed.add(nodeId);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Unresolved)) throw error;
|
||||
}
|
||||
}
|
||||
const kept = samplerIds.filter(nodeId => !absorbed.has(nodeId));
|
||||
return kept.length ? kept : samplerIds;
|
||||
}
|
||||
|
||||
/** `continued_stages`: lineage predecessors of the non-detailer candidates, in order, once. */
|
||||
function continuedStages(resolver, samplerIds) {
|
||||
const found = [];
|
||||
for (const nodeId of samplerIds) {
|
||||
const entry = resolver.semantics.samplers.get(resolver.graph.get(nodeId).get('class_type'));
|
||||
if (entry.strategy === DETAILER || entry.latent === undefined || entry.latent === null) continue;
|
||||
let earlier;
|
||||
try {
|
||||
const [, inputs] = resolver.node(nodeId);
|
||||
earlier = lineagePredecessor(resolver, pyGet(inputs, entry.latent));
|
||||
} catch (error) {
|
||||
if (!(error instanceof Unresolved)) throw error;
|
||||
continue;
|
||||
}
|
||||
if (earlier !== null && earlier !== nodeId && !found.includes(earlier)) found.push(earlier);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** `file_stages`: stages not proven preview-only; all of them when every one is. */
|
||||
function fileStages(resolver, samplerIds) {
|
||||
if (samplerIds.length < 2) return samplerIds;
|
||||
@@ -172,3 +277,57 @@ function reachesOutput(resolver, nodeId, reverse) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** `lineage_predecessor`: the sampler whose output a latent input continues through `lineage` nodes, or null. */
|
||||
function lineagePredecessor(resolver, value) {
|
||||
const seen = new Set();
|
||||
while (isLink(value) && seen.size < resolver.maxDepth) {
|
||||
const nodeId = String(value[0]), slot = value[1];
|
||||
if (seen.has(nodeId)) return null;
|
||||
seen.add(nodeId);
|
||||
const [name, inputs] = resolver.node(nodeId);
|
||||
if (resolver.stageClasses.has(name)) return slot === 0 ? nodeId : null;
|
||||
if (name === 'Reroute' && slot === 0) { value = rerouteValue(inputs); continue; }
|
||||
const entry = resolver.semantics.lineage.get(slotKey(name, slot));
|
||||
if (entry === undefined) return null;
|
||||
value = pyGet(inputs, entry.input);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `latent_stages`: the one earlier sampler a latent continues, null for a single stage; else unattributed. */
|
||||
export function latentStages(resolver, origin, value) {
|
||||
if (!isLink(value)) throw new Unresolved('Unknown latent ancestry', 'malformed');
|
||||
const start = String(value[0]), queue = [[start, 0]], queued = new Set([start]);
|
||||
let earlier = null, lineage = null, traced = false;
|
||||
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 >= resolver.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit');
|
||||
resolver.budget.spend();
|
||||
const [name, inputs] = resolver.node(nodeId);
|
||||
if (resolver.stageClasses.has(name)) {
|
||||
if (!traced) { lineage = lineagePredecessor(resolver, value); traced = true; }
|
||||
if (nodeId !== lineage) throw new Unresolved(MULTISTAGE_UNATTRIBUTED_REASON);
|
||||
earlier = nodeId;
|
||||
continue;
|
||||
}
|
||||
if (resolver.stageShaped(name, inputs)) throw resolver.unsupported('Unsupported latent ancestry', nodeId);
|
||||
resolver.enqueue(queue, queued, inputs, depth + 1);
|
||||
}
|
||||
return earlier;
|
||||
}
|
||||
|
||||
/** `part`: a PromptManager prepend or append, traced, or left out when a listed run-time node feeds it directly. */
|
||||
export function promptPart(resolver, value, depth, omitted) {
|
||||
try {
|
||||
const [text, path] = resolver.text(value, 'string', depth + 1);
|
||||
return [text, path];
|
||||
} catch (error) {
|
||||
// A listed run-time node raises at itself, so a deeper run-time source never matches here.
|
||||
if (!(error instanceof Unresolved) || error.code !== 'runtime-value' || !isLink(value) ||
|
||||
!resolver.semantics.runtime.has(resolver.node(String(value[0]))[0])) throw error;
|
||||
omitted.push(String(value[0]));
|
||||
return ['', []];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,11 @@ export const RUNTIME_REASON = 'Text is decided at run time';
|
||||
export const EMPTY_POSITIVE_REASON = 'Positive prompt is empty';
|
||||
export const IDENTICAL_BRANCHES_REASON = 'Every sampler branch uses the same prompt pair';
|
||||
export const MULTISTAGE_UNATTRIBUTED_REASON = 'Multistage sampler ancestry is ambiguous';
|
||||
export const ADDED_CONDITIONING_REASON = 'A later stage adds conditioning to the base prompt';
|
||||
export const RUNTIME_PARTS_SOURCE = 'runtime-parts-omitted';
|
||||
export const RUNTIME_PARTS_REASON = 'Text added at run time, such as trigger words, is not included';
|
||||
export const ADDED_CONDITIONING_SOURCE = 'added-conditioning';
|
||||
export const RUNTIME_AND_ADDED_SOURCE = 'runtime-parts-omitted-and-added-conditioning';
|
||||
export const MALFORMED_EVIDENCE_REASON = 'Prompt evidence is malformed';
|
||||
export const UNREADABLE_METADATA_REASON = 'Media metadata could not be read';
|
||||
export const SEMANTICS_UNAVAILABLE_REASON = 'Inspection node semantics are unavailable';
|
||||
@@ -119,6 +124,11 @@ export function isLink(value) {
|
||||
const [source, slot] = value;
|
||||
return (typeof source === 'string' || isInt(source)) && isInt(slot) && (typeof slot === 'bigint' ? slot >= 0n : slot >= 0);
|
||||
}
|
||||
/** `_reroute_value`: a Reroute's one input (`value`, `input` or `in`), else null. */
|
||||
export function rerouteValue(inputs) {
|
||||
const keys = ['value', 'input', 'in'].filter(key => inputs.has(key));
|
||||
return keys.length === 1 ? inputs.get(keys[0]) : null;
|
||||
}
|
||||
export const nodeRef = (nodeId, classType) => ({ id: pySlice(nodeId, CLASS_NAME_LENGTH), class_type: typeof classType === 'string' ? pySlice(classType, CLASS_NAME_LENGTH) : null });
|
||||
|
||||
export function result(state, { positive = null, negative = null, samplerId = null, reason = '', candidates = [], reasonCode = null, node = null } = {}) {
|
||||
@@ -225,10 +235,20 @@ export function compileSemantics(doc) {
|
||||
const composites = byClass('composites'), savers = byClass('savers');
|
||||
const conditioning = bySlot('conditioning'), encoders = bySlot('encoders'), strings = bySlot('strings');
|
||||
const runtime = bySet('runtime'), nonFileOutputs = bySet('non_file_outputs');
|
||||
// `lineage` describes an output's image or latent, not its prompt: its classes are not claimed.
|
||||
if (!Array.isArray(doc.lineage)) fail('category lineage must be a list');
|
||||
const lineage = new Map();
|
||||
for (const row of doc.lineage) {
|
||||
if (!plain(row) || typeof row.class !== 'string' || !Number.isInteger(row.slot) || row.slot < 0 || row.slot > 255) fail('lineage row');
|
||||
if (row.strategy !== 'continue' || typeof row.input !== 'string' || !row.input) fail('lineage row');
|
||||
const key = slotKey(row.class, row.slot);
|
||||
if (lineage.has(key)) fail('lineage: duplicate output key');
|
||||
lineage.set(key, { strategy: row.strategy, input: row.input });
|
||||
}
|
||||
const stageInputs = doc.stage_inputs;
|
||||
if (!plain(stageInputs) || !Array.isArray(stageInputs.shaped) || !Array.isArray(stageInputs.with_model)) fail('stage_inputs');
|
||||
return Object.freeze({
|
||||
samplers, guiders, selectors, composites, savers, conditioning, encoders, strings, runtime, nonFileOutputs,
|
||||
samplers, guiders, selectors, composites, savers, conditioning, encoders, strings, runtime, nonFileOutputs, lineage,
|
||||
stageInputs: { shaped: [...stageInputs.shaped], withModel: [...stageInputs.with_model] }, allClasses: new Set(owners.keys()),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
MAX_JOIN_INPUTS,
|
||||
MAX_SUBGRAPH_DEPTH,
|
||||
MAX_WORKFLOW_NODES,
|
||||
MULTISTAGE_UNATTRIBUTED_REASON,
|
||||
NO_SAMPLER_NAMES,
|
||||
OVERWRITTEN_PROMPT_REASON,
|
||||
PROFILE,
|
||||
@@ -39,12 +38,14 @@ import {
|
||||
explained,
|
||||
isLink,
|
||||
nodeRef,
|
||||
RUNTIME_PARTS_REASON,
|
||||
RUNTIME_PARTS_SOURCE,
|
||||
readPairExecution,
|
||||
result,
|
||||
sideOf,
|
||||
slotKey,
|
||||
} from './local-resolver-support.mjs';
|
||||
import { finalStages, outcomeKey, sharedPair, stage } from './local-resolver-stages.mjs';
|
||||
import { finalStages, latentStages, outcomeKey, promptPart, sharedPair, stage } from './local-resolver-stages.mjs';
|
||||
import { parameters } from './local-parameters.mjs';
|
||||
|
||||
export { SemanticsError, compileSemantics, readPairExecution, outcomeMessage, isLink } from './local-resolver-support.mjs';
|
||||
@@ -69,6 +70,7 @@ class GraphResolver {
|
||||
this.active = new Set();
|
||||
this.metadata = metadata ?? new Map();
|
||||
this.textSources = new Map();
|
||||
this.omitting = new Set();
|
||||
this.choices = new Map();
|
||||
this.tainted = false;
|
||||
this.latentWalks = new Map();
|
||||
@@ -167,11 +169,13 @@ class GraphResolver {
|
||||
[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);
|
||||
const omitted = [];
|
||||
const [before, beforePath] = promptPart(this, pyGet(inputs, 'prepend_text', ''), depth, omitted);
|
||||
const [after, afterPath] = promptPart(this, pyGet(inputs, 'append_text', ''), depth, omitted);
|
||||
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];
|
||||
if (omitted.length) this.omitting.add(slotKey(nodeId, slot));
|
||||
}
|
||||
}
|
||||
} else if ((name === 'StringConcatenate' || name === 'Text Concatenate') && slot === 0 && kind === 'string') {
|
||||
@@ -447,6 +451,10 @@ class GraphResolver {
|
||||
text_source: this.textSources.get(slotKey(origin.node_id, origin.output_slot)) ?? 'graph-inferred',
|
||||
};
|
||||
if (guiderId !== null) provenance.guider_id = guiderId;
|
||||
const omitted = [];
|
||||
for (const step of path) if (this.omitting.has(slotKey(step.node_id, step.output_slot)) && !omitted.includes(step.node_id)) omitted.push(step.node_id);
|
||||
// Through encoders, concatenations and chained PromptManagers (D125).
|
||||
if (omitted.length) { provenance.text_source = RUNTIME_PARTS_SOURCE; provenance.omitted_parts = omitted; }
|
||||
return { text, storage_text: storageText, provenance };
|
||||
}
|
||||
|
||||
@@ -527,7 +535,8 @@ class GraphResolver {
|
||||
return result('absent', { positive, samplerId, reason: absentReason });
|
||||
}
|
||||
negative = this.side(inputs.get(negativeName), samplerId, guiderId, depth);
|
||||
return result('resolved', { positive, negative, samplerId });
|
||||
const omitted = [positive, negative].some(side => side.provenance.text_source === RUNTIME_PARTS_SOURCE);
|
||||
return result('resolved', { positive, negative, samplerId, reason: omitted ? RUNTIME_PARTS_REASON : '' });
|
||||
} catch (error) {
|
||||
if (!(error instanceof Unresolved)) throw error;
|
||||
return result('unresolved', { positive, negative, samplerId, reason: error.message, reasonCode: error.code, node: error.node });
|
||||
@@ -543,11 +552,13 @@ class GraphResolver {
|
||||
}
|
||||
}
|
||||
|
||||
/** `latent_checked`: `latent_stages` once per sampler; returns the continued earlier stage, or null. */
|
||||
latentChecked(samplerId, value) {
|
||||
let error;
|
||||
let error, earlier = null;
|
||||
if (this.latentWalks.has(samplerId)) {
|
||||
const [walked, cost] = this.latentWalks.get(samplerId);
|
||||
const [walked, cost, found] = this.latentWalks.get(samplerId);
|
||||
error = walked;
|
||||
earlier = found;
|
||||
if (!this.latentCharged.has(samplerId)) {
|
||||
this.budget.charge(cost);
|
||||
this.latentCharged.add(samplerId);
|
||||
@@ -555,16 +566,17 @@ class GraphResolver {
|
||||
} else {
|
||||
const before = this.budget.allowance;
|
||||
try {
|
||||
this.latentStages(samplerId, value);
|
||||
earlier = latentStages(this, 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.latentWalks.set(samplerId, [error, before - this.budget.allowance, earlier]);
|
||||
this.latentCharged.add(samplerId);
|
||||
}
|
||||
if (error !== null) throw new Unresolved(error.message, error.code, error.node === null ? null : { ...error.node });
|
||||
return earlier;
|
||||
}
|
||||
|
||||
enqueue(queue, queued, inputs, depth) {
|
||||
@@ -582,21 +594,6 @@ class GraphResolver {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@ const statuses = {
|
||||
ambiguous: 'Multiple possible prompts. Source could not be determined.',
|
||||
unresolved: 'Prompt could not be resolved.',
|
||||
};
|
||||
// D125 (maintainer option A): a PromptManager prepend or append that a run-time node decided (LoRA trigger words) is
|
||||
// not in the file, so the shown text leaves it out and says so. D126: the conditioning a later stage combined with
|
||||
// the base prompt is not in the shown text either. Each source shows its muted line under the text.
|
||||
export const RUNTIME_PARTS_NOTE = 'Text added at run time, such as trigger words, is not included.';
|
||||
export const ADDED_CONDITIONING_NOTE = 'Conditioning added at a later stage is not included.';
|
||||
const SOURCE_NOTES = Object.freeze({
|
||||
'runtime-parts-omitted': [RUNTIME_PARTS_NOTE],
|
||||
'added-conditioning': [ADDED_CONDITIONING_NOTE],
|
||||
'runtime-parts-omitted-and-added-conditioning': [RUNTIME_PARTS_NOTE, ADDED_CONDITIONING_NOTE],
|
||||
});
|
||||
const ROLE_LABELS = Object.freeze({positive: '+ Positive prompt', negative: '\u2212 Negative prompt'});
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
@@ -114,6 +124,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
// Production keeps a confirmed prompt keyboard-reachable for reading and selecting long text.
|
||||
text.tabIndex = 0;
|
||||
block.append(text);
|
||||
for (const note of Object.hasOwn(SOURCE_NOTES, record?.source) ? SOURCE_NOTES[record.source] : []) block.append(el('p', note, 'small muted'));
|
||||
} else block.append(el('p', typeof record?.message === 'string' && record.message ? record.message : statuses[record?.status] || statuses.unresolved, 'prompt-text muted'));
|
||||
container.append(block);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user