"""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}