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:
Vito Sansevero
2026-10-23 09:35:20 -07:00
parent 6c0424fc87
commit 7a249433fe
14 changed files with 516 additions and 119 deletions
+42 -8
View File
@@ -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
File diff suppressed because one or more lines are too long
+14 -1
View File
@@ -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',()=>{
+47
View File
@@ -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()}
+45
View File
@@ -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
View File
@@ -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)
+118
View File
@@ -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
View File
@@ -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:
+58 -14
View File
@@ -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
+4 -2
View File
@@ -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;
+87 -14
View File
@@ -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';
+10 -62
View File
@@ -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;
}
}
+10 -4
View File
@@ -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);
}