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:
+42
-8
@@ -425,7 +425,10 @@ ConditioningAverage, which can weight a part away), one of its parts is exactly
|
||||
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. A combine without the base text, a different negative, or
|
||||
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
|
||||
@@ -440,9 +443,15 @@ 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 (D-NEW-R1, placeholder
|
||||
copy the concept does not have; the evidence badge stays "Workflow inferred"). The
|
||||
toggle's output is never computed. A run-time node further upstream (inside a
|
||||
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
|
||||
@@ -1940,7 +1949,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
|
||||
@@ -2012,6 +2021,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
|
||||
@@ -2116,9 +2140,19 @@ and skip the parse.
|
||||
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 that final stage's, D19), the card shows no seed: the parser would show
|
||||
another stage's. Only such graphs are resolved for the card, with
|
||||
`INSPECTION_PROFILE` (`test_a_chain_the_inspector_names_one_stage_of_shows_no_differing_seed`;
|
||||
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
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -82,7 +82,7 @@ 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 (D-NEW-R1)',()=>{
|
||||
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{
|
||||
@@ -95,6 +95,19 @@ test('a prompt that left out run-time trigger words says so under its text (D-NE
|
||||
}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',()=>{
|
||||
|
||||
@@ -525,6 +525,53 @@ def _runtime_parts():
|
||||
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()}
|
||||
|
||||
|
||||
|
||||
@@ -269,6 +269,51 @@ def hires_fix_with_trigger_words():
|
||||
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()
|
||||
|
||||
+25
-3
@@ -7,7 +7,6 @@ import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils import card_seed as card_seed_rules # noqa: E402
|
||||
from utils.card_seed import ( # noqa: E402
|
||||
MAX_RECORD_BYTES,
|
||||
card_seed,
|
||||
@@ -97,6 +96,27 @@ class CardSeed(unittest.TestCase):
|
||||
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})
|
||||
@@ -255,8 +275,10 @@ class CardSeed(unittest.TestCase):
|
||||
except GenerationParamsError:
|
||||
self.assertIsNone(params)
|
||||
continue
|
||||
if card_seed_rules._inspector_names_another_seed(values):
|
||||
# The inspector names a chain stage with another seed.
|
||||
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)
|
||||
|
||||
@@ -133,6 +133,62 @@ class TestFluxThreeStage(unittest.TestCase):
|
||||
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"))
|
||||
@@ -284,6 +340,68 @@ class TestRunTimePromptManagerParts(unittest.TestCase):
|
||||
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")
|
||||
|
||||
+53
-10
@@ -9,9 +9,10 @@ browser parser the inspector fills from since #251). No separate seed
|
||||
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 (a
|
||||
resolved multi-stage chain, whose facts are that stage's), the card shows none.
|
||||
Only such graphs are resolved, with the inspector's own profile.
|
||||
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
|
||||
@@ -21,6 +22,7 @@ from collections.abc import Mapping
|
||||
from .generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
_is_sampler,
|
||||
extract_generation_params,
|
||||
)
|
||||
from .inspection_response import project_graph
|
||||
@@ -84,7 +86,9 @@ def header_params(info) -> dict | None:
|
||||
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):
|
||||
if isinstance(params, dict) and _inspector_names_another_seed(
|
||||
graphs, card_seed(params)
|
||||
):
|
||||
return {**params, "seed": None}
|
||||
return params
|
||||
|
||||
@@ -92,13 +96,48 @@ def header_params(info) -> dict | None:
|
||||
_SEED_INPUTS = ("seed", "noise_seed", "noise")
|
||||
|
||||
|
||||
def _inspector_names_another_seed(graphs: dict) -> bool:
|
||||
"""Whether the inspector may show another stage's seed than the parser's.
|
||||
def _stage_seed(graph: Mapping, node_id: str) -> str | None:
|
||||
"""The seed the parser reads for one sampler node, or None when unknown.
|
||||
|
||||
True when the prompt graph's sampler stages read different seed inputs and
|
||||
the inspection resolution names one sampler for a resolved pair: the
|
||||
inspector's facts are then that stage's (D19), not the parser's pick.
|
||||
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
|
||||
@@ -125,7 +164,11 @@ def _inspector_names_another_seed(graphs: dict) -> bool:
|
||||
)
|
||||
except RecursionError:
|
||||
return True
|
||||
return resolution["state"] == "resolved" and resolution["sampler_id"] is not None
|
||||
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:
|
||||
|
||||
@@ -196,9 +196,13 @@ MULTISTAGE_UNATTRIBUTED_REASON = "Multistage sampler ancestry is ambiguous"
|
||||
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 (D-NEW-R1).
|
||||
# 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``).
|
||||
@@ -318,12 +322,30 @@ 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"]),
|
||||
)
|
||||
|
||||
|
||||
@@ -400,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 = {}
|
||||
@@ -574,7 +598,7 @@ class _GraphResolver:
|
||||
)
|
||||
path = before_path + path + after_path
|
||||
if omitted:
|
||||
self.text_sources[(node_id, slot)] = RUNTIME_PARTS_SOURCE
|
||||
self.omitting.add((node_id, slot))
|
||||
elif (
|
||||
name in ("StringConcatenate", "Text Concatenate")
|
||||
and slot == 0
|
||||
@@ -648,10 +672,11 @@ class _GraphResolver:
|
||||
"""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 whose output is decided at run time (``runtime``, such
|
||||
as LoraManager's TriggerWord Toggle): that part is not in the file, so
|
||||
it is left out and recorded in ``omitted``. Anything else, including a
|
||||
run-time node further upstream, keeps its trace or its failure.
|
||||
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)
|
||||
@@ -660,8 +685,7 @@ class _GraphResolver:
|
||||
self.semantics is None
|
||||
or error.code != "runtime-value"
|
||||
or not _link(value)
|
||||
or error.node is None
|
||||
or error.node["id"] != str(value[0])[:_CLASS_NAME_LENGTH]
|
||||
or self.node(str(value[0]))[0] not in self.semantics.runtime
|
||||
):
|
||||
raise
|
||||
omitted.append(str(value[0]))
|
||||
@@ -1038,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]:
|
||||
@@ -1232,7 +1265,7 @@ class _GraphResolver:
|
||||
found = self.composite_of(inputs.get(conditioning["positive"]))
|
||||
if found is None:
|
||||
return None
|
||||
parts, depth = found
|
||||
parts, depth, composite = found
|
||||
texts = [self.text(part, "conditioning", depth)[0] for part in parts]
|
||||
if base["positive"]["text"] not in texts:
|
||||
return None
|
||||
@@ -1243,9 +1276,18 @@ class _GraphResolver:
|
||||
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",
|
||||
base["positive"],
|
||||
positive,
|
||||
base["negative"],
|
||||
base["sampler_id"],
|
||||
ADDED_CONDITIONING_REASON,
|
||||
@@ -1255,7 +1297,9 @@ class _GraphResolver:
|
||||
"""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 and the depth reached.
|
||||
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:
|
||||
@@ -1269,10 +1313,10 @@ class _GraphResolver:
|
||||
if forward is not None and forward["strategy"] == "passthrough":
|
||||
value = inputs.get(forward["input"])
|
||||
continue
|
||||
composite = self.semantics.composites.get(name) if slot == 0 else None
|
||||
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
|
||||
return [inputs.get(part) for part in composite["inputs"]], depth, node_id
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -156,8 +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.
|
||||
// A prompt that left out a run-time PromptManager part (D-NEW-R1) is still inferred from the workflow.
|
||||
const EVIDENCE_BADGES = [['execution', 'Execution recorded'], ['graph-inferred', 'Workflow inferred'], ['runtime-parts-omitted', '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,18 +1,30 @@
|
||||
// Candidate selection for local-resolver.mjs (#249 local Inspect, PR L3 Task 9): ports of
|
||||
// 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`, plus `_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.
|
||||
// `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 { ADDED_CONDITIONING_REASON, DETAILER, IDENTICAL_BRANCHES_REASON, Unresolved, isLink, rerouteValue, result, slotKey } 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) {
|
||||
@@ -73,11 +85,12 @@ function addedConditioning(resolver, samplerId, stages) {
|
||||
const entry = resolver.semantics.samplers.get(name);
|
||||
if (entry.strategy === 'custom-advanced') return null;
|
||||
const conditioning = entry.conditioning;
|
||||
let negative;
|
||||
let negative, composite;
|
||||
try {
|
||||
const found = compositeOf(resolver, pyGet(inputs, conditioning.positive));
|
||||
if (found === null) return null;
|
||||
const [parts, depth] = found;
|
||||
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;
|
||||
@@ -87,10 +100,15 @@ function addedConditioning(resolver, samplerId, stages) {
|
||||
return null;
|
||||
}
|
||||
if (negative.text !== base.negative.text) return null;
|
||||
return result('resolved', { positive: base.positive, negative: base.negative, samplerId: base.sampler_id, reason: ADDED_CONDITIONING_REASON });
|
||||
// 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] of the `additive` composite a conditioning input reaches, or null. */
|
||||
/** `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) {
|
||||
@@ -100,9 +118,10 @@ function compositeOf(resolver, value) {
|
||||
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; }
|
||||
const composite = slot === 0 ? resolver.semantics.composites.get(name) : undefined;
|
||||
// `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];
|
||||
return [composite.inputs.map(part => pyGet(inputs, part)), depth, nodeId];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -171,7 +190,7 @@ function continuedStages(resolver, samplerIds) {
|
||||
let earlier;
|
||||
try {
|
||||
const [, inputs] = resolver.node(nodeId);
|
||||
earlier = resolver.lineagePredecessor(pyGet(inputs, entry.latent));
|
||||
earlier = lineagePredecessor(resolver, pyGet(inputs, entry.latent));
|
||||
} catch (error) {
|
||||
if (!(error instanceof Unresolved)) throw error;
|
||||
continue;
|
||||
@@ -258,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 ['', []];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ export const MULTISTAGE_UNATTRIBUTED_REASON = 'Multistage sampler ancestry is am
|
||||
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';
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
MAX_JOIN_INPUTS,
|
||||
MAX_SUBGRAPH_DEPTH,
|
||||
MAX_WORKFLOW_NODES,
|
||||
MULTISTAGE_UNATTRIBUTED_REASON,
|
||||
NO_SAMPLER_NAMES,
|
||||
OVERWRITTEN_PROMPT_REASON,
|
||||
PROFILE,
|
||||
@@ -42,12 +41,11 @@ import {
|
||||
RUNTIME_PARTS_REASON,
|
||||
RUNTIME_PARTS_SOURCE,
|
||||
readPairExecution,
|
||||
rerouteValue,
|
||||
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';
|
||||
@@ -72,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();
|
||||
@@ -171,12 +170,12 @@ class GraphResolver {
|
||||
if (adapted.compose || adapted.storageInput) storageText = text;
|
||||
if (adapted.compose) {
|
||||
const omitted = [];
|
||||
const [before, beforePath] = this.part(pyGet(inputs, 'prepend_text', ''), depth, omitted);
|
||||
const [after, afterPath] = this.part(pyGet(inputs, 'append_text', ''), depth, 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.textSources.set(slotKey(nodeId, slot), RUNTIME_PARTS_SOURCE);
|
||||
if (omitted.length) this.omitting.add(slotKey(nodeId, slot));
|
||||
}
|
||||
}
|
||||
} else if ((name === 'StringConcatenate' || name === 'Text Concatenate') && slot === 0 && kind === 'string') {
|
||||
@@ -407,19 +406,6 @@ class GraphResolver {
|
||||
return [text, path, text];
|
||||
}
|
||||
|
||||
/** `part`: a PromptManager prepend or append, traced, or left out when a direct run-time feeder decides it. */
|
||||
part(value, depth, omitted) {
|
||||
try {
|
||||
const [text, path] = this.text(value, 'string', depth + 1);
|
||||
return [text, path];
|
||||
} catch (error) {
|
||||
if (!(error instanceof Unresolved) || error.code !== 'runtime-value' || !isLink(value) || error.node === null ||
|
||||
error.node.id !== pySlice(String(value[0]), CLASS_NAME_LENGTH)) throw error;
|
||||
omitted.push(String(value[0]));
|
||||
return ['', []];
|
||||
}
|
||||
}
|
||||
|
||||
sourceOf(path) {
|
||||
if (!path.length) return 'graph-inferred';
|
||||
return this.textSources.get(slotKey(path[0].node_id, path[0].output_slot)) ?? 'graph-inferred';
|
||||
@@ -465,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 };
|
||||
}
|
||||
|
||||
@@ -576,7 +566,7 @@ class GraphResolver {
|
||||
} else {
|
||||
const before = this.budget.allowance;
|
||||
try {
|
||||
earlier = this.latentStages(samplerId, value);
|
||||
earlier = latentStages(this, samplerId, value);
|
||||
error = null;
|
||||
} catch (raised) {
|
||||
if (!(raised instanceof Unresolved) || raised.code === 'work-limit') throw raised;
|
||||
@@ -589,26 +579,6 @@ class GraphResolver {
|
||||
return earlier;
|
||||
}
|
||||
|
||||
/** `lineage_predecessor`: the sampler whose output a latent input continues through `lineage` nodes, or null. */
|
||||
lineagePredecessor(value) {
|
||||
const seen = new Set();
|
||||
while (isLink(value) && seen.size < this.maxDepth) {
|
||||
const nodeId = String(value[0]), slot = value[1];
|
||||
if (seen.has(nodeId)) return null;
|
||||
seen.add(nodeId);
|
||||
const [name, inputs] = this.node(nodeId);
|
||||
if (this.stageClasses.has(name)) return slot === 0 ? nodeId : null;
|
||||
if (name === 'Reroute' && slot === 0) {
|
||||
value = rerouteValue(inputs);
|
||||
continue;
|
||||
}
|
||||
const entry = this.semantics.lineage.get(slotKey(name, slot));
|
||||
if (entry === undefined) return null;
|
||||
value = pyGet(inputs, entry.input);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
enqueue(queue, queued, inputs, depth) {
|
||||
this.scan(inputs.size);
|
||||
for (const item of inputs.values()) {
|
||||
@@ -624,28 +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]);
|
||||
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 >= 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)) {
|
||||
if (!traced) { lineage = this.lineagePredecessor(value); traced = true; }
|
||||
if (nodeId !== lineage) throw new Unresolved(MULTISTAGE_UNATTRIBUTED_REASON);
|
||||
earlier = nodeId;
|
||||
continue;
|
||||
}
|
||||
if (this.stageShaped(name, inputs)) throw this.unsupported('Unsupported latent ancestry', nodeId);
|
||||
this.enqueue(queue, queued, inputs, depth + 1);
|
||||
}
|
||||
return earlier;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,10 +15,16 @@ const statuses = {
|
||||
ambiguous: 'Multiple possible prompts. Source could not be determined.',
|
||||
unresolved: 'Prompt could not be resolved.',
|
||||
};
|
||||
// D-NEW-R1 (placeholder copy, not in the concept; 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.
|
||||
export const RUNTIME_PARTS_SOURCE = 'runtime-parts-omitted';
|
||||
// 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);
|
||||
@@ -118,7 +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);
|
||||
if (record?.source === RUNTIME_PARTS_SOURCE) block.append(el('p', RUNTIME_PARTS_NOTE, 'small muted'));
|
||||
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