Files
DumiFlex 4ff45d2492 feat(toggles): every toggle can have a Value and a Negative, plus $name.bool (#367)
A toggle with a Value writes it to its variable while on and nothing while off; its Negative goes with the variable. $name.bool reads the switch (true/false) across nodes, in text and in conditions, through __wp_toggle_bools__. Payloads reading $name.bool stamp schema 12; bool is reserved as a tag group.
2026-11-12 02:12:08 +01:00

934 lines
38 KiB
Python
Raw Permalink 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 contextlib import nullcontext
from typing import Any
from engine.syntax.streams import site_label, sub_stream
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] = []
empty_refs: list[int] = []
# Each pick site rolls on its own stream (engine/syntax/streams.py), so
# one site's draws never move another's.
# A context with no stream key (unit tests, previews) skips the site
# labels: building one per token was most of a big resolve's time.
seen: dict[str, int] = {}
keyed = bool(getattr(ctx, "stream_key", None))
def site(i: int) -> Any:
return sub_stream(ctx, site_label(tokens[i].raw, _after(tokens, i), seen)) \
if keyed else _NO_STREAM
for i, tok in enumerate(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:
with site(i):
resolved = _resolve_ref(tok, ctx, depth, visited)
if not resolved:
empty_refs.append(len(parts))
parts.append(resolved)
elif tok.kind == TokenKind.DP_BRACE:
with site(i):
parts.append(_resolve_inline_pick(tok, ctx, depth, visited))
elif tok.kind == TokenKind.DP_MULTI:
with site(i):
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_tidy(parts, empty_refs)
_NO_STREAM = nullcontext()
def _after(tokens: list[Token], i: int) -> str:
"""The literal text right after token `i` (its site's context)."""
nxt = tokens[i + 1] if i + 1 < len(tokens) else None
return nxt.raw if nxt is not None and nxt.kind == TokenKind.TEXT else ""
_HWS = " \t"
def _join_tidy(parts: list[str], empty_refs: list[int]) -> str:
"""Join resolved parts, closing the gap each empty ``@{}`` ref leaves.
A ref that resolves to nothing (a placeholder or deleted module, a filter
that matched no option, an empty option) used to leave its surroundings
behind: ``red @{x} dress`` became ``red dress`` and ``red, @{x}, dress``
became ``red, , dress``. At each such seam this drops one doubled comma,
then one side of doubled spaces/tabs, and at the start or end of the text
the dangling comma and spaces. Newlines and everything away from the seam
are left exactly as written. Mirrored in ``resolveTokens.ts``.
"""
if not empty_refs:
return "".join(parts)
out = list(parts)
for i in empty_refs:
left = next((j for j in range(i - 1, -1, -1) if out[j]), None)
right = next((j for j in range(i + 1, len(out)) if out[j]), None)
if left is None or right is None:
if right is not None:
out[right] = _strip_lead(out[right])
elif left is not None:
out[left] = _strip_trail(out[left])
continue
if out[left].rstrip(_HWS).endswith(",") and out[right].lstrip(_HWS).startswith(","):
out[right] = out[right].lstrip(_HWS)[1:]
if out[right] and out[left][-1:] in (" ", "\t") and out[right][0] in _HWS:
out[right] = out[right].lstrip(_HWS)
return "".join(out)
def _strip_lead(text: str) -> str:
text = text.lstrip(_HWS)
return text[1:].lstrip(_HWS) if text.startswith(",") else text
def _strip_trail(text: str) -> str:
text = text.rstrip(_HWS)
return text[:-1].rstrip(_HWS) if text.endswith(",") else text
_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"
),
)
# As written, accessor included (`$c.0` stays `$c.0`, not `$c`).
return tok.raw
# 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 == NEG_ACCESSOR:
# `$name.neg` reads the variable's negatives. Not a read of the
# variable itself: it carries no negatives along (that would echo
# them) and does not count as rendering `$name`.
return _resolve_negative(tok, ctx)
if axis == BOOL_ACCESSOR:
return _resolve_bool(tok, ctx)
if axis:
out = _resolve_axis(tok, ctx, str(axis))
_note_read(ctx, name, tok.meta.get("index"), out)
return out
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.
out = deref_var_value(value, tok.meta.get("index"))
_note_read(ctx, name, tok.meta.get("index"), out)
return out
#: The accessor that reads a variable's negatives (`$name.neg`,
#: `$name.K.neg`). Reserved: an `accepts` tag group cannot use this name.
NEG_ACCESSOR = "neg"
#: The accessor that reads a WP Toggles variable's on/off (`$name.bool`),
#: whatever value the toggle writes. Reserved like `neg`.
BOOL_ACCESSOR = "bool"
#: Names an `accepts` tag group may not use: they are accessors.
RESERVED_ACCESSORS = frozenset({NEG_ACCESSOR, BOOL_ACCESSOR})
def _resolve_bool(tok: Token, ctx: ResolveContext) -> str:
"""Render `$name.bool`: "true" / "false". Not a read of `$name` itself,
so it carries no negatives along."""
name = str(tok.meta.get("name", ""))
getter: Any = getattr(ctx, "get_bool", None)
got: Any = getter(name) if callable(getter) else None
if got is None:
own = ctx.get_var(name)
if isinstance(own, str) and own.strip().lower() in ("true", "false"):
return own.strip().lower()
if own 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 ""
return str(got)
def _resolve_negative(tok: Token, ctx: ResolveContext) -> str:
"""Render `$name.neg` / `$name.K.neg`: the negatives filed for `name`
(all of them, or pick K's plus the whole-value ones), tag-deduped.
A variable with no negatives renders empty without a warning, since most
variables have none. Only an unbound name that also has none warns."""
name = str(tok.meta.get("name", ""))
index = tok.meta.get("index")
getter: Any = getattr(ctx, "get_negative", None)
got: Any = getter(name, index if isinstance(index, int) else None) if callable(getter) else None
if got is None and ctx.get_var(name) 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 got or ""
def _note_read(ctx: ResolveContext, name: str, index: Any, out: str) -> None:
"""Tell a collecting context (send-to-negative) which binding this read
rendered, and which pick. An indexed read that rendered nothing (out of
range) carries nothing."""
note = getattr(ctx, "note_var_read", None)
if not callable(note):
return
if index is not None and not out:
return
note(name, index if isinstance(index, int) else None)
def _note_ref_negative(ctx: ResolveContext, option: dict | None) -> None:
note = getattr(ctx, "note_ref_negative", None)
if callable(note) and isinstance(option, dict):
note(option)
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:
declared = ctx.declared_axes(name)
if declared is not None and axis in declared:
# The axis exists; this roll's option just carries no tag on it.
# Legitimate (the editor already advises on such options), so it
# is an info note, and asking whether the group is marked
# `accepts` would send the user to fix something that is fine.
_push_warning(
ctx,
type="axis_untagged_pick",
severity="info",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"name": name, "axis": axis, "surface": ctx.surface},
message=(
f"${name}.{axis} is empty: the picked ${name} option "
f"has no '{axis}' tag"
),
)
return ""
if declared:
hint = (
f"its wildcard's accepts groups are {', '.join(declared)}. "
f"Is '{axis}' a classify group, or misspelled?"
)
elif declared is not None:
hint = (
"its wildcard has no accepts groups. Is that tag group "
"marked 'accepts'?"
)
else:
hint = "is that tag group marked 'accepts'?"
_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,
"declared": declared,
},
message=f"${name} has no '{axis}' axis — {hint}",
)
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`` -> ``[]``. Items weighted 0 are never picked,
so fewer than ``k`` come back once the live ones run out.
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):
# Stop once nothing live is left: a weight-0 item (disabled, or shut
# out by a constraint) is never drawn, even to fill the count.
if not available_idx or sum(max(0.0, w) for w in available_w) <= 0:
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", "")
# Weight 0 is how a constraint (or the user) shuts an option
# out: it stays out of the multi-pick too. A missing weight is 1.
pool = [o for o in ref_option_pool(ref_tok, ctx)
if float(1 if o.get("weight") is None else o["weight"]) > 0]
if not pool:
return ""
values = [str(o.get("value", "")) for o in pool]
weights = [float(1 if o.get("weight") is None else o["weight"]) 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: Any = getattr(ctx, "get_carrier", None)
set_carrier: Any = getattr(ctx, "set_carrier", None)
saved_carrier: Any = (
get_carrier() if callable(get_carrier) else None
)
if callable(set_carrier):
set_carrier(ref_uuid, None)
try:
parts: list[str] = []
id_of = {str(o.get("value", "")): o.get("id") for o in pool}
opt_of = {str(o.get("value", "")): o for o in pool}
for i, v in enumerate(chosen):
_note_ref_negative(ctx, opt_of.get(v))
logged = _log_ref_pick(ctx, ref_uuid, id_of.get(v), depth, v)
with sub_stream(ctx, f"item{i}"):
out = _resolve_tokens(
tokenize_text(v), ctx, depth=depth + 1,
visited=visited + (ref_uuid,),
) if v else ""
if logged is not None:
logged["value"] = out
parts.append(out)
return sep.join(parts)
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 i in range(count):
j = _weighted_pick_index(weights, ctx.rng)
v = branch_values[j]
with sub_stream(ctx, f"item{i}"):
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 i, branch_text in enumerate(chosen_values):
if not branch_text:
resolved.append("")
continue
nested = tokenize_text(branch_text)
with sub_stream(ctx, f"item{i}"):
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
if ast is not None and isinstance(filter_expr, str) and "$" in filter_expr:
# Schema v11: `$name` terms read the run's variables (as a use's
# pool filter does). Quiet resolves (negatives) file nothing.
from engine.syntax.subcat_filter import bind, filter_variable_warning, values_of
from engine.syntax.subcat_filter import wildcard_tags as _wildcard_tags
src = payload_dict if isinstance(payload_dict, dict) else module
ast, notes = bind(
ast, lambda n: values_of(ctx.get_var(n)), _wildcard_tags(src),
src.get("tag_groups") if isinstance(src, dict) else None,
)
for note in notes:
ctx.warnings.append(filter_variable_warning(
note, where=f"@{{{uuid}:{filter_expr}}}: ",
))
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 []
# Schema v9 fallback (engine/modules/_fallback.py): the flagged option
# rolls normally and returns as the whole pool when the constraints below
# leave nothing to pick.
from engine.modules._fallback import fallback_warning, find_fallback, pool_is_dead
fallback = find_fallback(options)
any_applied = False
get_constraints: Any = getattr(ctx, "get_constraints", None)
get_picks: Any = getattr(ctx, "get_picks", None)
if callable(get_constraints) and callable(get_picks):
constraints: Any = get_constraints()
if constraints:
from engine.modules._constraints import (
apply_constraints_for_target,
warn_excludes_all,
)
from engine.modules.wildcard_handler import _accepts_axes
# 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: Any = getattr(ctx, "get_constraint_hits", None)
hits = get_hits() if callable(get_hits) else {}
get_carrier_ctx: Any = getattr(ctx, "get_carrier_ctx", None)
carrier_ctx = get_carrier_ctx() if callable(get_carrier_ctx) else None
picks: Any = get_picks()
options, any_applied = apply_constraints_for_target(
options, uuid, constraints, picks, ctx.warnings,
hits=hits, firing_uid=None, carrier_ctx=carrier_ctx,
# TARGET-side accepts axes for a nested @{} target, so its
# multi-tag accepts option survives when the source allows any
# alternative (same as the top-level path).
target_axes=(
_accepts_axes(payload_dict)
if isinstance(payload_dict, dict) else {}
),
)
if any_applied and fallback is None:
warn_excludes_all(options, uuid, ctx.warnings)
if fallback is not None and pool_is_dead(options):
ctx.warnings.append(fallback_warning(uuid, fallback, constrained=any_applied))
return [{**fallback, "weight": 1}]
return options
def _log_ref_pick(
ctx: ResolveContext, uuid: str, option_id: Any, depth: int, raw: str,
) -> dict[str, Any] | None:
"""Record one nested `@{uuid}` pick in the opt-in ref log, if the
context carries one (the Test Runner seeds it; canvas runs don't).
Returns the stored row so the caller can add the expanded `value`
after recursing, or None when nothing is being logged."""
log_ref: Any = getattr(ctx, "log_ref", None)
if not callable(log_ref):
return None
module = ctx.get_module(uuid)
row: Any = log_ref({
"uuid": uuid,
"name": (module or {}).get("name") or uuid,
"option_id": option_id,
"depth": depth,
"raw": raw,
})
return row
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 ""
# Send-to-negative: the picked option's negative belongs to whatever this
# text resolves into (the carrier binding). Noted even for an empty pick,
# so a "none" option can still say what it rules out.
_note_ref_negative(ctx, chosen)
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)
# Opt-in ref log (Test Runner): record this pick before recursing so
# the log reads in pre-order, then fill in what it expanded to.
logged = _log_ref_pick(ctx, uuid, chosen.get("id"), depth, chosen_value)
get_carrier: Any = getattr(ctx, "get_carrier", None)
set_carrier: Any = getattr(ctx, "set_carrier", None)
if callable(get_carrier) and callable(set_carrier):
saved_carrier: Any = get_carrier()
set_carrier(uuid, chosen.get("id"))
try:
out = _resolve_tokens(
nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,)
)
finally:
set_carrier(*saved_carrier)
else:
out = _resolve_tokens(nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,))
if logged is not None:
logged["value"] = out
return out