704 lines
31 KiB
Python
704 lines
31 KiB
Python
"""Wildcard module resolver — weighted RNG over options, syntax via resolve_text.
|
||
|
||
Each option's `value` may contain $var, @{uuid}, {a|b|c}, {N$$sep$$...}, and
|
||
escapes. After picking the option, resolve_text expands the syntax against
|
||
the runtime context.
|
||
|
||
Constraint integration: when the chain has registered constraints in
|
||
``ctx["__wp_constraints__"]`` whose ``target_wildcard_id`` matches the
|
||
current module's id, this handler applies the constraint matrix +
|
||
exception list to the option pool's weights before picking. The source
|
||
wildcard MUST have already been resolved earlier in the chain — picks
|
||
are recorded in ``ctx["__wp_picks__"][module_id]`` so a downstream
|
||
constraint-aware target can look them up. If a constraint fires
|
||
against a source that hasn't been picked yet (graph ordering bug), the
|
||
constraint is skipped and an ``unknown_constraint_source`` warning is
|
||
emitted.
|
||
"""
|
||
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 as _derive_module_rng
|
||
from engine.modules.dispatcher import ModuleHandler
|
||
from engine.syntax import resolve_text
|
||
from engine.syntax.resolve import pick_k_unique
|
||
from engine.syntax.subcat_filter import (
|
||
matches as _subcat_matches,
|
||
)
|
||
from engine.syntax.subcat_filter import (
|
||
parse as _parse_subcat,
|
||
)
|
||
from engine.syntax.subcat_filter import (
|
||
validate_subcat_name,
|
||
)
|
||
from engine.syntax.types import ListVar
|
||
|
||
_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
||
_MAX_IDENT_LEN = 64
|
||
|
||
# `_derive_module_rng` lifted to engine/modules/_seed.py so combine +
|
||
# fixed_values handlers share the same helper (Phase: combine v2 +
|
||
# syntax parity cycle). The private alias preserves the existing call
|
||
# site at line ~376 unchanged.
|
||
|
||
|
||
def _coerce_pick_int(value: Any, default: int) -> int:
|
||
"""SP2a: coerce a pick-count field (`pick_min`/`pick_max`) to int, falling
|
||
back to `default` on junk. The engine is reachable by hand-built and legacy
|
||
instances, so a non-numeric value must degrade to single-pick rather than
|
||
raise out of resolve() — mirrors the locked_seed coercion guard."""
|
||
if value is None:
|
||
return default
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
def _pick_weighted(options: list[dict[str, Any]], rng) -> dict[str, Any] | None:
|
||
"""Pick one option weighted by `weight`. Uses rng (no module-global random)."""
|
||
if not options:
|
||
return None
|
||
total = sum(max(0.0, float(o.get("weight", 1))) for o in options)
|
||
if total <= 0:
|
||
return options[0]
|
||
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 _apply_constraint_to_options(
|
||
options: list[dict[str, Any]],
|
||
constraint: dict[str, Any],
|
||
source_pick: dict[str, Any],
|
||
adjustment_warnings: list[dict[str, Any]] | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
"""Adjust option weights according to a single constraint, given the
|
||
already-picked source option(s).
|
||
|
||
SP3: weight adjustment is delegated to
|
||
:func:`engine.modules._constraint_math.combine_constraint_factor`, which
|
||
multiplies one factor per matching matrix cell (an option on N source-tag
|
||
rows multiplies N times — true multi-tag) across every source pick (true
|
||
multi-pick). The exception layer (literal `(source_value, target_value)`
|
||
pair) still overrides the matrix for that pick, and `exclude` is absorbing
|
||
(factor 0 → weight 0, option drops out).
|
||
|
||
`source_pick["picks"]` is the per-pick list (`[{value, tags}]`) recorded by
|
||
`_record_pick` / `_record_pick_multi`. Legacy callers that hand-build a
|
||
source dict without it fall back to a single synthetic pick from the
|
||
top-level `value` + `sub_categories`.
|
||
|
||
Returns a shallow-copied list — the input options array is never mutated,
|
||
so the underlying snapshot stays clean across runs.
|
||
|
||
`adjustment_warnings` (optional) collects editor-footgun warnings the
|
||
caller forwards to the chain warning list:
|
||
- ``constraint_factor_ignored_on_allow`` — a `mode: allow` cell carries a
|
||
non-1 factor (user expects weight×factor, engine ignores it).
|
||
- ``unknown_constraint_mode`` — a typo'd mode (`exlcude`/`alllow`) the
|
||
combine fn treats as a no-op.
|
||
These are diagnostic only; they never change the computed weight.
|
||
"""
|
||
from engine.modules._constraint_math import EXCLUDE, combine_constraint_factor
|
||
|
||
matrix = constraint.get("matrix") or {}
|
||
exceptions = constraint.get("exceptions") or []
|
||
|
||
# Per-pick source list (SP3). Fall back to a single synthetic pick for
|
||
# legacy callers that pass only the top-level value + sub_categories.
|
||
picks = source_pick.get("picks")
|
||
if not isinstance(picks, list):
|
||
picks = [{
|
||
"value": source_pick.get("value", ""),
|
||
"tags": list(source_pick.get("sub_categories") or []),
|
||
}]
|
||
|
||
# Pre-scan for diagnostic warnings only. The combine fn intentionally
|
||
# treats `allow` and unknown modes as factor 1.0 (no weight change), so it
|
||
# can't surface them — mirror its (pick × option) rule selection here,
|
||
# exception-pair winning over matrix cell, and warn where the selected rule
|
||
# is an ignored-factor `allow` or an unrecognised mode.
|
||
if adjustment_warnings is not None:
|
||
_scan_constraint_warnings(
|
||
picks, options, matrix, exceptions, adjustment_warnings,
|
||
)
|
||
|
||
adjusted: list[dict[str, Any]] = []
|
||
for opt in options:
|
||
option = {"value": opt.get("value", ""), "tags": opt.get("sub_categories") or []}
|
||
f = combine_constraint_factor(picks, option, matrix, exceptions)
|
||
weight = float(opt.get("weight", 1))
|
||
weight = 0.0 if f is EXCLUDE else max(0.0, weight * float(f))
|
||
adjusted.append({**opt, "weight": weight})
|
||
return adjusted
|
||
|
||
|
||
_KNOWN_CONSTRAINT_MODES = frozenset({"allow", "exclude", "boost", "reduce"})
|
||
|
||
|
||
def _exc_pair_index(exceptions: list[Any]) -> dict[tuple[str, str], dict[str, Any]]:
|
||
"""Index exceptions by (source_value, target_value), mirroring the
|
||
combine fn's key fallback (tier-2 `source_value`/`target_value` names,
|
||
then legacy `source`/`target`). Empty string is a valid key — it's the
|
||
null-option marker (`is_null` options carry `value: ""`)."""
|
||
out: dict[tuple[str, str], dict[str, Any]] = {}
|
||
for exc in exceptions or []:
|
||
if not isinstance(exc, dict):
|
||
continue
|
||
s = exc.get("source_value")
|
||
if s is None:
|
||
s = exc.get("source")
|
||
t = exc.get("target_value")
|
||
if t is None:
|
||
t = exc.get("target")
|
||
if isinstance(s, str) and isinstance(t, str):
|
||
out[(s, t)] = exc
|
||
return out
|
||
|
||
|
||
def _scan_constraint_warnings(
|
||
picks: list[dict[str, Any]],
|
||
options: list[dict[str, Any]],
|
||
matrix: dict[str, Any],
|
||
exceptions: list[Any],
|
||
adjustment_warnings: list[dict[str, Any]],
|
||
) -> None:
|
||
"""Append `constraint_factor_ignored_on_allow` / `unknown_constraint_mode`
|
||
diagnostics for the rules that fire (per pick × option). Selection order
|
||
matches `combine_constraint_factor`: an exception on the literal value pair
|
||
wins over a matrix cell, and only the matched rule is inspected."""
|
||
exc_by_pair = _exc_pair_index(exceptions)
|
||
for pick in picks:
|
||
p_value = str(pick.get("value", ""))
|
||
p_tags = pick.get("tags") or []
|
||
for opt in options:
|
||
opt_value = str(opt.get("value", ""))
|
||
opt_tags = opt.get("sub_categories") or []
|
||
|
||
exc = exc_by_pair.get((p_value, opt_value))
|
||
if exc is not None:
|
||
_warn_for_rule(exc, p_value, opt_value, adjustment_warnings)
|
||
continue
|
||
|
||
for s in p_tags:
|
||
row = matrix.get(s)
|
||
if not isinstance(row, dict):
|
||
continue
|
||
for t in opt_tags:
|
||
rule = row.get(t)
|
||
if isinstance(rule, dict):
|
||
_warn_for_rule(rule, p_value, opt_value, adjustment_warnings)
|
||
|
||
|
||
def _warn_for_rule(
|
||
rule: dict[str, Any],
|
||
src_value: str,
|
||
opt_value: str,
|
||
adjustment_warnings: list[dict[str, Any]],
|
||
) -> None:
|
||
mode = rule.get("mode")
|
||
if mode == "allow":
|
||
# `allow` is a weight no-op; a non-1 factor is almost certainly a
|
||
# user expecting weight×factor and silently getting full weight.
|
||
try:
|
||
factor = float(rule.get("factor", 1.0))
|
||
except (TypeError, ValueError):
|
||
factor = 1.0
|
||
if factor != 1.0:
|
||
adjustment_warnings.append({
|
||
"type": "constraint_factor_ignored_on_allow",
|
||
"factor": factor,
|
||
"src_value": src_value,
|
||
"opt_value": opt_value,
|
||
})
|
||
elif mode not in _KNOWN_CONSTRAINT_MODES:
|
||
# Typo like "exlcude" / "alllow". The combine fn no-ops it; bubble up
|
||
# so the caller can surface `unknown_constraint_mode`.
|
||
adjustment_warnings.append({
|
||
"type": "unknown_constraint_mode",
|
||
"mode": mode,
|
||
"src_value": src_value,
|
||
"opt_value": opt_value,
|
||
})
|
||
|
||
|
||
def _record_pick(ctx: Any, chosen: dict[str, Any]) -> None:
|
||
"""Stash the picked option dict in `ctx["__wp_picks__"][module_id]` so a
|
||
downstream constraint-aware wildcard can look up its source's value +
|
||
sub_categories. Keyed by the active module id (set by pipeline.py).
|
||
"""
|
||
module_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
|
||
if not module_id:
|
||
return
|
||
bucket = ctx.setdefault("__wp_picks__", {})
|
||
if isinstance(bucket, dict):
|
||
bucket[module_id] = {
|
||
"value": chosen.get("value"),
|
||
"sub_categories": chosen.get("sub_categories", []),
|
||
"id": chosen.get("id"),
|
||
# SP3: per-pick list the combine fn reads. A single pick is a
|
||
# one-element list so the multi-tag/multi-pick applier has a
|
||
# uniform shape regardless of single- vs multi-select source.
|
||
"picks": [{
|
||
"value": chosen.get("value", ""),
|
||
"tags": list(chosen.get("sub_categories") or []),
|
||
}],
|
||
}
|
||
|
||
|
||
def _record_pick_multi(ctx: Any, chosen_list: list[dict[str, Any]], sep: str) -> None:
|
||
"""Multi-select counterpart to `_record_pick` (SP2a). Stash the joined
|
||
value + the individual picked values + the union of their sub-categories,
|
||
so the debug Picks view can render the list. Single-pick records (via
|
||
`_record_pick`) keep their original shape."""
|
||
module_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
|
||
if not module_id:
|
||
return
|
||
bucket = ctx.setdefault("__wp_picks__", {})
|
||
if not isinstance(bucket, dict):
|
||
return
|
||
values = [str(c.get("value", "")) for c in chosen_list]
|
||
union: list[str] = []
|
||
for c in chosen_list:
|
||
for t in (c.get("sub_categories") or []):
|
||
if t not in union:
|
||
union.append(t)
|
||
# SP3: per-pick list (value + that pick's own tags, NOT the union) so the
|
||
# combine fn can apply the matrix per pick. The union above stays for the
|
||
# debug Picks view; `picks` is what constraint application reads.
|
||
per_pick = [
|
||
{"value": str(c.get("value", "")), "tags": list(c.get("sub_categories") or [])}
|
||
for c in chosen_list
|
||
]
|
||
bucket[module_id] = {
|
||
"value": sep.join(values),
|
||
"values": values,
|
||
"sub_categories": union,
|
||
"id": chosen_list[0].get("id") if chosen_list else None,
|
||
"picks": per_pick,
|
||
}
|
||
|
||
|
||
class WildcardHandler(ModuleHandler):
|
||
type_id = "wildcard"
|
||
|
||
@staticmethod
|
||
def _apply_pool_filter(
|
||
options: list[dict[str, Any]],
|
||
expr: str,
|
||
*,
|
||
exclude_null: bool,
|
||
) -> list[dict[str, Any]]:
|
||
"""Narrow an option pool by a boolean sub-category expression.
|
||
|
||
`expr` is parsed via the shared subcat_filter matcher and tested
|
||
against each non-null option's tag set (`sub_categories`). Empty
|
||
/ whitespace `expr` ⇒ no tag filtering. The null option
|
||
(`is_null`) is governed solely by `exclude_null`; the expression
|
||
never applies to it (spec §3.4).
|
||
"""
|
||
ast = _parse_subcat(expr) if isinstance(expr, str) else None
|
||
out: list[dict[str, Any]] = []
|
||
for o in options:
|
||
if o.get("is_null"):
|
||
if not exclude_null:
|
||
out.append(o)
|
||
continue
|
||
if _subcat_matches(ast, set(o.get("sub_categories") or [])):
|
||
out.append(o)
|
||
return out
|
||
|
||
@classmethod
|
||
def validate_payload(cls, payload: dict[str, Any]) -> None:
|
||
if not isinstance(payload, dict):
|
||
raise ValueError("wildcard payload must be an object")
|
||
options = payload.get("options")
|
||
if not isinstance(options, list):
|
||
raise ValueError("wildcard payload.options must be a list")
|
||
|
||
# Sub-category registry — the allowed tag names. Validated first
|
||
# so each option's membership + tag_groups can be checked against
|
||
# it. `validate_subcat_name` enforces the §3.6 name rules (single
|
||
# token; no whitespace / boolean-grammar / ref chars; not a
|
||
# reserved word incl. `null`).
|
||
registry_list = payload.get("sub_categories", [])
|
||
if not isinstance(registry_list, list):
|
||
raise ValueError("wildcard payload.sub_categories must be a list")
|
||
registry: set[str] = set()
|
||
for i, sc in enumerate(registry_list):
|
||
if not isinstance(sc, str):
|
||
raise ValueError(
|
||
f"wildcard payload.sub_categories[{i}] must be a string"
|
||
)
|
||
name_err = validate_subcat_name(sc)
|
||
if name_err:
|
||
raise ValueError(
|
||
f"wildcard payload.sub_categories[{i}]: {name_err}"
|
||
)
|
||
registry.add(sc)
|
||
|
||
# Optional UI-only grouping of registry tags into named axes. The
|
||
# engine ignores it at resolve time; validate the shape, that
|
||
# every member is a registry tag, and that a tag lives in at most
|
||
# one group.
|
||
tag_groups = payload.get("tag_groups")
|
||
if tag_groups is not None:
|
||
if not isinstance(tag_groups, dict):
|
||
raise ValueError("wildcard payload.tag_groups must be an object")
|
||
seen_grouped: set[str] = set()
|
||
for gname, members in tag_groups.items():
|
||
if not isinstance(gname, str) or not isinstance(members, list):
|
||
raise ValueError(
|
||
"wildcard payload.tag_groups entries must be "
|
||
"name -> string[]"
|
||
)
|
||
for mem in members:
|
||
if mem not in registry:
|
||
raise ValueError(
|
||
f"wildcard payload.tag_groups[{gname!r}]: {mem!r} "
|
||
f"not in sub_categories registry"
|
||
)
|
||
if mem in seen_grouped:
|
||
raise ValueError(
|
||
f"wildcard payload.tag_groups: {mem!r} appears in "
|
||
f"more than one group"
|
||
)
|
||
seen_grouped.add(mem)
|
||
|
||
seen_ids: set[str] = set()
|
||
null_count = 0
|
||
for i, opt in enumerate(options):
|
||
if not isinstance(opt, dict):
|
||
raise ValueError(f"wildcard payload.options[{i}] must be an object")
|
||
# Option ids must be unique across the pool — `pinned_option_id`
|
||
# lookup, `enabled_options` toggles, and `option_weights`
|
||
# overrides all key by id, so dupes make those features
|
||
# nondeterministic.
|
||
opt_id = opt.get("id")
|
||
if not isinstance(opt_id, str) or not opt_id:
|
||
raise ValueError(
|
||
f"wildcard payload.options[{i}].id must be a string"
|
||
)
|
||
if opt_id in seen_ids:
|
||
raise ValueError(
|
||
f"wildcard payload.options[{i}].id {opt_id!r} duplicates "
|
||
f"an earlier option id"
|
||
)
|
||
seen_ids.add(opt_id)
|
||
value = opt.get("value", "")
|
||
is_null = bool(opt.get("is_null"))
|
||
if is_null:
|
||
null_count += 1
|
||
# Null option contract: value must be empty string, no
|
||
# sub_category. The flag is the source of truth — see
|
||
# docs/superpowers/specs/2026-05-24-null-wildcard-option-design.md.
|
||
if value != "":
|
||
raise ValueError(
|
||
f"wildcard payload.options[{i}] null option "
|
||
f"{opt_id!r} must have empty value (got {value!r})"
|
||
)
|
||
null_subs = opt.get("sub_categories", [])
|
||
if null_subs:
|
||
raise ValueError(
|
||
f"wildcard payload.options[{i}] null option "
|
||
f"{opt_id!r} must have no sub_categories (got "
|
||
f"{null_subs!r})"
|
||
)
|
||
else:
|
||
if not isinstance(value, str) or value == "":
|
||
raise ValueError(
|
||
f"wildcard payload.options[{i}].value must be a "
|
||
f"non-empty string (use is_null=True for the null option)"
|
||
)
|
||
weight = opt.get("weight", 1)
|
||
if not isinstance(weight, (int, float)) or isinstance(weight, bool):
|
||
raise ValueError(
|
||
f"wildcard payload.options[{i}].weight must be a number"
|
||
)
|
||
if float(weight) < 0:
|
||
raise ValueError(
|
||
f"wildcard payload.options[{i}].weight must not be negative"
|
||
)
|
||
subs = opt.get("sub_categories", [])
|
||
if not isinstance(subs, list):
|
||
raise ValueError(
|
||
f"wildcard payload.options[{i}].sub_categories must be a list"
|
||
)
|
||
for s in subs:
|
||
if not isinstance(s, str):
|
||
raise ValueError(
|
||
f"wildcard payload.options[{i}].sub_categories must be "
|
||
f"strings"
|
||
)
|
||
if s not in registry:
|
||
raise ValueError(
|
||
f"wildcard payload.options[{i}].sub_categories: {s!r} "
|
||
f"not in sub_categories registry"
|
||
)
|
||
if null_count > 1:
|
||
raise ValueError(
|
||
f"wildcard payload may have at most one null option "
|
||
f"(found {null_count})"
|
||
)
|
||
binding = payload.get("var_binding")
|
||
if binding is not None:
|
||
if not isinstance(binding, str):
|
||
raise ValueError("wildcard payload.var_binding must be a string")
|
||
# Empty string used to slip through (the old guard was
|
||
# `if binding and not match`, which short-circuited on
|
||
# falsy). Reject explicitly + bound length + reject the
|
||
# `__dunder` prefix (collides with engine-internal key
|
||
# convention — see engine/modules/types.py:strip_internals).
|
||
if not binding:
|
||
raise ValueError("wildcard payload.var_binding must not be empty")
|
||
if len(binding) > _MAX_IDENT_LEN:
|
||
raise ValueError(
|
||
f"wildcard payload.var_binding must be at most "
|
||
f"{_MAX_IDENT_LEN} chars (got {len(binding)})"
|
||
)
|
||
if binding.startswith("__"):
|
||
raise ValueError(
|
||
"wildcard payload.var_binding must not start with '__' "
|
||
"(reserved for engine-internal keys)"
|
||
)
|
||
if not _IDENT_RE.match(binding):
|
||
raise ValueError(
|
||
f"wildcard payload.var_binding {binding!r} is not a valid identifier"
|
||
)
|
||
|
||
@classmethod
|
||
def resolve(
|
||
cls,
|
||
payload: dict[str, Any],
|
||
instance: dict[str, Any],
|
||
ctx: Any,
|
||
) -> dict[str, str]:
|
||
binding = instance.get("variable_binding") or payload.get("var_binding") or ""
|
||
if not binding:
|
||
return {}
|
||
|
||
options: list[dict[str, Any]] = list(payload.get("options", []))
|
||
|
||
# `pinned` mode short-circuits the RNG: always pick the option
|
||
# whose id matches `pinned_option_id`. Falls through to random
|
||
# (with a warning) if the pinned id can't be found — handles
|
||
# the case where the user pinned an option that the library
|
||
# has since removed. Honors enabled_options + weights normally
|
||
# in every other mode.
|
||
mode = instance.get("mode")
|
||
if mode == "pinned":
|
||
pinned_id = instance.get("pinned_option_id")
|
||
pinned = next((o for o in options if o.get("id") == pinned_id), None)
|
||
if pinned is not None:
|
||
# Track the pinned pick the same way as a random pick —
|
||
# downstream constraint-aware wildcards need source
|
||
# info regardless of how the source resolved its option.
|
||
_record_pick(ctx, pinned)
|
||
value = str(pinned.get("value", ""))
|
||
if not value:
|
||
return {binding: ""}
|
||
resolve_ctx = build_resolve_ctx(ctx, surface="wildcard")
|
||
return {binding: resolve_text(value, resolve_ctx)}
|
||
# else: pinned target is missing — fall through to random.
|
||
|
||
# `category_filter` narrows the option pool to entries whose tag
|
||
# set (`sub_categories`) satisfies a boolean expression
|
||
# (`and`/`or`/`not`/parens; comma=or). Empty / missing = no tag
|
||
# filter. `exclude_null` is a separate flag: when true the null
|
||
# option is dropped, otherwise it is an orthogonal "no-output"
|
||
# slot that survives every filter (the expression never applies
|
||
# to it). Untagged non-null options bypass the expression unless
|
||
# a bare tag term excludes them (spec §3.4). See the shared
|
||
# matcher in engine/syntax/subcat_filter.py.
|
||
category_filter = instance.get("category_filter")
|
||
exclude_null = bool(instance.get("exclude_null", False))
|
||
if (isinstance(category_filter, str) and category_filter.strip()) or exclude_null:
|
||
options = cls._apply_pool_filter(
|
||
options,
|
||
category_filter if isinstance(category_filter, str) else "",
|
||
exclude_null=exclude_null,
|
||
)
|
||
|
||
enabled = instance.get("enabled_options")
|
||
if enabled is not None:
|
||
allowed = set(enabled)
|
||
options = [o for o in options if o.get("id") in allowed]
|
||
|
||
if not options:
|
||
return {binding: ""}
|
||
|
||
# Per-instance weight overrides — replaces (not multiplies) the
|
||
# library weight when the option's id appears in the override
|
||
# map. Missing/non-numeric overrides leave the library weight
|
||
# untouched. Shallow-copy each option so we don't mutate the
|
||
# underlying snapshot payload (which is shared across runs).
|
||
weight_overrides = instance.get("option_weights")
|
||
if isinstance(weight_overrides, dict) and weight_overrides:
|
||
adjusted: list[dict[str, Any]] = []
|
||
for o in options:
|
||
oid = o.get("id")
|
||
if oid in weight_overrides:
|
||
try:
|
||
new_weight = float(weight_overrides[oid])
|
||
except (TypeError, ValueError):
|
||
adjusted.append(o)
|
||
continue
|
||
o = {**o, "weight": new_weight}
|
||
adjusted.append(o)
|
||
options = adjusted
|
||
|
||
# Constraint application — each constraint targeting THIS module
|
||
# adjusts the option pool's weights based on what its source
|
||
# wildcard already picked. Apply logic extracted to
|
||
# `engine.modules._constraints` so the nested-`@{}` path in
|
||
# `engine.syntax.resolve` shares the same implementation.
|
||
from engine.modules._constraints import (
|
||
apply_constraints_for_target,
|
||
warn_excludes_all,
|
||
)
|
||
my_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
|
||
any_constraint_applied = False
|
||
if my_id:
|
||
constraints = ctx.get("__wp_constraints__") if ctx is not None else None
|
||
picks = ctx.get("__wp_picks__") if ctx is not None else None
|
||
# SP3 reach selector: thread the ctx-resident per-constraint
|
||
# hit counter so each covering constraint re-weights this
|
||
# firing target instance (no more one-shot / consumed-set).
|
||
# `firing_uid` identifies THIS direct top-level instance for
|
||
# the `pick` selector's occurrence match.
|
||
hits = (
|
||
ctx.setdefault("__wp_constraint_hits__", {})
|
||
if isinstance(ctx, dict) else {}
|
||
)
|
||
firing_uid = (
|
||
ctx.get("__wp_current_module_uid__")
|
||
if isinstance(ctx, dict) else None
|
||
)
|
||
options, any_constraint_applied = apply_constraints_for_target(
|
||
options, my_id, constraints, picks, ctx["__wp_warnings__"],
|
||
hits=hits, firing_uid=firing_uid,
|
||
)
|
||
if any_constraint_applied:
|
||
warn_excludes_all(options, my_id or "", ctx["__wp_warnings__"])
|
||
|
||
# Effective seed selection:
|
||
# - locked_seed when present → reproducible per-instance
|
||
# - chain seed (`__wp_node_seed__`) otherwise
|
||
# Both paths feed the SAME `_derive_module_rng(seed, binding)`,
|
||
# so locking with `locked_seed = chain_seed` reproduces the
|
||
# unlocked pick exactly. This is the contract the lock UX
|
||
# relies on: the user sees a roll, locks it, captures the
|
||
# current chain seed, future runs reproduce the same roll.
|
||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||
locked_seed = instance.get("locked_seed")
|
||
if locked_seed is not None:
|
||
try:
|
||
effective_seed = int(locked_seed)
|
||
except (TypeError, ValueError):
|
||
effective_seed = chain_seed
|
||
locked_seed = None
|
||
else:
|
||
effective_seed = chain_seed
|
||
rng = _derive_module_rng(effective_seed, binding)
|
||
|
||
# SP2a multi-select: a count range other than (1, 1) picks N options
|
||
# without replacement and binds a ListVar. The null option is never in
|
||
# the multi-pick pool (exclude_null is single-pick-only); "maybe
|
||
# nothing" is min=0. (1, 1) falls through to the single-pick path
|
||
# below unchanged — same RNG draws, so existing locked seeds reproduce.
|
||
raw_min = _coerce_pick_int(instance.get("pick_min"), 1)
|
||
raw_max = _coerce_pick_int(instance.get("pick_max"), raw_min)
|
||
lo = max(0, min(raw_min, raw_max))
|
||
hi = max(lo, raw_max)
|
||
if not (lo == 1 and hi == 1):
|
||
pool = [o for o in options if not o.get("is_null")]
|
||
weights = [max(0.0, float(o.get("weight", 1))) for o in pool]
|
||
pool_n = len(pool)
|
||
independent = bool(instance.get("pick_independent", False))
|
||
if independent:
|
||
# SP2c: independent multi-pick draws WITH replacement (repeats
|
||
# allowed), mirroring the inline `~` flag — so the count is the
|
||
# requested range and is NOT clamped to the pool size.
|
||
count = lo if lo == hi else lo + int(rng.random() * (hi - lo + 1))
|
||
picks = [
|
||
p for p in (_pick_weighted(pool, rng) for _ in range(count))
|
||
if p is not None
|
||
]
|
||
else:
|
||
lo_c, hi_c = min(lo, pool_n), min(hi, pool_n)
|
||
count = lo_c if lo_c == hi_c else lo_c + int(rng.random() * (hi_c - lo_c + 1))
|
||
picks = pick_k_unique(pool, weights, count, rng)
|
||
sep = instance.get("pick_separator")
|
||
if not isinstance(sep, str):
|
||
sep = ", "
|
||
items: list[str] = []
|
||
saved_rng_multi = ctx.get("__wp_rng__")
|
||
ctx["__wp_rng__"] = rng
|
||
try:
|
||
multi_ctx = build_resolve_ctx(ctx, surface="wildcard")
|
||
for opt in picks:
|
||
items.append(resolve_text(str(opt.get("value", "")), multi_ctx))
|
||
finally:
|
||
ctx["__wp_rng__"] = saved_rng_multi
|
||
_record_pick_multi(ctx, picks, sep)
|
||
return {binding: ListVar(items, sep)}
|
||
|
||
chosen = _pick_weighted(options, rng)
|
||
if chosen is None:
|
||
return {binding: ""}
|
||
|
||
# Record this wildcard's pick so a downstream constraint-aware
|
||
# wildcard can read it. Done BEFORE resolve_text so even an
|
||
# empty-string-value pick is registered (matters if a
|
||
# constraint exception keys on the literal empty pick value).
|
||
_record_pick(ctx, chosen)
|
||
|
||
value = str(chosen.get("value", ""))
|
||
if not value:
|
||
# Empty / null pick — no ref to resolve.
|
||
return {binding: ""}
|
||
|
||
# Swap `ctx['__wp_rng__']` to the per-module rng for the
|
||
# duration of `resolve_text`. The swap is UNCONDITIONAL —
|
||
# both locked and unlocked paths run nested resolution
|
||
# against the per-module rng — because:
|
||
# - it's the only way locking can reproduce the unlocked
|
||
# pick (same effective_seed → same per-module rng →
|
||
# same pick AND same nested rolls).
|
||
# - it isolates wildcards from each other within one chain
|
||
# run: inline `{a|b|c}` picks no longer depend on
|
||
# module ordering / sibling picks.
|
||
# `build_resolve_ctx` snapshots the rng reference at call
|
||
# time, so it must be built INSIDE the swap.
|
||
saved_rng = ctx.get("__wp_rng__")
|
||
ctx["__wp_rng__"] = rng
|
||
try:
|
||
resolve_ctx = build_resolve_ctx(ctx, surface="wildcard")
|
||
# SP3 nested-pick reach: THIS wildcard is the carrier of any
|
||
# `@{T}` ref inside `value`. Stamp (its per-instance uid, the
|
||
# chosen option's id) on the resolve frame so a constraint on
|
||
# T whose `pick` selector lists this (carrier_uid, option_id)
|
||
# nested occurrence matches. A `pick` keyed on a DEEPER
|
||
# carrier won't match this top-level uid — that's by design
|
||
# (only one-hop carriers are individually pickable). Set on
|
||
# the freshly-built ctx (carrier defaults None); no restore
|
||
# needed — resolve_ctx is scoped to this resolve_text call.
|
||
set_carrier = getattr(resolve_ctx, "set_carrier", None)
|
||
if callable(set_carrier):
|
||
set_carrier(
|
||
ctx.get("__wp_current_module_uid__"), chosen.get("id"),
|
||
)
|
||
resolved = resolve_text(value, resolve_ctx)
|
||
finally:
|
||
ctx["__wp_rng__"] = saved_rng
|
||
|
||
return {binding: resolved}
|