Files
ComfyAssets-ComfyUI_PromptM…/tests/test_execution_context_graph.py
T
Vito bae542c9ac feat(history): persist exact execution prompt contexts (#213)
* feat(history): define exact bounded execution contexts

* fix(history): retain observable sources and validate evidence

* feat(history): migrate execution contexts to schema v2

* feat(history): persist immutable execution context snapshots

* fix(history): preserve package identity for execution contexts

* feat(history): bound context payloads beside lifecycle finalization

* fix(history): fence context commits and resolve stored run identity

* fix(history): bound persisted identifier bytes before restore reads

* feat(history): bind exact runtime context to execution attempts

* test(history): discover owner invalidation callback regression

* feat(history): capture cached prompt strings without mutating host caches

* test(history): verify durable contexts on real ComfyUI hosts

* fix(history): scope transforms to captured source paths

* test(ci): update execution context tooling fixtures (#209)

* fix(history): harden context validation and worker fixtures (#209)

* fix(history): honor close deadlines after early joins (#209)
2026-10-17 19:51:22 -07:00

341 lines
15 KiB
Python

"""Projection must retain declared roles without claiming sampler consumption."""
import unittest
from test_execution_contexts import load_module
def node(kind, **inputs):
return {"class_type": kind, "inputs": inputs}
def graph(kind="KSampler", source="PromptManagerPair"):
return {
"p": node(
source,
text=" cat ",
negative_text="",
prepend_text="pre",
append_text="post",
),
"s": node(kind, positive=["p", 0], negative=["p", 1]),
}
def transformed_input_graph(field: str, unresolved: bool = False) -> dict:
g = graph()
g["upstream"] = node("PromptManagerText", text="looks literal")
g["dynamic"] = node("RuntimeGenerator")
g["join"] = node(
"StringConcatenate",
string_a=["upstream", 0],
string_b=["dynamic", 0] if unresolved else " suffix",
)
if unresolved:
g["inner"] = node(
"StringConcatenate", string_a=["upstream", 0], string_b=" inner"
)
g["join"]["inputs"]["string_a"] = ["inner", 0]
g["p"]["inputs"][field] = ["join", 0]
return g
class ProjectionTests(unittest.TestCase):
def setUp(self):
self.m = load_module("execution_context_graph")
def test_projection_preserves_roles(self):
for kind in ("KSampler", "KSamplerAdvanced", "SamplerCustom"):
g = graph(kind)
result = self.m.project_contexts(g, None)
self.assertEqual(len(result.recipes), 1)
recipe = result.recipes[0]
self.assertEqual(recipe.anchor_id, "s")
self.assertEqual(recipe.positive.inferred.raw.value, " cat ")
self.assertEqual(recipe.positive.inferred.effective.value, "pre cat post")
self.assertEqual(recipe.negative.inferred.effective.value, "")
self.assertEqual(recipe.positive.sources[0].output_slot, 0)
g["s"]["inputs"]["positive"], g["s"]["inputs"]["negative"] = ["p", 1], [
"p",
0,
]
crossed = self.m.project_contexts(g, None).recipes[0]
self.assertEqual(crossed.positive.inferred.raw.value, "")
self.assertEqual(crossed.negative.sources[0].output_slot, 0)
for source in ("PromptManager", "PromptManagerFocused", "PromptManagerSplit"):
g = graph(source=source)
g["clip"] = node("CLIPTextEncode", text=["p", 1])
g["s"]["inputs"]["negative"] = ["clip", 0]
recipe = self.m.project_contexts(g, None).recipes[0]
self.assertEqual(recipe.negative.inferred.raw.value, " cat ")
self.assertEqual(recipe.negative.sources[0].output_slot, 1)
for source, slot in (
("PromptManagerText", 0),
("PromptManagerPair", 2),
("PromptManagerPair", 3),
):
g = graph(source=source)
g["clip"] = node("CLIPTextEncode", text=["p", slot])
g["s"]["inputs"] = {"positive": ["clip", 0], "negative": ["clip", 0]}
self.assertEqual(
self.m.project_contexts(g, None)
.recipes[0]
.positive.sources[0]
.output_slot,
slot,
)
g = graph()
g["p"] = node("CLIPTextEncode", text=["literal", 0])
g["literal"] = node("PrimitiveString", value=" exact ")
g["s"]["inputs"]["negative"] = ["p", 0]
self.assertEqual(
self.m.project_contexts(g, None)
.recipes[0]
.positive.inferred.effective.value,
" exact ",
)
g["p"] = node("UnknownTransform", text="secret")
role = self.m.project_contexts(g, None).recipes[0].positive
self.assertIsNone(role.inferred)
self.assertTrue(role.reason)
def test_output_selection_and_scalar_isolation(self):
g = graph()
g["other"] = node("KSampler", positive=["p", 0], negative=["p", 1])
g["decode"] = node("VAEDecode", samples=["s", 0])
g["save"] = node("SaveImage", images=["decode", 0])
result = self.m.project_contexts(g, ["save"])
self.assertEqual(tuple(r.anchor_id for r in result.recipes), ("s",))
g["p"]["inputs"]["text"] = "changed"
self.assertEqual(result.recipes[0].positive.inferred.raw.value, " cat ")
self.assertEqual(self.m.project_contexts(g, []).recipes, ())
for outputs in (["missing"], [object()], "save"):
result = self.m.project_contexts(g, outputs)
self.assertEqual(result.recipes, ())
self.assertTrue(result.reason)
def test_selected_ancestry_ignores_unrelated_widget_arrays(self):
for widget in (
[1, 2, 3],
("s", 0, "widget"),
[],
["s"],
["s", "0"],
["s", True],
["s", -1],
[["s", 0], 0],
):
with self.subTest(widget=widget):
g = graph()
g["other"] = node("KSampler", positive=["p", 0], negative=["p", 1])
g["adapter"] = node("CustomAdapter", samples=("s", 0), widget=widget)
g["save"] = node("CustomOutput", images=["adapter", 0])
result = self.m.project_contexts(g, ["save"])
self.assertEqual(tuple(r.anchor_id for r in result.recipes), ("s",))
self.assertEqual(result.recipes[0].positive.inferred.raw.value, " cat ")
def test_two_element_link_shapes_are_followed_conservatively(self):
g = graph()
# A typed two-element value cannot be distinguished from a real link.
g["save"] = node("CustomOutput", widget=["s", 0])
self.assertEqual(
tuple(r.anchor_id for r in self.m.project_contexts(g, ["save"]).recipes),
("s",),
)
for value in (["missing", 0], ["x" * 129, 0]):
g["save"]["inputs"]["widget"] = value
result = self.m.project_contexts(g, ["save"])
self.assertEqual(result.recipes, ())
self.assertTrue(result.reason)
def test_projection_limits(self):
for count, succeeds in ((500, True), (501, False)):
g = graph()
g.update({str(i): node("Unused") for i in range(count - 2)})
self.assertEqual(bool(self.m.project_contexts(g, None).recipes), succeeds)
for count, succeeds in ((16, True), (17, False)):
g = {"p": graph()["p"]}
g.update({str(i): graph()["s"] for i in range(count)})
self.assertEqual(bool(self.m.project_contexts(g, None).recipes), succeeds)
for depth, succeeds in ((64, True), (65, False)):
g = graph()
# The CLIP plus reroutes plus literal give exactly depth visited links.
g["p"] = node("CLIPTextEncode", text=["0", 0])
for i in range(depth - 2):
g[str(i)] = node("Reroute", value=[str(i + 1), 0])
g[str(depth - 2)] = node("PrimitiveString", value="known")
role = self.m.project_contexts(g, None).recipes[0].positive
self.assertEqual(role.inferred is not None, succeeds)
g = graph()
g["s" * 129] = g.pop("s")
self.assertFalse(self.m.project_contexts(g, None).recipes)
def test_dynamic_pm_inputs_preserve_observable_sources(self):
for kind, positive_slot, negative_slot in (
("PromptManagerText", 0, 0),
("PromptManagerPair", 2, 3),
("PromptManager", 1, 1),
("PromptManagerFocused", 1, 1),
("PromptManagerSplit", 1, 1),
("PromptManagerPair", 0, 1),
):
with self.subTest(kind=kind, slots=(positive_slot, negative_slot)):
g = graph(source=kind)
g["generator"] = node("UnknownGenerator")
g["p"]["inputs"]["text"] = ["generator", 0]
g["p"]["inputs"]["negative_text"] = ["generator", 0]
g["pos"] = node("CLIPTextEncode", text=["p", positive_slot])
g["neg"] = node("CLIPTextEncode", text=["p", negative_slot])
g["s"]["inputs"] = {"positive": ["pos", 0], "negative": ["neg", 0]}
if kind == "PromptManagerPair" and positive_slot == 0:
g["s"]["inputs"] = {"positive": ["p", 1], "negative": ["p", 0]}
positive_slot, negative_slot = 1, 0
result = self.m.project_contexts(g, None)
recipe = result.recipes[0]
for role, slot in (
(recipe.positive, positive_slot),
(recipe.negative, negative_slot),
):
self.assertEqual(
tuple(ref.output_slot for ref in role.sources), (slot,)
)
self.assertIsNone(role.inferred)
self.assertTrue(role.reason)
self.assertTrue(result.sources)
def test_graph_evidence_distinguishes_literals_from_semantics(self):
result = self.m.project_contexts(graph(), None)
evidence = result.recipes[0].positive.inferred
self.assertEqual(evidence.raw.provenance, "inferred")
self.assertEqual(evidence.raw.source, "graph_literal")
self.assertEqual(evidence.effective.source, "graph_semantics")
def test_derived_raw_and_composition_preserve_field_sources(self):
g = graph(source="PromptManagerText")
g["join"] = node(
"Text Concatenate",
text_a=" a ",
text_b=" b ",
delimiter="-",
clean_whitespace="true",
)
g["p"]["inputs"]["text"] = ["join", 0]
g["clip"] = node("CLIPTextEncode", text=["p", 0])
g["s"]["inputs"] = {"positive": ["clip", 0], "negative": ["clip", 0]}
role = self.m.project_contexts(g, None).recipes[0].positive
self.assertIsNotNone(role.inferred)
self.assertEqual(role.inferred.raw.value, "a-b")
self.assertEqual(role.inferred.raw.source, "graph_semantics")
self.assertEqual(role.inferred.prepend.source, "graph_literal")
def test_selected_custom_outputs_and_multistage_ancestry(self):
g = graph()
g["earlier"] = node("SamplerCustom", positive=["p", 0], negative=["p", 1])
g["s"]["inputs"]["latent_image"] = ["earlier", 0]
g["adapter"] = node("UnknownImageAdapter", source=["s", 0])
g["custom"] = node("CustomOutput", images=["adapter", 0])
result = self.m.project_contexts(g, ["custom"])
self.assertEqual(tuple(r.anchor_id for r in result.recipes), ("s", "earlier"))
def test_total_resolution_visits_are_bounded(self):
# Sixteen recipes each resolving two independent chains of length 16
# exceed 500 total visits even though the graph has fewer than 500 nodes.
g = {"v": node("PrimitiveString", value="known")}
for i in range(15):
g[str(i)] = node("Reroute", value=[str(i + 1), 0] if i < 14 else ["v", 0])
g["clip"] = node("CLIPTextEncode", text=["0", 0])
for i in range(16):
g["s" + str(i)] = node(
"KSampler", positive=["clip", 0], negative=["clip", 0]
)
result = self.m.project_contexts(g, None)
self.assertTrue(result.reason)
self.assertFalse(
result.recipes
and all(
r.positive.inferred is not None and r.negative.inferred is not None
for r in result.recipes
)
)
def test_known_concatenation_remains_inferred(self):
g = graph(source="PromptManagerText")
g["join"] = node(
"StringConcatenate", string_a=["p", 0], string_b="!", delimiter=""
)
g["clip"] = node("CLIPTextEncode", text=["join", 0])
g["s"]["inputs"] = {"positive": ["clip", 0], "negative": ["clip", 0]}
role = self.m.project_contexts(g, None).recipes[0].positive
self.assertIsNotNone(role.inferred)
self.assertEqual(role.inferred.effective.value, "pre cat post!")
self.assertEqual(role.inferred.effective.provenance, "inferred")
self.assertEqual(role.sources[0].node_id, "p")
self.assertEqual(role.reason, "source-transformed")
def test_transform_provenance_stops_at_selected_pm_capture_boundary(self):
for field in ("text", "prepend_text", "append_text"):
for unresolved in (False, True):
with self.subTest(field=field, unresolved=unresolved):
g = transformed_input_graph(field, unresolved)
g["outer"] = node(
"StringConcatenate", string_a=["p", 2], string_b=" outer"
)
g["clip"] = node("CLIPTextEncode", text=["outer", 0])
g["s"]["inputs"]["negative"] = ["clip", 0]
result = self.m.project_contexts(g, None)
positive, negative = (
result.recipes[0].positive,
result.recipes[0].negative,
)
self.assertEqual(
[(ref.node_id, ref.output_slot) for ref in positive.sources],
[("p", 0)],
)
self.assertNotEqual(positive.reason, "source-transformed")
self.assertEqual(negative.reason, "source-transformed")
self.assertEqual(negative.sources[0].node_id, "p")
def test_malformed_inputs_do_not_stringify(self):
class HostValue:
def __str__(self):
raise AssertionError("host object stringified")
for g in (
{HostValue(): node("KSampler")},
{"s": node(HostValue())},
{"s": node("KSampler", positive=HostValue())},
):
self.m.project_contexts(g, None)
def test_composed_affixes_and_retained_projection_are_bounded(self):
g = graph()
g["p"]["inputs"].update(
text="x" * 65536, prepend_text="y" * 65536, append_text="z" * 65536
)
result = self.m.project_contexts(g, None)
self.assertIsNone(result.recipes[0].positive.inferred)
g = graph()
g["p"]["inputs"]["text"] = "x" * 60000
for i in range(15):
g[str(i)] = node("KSampler", positive=["p", 0], negative=["p", 0])
result = self.m.project_contexts(g, None)
self.assertEqual(result.recipes, ())
def test_partial_source_snapshots_keep_literal_fields_without_stateful_substitution(
self,
):
for upstream in ("RuntimeGenerator", "PromptManagerText"):
g = graph()
g["dynamic"] = node(upstream, text="looks literal")
g["p"]["inputs"]["text"] = ["dynamic", 0]
projection = self.m.project_contexts(g, None)
positive = next(
snapshot
for ref, snapshot in projection.source_snapshots
if ref.output_slot == 0
)
self.assertIsNone(positive.raw.value)
self.assertIsNone(positive.effective.value)
self.assertEqual(positive.prepend.value, "pre")
self.assertEqual(positive.append.value, "post")
self.assertEqual(positive.append.provenance, "inferred")