feat(engine): wildcardhandler with weighted rng and @{ref} resolution

This commit is contained in:
DumiFlex
2026-04-26 16:24:54 +03:00
parent da0daf47e0
commit 6626aca3d7
3 changed files with 242 additions and 0 deletions
+9
View File
@@ -40,3 +40,12 @@ __all__ += [
"register_handler",
"resolve_module",
]
from engine.modules.wildcard_handler import RecursionLimitExceeded, WildcardHandler # noqa: E402
register_handler(WildcardHandler)
__all__ += [
"RecursionLimitExceeded",
"WildcardHandler",
]
+72
View File
@@ -0,0 +1,72 @@
"""Wildcard module resolver — weighted RNG over options, ``@{ref}`` expansion."""
from __future__ import annotations
import random
import re
from typing import Any
from engine.modules.dispatcher import ModuleHandler
_REF_RE = re.compile(r"@\{([a-zA-Z0-9_\-]+)\}")
MAX_REF_DEPTH = 8
class RecursionLimitExceeded(RuntimeError):
"""Raised when ``@{ref}`` chain exceeds ``MAX_REF_DEPTH``."""
def _pick_weighted(options: list[dict[str, Any]]) -> dict[str, Any] | None:
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 = random.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 _expand_refs(value: str, ctx: Any, depth: int) -> str:
if depth >= MAX_REF_DEPTH:
raise RecursionLimitExceeded(f"max @{{ref}} depth at {value!r}")
if ctx is None or not hasattr(ctx, "resolve_ref"):
return _REF_RE.sub("", value)
def _sub(match: re.Match[str]) -> str:
return ctx.resolve_ref(match.group(1), depth=depth + 1)
return _REF_RE.sub(_sub, value)
class WildcardHandler(ModuleHandler):
type_id = "wildcard"
@classmethod
def resolve(
cls,
payload: dict[str, Any],
instance: dict[str, Any],
ctx: Any,
*,
_depth: int = 0,
) -> dict[str, str]:
binding = instance.get("variable_binding") or ""
if not binding:
return {}
options: list[dict[str, Any]] = list(payload.get("options", []))
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: ""}
chosen = _pick_weighted(options)
if chosen is None:
return {binding: ""}
value = str(chosen.get("value", ""))
expanded = _expand_refs(value, ctx, depth=_depth)
return {binding: expanded}
@@ -0,0 +1,161 @@
"""Tests for WildcardHandler — weighted RNG + @{ref} resolution."""
import random
import pytest
from engine.modules.wildcard_handler import RecursionLimitExceeded, WildcardHandler
def _payload(options):
return {"options": options}
def test_resolve_picks_one_option(monkeypatch):
payload = _payload([
{"id": "a", "value": "alpha", "weight": 1},
{"id": "b", "value": "beta", "weight": 1},
])
monkeypatch.setattr(random, "random", lambda: 0.0)
out = WildcardHandler.resolve(
payload, instance={"variable_binding": "$x"}, ctx=None,
)
assert out == {"$x": "alpha"}
def test_resolve_respects_weights_heavy_b(monkeypatch):
payload = _payload([
{"id": "a", "value": "alpha", "weight": 1},
{"id": "b", "value": "beta", "weight": 99},
])
monkeypatch.setattr(random, "random", lambda: 0.5) # → cumulative 0.5*100 = 50, beta wins
out = WildcardHandler.resolve(
payload, instance={"variable_binding": "$x"}, ctx=None,
)
assert out == {"$x": "beta"}
def test_resolve_empty_options_returns_empty_string():
out = WildcardHandler.resolve(
_payload([]), instance={"variable_binding": "$x"}, ctx=None,
)
assert out == {"$x": ""}
def test_resolve_filters_by_enabled_options(monkeypatch):
payload = _payload([
{"id": "a", "value": "alpha", "weight": 1},
{"id": "b", "value": "beta", "weight": 1},
])
monkeypatch.setattr(random, "random", lambda: 0.0)
out = WildcardHandler.resolve(
payload,
instance={"variable_binding": "$x", "enabled_options": ["b"]},
ctx=None,
)
assert out == {"$x": "beta"}
def test_resolve_at_ref_calls_ctx_resolve(monkeypatch):
payload = _payload([{"id": "a", "value": "@{other_wc}", "weight": 1}])
monkeypatch.setattr(random, "random", lambda: 0.0)
class Ctx:
def __init__(self):
self.calls = []
def resolve_ref(self, ref_id, *, depth):
self.calls.append((ref_id, depth))
return "expanded"
ctx = Ctx()
out = WildcardHandler.resolve(
payload, instance={"variable_binding": "$x"}, ctx=ctx,
)
assert out == {"$x": "expanded"}
assert ctx.calls == [("other_wc", 1)]
def test_resolve_at_ref_max_depth_raises(monkeypatch):
payload = _payload([{"id": "a", "value": "@{a}", "weight": 1}])
monkeypatch.setattr(random, "random", lambda: 0.0)
class Ctx:
def resolve_ref(self, ref_id, *, depth):
return WildcardHandler.resolve(
payload, instance={"variable_binding": "$x"},
ctx=self, _depth=depth,
)["$x"]
with pytest.raises(RecursionLimitExceeded):
WildcardHandler.resolve(
payload, instance={"variable_binding": "$x"}, ctx=Ctx(),
)
def test_resolve_returns_empty_when_no_binding():
payload = _payload([{"id": "a", "value": "x", "weight": 1}])
out = WildcardHandler.resolve(payload, instance={}, ctx=None)
assert out == {}
def test_resolve_at_ref_strips_when_ctx_none(monkeypatch):
"""No ctx → @{ref} expands to empty string."""
payload = _payload([{"id": "a", "value": "before @{x} after", "weight": 1}])
monkeypatch.setattr(random, "random", lambda: 0.0)
out = WildcardHandler.resolve(
payload, instance={"variable_binding": "$y"}, ctx=None,
)
assert out == {"$y": "before after"}
def test_resolve_zero_weight_falls_back_to_first(monkeypatch):
payload = _payload([
{"id": "a", "value": "alpha", "weight": 0},
{"id": "b", "value": "beta", "weight": 0},
])
monkeypatch.setattr(random, "random", lambda: 0.7)
out = WildcardHandler.resolve(
payload, instance={"variable_binding": "$x"}, ctx=None,
)
assert out == {"$x": "alpha"} # falls back to first option
def test_resolve_negative_weight_clamped_to_zero(monkeypatch):
payload = _payload([
{"id": "a", "value": "alpha", "weight": -5},
{"id": "b", "value": "beta", "weight": 1},
])
monkeypatch.setattr(random, "random", lambda: 0.5)
out = WildcardHandler.resolve(
payload, instance={"variable_binding": "$x"}, ctx=None,
)
assert out == {"$x": "beta"} # negative clamped, only beta has positive weight
def test_resolve_default_weight_is_1(monkeypatch):
"""Options without 'weight' key default to weight=1."""
payload = _payload([
{"id": "a", "value": "alpha"}, # no weight
{"id": "b", "value": "beta"}, # no weight
])
monkeypatch.setattr(random, "random", lambda: 0.0)
out = WildcardHandler.resolve(
payload, instance={"variable_binding": "$x"}, ctx=None,
)
assert out == {"$x": "alpha"}
def test_handler_type_id_is_wildcard():
assert WildcardHandler.type_id == "wildcard"
def test_resolve_via_dispatcher_after_import():
"""Importing engine.modules auto-registers WildcardHandler in the dispatcher."""
from engine.modules import resolve_module
snap = {
"type": "wildcard",
"payload": _payload([{"id": "a", "value": "x", "weight": 1}]),
"instance": {"variable_binding": "$z"},
}
out = resolve_module(snap, ctx=None)
assert out == {"$z": "x"}