"""Shared constraint application — used by wildcard_handler (chain-level target wildcards) and engine.syntax.resolve._resolve_ref (nested-via-@{} target wildcards). Pre-2026-05 the constraint matrix only applied when the target wildcard ran as a top-level module in the chain. Wildcards reached through ``@{uuid}`` refs inside another wildcard's option value silently bypassed the matrix, defeating the whole point of constraints in any composed pipeline (outfit-set wildcards, palette reuse, etc.). This module pulls the apply logic out of wildcard_handler so both surfaces share one implementation. The helper takes raw primitives (options list, target uuid, constraints bucket, picks bucket, warnings list) so it can be called from any context that has access to those — full pipeline ctx dict (wildcard handler) or narrow ResolveContext-style accessors (ref resolver). """ from __future__ import annotations from typing import Any from engine.modules.wildcard_handler import _apply_constraint_to_options def _push_constraint_warning( warnings: list[dict[str, Any]], *, type_: str, target_uuid: str, detail: dict[str, Any], ) -> None: """Mirror the warning shape used elsewhere in the engine so the debug viewer can render it uniformly.""" warnings.append({ "type": type_, "severity": "warn", "module_id": target_uuid, "source_field": "", "position": 0, "token_index": None, "detail": detail, "message": ( f"constraint source not yet picked: {detail.get('source_wildcard_id')!r}" if type_ == "unknown_constraint_source" else f"constraint warning: {detail!r}" ), }) def _occurrence_matches(pick_entries, firing_uid, carrier_ctx) -> bool: """True when an explicit ``pick`` reach selector covers THIS firing target occurrence. Two occurrence kinds: - ``direct`` — a top-level wildcard instance. Matched by its per-instance uid (``firing_uid``). ``carrier_ctx`` is ``None`` for these (the wildcard handler fires them directly). - ``nested`` — a ``@{target}`` ref resolved inside a carrier wildcard's chosen option. Matched by (carrier_uid, option_id). ``carrier_ctx`` carries those, threaded by the nested-resolve path (``engine.syntax.resolve``). The direct-firing wildcard handler passes ``carrier_ctx=None`` (it fires top-level instances, not nested refs), so the two kinds never cross-match. """ for p in pick_entries: if not isinstance(p, dict): continue if carrier_ctx is None: if p.get("kind") == "direct" and p.get("uid") == firing_uid: return True else: if ( p.get("kind") == "nested" and p.get("carrier_uid") == carrier_ctx.get("carrier_uid") and p.get("option_id") == carrier_ctx.get("option_id") ): return True return False def apply_constraints_for_target( options: list[dict[str, Any]], target_uuid: str, constraints: list[dict[str, Any]] | None, picks: dict[str, dict[str, Any]] | None, warnings: list[dict[str, Any]], *, consumed=None, hits=None, firing_uid=None, carrier_ctx=None, ) -> tuple[list[dict[str, Any]], bool]: """Apply EVERY constraint whose reach selector covers this firing target instance, combined sequentially. Returns ``(adjusted_options, any_applied)``. ``any_applied`` is True iff at least one constraint actually fired — callers use it to decide whether to run the excludes-all check. **Reach selector (SP3).** Each constraint carries ``target_select = {mode, count?, picks?}`` (default ``{mode: "all"}``). On every firing target instance the per-constraint hit counter ``hits[cid]`` increments; the selector then decides coverage: - ``all`` — covers every instance (re-weights every downstream target occurrence). - ``first`` — covers only the 1st (``n == 1``). - ``next`` — covers the first ``count`` (``n <= count``). - ``pick`` — covers the explicitly-listed occurrences (``_occurrence_matches``). Multiple covering constraints stack: their factors multiply in registration order and ``exclude`` is absorbing. This replaces the pre-SP3 one-shot / consumed-set model. Reach is downstream-relative by construction: a constraint only appears in ``ctx["__wp_constraints__"]`` after its own module ran, so target occurrences upstream of it never see it. ``hits`` is the ctx-resident ``__wp_constraint_hits__`` dict; the caller threads it from ctx so first/next counters persist across the run (direct + nested encounters share one counter). ``consumed`` is accepted but ignored — kept so legacy callers don't break. Each constraint warns + skips when its source wildcard hasn't been picked yet — caller code is responsible for ordering source wildcards before the target's roll. The warning gives the user a signal in WP_Debug instead of a silent no-op. """ if not constraints or not isinstance(constraints, list): return options, False if not isinstance(picks, dict): picks = {} if hits is None: hits = {} any_applied = False for c in constraints: if not isinstance(c, dict) or c.get("target_wildcard_id") != target_uuid: continue cid = c.get("__constraint_module_id__") src_id = c.get("source_wildcard_id") src_pick = picks.get(src_id) if isinstance(src_id, str) else None if not isinstance(src_pick, dict): _push_constraint_warning( warnings, type_="unknown_constraint_source", target_uuid=target_uuid, detail={ "source_wildcard_id": src_id, "target_wildcard_id": target_uuid, "hint": ( "source must be picked before target — check chain order" ), }, ) continue if cid is not None: hits[cid] = hits.get(cid, 0) + 1 n = hits.get(cid, 1) sel = c.get("target_select") or {"mode": "all"} mode = sel.get("mode", "all") if mode == "first": covered = n == 1 elif mode == "next": try: cnt = int(sel.get("count", 1)) except (TypeError, ValueError): cnt = 1 covered = n <= cnt elif mode == "pick": covered = _occurrence_matches( sel.get("picks") or [], firing_uid, carrier_ctx ) else: covered = True if not covered: continue adjust_warnings: list[dict[str, Any]] = [] options = _apply_constraint_to_options( options, c, src_pick, adjust_warnings, ) any_applied = True for w in adjust_warnings: _push_constraint_warning( warnings, type_=w.get("type", "unknown_constraint_mode"), target_uuid=target_uuid, detail={ **{k: v for k, v in w.items() if k != "type"}, "source_wildcard_id": src_id, "target_wildcard_id": target_uuid, }, ) return options, any_applied def warn_excludes_all( options: list[dict[str, Any]], target_uuid: str, warnings: list[dict[str, Any]], ) -> None: """Emit the ``constraint_excludes_all_options`` warning when post- application weights sum to zero. ``_pick_weighted`` falls back to ``options[0]`` silently — without this warning the user sees the same option forever with no signal that constraints over-narrowed the pool. Call AFTER ``apply_constraints_for_target`` returned ``any_applied``. """ if not options: return total = sum(max(0.0, float(o.get("weight", 1))) for o in options) if total <= 0: _push_constraint_warning( warnings, type_="constraint_excludes_all_options", target_uuid=target_uuid, detail={ "target_wildcard_id": target_uuid, "hint": "every option was excluded; falling back to options[0]", }, )