Files
DumiFlex-ComfyUI-Wildcard-P…/engine/syntax/resolve.py
T
DumiFlex 05d53a0c6c fix(wildcard): an excluded pool emits nothing instead of the first option
_pick_weighted returned options[0] when every weight was zero. That looked
like a working pick - a real option, right shape, right place - and stayed
wrong silently until the list was reordered and the choice changed. It hid
both over-narrowed constraints and a wildcard whose options were all
disabled.

Now returns None, which every caller already turned into an empty binding.
That is what the null option means, so the engine had the vocabulary for
this already.

Applies to all three pools that constraints can zero: the chain handler, the
nested-@{} path in the resolver, and the SPA preview. The inline {a|b}
branch weights are left alone - a user writing {0::a|0::b} is a different
situation and constraints never touch it.

Three tests asserted the old behaviour and are rewritten to state the new
one; the constraint_excludes_all_options warning stays, and now
distinguishes an over-narrowed pool from a legitimate null roll.
2026-08-18 17:38:05 +03:00

653 lines
26 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.
"""Token-walking resolver. Mirrors `resolveTokens.ts` (Task 12 will land that).
Each token kind has a `_resolve_<kind>` helper. The main loop walks tokens
left-to-right, dispatches by kind, and concatenates results. Recursion
happens inside `_resolve_ref` (Task 9) and brace resolvers (Tasks 10–11).
"""
from __future__ import annotations
import logging
from engine.syntax.subcat_filter import matches as _subcat_matches
from engine.syntax.subcat_filter import parse as _parse_subcat
from engine.syntax.tokenize import tokenize_text
from engine.syntax.types import (
CycleDetectedError,
RecursionLimitExceeded,
RefOutOfSurfaceError,
ResolveContext,
Token,
TokenKind,
UnknownRefError,
deref_var_value,
)
logger = logging.getLogger(__name__)
def resolve_text(text: str, ctx: ResolveContext) -> str:
"""Resolve `text` against `ctx`, returning the final string.
Walks tokens once; for each token, dispatches to a per-kind resolver.
`depth` and `visited` are managed internally during ref recursion.
"""
tokens = tokenize_text(text)
return _resolve_tokens(tokens, ctx, depth=0, visited=())
def _resolve_tokens(
tokens: list[Token],
ctx: ResolveContext,
depth: int,
visited: tuple[str, ...],
) -> str:
parts: list[str] = []
for tok in tokens:
if tok.kind == TokenKind.TEXT:
parts.append(tok.raw)
elif tok.kind == TokenKind.ESCAPE:
parts.append(tok.meta.get("literal", ""))
elif tok.kind == TokenKind.VAR:
parts.append(_resolve_var(tok, ctx))
elif tok.kind == TokenKind.REF:
parts.append(_resolve_ref(tok, ctx, depth, visited))
elif tok.kind == TokenKind.DP_BRACE:
parts.append(_resolve_inline_pick(tok, ctx, depth, visited))
elif tok.kind == TokenKind.DP_MULTI:
parts.append(_resolve_multi_pick(tok, ctx, depth, visited))
elif tok.kind == TokenKind.DP_PIPE:
# DP_PIPE is never emitted as a top-level token by the current
# tokenizer (pipes are absorbed into branch boundaries). If one
# appears here, that's a tokenizer bug.
raise AssertionError("DP_PIPE should never be top-level")
else:
raise AssertionError(f"unknown token kind: {tok.kind}")
return "".join(parts)
_VAR_SURFACES_ALLOWED = frozenset(["combine", "derivation", "assembler"])
def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
"""Look up a variable in ctx. Returns empty string for missing or
internal-key vars. Missing vars also emit an `unknown_var` warning so
the user gets a signal when a Combine / Derivation / Constraint
template references a variable not bound by any upstream module.
Internal-`__`-prefixed keys are never substituted and never warn —
they're engine bookkeeping (`__wp_rng__`, `__wp_catalog__`, etc.).
Surface-gated: only combine/derivation/assembler surfaces support
$var reads. Wildcard + fixed_values surfaces are binding PRODUCERS
(not consumers); $var reads there warn + render literal in lenient
mode, raise VarOutOfSurfaceError in strict mode."""
name = tok.meta.get("name", "")
if name.startswith("__"):
return ""
if ctx.surface not in _VAR_SURFACES_ALLOWED:
from engine.syntax.types import VarOutOfSurfaceError
if ctx.strict:
raise VarOutOfSurfaceError(name, ctx.surface)
_push_warning(
ctx,
type="var_out_of_surface",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"name": name, "surface": ctx.surface},
message=(
f"${name} ignored in {ctx.surface!r} surface — only "
f"combine / derivation / assembler surfaces support $var reads"
),
)
return f"${name}"
# An axis read is a different lookup against a different bucket, so it
# branches BEFORE the var lookup — `$outfit.SHOES` must resolve even when
# nothing bound `$outfit` itself, and must not raise `unknown_var` on the
# way past.
axis = tok.meta.get("axis")
if axis:
return _resolve_axis(tok, ctx, str(axis))
value = ctx.get_var(name)
if value is None:
_push_warning(
ctx,
type="unknown_var",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"name": name, "surface": ctx.surface},
message=f"Unknown variable ${name}",
)
return ""
# SP2a list accessor: `$name` joins a ListVar with its separator;
# `$name.K` indexes (0-based, out-of-range -> ""). A plain string behaves
# as a 1-element list so `$str.0` == `$str` and `$str.1` == "". The
# accessor + ListVar-fold contract lives in deref_var_value
# (engine/syntax/types.py); the derivation + converter reads share it.
return deref_var_value(value, tok.meta.get("index"))
def _resolve_axis(tok: Token, ctx: ResolveContext, axis: str) -> str:
"""Render `$name.AXIS`, `$name.K.AXIS` and `$name.AXIS.K`.
Never re-rolls: the choice was made when the wildcard picked, and this
reads it back — that is what makes two modules, or two nodes, agree.
Deliberately does NOT route through `deref_var_value`. An axis value is not
a ListVar, and keeping it off that path leaves the SP2a accessor contract
untouched.
"""
name = str(tok.meta.get("name", ""))
got = ctx.get_axis(name, axis)
if got is None:
_push_warning(
ctx,
type="unknown_tag_axis",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"name": name, "axis": axis, "surface": ctx.surface},
message=(
f"${name} has no '{axis}' axis — is that tag group marked "
f"'accepts'?"
),
)
return ""
index = tok.meta.get("index")
if isinstance(got, list):
if index is None:
return ", ".join(g for g in got if g)
if isinstance(index, int) and 0 <= index < len(got):
return got[index] or ""
return ""
# A single pick behaves as a one-element list, mirroring deref_var_value so
# `$x.0.AXIS` == `$x.AXIS` and any higher index is empty.
if index is not None:
return str(got) if index == 0 else ""
return str(got)
def _push_warning(ctx: ResolveContext, **fields) -> None:
"""Append a warning dict to ctx.warnings. Helper to keep call sites tidy."""
ctx.warnings.append(fields)
def _pick_weighted(options: list[dict], rng) -> dict | None:
"""Pick one option weighted by `weight`. Returns None for empty list.
Mirrors the pre-Phase-5 wildcard_handler._pick_weighted but takes `rng`
as an explicit parameter instead of using random.random().
"""
if not options:
return None
total = sum(max(0.0, float(o.get("weight", 1))) for o in options)
if total <= 0:
# Nothing is selectable — every option is either weight 0 (documented
# as "disable without deleting") or was excluded by a constraint.
# Returning options[0] here looked like a working pick: a real option,
# right shape, right place, and wrong. It stayed wrong silently until
# the list was reordered. Callers turn None into an empty binding,
# which is what the null option already means.
return None
r = rng.random() * total
acc = 0.0
for opt in options:
acc += max(0.0, float(opt.get("weight", 1)))
if r <= acc:
return opt
return options[-1]
def _resolve_inline_pick(
tok: Token,
ctx: ResolveContext,
depth: int,
visited: tuple[str, ...],
) -> str:
"""Pick one branch uniformly at random; recursively resolve its content."""
# Assembler surface is seedless — its RNG always rolls from seed 0, so
# an inline pick there would freeze on the same branch every run. That's
# misleading, so render the source verbatim instead and let the user
# produce the random value in a seeded module (Wildcard / Combine /
# Derivation inside a WP Context) and reference its $var here. Other
# surfaces (wildcard / combine / derivation) run inside a seeded Context
# and keep resolving normally.
if ctx.surface == "assembler":
return tok.raw
branches: list[str] = tok.meta.get("branches", [])
if not branches:
return ""
# Per-branch weights (`N::value`), same micro-syntax the multi-pick block
# honors. Strip the prefix off every branch so it never leaks into output;
# only switch to weighted selection when a branch actually carries a weight,
# so unweighted `{a|b|c}` keeps its byte-for-byte seed sequence (legacy
# randrange path — changing the draw would shift every existing seed).
values = [_strip_branch_weight(b) for b in branches]
has_weights = any(v != b for v, b in zip(values, branches, strict=True))
if has_weights:
weights = [_parse_branch_weight(b) for b in branches]
chosen_idx = _weighted_pick_index(weights, ctx.rng)
else:
chosen_idx = ctx.rng.randrange(len(branches))
chosen = values[chosen_idx]
if not chosen:
return ""
nested_tokens = tokenize_text(chosen)
return _resolve_tokens(nested_tokens, ctx, depth=depth, visited=visited)
def _parse_branch_weight(branch: str) -> float:
"""Parse `N::value` micro-syntax. Returns 1.0 if no prefix."""
if "::" not in branch:
return 1.0
prefix, _, _ = branch.partition("::")
try:
n = float(prefix)
if n <= 0:
return 0.0
return n
except ValueError:
return 1.0
def _strip_branch_weight(branch: str) -> str:
"""Return branch text without the `N::` weight prefix, if present."""
if "::" not in branch:
return branch
prefix, _, rest = branch.partition("::")
try:
float(prefix)
return rest
except ValueError:
return branch
def _weighted_pick_index(weights: list[float], rng) -> int:
"""Return an index into `weights` weighted by value. Total > 0 assumed."""
total = sum(max(0.0, w) for w in weights)
if total <= 0:
return 0
r = rng.random() * total
acc = 0.0
for idx, w in enumerate(weights):
acc += max(0.0, w)
if r <= acc:
return idx
return len(weights) - 1
def pick_k_unique(items, weights, k, rng):
"""Pick ``k`` items from ``items`` without replacement, weighted by the
parallel ``weights`` list, returned in pick order. ``k`` is clamped to
``len(items)``; ``k <= 0`` -> ``[]``.
Shared by the inline ``{N$$sep$$}`` block (``_resolve_multi_pick``) and by
multi-select wildcards (``wildcard_handler``). Draws from ``rng`` in the
same order the inline block always has, so seed behavior is unchanged.
"""
n = min(max(0, k), len(items))
available_idx = list(range(len(items)))
available_w = list(weights)
chosen = []
for _ in range(n):
if not available_idx:
break
j = _weighted_pick_index(available_w, rng)
chosen.append(items[available_idx.pop(j)])
available_w.pop(j)
return chosen
def _resolve_multi_pick(
tok: Token,
ctx: ResolveContext,
depth: int,
visited: tuple[str, ...],
) -> str:
"""Resolve a multi-pick block, joined by `sep`.
SP2b: `count` is a range `[min, max]` (a fixed `{N$$}` has min==max), and
`independent` (the `~` flag) toggles unique-without-replacement (default)
vs N draws WITH replacement. A lone nested-ref body in unique mode draws N
DISTINCT options from the ref's filtered pool (a per-site SP2a). Each chosen
value is recursively resolved.
"""
# Seedless assembler surface — see _resolve_inline_pick. Render verbatim
# rather than freezing a deterministic seed-0 multi-pick.
if ctx.surface == "assembler":
return tok.raw
cmin: int = tok.meta.get("min", tok.meta.get("count", 0))
cmax: int = tok.meta.get("max", tok.meta.get("count", 0))
independent: bool = bool(tok.meta.get("independent", False))
sep: str = tok.meta.get("sep", "")
branches: list[str] = list(tok.meta.get("branches", []))
# Range count: a fixed N (min==max) does NOT draw from rng, so a `{N$$}`
# reproduces the pre-SP2b seed sequence byte-for-byte. A true range draws.
count = cmin if cmin == cmax else ctx.rng.randint(cmin, cmax)
if count <= 0:
return ""
# Lone nested-ref body, unique mode, count>=2, wildcard surface → pick N
# DISTINCT options from the ref's filtered pool (per-site SP2a multi-select).
# count==1 stays on the legacy branch path below so its seed is unchanged.
if not independent and count >= 2 and len(branches) == 1 and ctx.surface == "wildcard":
bt = tokenize_text(branches[0])
if len(bt) == 1 and bt[0].kind == TokenKind.REF:
ref_tok = bt[0]
ref_uuid = ref_tok.meta.get("uuid", "")
pool = ref_option_pool(ref_tok, ctx)
if not pool:
return ""
values = [str(o.get("value", "")) for o in pool]
weights = [float(o.get("weight", 1) or 1) for o in pool]
chosen = pick_k_unique(values, weights, count, ctx.rng)
# The multi-picked ref is the carrier of any `@{X}` inside the
# values it resolves. We pick on VALUES (not option dicts) so a
# specific option id isn't tracked here — set carrier to
# (ref_uuid, None) for the recursion. option_id None can't match
# a real `pick` entry, so a deeper `@{X}` falls back to
# positional first/next/all (design: multi-pick children aren't
# individually pickable). Restore the enclosing carrier after.
get_carrier = getattr(ctx, "get_carrier", None)
set_carrier = getattr(ctx, "set_carrier", None)
saved_carrier = (
get_carrier() if callable(get_carrier) else None
)
if callable(set_carrier):
set_carrier(ref_uuid, None)
try:
return sep.join(
_resolve_tokens(
tokenize_text(v), ctx, depth=depth + 1,
visited=visited + (ref_uuid,),
) if v else ""
for v in chosen
)
finally:
if callable(set_carrier) and saved_carrier is not None:
set_carrier(*saved_carrier)
weights = [_parse_branch_weight(b) for b in branches]
branch_values = [_strip_branch_weight(b) for b in branches]
resolved: list[str] = []
if independent:
# N draws WITH replacement over the branches; each resolved
# independently (a lone ref re-rolls every time → variety from nesting).
for _ in range(count):
j = _weighted_pick_index(weights, ctx.rng)
v = branch_values[j]
resolved.append(
_resolve_tokens(tokenize_text(v), ctx, depth=depth, visited=visited)
if v else ""
)
return sep.join(resolved)
# Unique over branches (legacy path, now range-aware): N distinct branches.
n_picks = min(count, len(branches))
if n_picks == 0:
return ""
chosen_values = pick_k_unique(branch_values, weights, n_picks, ctx.rng)
for branch_text in chosen_values:
if not branch_text:
resolved.append("")
continue
nested = tokenize_text(branch_text)
resolved.append(_resolve_tokens(nested, ctx, depth=depth, visited=visited))
return sep.join(resolved)
def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
"""Filtered + constrained option pool a ``@{uuid[#name][:expr][!null]}`` ref
resolves against — catalog lookup, the ``:expr``/``!null`` sub-category
filter, and chain constraints. Shared by ``_resolve_ref`` (single weighted
pick) and the SP2b lone-ref multi-pick path.
Returns ``[]`` (pushing the same ``unknown_ref`` /
``ref_subcategory_empty_pool`` warnings, and raising ``UnknownRefError`` in
strict mode for an unknown ref) when there is nothing to pick. Does NOT
apply the depth / cycle / surface guards — those stay in ``_resolve_ref``
and the multi-pick caller.
"""
uuid = tok.meta.get("uuid", "")
module = ctx.get_module(uuid)
if module is None:
if ctx.strict:
raise UnknownRefError(uuid)
cached_name = tok.meta.get("name") if hasattr(tok, "meta") else None
_push_warning(
ctx,
type="unknown_ref",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"uuid": uuid, "name": cached_name},
message=f"Unknown wildcard ref @{{{uuid}{'#' + cached_name if cached_name else ''}}}",
)
return []
payload_dict = module.get("payload")
if isinstance(payload_dict, dict):
options = list(payload_dict.get("options", []))
else:
options = list(module.get("options", []))
filter_expr = tok.meta.get("filter_expr")
exclude_null = bool(tok.meta.get("exclude_null"))
if (isinstance(filter_expr, str) and filter_expr.strip()) or exclude_null:
pool_total = len(options)
ast = _parse_subcat(filter_expr) if isinstance(filter_expr, str) else None
options = [
o for o in options
if isinstance(o, dict)
and (
(o.get("is_null") and not exclude_null)
or (
not o.get("is_null")
and _subcat_matches(ast, set(o.get("sub_categories") or []))
)
)
]
if not options:
# "Matched no options" on its own is true and useless — it cannot
# tell a wrong filter from a right filter aimed at a stale pool.
# Report WHICH pool was searched, how big it was, and what tags it
# actually offers, so the panel answers the question instead of
# restating the symptom.
available = sorted({
t
for o in (payload_dict.get("options", []) if isinstance(payload_dict, dict)
else module.get("options", []))
if isinstance(o, dict)
for t in (o.get("sub_categories") or [])
if isinstance(t, str)
})
pool_origin = module.get("pool_origin")
library_count = module.get("library_option_count")
detail = {
"uuid": uuid,
"filter_expr": filter_expr,
"exclude_null": exclude_null,
"pool_total": pool_total,
"available_sub_categories": available,
}
if pool_origin:
detail["pool_origin"] = pool_origin
if isinstance(library_count, int):
detail["library_option_count"] = library_count
where = (
"this node's copy" if pool_origin == "node"
else "the library" if pool_origin == "library"
else "the pool"
)
drift = (
f"; the library now has {library_count}"
if isinstance(library_count, int) else ""
)
_push_warning(
ctx,
type="ref_subcategory_empty_pool",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail=detail,
message=(
f"@{{{uuid}:{filter_expr or ''}"
f"{'!null' if exclude_null else ''}}} matched no options — "
f"searched {where} ({pool_total} option"
f"{'' if pool_total == 1 else 's'}){drift}"
),
)
return []
get_constraints = getattr(ctx, "get_constraints", None)
get_picks = getattr(ctx, "get_picks", None)
if callable(get_constraints) and callable(get_picks):
constraints = get_constraints()
if constraints:
from engine.modules._constraints import (
apply_constraints_for_target,
warn_excludes_all,
)
# SP3: share the ctx-resident hit counter so a nested-ref
# target occurrence counts toward the same first/next
# coverage as direct top-level instances. `carrier_ctx` is
# the identity of the carrier wildcard whose chosen option
# value textually contains THIS `@{uuid}` ref — set on the
# resolve frame by the enclosing wildcard handler (top-level
# carrier) or by `_resolve_ref` (deeper carrier). A `pick`
# selector's `nested` occurrence matches on it; first / next
# / all ignore it. None when no carrier is set (a ref at the
# surface root) — then `pick` simply won't cover.
get_hits = getattr(ctx, "get_constraint_hits", None)
hits = get_hits() if callable(get_hits) else {}
get_carrier_ctx = getattr(ctx, "get_carrier_ctx", None)
carrier_ctx = get_carrier_ctx() if callable(get_carrier_ctx) else None
options, any_applied = apply_constraints_for_target(
options, uuid, constraints, get_picks(), ctx.warnings,
hits=hits, firing_uid=None, carrier_ctx=carrier_ctx,
)
if any_applied:
warn_excludes_all(options, uuid, ctx.warnings)
return options
def _resolve_ref(
tok: Token,
ctx: ResolveContext,
depth: int,
visited: tuple[str, ...],
) -> str:
uuid = tok.meta.get("uuid", "")
# Depth check (first — prevents infinite recursion even on cyclic graphs)
if depth >= ctx.max_ref_depth:
chain = list(visited) + [uuid]
if ctx.strict:
raise RecursionLimitExceeded(
f"max ref depth {ctx.max_ref_depth} exceeded: "
+ " → ".join(f"@{{{u}}}" for u in chain)
)
_push_warning(
ctx,
type="recursion_limit",
severity="error",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"chain": chain, "limit": ctx.max_ref_depth},
message=f"Recursion limit reached at depth {ctx.max_ref_depth}",
)
return ""
# Cycle check
if uuid in visited:
chain = list(visited) + [uuid]
if ctx.strict:
raise CycleDetectedError(chain)
_push_warning(
ctx,
type="cycle_detected",
severity="error",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"chain": chain},
message="Cycle: " + " → ".join(f"@{{{u}}}" for u in chain),
)
return ""
# Surface check — wildcard options AND derivation action values may host
# `@{}` refs (both run resolve_text inside a seeded Context with the chain
# constraint bucket threaded). combine + assembler stay gated.
if ctx.surface not in ("wildcard", "derivation"):
if ctx.strict:
raise RefOutOfSurfaceError(uuid, ctx.surface)
# Try to resolve module name for the warning detail (best-effort)
module = ctx.get_module(uuid)
name = module.get("var_binding") if module else None
_push_warning(
ctx,
type="ref_out_of_surface",
severity="info",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"uuid": uuid, "name": name, "surface": ctx.surface},
message=f"@{{{uuid}}} ignored in {ctx.surface!r} surface",
)
return ""
# Catalog lookup + `:expr`/`!null` filter + chain constraints, factored
# into ref_option_pool (shared with the SP2b lone-ref multi-pick path).
# The strict-unknown raise + the unknown / empty-pool warnings live there.
options = ref_option_pool(tok, ctx)
if not options:
return ""
chosen = _pick_weighted(options, ctx.rng)
if chosen is None:
return ""
chosen_value = str(chosen.get("value", ""))
if not chosen_value:
return ""
# Recursive resolve with depth+1, visited extended. This ref'd
# wildcard is the carrier of any `@{X}` inside its OWN chosen option
# value, so stamp (its uuid, the chosen option's id) on the frame for
# the duration of the recursion, then restore the prior carrier. The
# carrier_uid here is the library uuid (a ref'd wildcard has no row
# `_uid`); a `pick` keyed on a top-level row uid won't match it —
# that's the expected deeper-than-one-hop behaviour (positional
# first/next/all cover deeper nesting). Mirrors the rng save/restore.
nested_tokens = tokenize_text(chosen_value)
get_carrier = getattr(ctx, "get_carrier", None)
set_carrier = getattr(ctx, "set_carrier", None)
if callable(get_carrier) and callable(set_carrier):
saved_carrier = get_carrier()
set_carrier(uuid, chosen.get("id"))
try:
return _resolve_tokens(
nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,)
)
finally:
set_carrier(*saved_carrier)
return _resolve_tokens(nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,))