feat(syntax): _resolve_ref with surface/depth/cycle checks + weighted pick

This commit is contained in:
DumiFlex
2026-04-28 05:25:09 +03:00
parent 9e05e1fb66
commit 51a859b095
2 changed files with 306 additions and 3 deletions
+139 -3
View File
@@ -6,8 +6,20 @@ happens inside `_resolve_ref` (Task 9) and brace resolvers (Tasks 10–11).
"""
from __future__ import annotations
import logging
from engine.syntax.tokenize import tokenize_text
from engine.syntax.types import ResolveContext, Token, TokenKind
from engine.syntax.types import (
CycleDetectedError,
RecursionLimitExceeded,
RefOutOfSurfaceError,
ResolveContext,
Token,
TokenKind,
UnknownRefError,
)
logger = logging.getLogger(__name__)
def resolve_text(text: str, ctx: ResolveContext) -> str:
@@ -35,8 +47,7 @@ def _resolve_tokens(
elif tok.kind == TokenKind.VAR:
parts.append(_resolve_var(tok, ctx))
elif tok.kind == TokenKind.REF:
# Implemented in Task 9
raise NotImplementedError("REF resolution lands in Task 9")
parts.append(_resolve_ref(tok, ctx, depth, visited))
elif tok.kind == TokenKind.DP_BRACE:
# Implemented in Task 10
raise NotImplementedError("DP_BRACE resolution lands in Task 10")
@@ -66,3 +77,128 @@ def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
# Revisit if user feedback shows confusion.
return ""
return str(value)
def _push_warning(ctx: ResolveContext, **fields) -> None:
"""Append a warning dict to ctx.warnings. Helper to keep call sites tidy."""
ctx.warnings.append(fields)
def _pick_weighted(options: list[dict], rng) -> dict | None:
"""Pick one option weighted by `weight`. Returns None for empty list.
Mirrors the pre-Phase-5 wildcard_handler._pick_weighted but takes `rng`
as an explicit parameter instead of using random.random().
"""
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 = rng.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 _resolve_ref(
tok: Token,
ctx: ResolveContext,
depth: int,
visited: tuple[str, ...],
) -> str:
uuid = tok.meta.get("uuid", "")
# Depth check (first — prevents infinite recursion even on cyclic graphs)
if depth >= ctx.max_ref_depth:
chain = list(visited) + [uuid]
if ctx.strict:
raise RecursionLimitExceeded(
f"max ref depth {ctx.max_ref_depth} exceeded: "
+ " → ".join(f"@{{{u}}}" for u in chain)
)
_push_warning(
ctx,
type="recursion_limit",
severity="error",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"chain": chain, "limit": ctx.max_ref_depth},
message=f"Recursion limit reached at depth {ctx.max_ref_depth}",
)
return ""
# Cycle check
if uuid in visited:
chain = list(visited) + [uuid]
if ctx.strict:
raise CycleDetectedError(chain)
_push_warning(
ctx,
type="cycle_detected",
severity="error",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"chain": chain},
message="Cycle: " + " → ".join(f"@{{{u}}}" for u in chain),
)
return ""
# Surface check
if ctx.surface != "wildcard":
if ctx.strict:
raise RefOutOfSurfaceError(uuid, ctx.surface)
# Try to resolve module name for the warning detail (best-effort)
module = ctx.get_module(uuid)
name = module.get("var_binding") if module else None
_push_warning(
ctx,
type="ref_out_of_surface",
severity="info",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"uuid": uuid, "name": name, "surface": ctx.surface},
message=f"@{{{uuid}}} ignored in {ctx.surface!r} surface",
)
return ""
# Catalog lookup
module = ctx.get_module(uuid)
if module is None:
if ctx.strict:
raise UnknownRefError(uuid)
_push_warning(
ctx,
type="unknown_ref",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"uuid": uuid, "name": None},
message=f"Unknown wildcard ref @{{{uuid}}}",
)
return ""
# Pick weighted option
options = module.get("options", [])
chosen = _pick_weighted(options, ctx.rng)
if chosen is None:
return ""
chosen_value = str(chosen.get("value", ""))
if not chosen_value:
return ""
# Recursive resolve with depth+1, visited extended
nested_tokens = tokenize_text(chosen_value)
return _resolve_tokens(nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,))
+167
View File
@@ -5,6 +5,8 @@ import random
from dataclasses import dataclass, field
from typing import Any
import pytest
from engine.syntax import resolve_text
from engine.syntax.types import SurfaceKind
@@ -66,3 +68,168 @@ def test_resolve_var_skips_internal_keys():
# Tokenizer recognizes $__wp_node_seed__ as a var, but resolver checks
# if name starts with __ and refuses.
assert resolve_text("$__wp_node_seed__", ctx) == ""
# ---------------------------------------------------------------------------
# REF resolution tests (Task 9)
# ---------------------------------------------------------------------------
def _wc(uuid: str, var_binding: str, options=None) -> dict:
"""Build a wildcard module dict matching ctx.get_module's return shape."""
if options is None:
options = [{"value": "default", "weight": 1}]
return {
"type": "wildcard",
"var_binding": var_binding,
"options": options,
}
def test_resolve_ref_in_wildcard_surface_picks_option():
ctx = _ctx(
surface="wildcard",
_modules={"a4f7b2e1": _wc("a4f7b2e1", "color", [{"value": "red", "weight": 1}])},
)
assert resolve_text("@{a4f7b2e1}", ctx) == "red"
def test_resolve_ref_unknown_uuid_lenient_emits_empty():
# Use a valid 8-hex-char UUID that isn't in the catalog.
ctx = _ctx(surface="wildcard", strict=False)
assert resolve_text("a @{00000000} b", ctx) == "a b"
assert any(w["type"] == "unknown_ref" for w in ctx.warnings)
def test_resolve_ref_unknown_uuid_strict_raises():
from engine.syntax import UnknownRefError
# Use a valid 8-hex-char UUID that isn't in the catalog.
ctx = _ctx(surface="wildcard", strict=True)
with pytest.raises(UnknownRefError) as exc:
resolve_text("@{00000000}", ctx)
assert exc.value.uuid == "00000000"
@pytest.mark.parametrize("surface", ["combine", "derivation", "assembler"])
def test_resolve_ref_out_of_surface_lenient_emits_empty(surface):
ctx = _ctx(
surface=surface,
strict=False,
_modules={"a4f7b2e1": _wc("a4f7b2e1", "x")},
)
assert resolve_text("a @{a4f7b2e1} thing", ctx) == "a thing"
assert any(w["type"] == "ref_out_of_surface" for w in ctx.warnings)
@pytest.mark.parametrize("surface", ["combine", "derivation", "assembler"])
def test_resolve_ref_out_of_surface_strict_raises(surface):
from engine.syntax import RefOutOfSurfaceError
ctx = _ctx(
surface=surface,
strict=True,
_modules={"a4f7b2e1": _wc("a4f7b2e1", "x")},
)
with pytest.raises(RefOutOfSurfaceError) as exc:
resolve_text("@{a4f7b2e1}", ctx)
assert exc.value.surface == surface
assert exc.value.uuid == "a4f7b2e1"
def test_resolve_ref_recursion_limit_lenient():
# Build a chain a → b → c → d → e (5 deep) with max_ref_depth=3.
# Depth-3 resolution returns "" + warning; rest of chain unwinds.
ctx = _ctx(
surface="wildcard",
max_ref_depth=3,
_modules={
"a0000001": _wc("a0000001", "a", [{"value": "@{b0000002}", "weight": 1}]),
"b0000002": _wc("b0000002", "b", [{"value": "@{c0000003}", "weight": 1}]),
"c0000003": _wc("c0000003", "c", [{"value": "@{d0000004}", "weight": 1}]),
"d0000004": _wc("d0000004", "d", [{"value": "leaf", "weight": 1}]),
},
)
out = resolve_text("@{a0000001}", ctx)
# depth 0: a -> resolves "@{b...}" at depth 1
# depth 1: b -> resolves "@{c...}" at depth 2
# depth 2: c -> resolves "@{d...}" at depth 3
# depth 3: REJECTED (>= max_ref_depth) -> ""
# So output is "" because the chain bottoms out empty.
assert out == ""
assert any(w["type"] == "recursion_limit" for w in ctx.warnings)
def test_resolve_ref_cycle_lenient():
ctx = _ctx(
surface="wildcard",
_modules={
"a0000001": _wc("a0000001", "a", [{"value": "@{b0000002}", "weight": 1}]),
"b0000002": _wc("b0000002", "b", [{"value": "@{a0000001}", "weight": 1}]),
},
)
out = resolve_text("@{a0000001}", ctx)
assert out == ""
cycle_warnings = [w for w in ctx.warnings if w["type"] == "cycle_detected"]
assert len(cycle_warnings) == 1
assert cycle_warnings[0]["detail"]["chain"] == ["a0000001", "b0000002", "a0000001"]
def test_resolve_ref_cycle_strict_raises():
from engine.syntax import CycleDetectedError
ctx = _ctx(
surface="wildcard",
strict=True,
_modules={
"a0000001": _wc("a0000001", "a", [{"value": "@{a0000001}", "weight": 1}]),
},
)
with pytest.raises(CycleDetectedError) as exc:
resolve_text("@{a0000001}", ctx)
assert exc.value.chain == ["a0000001", "a0000001"]
def test_resolve_ref_weighted_pick_deterministic():
"""Same seed → same pick. Weight skews distribution but still deterministic."""
ctx1 = _ctx(
rng=random.Random(42),
surface="wildcard",
_modules={"a4f7b2e1": _wc("a4f7b2e1", "x", [
{"value": "red", "weight": 1},
{"value": "blue", "weight": 100},
{"value": "green", "weight": 1},
])},
)
ctx2 = _ctx(
rng=random.Random(42),
surface="wildcard",
_modules={"a4f7b2e1": _wc("a4f7b2e1", "x", [
{"value": "red", "weight": 1},
{"value": "blue", "weight": 100},
{"value": "green", "weight": 1},
])},
)
assert resolve_text("@{a4f7b2e1}", ctx1) == resolve_text("@{a4f7b2e1}", ctx2)
def test_resolve_ref_repeated_resolves_independently():
"""Two @{uuid} occurrences sample independently, not cached."""
ctx = _ctx(
rng=random.Random(42),
surface="wildcard",
_modules={"a4f7b2e1": _wc("a4f7b2e1", "x", [
{"value": "A", "weight": 1},
{"value": "B", "weight": 1},
{"value": "C", "weight": 1},
{"value": "D", "weight": 1},
])},
)
# With seed 42 and 4 equiprobable options, two consecutive picks
# should usually differ. After running once, observe the actual value
# and pin it for stability.
out = resolve_text("@{a4f7b2e1}-@{a4f7b2e1}", ctx)
assert "-" in out
parts = out.split("-")
assert len(parts) == 2
assert parts[0] in {"A", "B", "C", "D"}
assert parts[1] in {"A", "B", "C", "D"}
# Pinned from first observed run with seed=42:
assert out == "C-A"