222 lines
8.3 KiB
Python
222 lines
8.3 KiB
Python
"""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]",
|
|
},
|
|
)
|