Files
DumiFlex-ComfyUI-Wildcard-P…/engine/modules/derivation_handler.py
T

360 lines
14 KiB
Python

"""Derivation module resolver — IF/ELIF/ELSE rules over the runtime context.
Each rule has an ordered list of branches (index 0 == IF, the rest == ELIF)
and an optional ``else`` clause. Rules evaluate independently top-to-bottom:
the first branch whose condition matches wins; if none match, the ``else``
action (if any) fires; otherwise the rule is a no-op.
Actions mutate the runtime context directly. Multiple rules can target the
same variable; later rules see earlier mutations. Action ``value`` strings
are resolved through resolve_text with surface="derivation" so $var tokens
and {a|b|c} picks work inside derivation action values.
"""
from __future__ import annotations
import re
from typing import Any
from engine.modules import build_resolve_ctx
from engine.modules._seed import derive_module_rng
from engine.modules.dispatcher import ModuleHandler
from engine.syntax import resolve_text
from engine.syntax.types import deref_var_value, split_var_accessor
_VALID_OPS = {
"equals", "not_equals", "contains", "matches",
# Presence-check ops added in 2026-05-09 cycle. `exists`/`not_exists`
# check key presence in ctx regardless of value (empty string still
# counts as present); `is_set`/`is_unset` additionally require the
# value to be non-empty. Both pairs ship so users can pick the
# semantics they need — wildcards can pick options with empty
# values, leaving keys present-but-empty.
"exists", "not_exists", "is_set", "is_unset",
}
_VALID_MODES = {"replace", "append", "prepend"}
def _ctx_get_raw(ctx: Any, name: str) -> Any:
"""Read the RAW stored value for `name` (may be a ListVar) through the
callable-getter -> dict-lookup indirection. Missing / error -> None."""
if ctx is None:
return None
getter = getattr(ctx, "get", None)
if callable(getter):
try:
return getter(name, None)
except TypeError:
try:
return getter(name)
except Exception:
return None
try:
if name in ctx: # type: ignore[operator]
return ctx[name] # type: ignore[index]
except Exception:
return None
return None
def _ctx_get(ctx: Any, name: str) -> str:
"""Read `name` as a string, honoring a `.K` list accessor and folding a
ListVar to its joined value (SP2a): `$mood.0` splits to base `mood` +
index 0. The fold/accessor contract lives in deref_var_value."""
base, index = split_var_accessor(name)
return deref_var_value(_ctx_get_raw(ctx, base), index)
def _ctx_has(ctx: Any, name: str) -> bool:
"""Whether `name` is a known key in `ctx`, regardless of value.
Mirrors `_ctx_get`'s indirection chain (callable getter →
`__contains__` / dict lookup) but returns bool. Required so the
`exists` / `not_exists` ops can distinguish "key absent" from
"key present but value is empty string" — `_ctx_get` collapses
both into `""`. Used only by presence-check ops; non-presence
ops continue to read through `_ctx_get`.
"""
if ctx is None:
return False
name = split_var_accessor(name)[0] # SP2a: presence checks the base var
# Engine ctx is a dict in tests + at runtime; check `__contains__`
# before falling back to a getter probe so we get the cheap path.
try:
return name in ctx # type: ignore[operator]
except Exception:
pass
getter = getattr(ctx, "get", None)
if callable(getter):
# Use a sentinel default so a "missing" key can be told apart
# from a key whose value is `None` or `""`.
sentinel = object()
try:
value = getter(name, sentinel)
except TypeError:
try:
value = getter(name)
except Exception:
return False
return value is not sentinel
return False
def _ctx_set(ctx: Any, name: str, value: str) -> None:
if ctx is None:
return
name = split_var_accessor(name)[0] # SP2a: writing $x.K targets the base var
setter = getattr(ctx, "set", None)
if callable(setter):
setter(name, value)
return
try:
ctx[name] = value # type: ignore[index]
except Exception:
return
def _match_condition(condition: dict[str, Any], ctx: Any) -> bool:
var = condition.get("var", "")
op = condition.get("op", "")
value = condition.get("value", "")
# Presence-check ops short-circuit before reading the value — they
# only care about key presence (and, for is_set/is_unset, whether
# the stored value is non-empty). The `value` field is ignored by
# these ops; the SPA disables the value input when one is selected.
if op == "exists":
return _ctx_has(ctx, var)
if op == "not_exists":
return not _ctx_has(ctx, var)
if op == "is_set":
return _ctx_has(ctx, var) and _ctx_get(ctx, var) != ""
if op == "is_unset":
return not _ctx_has(ctx, var) or _ctx_get(ctx, var) == ""
actual = _ctx_get(ctx, var)
if op == "equals":
return actual == value
if op == "not_equals":
return actual != value
if op == "contains":
return value in actual
if op == "matches":
try:
return re.search(value, actual) is not None
except re.error:
return False
return False
def _apply_action(
action: dict[str, Any],
ctx: Any,
resolve_ctx: Any,
) -> tuple[str, str] | None:
target = action.get("target_var", "")
if not target:
return None
mode = action.get("mode", "replace")
raw_value = str(action.get("value", ""))
new_value = resolve_text(raw_value, resolve_ctx)
if mode == "replace":
result = new_value
elif mode == "append":
result = _ctx_get(ctx, target) + new_value
elif mode == "prepend":
result = new_value + _ctx_get(ctx, target)
else:
return None
_ctx_set(ctx, target, result)
return target, result
def _validate_condition(condition: Any, where: str) -> None:
if not isinstance(condition, dict):
raise ValueError(f"derivation {where}.condition must be an object")
var = condition.get("var")
if not isinstance(var, str) or not var:
raise ValueError(f"derivation {where}.condition.var must be a non-empty string")
op = condition.get("op")
if op not in _VALID_OPS:
raise ValueError(
f"derivation {where}.condition.op must be one of {sorted(_VALID_OPS)}"
)
if not isinstance(condition.get("value", ""), str):
raise ValueError(f"derivation {where}.condition.value must be a string")
def _validate_action(action: Any, where: str) -> None:
if not isinstance(action, dict):
raise ValueError(f"derivation {where}.action must be an object")
target = action.get("target_var")
if not isinstance(target, str) or not target:
raise ValueError(
f"derivation {where}.action.target_var must be a non-empty string"
)
mode = action.get("mode", "replace")
if mode not in _VALID_MODES:
raise ValueError(
f"derivation {where}.action.mode must be one of {sorted(_VALID_MODES)}"
)
if not isinstance(action.get("value", ""), str):
raise ValueError(f"derivation {where}.action.value must be a string")
class DerivationHandler(ModuleHandler):
"""IF / ELIF / ELSE rule evaluator that mutates ctx."""
type_id = "derivation"
@classmethod
def validate_payload(cls, payload: dict[str, Any]) -> None:
if not isinstance(payload, dict):
raise ValueError("derivation payload must be an object")
rules = payload.get("rules")
if not isinstance(rules, list):
raise ValueError("derivation payload.rules must be a list")
seen_rule_ids: set[str] = set()
for ri, rule in enumerate(rules):
if not isinstance(rule, dict):
raise ValueError(f"derivation payload.rules[{ri}] must be an object")
rule_id = rule.get("id")
if not isinstance(rule_id, str) or not rule_id:
raise ValueError(
f"derivation payload.rules[{ri}].id must be a non-empty string"
)
# Rule ids index `instance.disabled_rule_ids` and
# `rule_order_override` — dupes make both ambiguous.
if rule_id in seen_rule_ids:
raise ValueError(
f"derivation payload.rules[{ri}].id {rule_id!r} duplicates "
f"an earlier rule id"
)
seen_rule_ids.add(rule_id)
branches = rule.get("branches")
if not isinstance(branches, list) or not branches:
raise ValueError(
f"derivation payload.rules[{ri}].branches must be a non-empty list"
)
for bi, branch in enumerate(branches):
if not isinstance(branch, dict):
raise ValueError(
f"derivation payload.rules[{ri}].branches[{bi}] must be an object"
)
_validate_condition(
branch.get("condition"), f"rules[{ri}].branches[{bi}]"
)
_validate_action(branch.get("action"), f"rules[{ri}].branches[{bi}]")
else_clause = rule.get("else")
if else_clause is not None:
if not isinstance(else_clause, dict):
raise ValueError(
f"derivation payload.rules[{ri}].else must be an object"
)
_validate_action(else_clause.get("action"), f"rules[{ri}].else")
@classmethod
def resolve(
cls,
payload: dict[str, Any],
instance: dict[str, Any],
ctx: Any,
) -> dict[str, str]:
cls.validate_payload(payload)
# Effective seed selection — same pattern wildcard / combine /
# fixed_values use. Locked seed pins {a|b|c} resolution in
# action.value across runs; without lock the chain seed
# propagates per-queue. Falls back to the host ctx's RNG when
# the ctx wasn't built with `__wp_rng__` (legacy callers).
if isinstance(ctx, dict) and "__wp_rng__" in ctx:
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
locked_seed = instance.get("locked_seed")
effective_seed = (
int(locked_seed) if isinstance(locked_seed, (int, float))
else chain_seed
)
module_id = str(ctx.get("__wp_module_id__") or "derivation")
rng = derive_module_rng(effective_seed, module_id)
ctx_local = {**ctx, "__wp_rng__": rng}
resolve_ctx = build_resolve_ctx(ctx_local, surface="derivation")
else:
resolve_ctx = build_resolve_ctx(ctx, surface="derivation")
# Tier-D instance overrides (2026-05-10 cycle).
disabled_rule_ids = set(instance.get("disabled_rule_ids") or [])
disabled_branch_keys = set(instance.get("disabled_branch_keys") or [])
action_overrides = instance.get("action_value_overrides") or {}
cond_overrides = instance.get("condition_value_overrides") or {}
# Per-instance rule reorder — sort by rule_order_override list.
# IDs not in the override fall through in their original library
# order at the end (defensive against partial reorders).
rules = list(payload.get("rules", []))
order = instance.get("rule_order_override")
if isinstance(order, list) and order:
ordering = {rid: i for i, rid in enumerate(order)}
tail = len(order)
rules.sort(
key=lambda r: ordering.get(r.get("id"), tail + 1),
)
out: dict[str, str] = {}
for rule in rules:
rule_id = rule.get("id", "")
if rule_id in disabled_rule_ids:
continue
applied = False
for bi, branch in enumerate(rule.get("branches", [])):
# Branch-level disable — `r1:1` etc. IF (bi=0) ignored
# even when listed because disabling IF == disabling rule
# (the per-rule toggle handles that case cleanly).
if bi != 0 and f"{rule_id}:{bi}" in disabled_branch_keys:
continue
# Condition-value override per branch index.
cond = branch.get("condition", {})
cond_override = (
cond_overrides.get(rule_id, {}).get(str(bi))
if isinstance(cond_overrides.get(rule_id), dict)
else None
)
if isinstance(cond_override, str):
cond = {**cond, "value": cond_override}
if _match_condition(cond, ctx):
# Action-value override per branch index.
action = branch.get("action", {})
action_override = (
action_overrides.get(rule_id, {}).get(str(bi))
if isinstance(action_overrides.get(rule_id), dict)
else None
)
if isinstance(action_override, str):
action = {**action, "value": action_override}
pair = _apply_action(action, ctx, resolve_ctx)
if pair is not None:
out[pair[0]] = pair[1]
applied = True
break
if not applied:
# ELSE skip when listed in disabled_branch_keys.
if f"{rule_id}:else" in disabled_branch_keys:
continue
else_clause = rule.get("else")
if isinstance(else_clause, dict):
action = else_clause.get("action", {})
# ELSE action-value override under `else` key.
action_override = (
action_overrides.get(rule_id, {}).get("else")
if isinstance(action_overrides.get(rule_id), dict)
else None
)
if isinstance(action_override, str):
action = {**action, "value": action_override}
pair = _apply_action(action, ctx, resolve_ctx)
if pair is not None:
out[pair[0]] = pair[1]
return out