* 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)
427 lines
16 KiB
Python
427 lines
16 KiB
Python
"""Bounded projection of declared sampler roles, independent of the host."""
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from .execution_contexts import PromptSnapshot, TextEvidence, text_size
|
|
from .prompt_pair_resolver import _DEFAULT_ADAPTERS
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SourceRef:
|
|
node_id: str
|
|
node_type: str
|
|
output_slot: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GraphRole:
|
|
sources: tuple[SourceRef, ...]
|
|
inferred: PromptSnapshot | None
|
|
reason: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DeclaredRecipe:
|
|
anchor_id: str
|
|
positive: GraphRole
|
|
negative: GraphRole
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GraphProjection:
|
|
recipes: tuple[DeclaredRecipe, ...]
|
|
sources: tuple[SourceRef, ...]
|
|
reason: str
|
|
source_snapshots: tuple[tuple[SourceRef, PromptSnapshot], ...] = ()
|
|
|
|
|
|
_SAMPLERS = {"KSampler", "KSamplerAdvanced", "SamplerCustom"}
|
|
_PM = {
|
|
"PromptManager",
|
|
"PromptManagerFocused",
|
|
"PromptManagerSplit",
|
|
"PromptManagerText",
|
|
"PromptManagerPair",
|
|
}
|
|
|
|
|
|
class _Unknown(ValueError):
|
|
pass
|
|
|
|
|
|
class _Limit(_Unknown):
|
|
pass
|
|
|
|
|
|
def _identifier(value: object) -> str:
|
|
if type(value) is int:
|
|
if abs(value) > 10**127:
|
|
raise _Unknown("invalid_node_identifier")
|
|
value = str(value)
|
|
try:
|
|
text_size(value, 512)
|
|
if len(value) > 128:
|
|
raise ValueError("oversized_node_identifier")
|
|
except ValueError:
|
|
raise _Unknown("invalid_node_identifier") from None
|
|
if not value:
|
|
raise _Unknown("invalid_node_identifier")
|
|
return value
|
|
|
|
|
|
def _link(value: object) -> tuple[str, int]:
|
|
if (
|
|
type(value) not in (tuple, list)
|
|
or len(value) != 2
|
|
or type(value[1]) is not int
|
|
or value[1] < 0
|
|
):
|
|
raise _Unknown("expected_typed_graph_link")
|
|
return _identifier(value[0]), value[1]
|
|
|
|
|
|
class _Projector:
|
|
def __init__(self, graph: dict) -> None:
|
|
self.graph = graph
|
|
self.visits = 0
|
|
self.retained = 0
|
|
self.transformed = False
|
|
self.selected_seen = set()
|
|
|
|
def node(self, node_id: str, depth: int) -> tuple[str, dict]:
|
|
self.visits += 1
|
|
if self.visits > 500 or depth > 64:
|
|
raise _Unknown("graph_resolution_limit_reached")
|
|
node = self.graph.get(node_id)
|
|
if type(node) is not dict or type(node.get("inputs")) is not dict:
|
|
raise _Unknown("missing_or_malformed_node")
|
|
if len(node["inputs"]) > 500:
|
|
raise _Unknown("node_input_limit_reached")
|
|
kind = node.get("class_type")
|
|
try:
|
|
text_size(kind, 128)
|
|
except ValueError:
|
|
raise _Unknown("invalid_node_type") from None
|
|
return kind, node["inputs"]
|
|
|
|
def text(
|
|
self, value: object, kind: str, depth: int = 1, active: frozenset = frozenset()
|
|
) -> tuple[PromptSnapshot, tuple]:
|
|
if type(value) is str and kind == "string":
|
|
text_size(value)
|
|
return self.snapshot(value, value, "", ""), ()
|
|
node_id, slot = _link(value)
|
|
key = (node_id, slot)
|
|
if key in active:
|
|
raise _Unknown("cyclic_prompt_graph")
|
|
name, inputs = self.node(node_id, depth)
|
|
active = active | {key}
|
|
if name == "Reroute" and slot == 0:
|
|
keys = [key for key in ("value", "input", "in") if key in inputs]
|
|
if len(keys) != 1:
|
|
raise _Unknown("ambiguous_reroute")
|
|
return self.text(inputs[keys[0]], kind, depth + 1, active)
|
|
if (
|
|
name in ("StringConcatenate", "Text Concatenate")
|
|
and slot == 0
|
|
and kind == "string"
|
|
):
|
|
keys = (
|
|
["string_a", "string_b"]
|
|
if name == "StringConcatenate"
|
|
else sorted(
|
|
key
|
|
for key in inputs
|
|
if type(key) is str and re.fullmatch(r"text_[a-z]", key)
|
|
)
|
|
)
|
|
delimiter = inputs.get(
|
|
"delimiter", "" if name == "StringConcatenate" else " "
|
|
)
|
|
text_size(delimiter)
|
|
clean_value = inputs.get("clean_whitespace", "true")
|
|
if name == "Text Concatenate" and type(clean_value) not in (str, bool):
|
|
raise _Unknown("invalid_whitespace_mode")
|
|
clean = name == "Text Concatenate" and (
|
|
clean_value is True
|
|
or type(clean_value) is str
|
|
and clean_value.lower() == "true"
|
|
)
|
|
if not keys:
|
|
raise _Unknown("missing_concatenation_inputs")
|
|
parts, sources, size = [], [], 0
|
|
for input_name in keys:
|
|
child, refs = self.text(
|
|
inputs.get(input_name, ""), "string", depth + 1, active
|
|
)
|
|
part = child.effective.value.strip() if clean else child.effective.value
|
|
if part or name == "StringConcatenate":
|
|
size += text_size(part) + (text_size(delimiter) if parts else 0)
|
|
if size > 65536:
|
|
raise _Unknown("text_field_limit_reached")
|
|
parts.append(part)
|
|
sources.extend(refs)
|
|
if len(sources) > 64:
|
|
raise _Unknown("source_reference_limit_reached")
|
|
text = delimiter.join(parts)
|
|
return self.snapshot(
|
|
text, text, "", "", composed=True, raw_composed=True
|
|
), tuple(sources)
|
|
adapter = _DEFAULT_ADAPTERS.get((name, slot))
|
|
if adapter is None or adapter.kind != kind:
|
|
raise _Unknown("unsupported_node_semantics_or_output_slot")
|
|
child, sources = self.text(
|
|
inputs.get(adapter.input_name), "string", depth + 1, active
|
|
)
|
|
if name in _PM:
|
|
sources = (SourceRef(node_id, name, slot),)
|
|
if adapter.compose:
|
|
before, before_sources = self.text(
|
|
inputs.get("prepend_text", ""), "string", depth + 1, active
|
|
)
|
|
after, after_sources = self.text(
|
|
inputs.get("append_text", ""), "string", depth + 1, active
|
|
)
|
|
raw = child.effective.value
|
|
prepend, append = before.effective.value, after.effective.value
|
|
parts = [part for part in (prepend.strip(), raw, append.strip()) if part]
|
|
if sum(text_size(part) for part in parts) + max(0, len(parts) - 1) > 65536:
|
|
raise _Unknown("text_field_limit_reached")
|
|
effective = " ".join(parts)
|
|
text_size(effective)
|
|
snapshot = self.retain(
|
|
PromptSnapshot(
|
|
child.effective,
|
|
TextEvidence(effective, "inferred", "graph_semantics", None),
|
|
before.effective,
|
|
after.effective,
|
|
)
|
|
)
|
|
if name not in _PM:
|
|
sources += before_sources + after_sources
|
|
elif name in _PM:
|
|
snapshot = self.retain(
|
|
PromptSnapshot(
|
|
child.effective,
|
|
child.effective,
|
|
TextEvidence("", "inferred", "graph_literal", None),
|
|
TextEvidence("", "inferred", "graph_literal", None),
|
|
)
|
|
)
|
|
else:
|
|
snapshot = child
|
|
if len(sources) > 64:
|
|
raise _Unknown("source_reference_limit_reached")
|
|
return snapshot, sources
|
|
|
|
def source_snapshot(self, source: SourceRef) -> PromptSnapshot:
|
|
name, inputs = self.node(source.node_id, 1)
|
|
adapter = _DEFAULT_ADAPTERS[(name, source.output_slot)]
|
|
unknown = TextEvidence(None, "unknown", "unknown", "missing")
|
|
|
|
def literal(value: object) -> TextEvidence:
|
|
try:
|
|
snapshot, sources = self.text(value, "string", 2)
|
|
# Stateful PM outputs cannot prove their injected result from inputs.
|
|
return unknown if sources else snapshot.effective
|
|
except _Limit:
|
|
raise
|
|
except ValueError:
|
|
return unknown
|
|
|
|
raw = literal(inputs.get(adapter.input_name))
|
|
before = literal(inputs.get("prepend_text", "") if adapter.compose else "")
|
|
after = literal(inputs.get("append_text", "") if adapter.compose else "")
|
|
return self.retain(PromptSnapshot(raw, unknown, before, after))
|
|
|
|
def discover_sources(
|
|
self, value: object, kind: str, depth: int = 1, active: frozenset = frozenset()
|
|
) -> tuple[SourceRef, ...]:
|
|
if type(value) is str and kind == "string":
|
|
return ()
|
|
node_id, slot = _link(value)
|
|
key = (node_id, slot)
|
|
if key in active:
|
|
raise _Unknown("cyclic_prompt_graph")
|
|
name, inputs = self.node(node_id, depth)
|
|
active = active | {key}
|
|
adapter = _DEFAULT_ADAPTERS.get((name, slot))
|
|
if adapter is not None and adapter.kind == kind:
|
|
if name in _PM:
|
|
return (SourceRef(node_id, name, slot),)
|
|
return self.discover_sources(
|
|
inputs.get(adapter.input_name), "string", depth + 1, active
|
|
)
|
|
if name == "Reroute" and slot == 0:
|
|
keys = [key for key in ("value", "input", "in") if key in inputs]
|
|
if len(keys) != 1:
|
|
raise _Unknown("ambiguous_reroute")
|
|
return self.discover_sources(inputs[keys[0]], kind, depth + 1, active)
|
|
if (
|
|
name in ("StringConcatenate", "Text Concatenate")
|
|
and slot == 0
|
|
and kind == "string"
|
|
):
|
|
keys = (
|
|
["string_a", "string_b"]
|
|
if name == "StringConcatenate"
|
|
else sorted(
|
|
key
|
|
for key in inputs
|
|
if type(key) is str and re.fullmatch(r"text_[a-z]", key)
|
|
)
|
|
)
|
|
sources = []
|
|
for key in keys:
|
|
sources.extend(
|
|
self.discover_sources(
|
|
inputs.get(key, ""), "string", depth + 1, active
|
|
)
|
|
)
|
|
if len(sources) > 64:
|
|
raise _Unknown("source_limit_reached")
|
|
self.transformed = self.transformed or bool(sources)
|
|
return tuple(sources)
|
|
return ()
|
|
|
|
def retain(self, snapshot: PromptSnapshot) -> PromptSnapshot:
|
|
size = sum(
|
|
(text_size(field.value) if field.value is not None else 0) + 64
|
|
for field in (
|
|
snapshot.raw,
|
|
snapshot.effective,
|
|
snapshot.prepend,
|
|
snapshot.append,
|
|
)
|
|
)
|
|
if self.retained + size > 1048576:
|
|
raise _Limit("projection_payload_limit_reached")
|
|
self.retained += size
|
|
return snapshot
|
|
|
|
def snapshot(
|
|
self,
|
|
raw: str,
|
|
effective: str,
|
|
prepend: str,
|
|
append: str,
|
|
composed: bool = False,
|
|
raw_composed: bool = False,
|
|
) -> PromptSnapshot:
|
|
return self.retain(
|
|
PromptSnapshot(
|
|
*(
|
|
TextEvidence(
|
|
value,
|
|
"inferred",
|
|
(
|
|
"graph_semantics"
|
|
if (composed and index == 1 or raw_composed and index == 0)
|
|
else "graph_literal"
|
|
),
|
|
None,
|
|
)
|
|
for index, value in enumerate((raw, effective, prepend, append))
|
|
)
|
|
)
|
|
)
|
|
|
|
def role(self, value: object) -> GraphRole:
|
|
sources = ()
|
|
try:
|
|
self.transformed = False
|
|
# Only the capture-source-to-consumer path determines transforms.
|
|
sources = self.discover_sources(value, "conditioning")
|
|
snapshot, _ = self.text(value, "conditioning")
|
|
return GraphRole(
|
|
sources, snapshot, "source-transformed" if self.transformed else ""
|
|
)
|
|
except _Limit:
|
|
raise
|
|
except ValueError as error:
|
|
reason = str(error) if isinstance(error, _Unknown) else "invalid_text_value"
|
|
return GraphRole(
|
|
sources,
|
|
None,
|
|
"source-transformed" if self.transformed and sources else reason,
|
|
)
|
|
|
|
def anchors(self, node_id: str, depth: int = 1) -> tuple[str, ...]:
|
|
if node_id in self.selected_seen:
|
|
return ()
|
|
self.selected_seen.add(node_id)
|
|
name, inputs = self.node(node_id, depth)
|
|
anchors = [node_id] if name in _SAMPLERS else []
|
|
for value in inputs.values():
|
|
if (
|
|
type(value) not in (tuple, list)
|
|
or len(value) != 2
|
|
or type(value[0]) not in (str, int)
|
|
or type(value[1]) is not int
|
|
or value[1] < 0
|
|
):
|
|
continue
|
|
parent, _ = _link(value)
|
|
anchors.extend(self.anchors(parent, depth + 1))
|
|
if len(anchors) > 16:
|
|
raise _Unknown("recipe_context_limit_reached")
|
|
return tuple(anchors)
|
|
|
|
|
|
def project_contexts(graph: object, outputs: object) -> GraphProjection:
|
|
"""Keep declared roles and graph inference, never evidence of consumption.
|
|
|
|
Sources are PM capture points. An inferred snapshot may compose text; its
|
|
presence never promotes a source observation to recorded effective text.
|
|
"""
|
|
try:
|
|
if type(graph) is not dict or len(graph) > 500:
|
|
raise _Unknown("invalid_or_oversized_graph")
|
|
normalized = {_identifier(key): value for key, value in graph.items()}
|
|
if len(normalized) != len(graph):
|
|
raise _Unknown("duplicate_graph_identifiers")
|
|
resolver = _Projector(normalized)
|
|
if outputs is None:
|
|
anchors = [
|
|
key
|
|
for key, value in normalized.items()
|
|
if type(value) is dict
|
|
and type(value.get("class_type")) is str
|
|
and value["class_type"] in _SAMPLERS
|
|
]
|
|
elif type(outputs) in (list, tuple) and len(outputs) <= 500:
|
|
anchors = []
|
|
for output in outputs:
|
|
anchors.extend(resolver.anchors(_identifier(output)))
|
|
anchors = list(dict.fromkeys(anchors))
|
|
else:
|
|
raise _Unknown("invalid_output_selection")
|
|
if len(anchors) > 16:
|
|
raise _Unknown("recipe_context_limit_reached")
|
|
recipes, sources = [], []
|
|
for anchor in anchors:
|
|
_, inputs = resolver.node(anchor, 1)
|
|
positive = resolver.role(inputs.get("positive"))
|
|
negative = resolver.role(inputs.get("negative"))
|
|
recipes.append(DeclaredRecipe(anchor, positive, negative))
|
|
sources.extend(positive.sources + negative.sources)
|
|
sources = tuple(dict.fromkeys(sources))
|
|
if len(sources) > 64:
|
|
raise _Unknown("source_reference_limit_reached")
|
|
reason = "" if recipes else "no_selected_sampler_anchors"
|
|
if any(
|
|
role.reason
|
|
for recipe in recipes
|
|
for role in (recipe.positive, recipe.negative)
|
|
):
|
|
reason = "some_roles_require_inferred_or_unresolved_text"
|
|
return GraphProjection(
|
|
tuple(recipes),
|
|
sources,
|
|
reason,
|
|
tuple((source, resolver.source_snapshot(source)) for source in sources),
|
|
)
|
|
except ValueError as error:
|
|
reason = str(error) if isinstance(error, _Unknown) else "invalid_text_value"
|
|
return GraphProjection((), (), reason)
|