Files
DumiFlex-ComfyUI-Wildcard-P…/wp_nodes/types.py
T
DumiFlex be1f765243 feat(engine): constraint reach selector (first/next/all) + multi-constraint combine; retire one-shot
Replace the constraint one-shot/consumed-set model with a reach selector:
each constraint's target_select (default {mode:"all"}) decides which firing
target instances it covers, and every covering constraint is applied +
combined sequentially. A per-constraint hit counter (__wp_constraint_hits__)
replaces the consumed-set; reach is downstream-relative (a constraint is only
in ctx after its module ran).

- apply_constraints_for_target rewritten: hits/firing_uid/carrier_ctx params,
  _occurrence_matches matcher (carrier_ctx None from callers — nested-pick
  threading is a later task). consumed= kept accepted+ignored for back-compat.
- claim_carrier_constraints deleted (the positional spill failsafe is obsolete
  without consumption); its import + both wildcard_handler call sites + the
  _claim_carried helper removed.
- resolve.py nested path shares the ctx hit counter (get_constraint_hits) so
  first/next coverage spans direct + nested encounters; carrier_ctx=None.
- pipeline.py finalisation now hits-based: constraint_never_applied when a
  selector covered zero, new constraint_partial_reach when next N / pick
  matched fewer than asked.
- cross-node: __wp_constraint_hits__ replaces __wp_consumed_constraints__ in
  _CROSS_NODE_INTERNAL_KEYS; build_payload dedup also collapses partial_reach.

Existing one-shot tests adapted to the new semantics:
- test_constraint_first_instance: the implicit "fires once" tests re-expressed
  via explicit target_select={mode:"first"}; added an all-mode-default test and
  a partial_reach finalisation test. The pre-seeded-consumed no-op test became a
  first-mode "counter already past 1" test (consumed-set concept gone).
- test_user_chain_simulation: consumed assertions → hit-count assertions; the
  carrier-claim tests (no-spill, null-pick claim, source-not-picked, sibling
  one-shots) rewritten to the new model — a carrier that doesn't resolve the ref
  no longer "claims" anything, so the constraint applies on the next actual
  resolution (or legitimately never fires → never_applied).
- test_module_interactions: "only first fires" composition test replaced with a
  two-all-constraints-compose-on-one-target test (both excludes zero the pool →
  excludes-all fallback), plus a single-all-constraint survivor test.
2026-06-11 06:21:57 +03:00

648 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Custom ComfyUI socket type ``PIPELINE_CONTEXT`` and widget-only types."""
import json
from dataclasses import dataclass, field
from typing import Any
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
from comfy_api.latest._io import ( # pyright: ignore[reportMissingImports]
ComfyTypeIO,
comfytype,
)
from engine.context import strip_engine_internals
from engine.modules import Module, module_from_dict
@dataclass(frozen=True)
class ContextPayload:
"""Value carried across ``PIPELINE_CONTEXT`` sockets.
``context`` holds user-facing variables (internals stripped). ``debug``
carries run metadata (upstream snapshot, seed, trace) for the debug node.
``internals`` is a deliberate carve-out for engine-internal state that
MUST survive cross-node boundaries — currently `__wp_picks__` (so a
downstream constraint-aware wildcard can read its source's pick from
a wildcard that lived in an upstream Context) and `__wp_constraints__`
(so a constraint registered upstream still applies to a downstream
target wildcard). The standard `strip_internals` filter would drop
these alongside trace/rng/etc; this field lets them travel without
leaking into the user-facing `context` payload that the assembler
consumes.
"""
context: dict[str, Any] = field(default_factory=dict)
debug: dict[str, Any] = field(default_factory=dict)
internals: dict[str, Any] = field(default_factory=dict)
@comfytype(io_type="PIPELINE_CONTEXT")
class PipelineContext(ComfyTypeIO):
"""Socket type carrying ContextPayload across nodes."""
Type = ContextPayload
@comfytype(io_type="WP_CONTEXT_MODULES")
class ContextModulesInput:
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_CONTEXT_MODULES"]``."""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None, # widget_type
None, # force_input
extra_dict,
raw_link,
advanced,
)
@comfytype(io_type="WP_DEBUG_VIEWER")
class DebugViewerInput:
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_DEBUG_VIEWER"]``."""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None,
None,
extra_dict,
raw_link,
advanced,
)
@comfytype(io_type="WP_CLEANER")
class CleanerWidgetInput:
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_CLEANER"]``.
The widget payload is a JSON string holding the cleaner config:
``{mode, intensity, rules_override, blocklist, preset_id?}``. The
cleaner node parses it at execute time via ``json.loads``.
"""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None,
None,
extra_dict,
raw_link,
advanced,
)
@comfytype(io_type="WP_INJECTOR_ROWS")
class InjectorRowsInput:
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_INJECTOR_ROWS"]``."""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None, # widget_type
None, # force_input
extra_dict,
raw_link,
advanced,
)
def deserialize_modules(payload: str) -> list[Module]:
"""Parse the widget JSON payload into a list of engine module dataclasses.
Raises ``ValueError`` on malformed JSON or a non-dict root.
"""
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid modules JSON: {exc.msg}") from exc
if not isinstance(data, dict):
raise ValueError(
f"Modules payload must be a JSON object, got {type(data).__name__}"
)
raw_modules = data.get("modules") or []
return [module_from_dict(entry) for entry in raw_modules]
def deserialize_node_input(
raw: "dict[str, Any] | str | None",
) -> "tuple[list[dict[str, Any]], dict[str, Any], list[str]]":
"""Parse a WP_Context node's ``modules`` widget value.
Returns ``(modules, catalog, pick_order)``:
- ``modules``: ordered list of every entry the user has in
the widget — wildcards, combines, derivations, constraints,
pipelines, fixed_values, all in one list. Same shape SPA
editors emit + ``PipelineEngine.run`` accepts.
- ``catalog``: the wildcard subset of ``modules``, keyed by
``id`` (= 8-hex uuid), each value mapped back to the
canonical ``SnapshotEntry`` shape the engine resolver
consumes via ``ctx["__wp_catalog__"]``.
- ``pick_order``: kept in the return signature for back-compat
with existing callers; always empty under the unified model
(the modules list itself preserves order).
Returns the modules portion as a ``list[dict]`` rather than the
typed ``list[Module]`` produced by ``deserialize_modules``. The
legacy ``module_from_dict`` requires fields the SPA shape doesn't
carry (and only knows how to type ``fixed_values``), so the raw-
dict path is the safe one for the unified shape — the engine
already coerces dicts at execute time.
Robust to malformed input: any failure path returns
``([], {}, [])`` so graph runs never crash on a broken widget
value.
"""
if raw is None:
return [], {}, []
if isinstance(raw, str):
try:
data = json.loads(raw)
except Exception:
return [], {}, []
else:
data = raw
if not isinstance(data, dict):
return [], {}, []
raw_modules = data.get("modules")
modules: list[dict[str, Any]] = (
list(raw_modules) if isinstance(raw_modules, list) else []
)
# Bundle enable-gate. `BundleInstance.enabled` used to cascade into
# every child's `module.enabled` at toggle time, which destroyed each
# child's individual on/off state every time the user disabled a
# bundle. Now the bundle gate is a non-destructive overlay applied at
# the engine boundary: child.instance.enabled stays whatever the user
# set, and we AND it with the bundle's gate here so the engine
# (which only knows about flat `modules[].enabled`) sees the
# effective view without needing bundle awareness.
#
# Effective enabled = bundle.enabled AND every ancestor bundle's
# enabled AND child.enabled. Tier-2 nesting walks one parent_uid
# hop; the API cap forbids deeper chains so the loop terminates
# in at most two steps.
raw_bundles = data.get("bundles")
bundle_enabled: dict[str, bool] = {}
bundle_parent: dict[str, str | None] = {}
if isinstance(raw_bundles, list):
for b in raw_bundles:
if not isinstance(b, dict):
continue
uid = b.get("_uid")
if isinstance(uid, str):
bundle_enabled[uid] = bool(b.get("enabled", True))
parent = b.get("parent_uid")
bundle_parent[uid] = parent if isinstance(parent, str) else None
if bundle_enabled:
def _bundle_chain_enabled(uid: str) -> bool:
# Walk up parent_uid, return false on the first disabled ancestor.
# Defensive depth cap (8) absorbs corrupt cycles even though
# the API tier-2 rule keeps real chains at 1–2 hops.
seen: set[str] = set()
cur: str | None = uid
depth = 0
while cur is not None and cur not in seen and depth < 8:
seen.add(cur)
if bundle_enabled.get(cur, True) is False:
return False
cur = bundle_parent.get(cur)
depth += 1
return True
gated: list[dict[str, Any]] = []
for m in modules:
if not isinstance(m, dict):
gated.append(m)
continue
origin = m.get("bundle_origin")
if isinstance(origin, str) and not _bundle_chain_enabled(origin):
# Shallow-clone so we don't mutate the workflow-state
# row the SPA holds onto for undo / restore.
gated.append({**m, "enabled": False})
else:
gated.append(m)
modules = gated
# Build the wildcard catalog from the modules list directly.
# Filter by `type == "wildcard"` and synthesise a SnapshotEntry
# per row keyed by `id`. Resolver looks up `ctx["__wp_catalog__"][uuid]`
# and reads `entry["payload"]["options"]` — that's the only
# contract this map needs to satisfy.
catalog: dict[str, dict[str, Any]] = {}
for m in modules:
if not isinstance(m, dict):
continue
if m.get("type") != "wildcard":
continue
mid = m.get("id")
if not isinstance(mid, str):
continue
payload = m.get("payload")
if not isinstance(payload, dict):
continue
catalog[mid] = {
"snapshot_version": 1,
"uuid": mid,
"type": "wildcard",
"name": m.get("meta", {}).get("name") if isinstance(m.get("meta"), dict) else "",
"payload": payload,
"payload_hash": m.get("payload_hash") or "",
"source": {"kind": "user"},
}
return modules, catalog, []
#: Internal ctx keys whose lifetime spans the full graph chain rather
#: than a single Context node. Pre-fix every `__`-prefixed key got
#: dropped at each socket boundary, so `__wp_picks__` (set when a
#: wildcard rolls) and `__wp_constraints__` (set when a constraint
#: module registers) didn't survive between nodes — a constraint in
#: Context B targeting a wildcard in Context C couldn't see the
#: source's pick recorded in Context A. Listed here explicitly so
#: future cross-node internals are an opt-in addition rather than a
#: default-leak.
_CROSS_NODE_INTERNAL_KEYS = (
"__wp_picks__",
"__wp_constraints__",
# `__wp_constraints__` propagates so a constraint registered in
# Context A can fire on a wildcard rolled in Context B. The matching
# SP3 reach-selector hit counter ALSO has to propagate — without it,
# Context B's first/next counters reset (a `first` constraint would
# re-fire on B's first target instance even though A already spent
# it), and B re-runs the never_applied end-of-run check against
# constraints A already applied, re-emitting the same warnings.
"__wp_constraint_hits__",
# User-marked-internal vars propagate across nodes as regular keys
# in `context`, but downstream PromptAssemblers need to know WHICH
# of those are internal so they can skip them at render time. Carry
# the flag map alongside picks/constraints so the next node's
# PromptAssembler can call `strip_internals` and re-apply the filter.
"__wp_internal_flags__",
# Loop bookkeeping from WP_ContextLoop. These reach the FIRST
# WP_Context (direct ContextLoop child) via the payload internals,
# but were dropped at that node's output boundary — so a SECOND,
# chained WP_Context defaulted loop_index=0 and seed_override=None,
# making its own seed (whatever drives it — widget OR an external
# node like rgthree Seed) identical across every iteration, and
# ignoring override entirely. Propagating them lets effective_chain_seed
# vary + override the seed for EVERY node in the chain, not just the
# first (2026-05-26).
# - __wp_loop_index__ : per-iteration index (drives the XOR shift)
# - __wp_seed_override__: derived base seed when override_seed=true
# - __wp_loop_seeds__ : the full derived series (debug display)
"__wp_loop_index__",
"__wp_seed_override__",
"__wp_loop_seeds__",
)
def build_payload(
ctx: dict[str, Any],
upstream_debug: dict[str, Any],
seed: int,
) -> ContextPayload:
"""Construct the socket-boundary payload. Strips internals from
`context`, but carries the cross-node-internal subset on the
dedicated `internals` field so the next node's execute can merge
them back into its ctx before running its own pipeline.
Trace + warnings accumulate across the chain in `debug.__wp_trace__` /
`debug.__wp_warnings__` (cumulative-merge pattern matching the
injector node) so a terminal WPDebug at the chain tail sees every
module + injector contribution, not just the immediate upstream's.
"""
internals = {
key: ctx[key]
for key in _CROSS_NODE_INTERNAL_KEYS
if key in ctx
}
upstream_trace = upstream_debug.get("__wp_trace__", [])
upstream_warnings = upstream_debug.get("__wp_warnings__", [])
this_trace = ctx.get("__wp_trace__", [])
this_warnings = ctx.get("__wp_warnings__", [])
# Dedup constraint finalisation warnings across the cross-node
# accumulator. Even with __wp_constraint_hits__ now propagating
# cross-node (so downstream knows what upstream applied), every
# Context still runs its own end-of-run check — if a constraint
# legitimately never fires anywhere (target wildcard absent from
# the whole chain) or under-reaches a `next N` / `pick` selector,
# every node along the way emits the same row and the user sees N
# copies. Keying on (type, module_id) here collapses them to one.
_DEDUP_CONSTRAINT_WARNINGS = frozenset({
"constraint_never_applied",
"constraint_partial_reach",
})
combined_warnings = list(upstream_warnings) + list(this_warnings)
deduped: list[dict[str, Any]] = []
seen_keys: set[tuple[str, str]] = set()
for w in combined_warnings:
if not isinstance(w, dict):
deduped.append(w)
continue
wtype = w.get("type")
if wtype in _DEDUP_CONSTRAINT_WARNINGS:
mid = w.get("module_id") or ""
key = (str(wtype), str(mid))
if key in seen_keys:
continue
seen_keys.add(key)
deduped.append(w)
return ContextPayload(
context=strip_engine_internals(ctx),
debug={
"upstream": upstream_debug,
"node_seed": seed,
"__wp_trace__": list(upstream_trace) + list(this_trace),
"__wp_warnings__": deduped,
},
internals=internals,
)
@comfytype(io_type="WP_VAR_PICKER")
class VarPickerInput:
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_VAR_PICKER"]``.
The widget renders a dropdown of upstream ``$var`` names + a live
parse preview strip. Stored as a plain string property
(``node.properties["var_name"]``) carrying the ``"$seed"`` form;
Python ``lookup_var`` strips the leading ``$`` on read.
"""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None,
None,
extra_dict,
raw_link,
advanced,
)
@comfytype(io_type="WP_CONTEXT_LOOP_CONFIG")
class ContextLoopConfigInput(ComfyTypeIO):
"""Custom type — Input renders as a widget via
``getCustomWidgets["WP_CONTEXT_LOOP_CONFIG"]`` (JSON string config);
Output carries the parsed dict so sibling nodes like WP_SeedList can
mirror WP_ContextLoop's count / strategy / base_seed without the
user double-wiring widgets.
Input payload: JSON string ``{strategy, override_seed,
iteration_var_name, bypass}``. ``WPContextLoop.execute`` parses it
defensively (recovery path tolerates missing / malformed keys).
Output payload: dict ``{count, strategy, base_seed, override_seed}``
emitted by ``WPContextLoop.execute`` — the resolved view (post-
bypass count, validated strategy). Receivers should still defend
against unexpected shapes when reading (see
``wp_nodes/seed_list.py::_resolve_config`` for the pattern).
"""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None,
None,
extra_dict,
raw_link,
advanced,
)
@comfytype(io_type="WP_CONTEXT_LOOP_WIDGET")
class ContextLoopWidgetInput(ComfyTypeIO):
"""Widget-only twin of ``ContextLoopConfigInput``.
Earlier the Loop's DOM widget AND the wire payload both used
``WP_CONTEXT_LOOP_CONFIG``. The ``getCustomWidgets`` factory is keyed
by TYPE, so registering a factory for ``WP_CONTEXT_LOOP_CONFIG`` made
EVERY consumer of that type (including WP_SeedList's
``loop_config`` socket) try to mount the Loop's DOM widget. Gating
the factory on input-name kept the widget off SeedList but ComfyUI
still hid the socket because a (broken / undefined) widget was
registered for the input's type.
Split fix: this widget-only type is unique to WP_ContextLoop's
own ``wp_context_loop_config`` input. ``WP_CONTEXT_LOOP_CONFIG``
stays as the wire-payload type — Loop's OUTPUT and SeedList's INPUT
both use that, with NO factory registered → ComfyUI renders them as
plain sockets and the user can wire Loop → SeedList directly.
Payload format is identical to ``ContextLoopConfigInput.Input``'s
(JSON string with the same keys). Kept as a separate class so the
canvas typing can discriminate without runtime ambiguity.
"""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None,
None,
extra_dict,
raw_link,
advanced,
)
@comfytype(io_type="WP_SEED_LIST_CONFIG")
class SeedListConfigInput(ComfyTypeIO):
"""Custom type — Input renders as a widget via
``getCustomWidgets["WP_SEED_LIST_CONFIG"]`` (JSON string config).
Holds WP_SeedList's strategy chip + two override toggles in a
single socketless DOM widget so the canvas isn't littered with
three separate stock widgets for what is conceptually one
configuration block.
Payload: JSON string ``{strategy, override_seed, override_config}``.
``WPSeedList.execute`` parses it defensively (unknown strategy →
"hash_index", non-bool → False) before resolving against the
optional ``loop_config`` socket.
"""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None,
None,
extra_dict,
raw_link,
advanced,
)