"""Derivation module resolver — IF/ELIF/ELSE rules over the runtime context. Each rule has an ordered list of branches (index 0 == IF, the rest == ELIF) and an optional ``else`` clause. Rules evaluate independently top-to-bottom: the first branch whose condition matches wins; if none match, the ``else`` action (if any) fires; otherwise the rule is a no-op. Actions mutate the runtime context directly. Multiple rules can target the same variable; later rules see earlier mutations. Action ``value`` strings are resolved through resolve_text with surface="derivation" so $var tokens and {a|b|c} picks work inside derivation action values. """ 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 from engine.modules.dispatcher import ModuleHandler from engine.syntax import resolve_text from engine.syntax.types import ( deref_var_value, parse_var_reference, split_var_accessor, ) _VALID_OPS = { "equals", "not_equals", "contains", "matches", # Presence-check ops added in 2026-05-09 cycle. `exists`/`not_exists` # check key presence in ctx regardless of value (empty string still # counts as present); `is_set`/`is_unset` additionally require the # value to be non-empty. Both pairs ship so users can pick the # semantics they need — wildcards can pick options with empty # values, leaving keys present-but-empty. "exists", "not_exists", "is_set", "is_unset", } _VALID_MODES = {"replace", "append", "prepend"} def _ctx_get_raw(ctx: Any, name: str) -> Any: """Read the RAW stored value for `name` (may be a ListVar) through the callable-getter -> dict-lookup indirection. Missing / error -> None.""" if ctx is None: return None getter = getattr(ctx, "get", None) if callable(getter): try: return getter(name, None) except TypeError: try: return getter(name) except Exception: return None try: if name in ctx: # type: ignore[operator] return ctx[name] # type: ignore[index] except Exception: return None return None def _ctx_axis(ctx: Any, base: str, axis: str, index: int | None) -> str: """Read a rolled axis choice out of `ctx["__wp_axes__"]`. Mirrors the resolver's `_resolve_axis` shaping so a condition and a template agree on what `$outfit.SHOES` means. Missing yields "" rather than raising: derivations are control flow, so an unanswerable condition is simply false. """ if not isinstance(ctx, dict): return "" entry = (ctx.get("__wp_axes__") or {}).get(base) if isinstance(entry, list): vals = [ e.get(axis, "") for e in entry if isinstance(e, dict) and e.get(axis) ] if index is None: return ", ".join(vals) return vals[index] if 0 <= index < len(vals) else "" if isinstance(entry, dict): got = entry.get(axis, "") # A single pick behaves as a one-element list, as everywhere else. if index is not None: return got if index == 0 else "" return got return "" def _ctx_get(ctx: Any, name: str) -> str: """Read `name` as a string, honoring a `.K` list accessor, a `.AXIS` read (2026-08 tag axes), and folding a ListVar to its joined value (SP2a): `$mood.0` splits to base `mood` + index 0. The fold/accessor contract lives in deref_var_value; the axis contract in `_ctx_axis`.""" base, index, axis = parse_var_reference(name) if axis: return _ctx_axis(ctx, base, axis, index) return deref_var_value(_ctx_get_raw(ctx, base), index) def _ctx_has(ctx: Any, name: str) -> bool: """Whether `name` is a known key in `ctx`, regardless of value. Mirrors `_ctx_get`'s indirection chain (callable getter → `__contains__` / dict lookup) but returns bool. Required so the `exists` / `not_exists` ops can distinguish "key absent" from "key present but value is empty string" — `_ctx_get` collapses both into `""`. Used only by presence-check ops; non-presence ops continue to read through `_ctx_get`. """ if ctx is None: return False base, _index, axis = parse_var_reference(name) if axis: # An axis is present when the binding actually rolled it. Checked # against the axes bucket, not ctx: `$outfit.SHOES` can exist while # `outfit` is unbound, and vice versa. entry = ( (ctx.get("__wp_axes__") or {}).get(base) if isinstance(ctx, dict) else None ) if isinstance(entry, dict): return axis in entry if isinstance(entry, list): return any(isinstance(e, dict) and axis in e for e in entry) return False name = base # SP2a: presence checks the base var # Engine ctx is a dict in tests + at runtime; check `__contains__` # before falling back to a getter probe so we get the cheap path. try: return name in ctx # type: ignore[operator] except Exception: pass getter = getattr(ctx, "get", None) if callable(getter): # Use a sentinel default so a "missing" key can be told apart # from a key whose value is `None` or `""`. sentinel = object() try: value = getter(name, sentinel) except TypeError: try: value = getter(name) except Exception: return False return value is not sentinel return False def _ctx_set(ctx: Any, name: str, value: str) -> None: if ctx is None: return name = split_var_accessor(name)[0] # SP2a: writing $x.K targets the base var setter = getattr(ctx, "set", None) if callable(setter): setter(name, value) return try: ctx[name] = value # type: ignore[index] except Exception: return def _match_condition(condition: dict[str, Any], ctx: Any) -> bool: var = condition.get("var", "") op = condition.get("op", "") value = condition.get("value", "") # Presence-check ops short-circuit before reading the value — they # only care about key presence (and, for is_set/is_unset, whether # the stored value is non-empty). The `value` field is ignored by # these ops; the SPA disables the value input when one is selected. if op == "exists": return _ctx_has(ctx, var) if op == "not_exists": return not _ctx_has(ctx, var) if op == "is_set": return _ctx_has(ctx, var) and _ctx_get(ctx, var) != "" if op == "is_unset": return not _ctx_has(ctx, var) or _ctx_get(ctx, var) == "" actual = _ctx_get(ctx, var) if op == "equals": return actual == value if op == "not_equals": return actual != value if op == "contains": return value in actual if op == "matches": try: return re.search(value, actual) is not None except re.error: return False return False def branch_carrier_key(rule_id: str, branch: int | str) -> str: """Carrier key for a derivation branch occurrence -- the `option_id` the SP3 nested-occurrence model matches a constraint `pick` against. `branch` is the 0-based branch index (0 = IF) or the literal ``"else"``. MUST stay byte-identical to the TS twin ``branchKey`` in ``src/extension/constraint-pairs.ts``; both are corpus-locked against ``tests/fixtures/constraint-corpus.json`` ``branch_key_cases``. """ return f"{rule_id}:{branch}" def _apply_action( action: dict[str, Any], ctx: Any, resolve_ctx: Any, carrier_key: str | None = None, ) -> tuple[str, str] | None: target = action.get("target_var", "") if not target: return None mode = action.get("mode", "replace") raw_value = str(action.get("value", "")) # Stamp this derivation instance as the CARRIER of any `@{}` in the action # value, keyed by the branch key (`${rule_id}:${bi}` / `${rule_id}:else`) — # the `option_id` the SP3 nested-occurrence model matches a `pick` against. # Save/restore around the resolve, mirroring the resolver's own carrier # save/restore for nested recursion. set_carrier = getattr(resolve_ctx, "set_carrier", None) get_carrier = getattr(resolve_ctx, "get_carrier", None) if carrier_key is not None and callable(set_carrier) and callable(get_carrier): module_uid = None if isinstance(ctx, dict): module_uid = ctx.get("__wp_current_module_uid__") or ctx.get("__wp_current_module_id__") prev = get_carrier() set_carrier(module_uid, carrier_key) try: new_value = resolve_text(raw_value, resolve_ctx) finally: set_carrier(*prev) else: new_value = resolve_text(raw_value, resolve_ctx) if mode == "replace": result = new_value elif mode == "append": result = _ctx_get(ctx, target) + new_value elif mode == "prepend": result = new_value + _ctx_get(ctx, target) else: return None _ctx_set(ctx, target, result) return target, result def _validate_condition(condition: Any, where: str) -> None: if not isinstance(condition, dict): raise ValueError(f"derivation {where}.condition must be an object") var = condition.get("var") if not isinstance(var, str) or not var: raise ValueError(f"derivation {where}.condition.var must be a non-empty string") op = condition.get("op") if op not in _VALID_OPS: raise ValueError( f"derivation {where}.condition.op must be one of {sorted(_VALID_OPS)}" ) if not isinstance(condition.get("value", ""), str): raise ValueError(f"derivation {where}.condition.value must be a string") def _validate_action(action: Any, where: str) -> None: if not isinstance(action, dict): raise ValueError(f"derivation {where}.action must be an object") target = action.get("target_var") if not isinstance(target, str) or not target: raise ValueError( f"derivation {where}.action.target_var must be a non-empty string" ) mode = action.get("mode", "replace") if mode not in _VALID_MODES: raise ValueError( f"derivation {where}.action.mode must be one of {sorted(_VALID_MODES)}" ) if not isinstance(action.get("value", ""), str): raise ValueError(f"derivation {where}.action.value must be a string") class DerivationHandler(ModuleHandler): """IF / ELIF / ELSE rule evaluator that mutates ctx.""" type_id = "derivation" @classmethod def validate_payload(cls, payload: dict[str, Any]) -> None: if not isinstance(payload, dict): raise ValueError("derivation payload must be an object") rules = payload.get("rules") if not isinstance(rules, list): raise ValueError("derivation payload.rules must be a list") seen_rule_ids: set[str] = set() for ri, rule in enumerate(rules): if not isinstance(rule, dict): raise ValueError(f"derivation payload.rules[{ri}] must be an object") rule_id = rule.get("id") if not isinstance(rule_id, str) or not rule_id: raise ValueError( f"derivation payload.rules[{ri}].id must be a non-empty string" ) # Rule ids index `instance.disabled_rule_ids` and # `rule_order_override` — dupes make both ambiguous. if rule_id in seen_rule_ids: raise ValueError( f"derivation payload.rules[{ri}].id {rule_id!r} duplicates " f"an earlier rule id" ) seen_rule_ids.add(rule_id) branches = rule.get("branches") if not isinstance(branches, list) or not branches: raise ValueError( f"derivation payload.rules[{ri}].branches must be a non-empty list" ) for bi, branch in enumerate(branches): if not isinstance(branch, dict): raise ValueError( f"derivation payload.rules[{ri}].branches[{bi}] must be an object" ) _validate_condition( branch.get("condition"), f"rules[{ri}].branches[{bi}]" ) _validate_action(branch.get("action"), f"rules[{ri}].branches[{bi}]") else_clause = rule.get("else") if else_clause is not None: if not isinstance(else_clause, dict): raise ValueError( f"derivation payload.rules[{ri}].else must be an object" ) _validate_action(else_clause.get("action"), f"rules[{ri}].else") @classmethod def resolve( cls, payload: dict[str, Any], instance: dict[str, Any], ctx: Any, ) -> dict[str, str]: cls.validate_payload(payload) # Effective seed selection — same pattern wildcard / combine / # fixed_values use. Locked seed pins {a|b|c} resolution in # action.value across runs; without lock the chain seed # propagates per-queue. Falls back to the host ctx's RNG when # the ctx wasn't built with `__wp_rng__` (legacy callers). # seed_scope == "hold" freezes the DICE per branch, NOT which branch # fires. The conditions still pick a branch live each iteration, so a # varying input can switch IF→ELIF and we use THAT branch — but every # branch's inline {a|b} / @{} roll is frozen to its frame-0 value via # the constant hold seed, keyed PER BRANCH (see _branch_resolve_ctx). # So each branch's value is identical every iteration it fires and # equals the value it WOULD have had at frame 0, independent of which # other branches fired. (Contrast wildcard/combine/fixed_values hold, # which freeze the whole resolved VALUE because a constraint can # reshape their pool; a derivation branch's pool is fixed, so a frozen # seed alone is enough — and freezing the branch SELECTION would be # wrong: the user wants the chosen branch to follow the inputs.) _hold = False effective_seed = 0 module_id = "derivation" ctx_local: dict[str, Any] = {} if isinstance(ctx, dict) and "__wp_rng__" in ctx: _hold = instance.get("seed_scope") == "hold" if _hold: chain_seed = int( ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0 ) else: chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0) locked_seed = instance.get("locked_seed") effective_seed = ( int(locked_seed) if isinstance(locked_seed, (int, float)) else chain_seed ) module_id = str(ctx.get("__wp_module_id__") or "derivation") rng = derive_module_rng(effective_seed, module_id) ctx_local = {**ctx, "__wp_rng__": rng} resolve_ctx = build_resolve_ctx(ctx_local, surface="derivation") else: resolve_ctx = build_resolve_ctx(ctx, surface="derivation") def _branch_resolve_ctx(branch_key: str) -> Any: # Non-hold (or no rng): the shared rng stream — existing # behaviour, branch values draw in fire order. Hold: an # INDEPENDENT rng per branch (keyed by rule:branch) so its value # is frozen to its frame-0 roll regardless of which other # branches drew before it in the shared stream. if not _hold: return resolve_ctx rng_b = derive_module_rng(effective_seed, f"{module_id}::{branch_key}") return build_resolve_ctx( {**ctx_local, "__wp_rng__": rng_b}, surface="derivation" ) # Tier-D instance overrides (2026-05-10 cycle). disabled_rule_ids = set(instance.get("disabled_rule_ids") or []) disabled_branch_keys = set(instance.get("disabled_branch_keys") or []) action_overrides = instance.get("action_value_overrides") or {} cond_overrides = instance.get("condition_value_overrides") or {} # Per-instance rule reorder — sort by rule_order_override list. # IDs not in the override fall through in their original library # order at the end (defensive against partial reorders). rules = list(payload.get("rules", [])) order = instance.get("rule_order_override") if isinstance(order, list) and order: ordering = {rid: i for i, rid in enumerate(order)} tail = len(order) rules.sort( key=lambda r: ordering.get(r.get("id"), tail + 1), ) out: dict[str, str] = {} for rule in rules: rule_id = rule.get("id", "") if rule_id in disabled_rule_ids: continue applied = False for bi, branch in enumerate(rule.get("branches", [])): # Branch-level disable — `r1:1` etc. IF (bi=0) ignored # even when listed because disabling IF == disabling rule # (the per-rule toggle handles that case cleanly). if bi != 0 and branch_carrier_key(rule_id, bi) in disabled_branch_keys: continue # Condition-value override per branch index. cond = branch.get("condition", {}) cond_override = ( cond_overrides.get(rule_id, {}).get(str(bi)) if isinstance(cond_overrides.get(rule_id), dict) else None ) if isinstance(cond_override, str): cond = {**cond, "value": cond_override} if _match_condition(cond, ctx): # Action-value override per branch index. action = branch.get("action", {}) action_override = ( action_overrides.get(rule_id, {}).get(str(bi)) if isinstance(action_overrides.get(rule_id), dict) else None ) if isinstance(action_override, str): action = {**action, "value": action_override} pair = _apply_action( action, ctx, _branch_resolve_ctx(branch_carrier_key(rule_id, bi)), carrier_key=branch_carrier_key(rule_id, bi), ) if pair is not None: out[pair[0]] = pair[1] applied = True break if not applied: # ELSE skip when listed in disabled_branch_keys. if branch_carrier_key(rule_id, "else") in disabled_branch_keys: continue else_clause = rule.get("else") if isinstance(else_clause, dict): action = else_clause.get("action", {}) # ELSE action-value override under `else` key. action_override = ( action_overrides.get(rule_id, {}).get("else") if isinstance(action_overrides.get(rule_id), dict) else None ) if isinstance(action_override, str): action = {**action, "value": action_override} pair = _apply_action( action, ctx, _branch_resolve_ctx(branch_carrier_key(rule_id, "else")), carrier_key=branch_carrier_key(rule_id, "else"), ) if pair is not None: out[pair[0]] = pair[1] return out