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.
934 lines
38 KiB
Python
934 lines
38 KiB
Python
"""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
|