feat(value-sources): add sandboxed-Jinja template combinator

A new `template` value source evaluates a hardened, sandboxed Jinja
expression over the live values of other value sources — the system's
first float combinator.

Backend:
- Shared engine (utils/template_expr.py): ImmutableSandboxedEnvironment with
  filters/tests and auto-injected globals stripped; only min/max/abs/round/
  clamp exposed; rejects **, string/collection-literal repetition, attribute
  access and non-global calls; NaN/inf-safe result coercion.
- TemplateValueSource model + TemplateValueStream runtime: compile-once,
  primitives-only eval context, raw[name] exposure, eval_interval throttle,
  ref-counted input acquire/release, rename-safe hot-update.
- Validation: unbound-variable + reserved-name rejection, reference
  cycle/depth guards (depth-only at create, full cycle at update), runtime
  acquire() depth backstop, and delete referential-integrity.
- API: Create/Update/Response schemas + discriminated unions, _RESPONSE_MAP,
  and an advisory POST /value-sources/validate-template endpoint.
- Demo seed: a static source plus a template combinator example.

Frontend:
- Editor modal section: repeatable inputs list (EntitySelect rows), a
  zero-dependency Jinja syntax highlighter, a hints/reference panel, and a
  debounced live validator that gates Save (stale-response-safe).
- Graph editor: read-only template node with one edge per input.
- i18n (en/ru/zh), icon, and card rendering.

Tests: engine, stream, factory/cycle, validate endpoint, and demo seed.
This commit is contained in:
2026-06-01 18:53:56 +03:00
parent 12b40e6071
commit 6de61b965e
30 changed files with 2805 additions and 12 deletions
@@ -0,0 +1,231 @@
"""Tests for TemplateValueStream (the Jinja combinator runtime)."""
from collections import defaultdict
from datetime import datetime, timezone
import pytest
from ledgrab.core.processing.value_stream import (
TemplateValueStream,
ValueStreamManager,
)
from ledgrab.storage.value_source import TemplateValueSource
# --- Fakes for precise control over input values / raw -----------------------
class _FakeStream:
_NO_RAW = object()
def __init__(self, value, raw=_NO_RAW):
self._value = value
self._raw = raw
def get_value(self):
return self._value
# get_raw_value only exists when a raw value was provided
def __getattr__(self, name):
if name == "get_raw_value" and self._raw is not _FakeStream._NO_RAW:
return lambda: self._raw
raise AttributeError(name)
class _FakeVSM:
def __init__(self, streams):
self._streams = streams # id -> _FakeStream
self.refcounts = defaultdict(int)
def acquire(self, vs_id):
self.refcounts[vs_id] += 1
return self._streams[vs_id]
def release(self, vs_id):
self.refcounts[vs_id] -= 1
def _inputs(*pairs):
return [{"name": n, "value_source_id": i} for n, i in pairs]
def _make(template, inputs, streams, default_value=0.0, eval_interval=None):
vsm = _FakeVSM(streams)
stream = TemplateValueStream(
template=template,
inputs=inputs,
default_value=default_value,
eval_interval=eval_interval,
value_stream_manager=vsm,
)
stream.start()
return stream, vsm
class TestEvaluation:
def test_eval_with_inputs(self):
stream, vsm = _make("min(a * 2, 1)", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.3)})
assert vsm.refcounts["vs_a"] == 1
assert stream.get_value() == pytest.approx(0.6)
def test_clamps_out_of_range(self):
stream, _ = _make("a * 10", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.5)})
assert stream.get_value() == 1.0 # 5.0 clamped
def test_two_inputs(self):
stream, _ = _make(
"(a + b) / 2",
_inputs(("a", "vs_a"), ("b", "vs_b")),
{"vs_a": _FakeStream(0.2), "vs_b": _FakeStream(0.8)},
)
assert stream.get_value() == pytest.approx(0.5)
def test_shared_id_single_ref(self):
# Two variables bound to the same source share one acquisition.
stream, vsm = _make(
"min(a + b, 1)",
_inputs(("a", "vs_x"), ("b", "vs_x")),
{"vs_x": _FakeStream(0.3)},
)
assert vsm.refcounts["vs_x"] == 1
assert stream.get_value() == pytest.approx(0.6)
class TestErrorHandling:
def test_div_by_zero_returns_default(self):
stream, _ = _make(
"a / 0", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.5)}, default_value=0.25
)
assert stream.get_value() == 0.25
def test_missing_variable_returns_default(self):
# template references 'b' but only 'a' is bound
stream, _ = _make(
"a + b", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.5)}, default_value=0.1
)
assert stream.get_value() == 0.1
def test_nan_returns_default(self):
stream, _ = _make(
"a - a", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(float("inf"))}, default_value=0.3
)
# inf - inf = nan -> default
assert stream.get_value() == 0.3
def test_invalid_template_uses_default(self):
stream, _ = _make(
"a +", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.5)}, default_value=0.42
)
assert stream.get_value() == 0.42
class TestRawExposure:
def test_raw_present_when_stream_exposes_it(self):
stream, _ = _make(
"raw['t'] / 100",
_inputs(("t", "vs_t")),
{"vs_t": _FakeStream(0.5, raw=42.0)},
)
assert stream.get_value() == pytest.approx(0.42)
def test_raw_absent_without_getter(self):
# input stream has no get_raw_value -> raw['t'] -> None -> error -> default
stream, _ = _make(
"raw['t'] / 100",
_inputs(("t", "vs_t")),
{"vs_t": _FakeStream(0.5)},
default_value=0.2,
)
assert stream.get_value() == 0.2
def test_non_numeric_raw_is_dropped(self):
# raw value is a string -> never crosses into sandbox -> raw['t'] absent
stream, _ = _make(
"raw['t'] / 100",
_inputs(("t", "vs_t")),
{"vs_t": _FakeStream(0.5, raw="playing")},
default_value=0.15,
)
assert stream.get_value() == 0.15
class TestLifecycle:
def test_stop_releases_all(self):
stream, vsm = _make(
"min(a + b, 1)",
_inputs(("a", "vs_a"), ("b", "vs_b")),
{"vs_a": _FakeStream(0.1), "vs_b": _FakeStream(0.2)},
)
stream.stop()
assert vsm.refcounts["vs_a"] == 0
assert vsm.refcounts["vs_b"] == 0
def test_eval_interval_caches(self):
backing = _FakeStream(0.2)
stream, _ = _make("a", _inputs(("a", "vs_a")), {"vs_a": backing}, eval_interval=3600.0)
first = stream.get_value()
backing._value = 0.9 # change the live input
# Cached within the interval -> still the first value.
assert stream.get_value() == pytest.approx(first)
class TestHotUpdate:
def test_swap_input_releases_old_acquires_new(self):
stream, vsm = _make(
"a", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.1), "vs_b": _FakeStream(0.9)}
)
assert vsm.refcounts["vs_a"] == 1
new_src = TemplateValueSource(
id="t1",
name="t",
source_type="template",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
template="a",
inputs=_inputs(("a", "vs_b")),
default_value=0.0,
)
stream.update_source(new_src)
assert vsm.refcounts["vs_a"] == 0 # old released
assert vsm.refcounts["vs_b"] == 1 # new acquired
assert stream.get_value() == pytest.approx(0.9)
def test_rename_keeps_same_source(self):
stream, vsm = _make("a", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.7)})
renamed = TemplateValueSource(
id="t1",
name="t",
source_type="template",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
template="b", # variable renamed a -> b, same source id
inputs=_inputs(("b", "vs_a")),
default_value=0.0,
)
stream.update_source(renamed)
assert vsm.refcounts["vs_a"] == 1 # not re-acquired (unchanged id)
assert stream.get_value() == pytest.approx(0.7)
class TestAcquireDepthBackstop:
def test_self_reference_does_not_overflow(self):
"""A cycle that bypassed storage validation must not stack-overflow."""
now = datetime.now(timezone.utc)
src = TemplateValueSource(
id="vs_cycle",
name="cycle",
source_type="template",
created_at=now,
updated_at=now,
template="x",
inputs=_inputs(("x", "vs_cycle")),
default_value=0.0,
)
class _CycleStore:
def get_source(self, vs_id):
return src
manager = ValueStreamManager(value_source_store=_CycleStore())
stream = manager.acquire("vs_cycle") # must terminate, not recurse forever
assert isinstance(stream.get_value(), float)