feat(inspect): trace conditioning through supported pass-through and selector nodes
This commit is contained in:
+10
-1
@@ -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.
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -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
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 988 B |
+135
-17
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();}
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user