feat(engine): wildcardhandler with weighted rng and @{ref} resolution
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user