feat(inspect): trace conditioning through supported pass-through and selector nodes

This commit is contained in:
Vito Sansevero
2026-10-19 15:00:33 -07:00
parent 56aa6f53b0
commit 4de839e4e5
11 changed files with 725 additions and 28 deletions
+10 -1
View File
@@ -232,7 +232,16 @@ semantics table `web/js/inspection-semantics.json` (at most 1 MiB and eight nest
levels, loaded on first use). With it, `SamplerCustomAdvanced` follows its `guider`
link (through Reroutes) to `CFGGuider` (positive and negative) or `BasicGuider`
(positive only; the negative is `absent`, "Guider has no negative input"). Any
other guider, such as `DualCFGGuider`, is `unsupported-node`. If the table cannot
other guider, such as `DualCFGGuider`, is `unsupported-node`. Conditioning is
traced through the table's pass-throughs (FluxGuidance, ReferenceLatent, the
ConditioningSet* nodes, ControlNet apply nodes, InpaintModelConditioning and
WanImageToVideo by output slot). `ConditioningZeroOut` is an explicit empty text
with source `zeroed-conditioning`. `easy ifElse` with a literal boolean follows that
branch only; with a linked boolean both branches are traced, equal texts resolve and
different texts make the sampler `ambiguous` with one candidate per choice (at most
32, else `work-limit`). ConditioningCombine/Concat/Average resolve only when every
part has the same text, otherwise `unresolved` with `composite` naming the node.
Results that depend on a forced choice are never memoized. If the table cannot
be loaded, graph inspection is `unresolved` ("Inspection node semantics are
unavailable") and ingestion is unaffected, because it never imports the table.
+1 -1
View File
@@ -15,7 +15,7 @@ Browser tests use the committed file; FFmpeg is not required to run them.
# Inspection parity fixtures
`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f17.png`, each
`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f23.png`, each
under 32 KiB) and `vectors.json`, used by the #240 inspection parity tests. Every
graph, prompt text, model name and filename prefix in them is original synthetic
content written for this repository. Nothing comes from user output. They are
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 B

+135 -17
View File
@@ -342,12 +342,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": "unsupported-node",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
@@ -385,8 +385,8 @@
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": "unsupported-node",
"reason": "Combined conditioning has different texts",
"reason_code": "composite",
"selection": null,
"positive": null,
"negative": null,
@@ -426,12 +426,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": "unsupported-node",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
@@ -888,12 +888,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": "unsupported-node",
"state": "absent",
"reason": "Guider has no negative input",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"positive": "a red fox in snow, detailed fur",
"negative": "<absent>",
"candidate_count": 0,
"facts": {
"seed": {
@@ -923,6 +923,90 @@
}
}
},
{
"name": "f20-inspection",
"fixture": "f20",
"profile": "inspection",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
"status": "known",
"value": "42"
},
"steps": {
"status": "known",
"value": "25"
},
"cfg": {
"status": "known",
"value": "6.5"
},
"sampler": {
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "known",
"value": "normal"
},
"model": {
"status": "known",
"value": "sdxl_base.safetensors"
}
}
}
},
{
"name": "f23-inspection",
"fixture": "f23",
"profile": "inspection",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "",
"candidate_count": 0,
"facts": {
"seed": {
"status": "known",
"value": "42"
},
"steps": {
"status": "known",
"value": "25"
},
"cfg": {
"status": "known",
"value": "6.5"
},
"sampler": {
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "known",
"value": "normal"
},
"model": {
"status": "known",
"value": "sdxl_base.safetensors"
}
}
}
},
{
"name": "f01-default",
"fixture": "f01",
@@ -1296,5 +1380,39 @@
"candidate_count": 0,
"facts": null
}
},
{
"name": "f20-default",
"fixture": "f20",
"profile": "default",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": null
}
},
{
"name": "f23-default",
"fixture": "f23",
"profile": "default",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": null
}
}
]
+21 -1
View File
@@ -2,7 +2,7 @@
Every graph here is original synthetic content (CC0): neutral texts, generic model
file names and invented filename prefixes. Nothing is copied from user outputs.
The shapes reproduce the parity audit's fixture numbering (``f01``..``f17``).
The shapes reproduce the parity audit's fixture numbering (``f01``..``f23``).
``build(name)`` returns ``{"prompt", "workflow", "extra_chunks"}``. ``write_png``
writes the two records the way core ``SaveImage`` does (Pillow ``PngInfo.add_text``,
@@ -66,6 +66,8 @@ COMMITTED = (
"f15",
"f16",
"f17",
"f20",
"f23",
)
# Built in a temporary directory at test time (too large to commit).
TEST_TIME = ("f01", "f04", "f05")
@@ -691,6 +693,22 @@ def _f17():
return _basic(graph, _ui(graph))
def _f20():
"""``easy ifElse`` with a literal boolean picks the positive branch."""
select = {
"class_type": "easy ifElse",
"inputs": {"boolean": True, "on_true": ["10", 0], "on_false": ["11", 0]},
}
extra = {"10": _enc(POS), "11": _enc("a grey wolf in fog")}
return _basic(_ksampler_graph(select, _enc(NEG), extra=extra))
def _f23():
"""The negative is ``ConditioningZeroOut`` of the positive encoder."""
zero = {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["6", 0]}}
return _basic(_ksampler_graph(_enc(POS), zero))
_BUILDERS = {
"f01": _f01,
"f01b": _f01b,
@@ -714,6 +732,8 @@ _BUILDERS = {
"f15": _f15,
"f16": _f16,
"f17": _f17,
"f20": _f20,
"f23": _f23,
}
@@ -3,7 +3,8 @@
// (web/metadata.html, web/js/gallery.js) show today on the synthetic inspection
// parity fixtures, so the #240 workspace work cannot change them by accident.
// Some answers are known to be wrong (f01b shows the preview-only detailer text
// as both roles; f16 shows the last of two conflicting prompt chunks). They are
// as both roles; f16 shows the last of two conflicting prompt chunks; f23 shows the
// positive text as the zeroed negative). They are
// recorded as-is and intentionally unchanged (spec section 11).
const test = require("node:test");
const assert = require("node:assert/strict");
@@ -56,6 +57,8 @@ const EXPECTED = {
f15: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f16: { positivePrompt: "a grey wolf in fog", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f17: { positivePrompt: "No prompt found", negativePrompt: "No negative prompt found", checkpoint: "flux1-dev.safetensors", steps: "Unknown", cfgScale: "Unknown", sampler: "euler", seed: "Unknown" },
f20: { positivePrompt: "No prompt found", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f23: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "a red fox in snow, detailed fur", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
};
test("legacy extractGenerationParams characterization covers every committed fixture", () => {
+11
View File
@@ -70,3 +70,14 @@ test('frozen fields disclose their exact source and keep null, empty and whitesp
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);assert.equal(sent.length,2);
}finally{scope.dispose();f.restore();}
});
test('a zeroed conditioning negative shows as an explicit empty negative with its own source',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:{status:'resolved',text:'positive',source:'graph-inferred'},negative:{status:'resolved',text:'',source:'zeroed-conditioning'}}});
assert.equal(f.walk(container,node=>node.textContent==='Zeroed conditioning (no negative text)').length,1);
assert.equal(f.walk(container,node=>node.textContent==='Empty negative prompt').length,1);
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);
assert.deepEqual(sent,[{positive:'positive',negative:''}]);
}finally{scope.dispose();f.restore();}
});
+334
View File
@@ -1249,6 +1249,340 @@ class TestGuiderSamplers(unittest.TestCase):
)
WOLF = "a grey wolf in fog"
def _encoded(text: str) -> dict:
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["4", 1]}}
def _with_positive(node: dict, extra: dict = None) -> dict:
"""``f02``'s KSampler graph whose positive input reads ``node`` (as node 6)."""
source = parity.build("f02")["prompt"]
source["6"] = node
source.update(extra or {})
return source
def _if_else(condition, on_true, on_false) -> dict:
return {
"class_type": "easy ifElse",
"inputs": {"boolean": condition, "on_true": on_true, "on_false": on_false},
}
def _image_exists() -> dict:
return {"class_type": "ImageExists", "inputs": {"image": ["13", 0]}}
def _sides(result: dict) -> tuple:
return tuple(
result[role]["text"] if isinstance(result[role], dict) else None
for role in ("positive", "negative")
)
class TestConditioningSemantics(unittest.TestCase):
"""Pass-throughs, zeroed conditioning, selectors and composites (inspection)."""
def test_flux_guidance_passthrough_resolves(self):
result = _inspect(parity.build("f06c")["prompt"])
self.assertEqual(result["state"], "resolved")
self.assertIsNone(result["reason_code"])
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
provenance = result["positive"]["provenance"]
self.assertEqual(
provenance["path"],
[{"node_id": "6", "output_slot": 0}, {"node_id": "10", "output_slot": 0}],
)
self.assertEqual(provenance["text_source"], "graph-inferred")
# f17: BasicGuider <- FluxGuidance <- CLIPTextEncode, negative absent.
guided = _inspect(parity.build("f17")["prompt"])
self.assertEqual(guided["state"], "absent")
self.assertEqual(guided["reason"], "Guider has no negative input")
self.assertEqual(guided["positive"]["text"], parity.POS)
def test_controlnet_apply_advanced_maps_slots(self):
source = parity.build("f06e")["prompt"]
result = _inspect(source)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
self.assertEqual(
result["negative"]["provenance"]["path"],
[{"node_id": "7", "output_slot": 1}, {"node_id": "11", "output_slot": 0}],
)
# Slot 0 of the negative's node is its positive output, never the negative.
source["3"]["inputs"]["negative"] = ["7", 0]
self.assertEqual(_sides(_inspect(source)), (parity.POS, parity.POS))
# An output slot the table does not list is unsupported, named.
source["3"]["inputs"]["negative"] = ["7", 2]
result = _inspect(source)
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(
result["node"], {"id": "7", "class_type": "ControlNetApplyAdvanced"}
)
def test_reference_latent_and_set_area_pass_through(self):
chain = [
("ReferenceLatent", {"latent": ["5", 0]}),
("ConditioningSetArea", {"width": 64, "height": 64, "x": 0, "y": 0}),
("ConditioningSetAreaPercentage", {"width": 1.0, "height": 1.0}),
("ConditioningSetAreaStrength", {"strength": 1.0}),
("ConditioningSetMask", {"mask": ["40", 0], "strength": 1.0}),
("ConditioningSetTimestepRange", {"start": 0.0, "end": 1.0}),
("ControlNetApply", {"control_net": ["41", 0], "strength": 1.0}),
]
extra, previous = {"10": _encoded(parity.POS)}, "10"
for index, (name, inputs) in enumerate(chain):
node_id = f"2{index}"
extra[node_id] = {
"class_type": name,
"inputs": {**inputs, "conditioning": [previous, 0]},
}
previous = node_id
source = _with_positive(extra.pop(previous), extra)
result = _inspect(source)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
path = result["positive"]["provenance"]["path"]
self.assertEqual(
[entry["node_id"] for entry in path],
["6", "25", "24", "23", "22", "21", "20", "10"],
)
def test_inpaint_model_conditioning_slots(self):
inpaint = {
"class_type": "InpaintModelConditioning",
"inputs": {
"positive": ["10", 0],
"negative": ["11", 0],
"vae": ["4", 2],
"pixels": ["13", 0],
"mask": ["14", 0],
"noise_mask": True,
},
}
extra = {"10": _encoded(parity.POS), "11": _encoded(parity.NEG)}
source = _with_positive(inpaint, extra)
source["3"]["inputs"]["negative"] = ["6", 1]
result = _inspect(source)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
self.assertEqual(result["positive"]["provenance"]["output_slot"], 0)
self.assertEqual(result["negative"]["provenance"]["output_slot"], 1)
def test_zeroed_negative_is_explicit_empty_with_distinct_source(self):
from utils.prompt_inspector import inspect_prompt_evidence
source = parity.build("f23")["prompt"]
result = _inspect(source)
self.assertEqual(result["state"], "resolved")
negative = result["negative"]
self.assertEqual((negative["text"], negative["storage_text"]), ("", ""))
self.assertEqual(negative["provenance"]["text_source"], "zeroed-conditioning")
self.assertEqual(
negative["provenance"]["path"], [{"node_id": "7", "output_slot": 0}]
)
self.assertEqual(result["positive"]["text"], parity.POS)
self.assertEqual(
result["positive"]["provenance"]["text_source"], "graph-inferred"
)
evidence = inspect_prompt_evidence({"prompt": source})["prompts"]
self.assertEqual(
evidence["negative"],
{"status": "resolved", "text": "", "source": "zeroed-conditioning"},
)
# The source survives pass-throughs and reroutes in front of it.
source["30"] = {
"class_type": "FluxGuidance",
"inputs": {"guidance": 3.5, "conditioning": ["7", 0]},
}
source["31"] = {"class_type": "Reroute", "inputs": {"value": ["30", 0]}}
source["3"]["inputs"]["negative"] = ["31", 0]
negative = _inspect(source)["negative"]
self.assertEqual(negative["text"], "")
self.assertEqual(negative["provenance"]["text_source"], "zeroed-conditioning")
# A zeroed positive is an empty positive, never a resolved prompt.
source["3"]["inputs"]["positive"] = ["7", 0]
result = _inspect(source)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason"], "Positive prompt is empty")
def test_if_else_literal_boolean_selects_branch(self):
source = parity.build("f20")["prompt"]
result = _inspect(source)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
self.assertEqual(
[entry["node_id"] for entry in result["positive"]["provenance"]["path"]],
["6", "10"],
)
source["6"]["inputs"]["boolean"] = False
self.assertEqual(_inspect(source)["positive"]["text"], WOLF)
# The branch that is not taken is never traced.
source["6"]["inputs"]["on_true"] = ["99", 0]
self.assertEqual(_inspect(source)["state"], "resolved")
for literal in (1, "true", None):
with self.subTest(literal=literal):
source["6"]["inputs"]["boolean"] = literal
result = _inspect(source)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "malformed")
self.assertIsNone(result["positive"])
def test_if_else_linked_boolean_converging_branches_resolve(self):
guidance = {
"class_type": "FluxGuidance",
"inputs": {"guidance": 3.5, "conditioning": ["10", 0]},
}
extra = {"10": _encoded(parity.POS), "11": guidance, "12": _image_exists()}
source = _with_positive(_if_else(["12", 1], ["11", 0], ["10", 0]), extra)
result = _inspect(source)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
self.assertEqual(result["sampler_id"], "3")
self.assertEqual(result["candidates"], [])
# Two encoders with the same text converge as well.
source["11"] = _encoded(parity.POS)
self.assertEqual(_inspect(source)["state"], "resolved")
def test_if_else_linked_boolean_diverging_branches_are_ambiguous_with_candidates(
self,
):
extra = {
"10": _encoded(parity.POS),
"11": _encoded(WOLF),
"12": _image_exists(),
}
source = _with_positive(_if_else(["12", 1], ["10", 0], ["11", 0]), extra)
result = _inspect(source)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["sampler_id"], "3")
self.assertIsNone(result["positive"])
self.assertIsNone(result["negative"])
candidates = result["candidates"]
self.assertEqual([item["state"] for item in candidates], ["resolved"] * 2)
self.assertEqual(
[_sides(item) for item in candidates],
[(parity.POS, parity.NEG), (WOLF, parity.NEG)],
)
self.assertEqual(
[
item["positive"]["provenance"]["path"][-1]["node_id"]
for item in candidates
],
["10", "11"],
)
# One selector read by both roles is one runtime choice, not four pairs.
source["3"]["inputs"]["negative"] = ["6", 0]
candidates = _inspect(source)["candidates"]
self.assertEqual(
[_sides(item) for item in candidates],
[(parity.POS, parity.POS), (WOLF, WOLF)],
)
# Independent selectors on each role multiply.
source["7"] = _if_else(["12", 1], ["10", 0], ["14", 0])
source["14"] = _encoded(parity.NEG)
source["3"]["inputs"]["negative"] = ["7", 0]
candidates = _inspect(source)["candidates"]
self.assertEqual(len(candidates), 4)
self.assertEqual(len({_sides(item) for item in candidates}), 4)
def test_diverging_branch_that_cannot_be_traced_is_never_guessed(self):
extra = {
"10": _encoded(parity.POS),
"11": {"class_type": "FutureEncoder", "inputs": {"text": WOLF}},
"12": _image_exists(),
}
source = _with_positive(_if_else(["12", 1], ["10", 0], ["11", 0]), extra)
result = _inspect(source)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["node"], {"id": "11", "class_type": "FutureEncoder"})
self.assertIsNone(result["positive"])
def test_selector_branches_are_bounded(self):
extra, previous = {"12": _image_exists()}, None
for index in range(6):
extra[f"a{index}"] = _encoded(f"left {index}")
extra[f"b{index}"] = _encoded(f"right {index}")
extra[f"s{index}"] = _if_else(["12", 1], [f"a{index}", 0], [f"b{index}", 0])
extra[f"c{index}"] = {
"class_type": "ConditioningCombine",
"inputs": {
"conditioning_1": [f"s{index}", 0],
"conditioning_2": [previous or f"s{index}", 0],
},
}
previous = f"c{index}"
source = _with_positive(
{"class_type": "Reroute", "inputs": {"value": [previous, 0]}}, extra
)
result = _inspect(source)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "work-limit")
self.assertEqual(result["candidates"], [])
# Branch tracing draws from the per-candidate visit allowance too.
small = _with_positive(
_if_else(["12", 1], ["10", 0], ["11", 0]),
{"10": _encoded(parity.POS), "11": _encoded(WOLF), "12": _image_exists()},
)
self.assertEqual(_inspect(small, max_visits=3)["reason_code"], "work-limit")
def test_conditioning_combine_distinct_texts_is_composite(self):
source = parity.build("f06d")["prompt"]
result = _inspect(source)
self.assertEqual(
(result["state"], result["reason_code"]), ("unresolved", "composite")
)
self.assertEqual(
result["node"], {"id": "6", "class_type": "ConditioningCombine"}
)
self.assertIsNone(result["positive"])
for name, inputs in (
("ConditioningConcat", ("conditioning_to", "conditioning_from")),
("ConditioningAverage", ("conditioning_to", "conditioning_from")),
):
with self.subTest(name=name):
source["6"] = {
"class_type": name,
"inputs": {inputs[0]: ["10", 0], inputs[1]: ["11", 0]},
}
result = _inspect(source)
self.assertEqual(result["reason_code"], "composite")
self.assertEqual(result["node"], {"id": "6", "class_type": name})
# A zeroed part is a different text too.
source["12"] = {
"class_type": "ConditioningZeroOut",
"inputs": {"conditioning": ["10", 0]},
}
source["6"]["inputs"]["conditioning_from"] = ["12", 0]
self.assertEqual(_inspect(source)["reason_code"], "composite")
def test_conditioning_combine_identical_texts_resolve(self):
source = parity.build("f06d")["prompt"]
source["11"]["inputs"]["text"] = parity.POS
result = _inspect(source)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
source["6"]["inputs"]["conditioning_2"] = ["10", 0]
self.assertEqual(_inspect(source)["state"], "resolved")
# A missing part is malformed, never "the other part".
del source["6"]["inputs"]["conditioning_2"]
result = _inspect(source)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "malformed")
def test_default_profile_keeps_conditioning_nodes_unsupported(self):
for name in ("f06c", "f06d", "f06e", "f20", "f23"):
with self.subTest(fixture=name):
result = resolve_prompt_pair({"prompt": parity.build(name)["prompt"]})
self.assertEqual(result["state"], "unresolved")
self.assertEqual(
result["reason"], "Unsupported node semantics or output slot"
)
self.assertIsNone(result["reason_code"])
REPO = Path(__file__).resolve().parents[1]
SEMANTICS_UNAVAILABLE = "Inspection node semantics are unavailable"
+208 -7
View File
@@ -143,6 +143,10 @@ _DIRECT_SAMPLER = {
# which needs output ancestry (later slice), so it is never a candidate alone.
_CANDIDATE_STRATEGIES = frozenset({"ksampler", "custom-advanced"})
_GUIDER_ABSENT_REASON = "Guider has no negative input"
# ConditioningZeroOut discards its input text: an explicit empty prompt.
_ZEROED_SOURCE = "zeroed-conditioning"
# Runtime selector choices expanded per sampler (the spec's candidate cap).
_MAX_BRANCH_CANDIDATES = 32
_SAMPLER_LIKE_INPUTS = ("latent_image", "guider", "positive", "negative")
_NO_SAMPLER_NAMES = 3
_CLASS_NAME_LENGTH = 64
@@ -159,6 +163,18 @@ class _Unresolved(Exception):
self.node = node
class _Diverging(Exception):
"""A runtime selector whose branches trace to different texts.
Raised from inside tracing; ``_GraphResolver.branches`` resolves the sampler
again once per choice, so each candidate is internally consistent.
"""
def __init__(self, node_id: str) -> None:
super().__init__(node_id)
self.node_id = node_id
def _node_ref(node_id: str, class_type: Any) -> dict:
name = class_type[:_CLASS_NAME_LENGTH] if isinstance(class_type, str) else None
return {"id": node_id[:_CLASS_NAME_LENGTH], "class_type": name}
@@ -230,6 +246,19 @@ def _result(
}
def _role_text(side: Any) -> Optional[str]:
return side["text"] if isinstance(side, dict) else None
def _outcome_key(result: Dict[str, Any]) -> tuple:
"""What a reader sees of a result: state and the two role texts."""
return (
result["state"],
_role_text(result["positive"]),
_role_text(result["negative"]),
)
def _legacy_result(result: Dict[str, Any]) -> Dict[str, Any]:
"""The default profile's wire: today's keys plus ``reason_code: None``."""
legacy = {key: value for key, value in result.items() if key != "node"}
@@ -280,6 +309,10 @@ class _GraphResolver:
self.active: set = set()
self.metadata = metadata or {}
self.text_sources: dict = {}
# Forced runtime selector choices (inspection); a result traced through
# one is "tainted" and never memoized, since it depends on the choice.
self.choices: dict = {}
self.tainted = False
# ``semantics`` (inspection) names the samplers and guiders it follows.
self.semantics = semantics
if semantics is None:
@@ -326,11 +359,15 @@ class _GraphResolver:
self.scanned -= units
def unsupported(self, message: str, node_id: str) -> _Unresolved:
return self.named(message, "unsupported-node", node_id)
def named(self, message: str, code: str, node_id: str) -> _Unresolved:
"""An outcome that names the node it is about (bounded class name)."""
node = self.graph.get(node_id)
class_type = node.get("class_type") if isinstance(node, dict) else None
if isinstance(class_type, str):
class_type = class_type[:_CLASS_NAME_LENGTH]
return _Unresolved(message, "unsupported-node", _node_ref(node_id, class_type))
return _Unresolved(message, code, _node_ref(node_id, class_type))
def bounded(self, text: str) -> str:
if len(text) > self.max_text_length:
@@ -368,6 +405,7 @@ class _GraphResolver:
return self.memo[key]
allowance = self.budget.allowance
outer_deepest = self.deepest
outer_tainted, self.tainted = self.tainted, False
self.deepest = depth
try:
self.budget.spend()
@@ -381,6 +419,8 @@ class _GraphResolver:
"Reroute input is not uniquely identifiable", "malformed"
)
text, path, storage_text = self.text(inputs[keys[0]], kind, depth + 1)
if self.semantics is not None:
self.inherit_source(node_id, slot, [path])
elif (name, slot) in self.adapters:
adapter = self.adapters[(name, slot)]
if not isinstance(adapter, OutputAdapter) or adapter.kind != kind:
@@ -471,6 +511,10 @@ class _GraphResolver:
parts.append(part)
text = self.built(delimiter.join(parts))
storage_text = text
elif self.semantics is not None and self.follows(name, slot, kind):
text, path, storage_text = self.semantic(
node_id, name, slot, kind, inputs, depth
)
else:
raise self.unsupported(
"Unsupported node semantics or output slot", node_id
@@ -488,14 +532,123 @@ class _GraphResolver:
self.path_entries is not None and self.path_entries < 0
):
raise _Unresolved("Provenance path limit reached", "work-limit")
self.memo[key] = (text, path, storage_text)
self.costs[key] = allowance - self.budget.allowance
self.heights[key] = self.deepest - depth
if not self.tainted:
self.memo[key] = (text, path, storage_text)
self.costs[key] = allowance - self.budget.allowance
self.heights[key] = self.deepest - depth
return text, path, storage_text
finally:
self.deepest = max(outer_deepest, self.deepest)
self.tainted = outer_tainted or self.tainted
self.active.discard(key)
def follows(self, name: str, slot: int, kind: str) -> bool:
"""Whether the semantics table traces this output (inspection only)."""
semantics = self.semantics
if slot == 0 and name in semantics.selectors:
return True
return kind == "conditioning" and (
(name, slot) in semantics.conditioning
or (slot == 0 and name in semantics.composites)
)
def semantic(
self, node_id: str, name: str, slot: int, kind: str, inputs: dict, depth: int
) -> Tuple[str, list, str]:
"""Trace a table-listed pass-through, zeroed, selector or composite output.
The caller (``text``) prepends this node to the returned path.
"""
semantics = self.semantics
entry = (
semantics.conditioning.get((name, slot)) if kind == "conditioning" else None
)
if entry is not None and entry["strategy"] == "zeroed":
# The upstream text is discarded, so it is not traced.
self.text_sources[(node_id, slot)] = _ZEROED_SOURCE
return "", [], ""
if entry is not None: # passthrough: one input carries the text
traced = self.text(inputs.get(entry["input"]), kind, depth + 1)
self.inherit_source(node_id, slot, [traced[1]])
return traced
if name in semantics.selectors:
return self.select(node_id, semantics.selectors[name], inputs, kind, depth)
parts = [
self.text(inputs.get(input_name), kind, depth + 1)
for input_name in semantics.composites[name]["inputs"]
]
if len({part[0] for part in parts}) != 1:
# Several texts are in effect at once: not one sendable prompt.
raise self.named(
"Combined conditioning has different texts", "composite", node_id
)
return self.merged(node_id, slot, parts)
def select(
self, node_id: str, entry: Mapping, inputs: dict, kind: str, depth: int
) -> Tuple[str, list, str]:
"""A literal condition picks one branch; a linked one is a runtime choice.
A runtime choice traces both branches. Equal texts converge; different
texts raise ``_Diverging`` unless this choice is already forced.
"""
condition = inputs.get(entry["condition"])
branches = entry["branches"]
if type(condition) is bool:
choice: Optional[bool] = condition
elif not _link(condition):
raise _Unresolved("Malformed selector condition", "malformed")
elif node_id in self.choices:
choice = self.choices[node_id]
self.tainted = True
else:
choice = None
if choice is not None:
branch = branches["true" if choice else "false"]
traced = self.text(inputs.get(branch), kind, depth + 1)
self.inherit_source(node_id, 0, [traced[1]])
return traced
parts = [
self.text(inputs.get(branches[label]), kind, depth + 1)
for label in ("true", "false")
]
if parts[0][0] != parts[1][0]:
raise _Diverging(node_id)
return self.merged(node_id, 0, parts)
def merged(self, node_id: str, slot: int, parts: list) -> Tuple[str, list, str]:
"""One text reached through several followed paths (deduplicated)."""
text = parts[0][0]
storages = {part[2] for part in parts}
storage_text = parts[0][2] if len(storages) == 1 else text
paths = [part[1] for part in parts]
self.scan(sum(len(path) for path in paths))
merged, seen = [], set()
for path in paths:
for step in path:
identity = (step["node_id"], step["output_slot"])
if identity not in seen:
seen.add(identity)
merged.append(step)
self.inherit_source(node_id, slot, paths)
return text, merged, storage_text
def source_of(self, path: list) -> str:
if not path:
return "graph-inferred"
origin = path[0]
return self.text_sources.get(
(origin["node_id"], origin["output_slot"]), "graph-inferred"
)
def inherit_source(self, node_id: str, slot: int, paths: list) -> None:
"""A forwarding node reports its input's text source when unambiguous."""
sources = {self.source_of(path) for path in paths}
if len(sources) == 1:
self.text_sources[(node_id, slot)] = sources.pop()
else:
self.text_sources.pop((node_id, slot), None)
def execution_record(self, node_id: str, inputs: dict) -> Optional[dict]:
try:
record = read_pair_execution(self.metadata, node_id)
@@ -604,7 +757,6 @@ class _GraphResolver:
return self.semantics.samplers[name]
def pair(self, sampler_id: str) -> dict:
positive = negative = None
self.budget.reset(self.per_candidate_visits)
if not self.legacy and self.budget.total <= 0:
return _result(
@@ -613,13 +765,62 @@ class _GraphResolver:
reason="Resolution visit limit reached",
reason_code="work-limit",
)
if self.semantics is None:
return self.sides(sampler_id)
return self.branches(sampler_id)
def branches(self, sampler_id: str) -> dict:
"""Resolve once per runtime selector choice that changes a role text.
Every run draws from the same candidate allowance. Results that agree
collapse to one; otherwise the sampler is ``ambiguous`` with one
candidate per choice set, in true-before-false order.
"""
pending: list = [{}]
results: list = []
try:
while pending:
self.choices = pending.pop(0)
try:
results.append(self.sides(sampler_id))
except _Diverging as diverging:
if len(results) + len(pending) + 2 > _MAX_BRANCH_CANDIDATES:
raise _Unresolved("Too many selector branches", "work-limit")
pending.extend(
{**self.choices, diverging.node_id: choice}
for choice in (True, False)
)
except _Unresolved as error:
return _result(
"unresolved",
sampler_id=sampler_id,
reason=str(error),
reason_code=error.code,
node=error.node,
)
finally:
self.choices = {}
if all(item["state"] == "unresolved" for item in results):
return results[0]
if len({_outcome_key(item) for item in results}) == 1:
return results[0]
return _result(
"ambiguous",
sampler_id=sampler_id,
reason="Selector branches have different prompts",
candidates=results,
)
def sides(self, sampler_id: str) -> dict:
positive = negative = None
try:
name, inputs = self.node(sampler_id)
entry = self.sampler_entry(name)
# A second sampler in the latent ancestry denotes a multistage run;
# associating the final image with one prompt pair would be a guess.
if entry["latent"] in inputs:
self.latent_source(inputs[entry["latent"]], set(), 0)
latent = entry.get("latent")
if latent is not None and latent in inputs:
self.latent_source(inputs[latent], set(), 0)
guider_id, depth = None, 0
conditioning = entry["conditioning"]
absent_reason = "Negative input is absent"
+1
View File
@@ -11,6 +11,7 @@ const sources = {
execution: 'Recorded at execution',
'graph-inferred': 'Inferred from the saved workflow',
'embedded-parameters': 'Embedded generation parameters',
'zeroed-conditioning': 'Zeroed conditioning (no negative text)',
'saved-association': 'Saved library association',
};
const statuses = {