From 118e9ba187b5456248968241c8664636c21165da Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Thu, 16 Jul 2026 14:39:41 -0400 Subject: [PATCH 01/23] make __signature__ a cached property --- effectful/ops/types.py | 2 +- tests/test_ops_syntax.py | 45 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/effectful/ops/types.py b/effectful/ops/types.py index 46419d7a8..4836a2818 100644 --- a/effectful/ops/types.py +++ b/effectful/ops/types.py @@ -83,7 +83,7 @@ def __init__(self, default: Callable[Q, V], name: str | None = None): self.__default__ = default self.__name__ = name or default.__name__ - @property + @functools.cached_property def __signature__(self): # Resolve forward references (e.g. -> "MyClass") using the # default function's __globals__. This handles module-level diff --git a/tests/test_ops_syntax.py b/tests/test_ops_syntax.py index 185b6132e..73d555482 100644 --- a/tests/test_ops_syntax.py +++ b/tests/test_ops_syntax.py @@ -25,7 +25,7 @@ syntactic_eq, trace, ) -from effectful.ops.types import NotHandled, Operation, Term +from effectful.ops.types import Expr, NotHandled, Operation, Term logger = logging.getLogger(__name__) @@ -1263,3 +1263,46 @@ def test_defop_forward_ref_mutual_recursion(): exp_term = tangent.exp() assert isinstance(exp_term, Term) assert typeof(exp_term) is _Coordinate + + +def test_bench_term_construction(benchmark): + """Benchmark polymorphic type checking during term construction.""" + + @defop + def _benchmark_identity[T](value: T) -> T: + raise NotHandled + + @defop + def _benchmark_keep_left[T, U](value: T, metadata: U) -> T: + raise NotHandled + + @defop + def _benchmark_lookup[K, V](values: Mapping[K, V], key: K) -> V: + raise NotHandled + + @defop + def _benchmark_first[T](values: collections.abc.Sequence[T]) -> T: + raise NotHandled + + _BENCHMARK_OPERATIONS: tuple[Callable[[Expr[int], int], Expr[int]], ...] = ( + lambda value, _: _benchmark_identity(value), + lambda value, index: _benchmark_keep_left(value, ("node", index)), + lambda value, _: _benchmark_lookup({"value": value}, "value"), + lambda value, _: _benchmark_first([value]), + ) + + def _make_benchmark_term(size: int) -> Term[int]: + """Construct a linear term containing exactly ``size`` applications.""" + if size < 1: + raise ValueError("term size must be positive") + + value: Expr[int] = 0 + for index in range(size): + operation = _BENCHMARK_OPERATIONS[index % len(_BENCHMARK_OPERATIONS)] + value = operation(value, index) + + assert isinstance(value, Term) + return value + + result = benchmark(_make_benchmark_term, 25) + assert isinstance(result, Term) From 4efcb45b95528092eba812a9dd7769969d99608f Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Thu, 16 Jul 2026 15:26:48 -0400 Subject: [PATCH 02/23] move cached __signature__ to new property --- effectful/ops/types.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/effectful/ops/types.py b/effectful/ops/types.py index 4836a2818..65e31de38 100644 --- a/effectful/ops/types.py +++ b/effectful/ops/types.py @@ -80,11 +80,19 @@ class Operation[**Q, V]: def __init__(self, default: Callable[Q, V], name: str | None = None): functools.update_wrapper(self, default) + # update_wrapper copies the wrapped callable's __dict__. Do not retain a + # signature cached by another Operation, since `default` may now be a + # bound version of that operation. + self.__dict__.pop("_signature", None) self.__default__ = default self.__name__ = name or default.__name__ - @functools.cached_property + @property def __signature__(self): + return self._signature + + @functools.cached_property + def _signature(self): # Resolve forward references (e.g. -> "MyClass") using the # default function's __globals__. This handles module-level # forward refs; local forward refs will raise NameError. From 1ddffe2c579955d6d679b2c686033eac84faedaa Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Fri, 17 Jul 2026 11:21:18 -0400 Subject: [PATCH 03/23] wip --- effectful/ops/semantics.py | 213 +++++++++++++++++++++++++++++++----- effectful/ops/syntax.py | 42 ++----- tests/test_ops_semantics.py | 108 +++++++++++++++++- 3 files changed, 303 insertions(+), 60 deletions(-) diff --git a/effectful/ops/semantics.py b/effectful/ops/semantics.py index de041b61f..898260ac8 100644 --- a/effectful/ops/semantics.py +++ b/effectful/ops/semantics.py @@ -8,7 +8,7 @@ from collections.abc import Callable from typing import Any -from effectful.ops.syntax import _CustomSingleDispatchCallable, defdata, defop +from effectful.ops.syntax import _CustomSingleDispatchCallable, defop from effectful.ops.types import ( Expr, Interpretation, @@ -20,6 +20,59 @@ apply = Operation.__apply__ +@defop +def _get_cache_key() -> object | None: + """Return the cache key for the current memoized interpretation. + + This is queried directly by :func:`evaluate`, rather than dispatched as an + ordinary operation, so that interpretations of :data:`apply` do not see it. + """ + return None + + +class MemoizedInterpretation(collections.abc.Mapping): + """An interpretation whose evaluations of terms are memoized. + + Results are stored on each term and keyed by this interpretation's private + identity. Memoization is therefore shared by every installation of this + object, while wrapping an interpretation a second time creates a fresh + cache namespace. + + Memoized interpretations should be deterministic and independent of + enclosing handlers. In particular, handlers that use :func:`fwd` to depend + on an enclosing interpretation are generally not safe to memoize. + """ + + def __init__(self, intp: Interpretation): + self._intp = intp + self._cache_key = object() + + def __iter__(self): + yield from self._intp + if _get_cache_key not in self._intp: + yield _get_cache_key + + def __len__(self): + return len(self._intp) + (_get_cache_key not in self._intp) + + def __getitem__(self, op: Operation): + if op is _get_cache_key: + return lambda: self._cache_key + return self._intp[op] + + +def memoize(intp: Interpretation) -> MemoizedInterpretation: + """Flag ``intp`` for term-local memoization. + + Calling ``memoize`` on an already memoized interpretation is idempotent. + The interpretation must be deterministic and independent of enclosing + handlers for cached evaluation to preserve its semantics. + """ + if isinstance(intp, MemoizedInterpretation): + return intp + return MemoizedInterpretation(intp) + + @defop def fwd(*args, **kwargs) -> Any: """Forward execution to the next most enclosing handler. @@ -166,11 +219,71 @@ def _evaluate_object[T](expr: T, **kwargs) -> T: return expr +_EVALUATION_CACHE_ATTR = "__effectful_evaluation_cache__" +_CACHE_MISSING = object() +_CACHE_PENDING = object() + + +def _current_cache_key() -> object | None: + """Return the current interpretation's cache key without effect dispatch.""" + from effectful.internals.runtime import get_interpretation + + impl = get_interpretation().get(_get_cache_key) + return None if impl is None else impl() + + +def _term_cache(expr: Term) -> dict[object, object]: + """Return the evaluation cache owned by ``expr``, creating it if needed.""" + try: + return object.__getattribute__(expr, _EVALUATION_CACHE_ATTR) + except AttributeError: + cache: dict[object, object] = {} + try: + object.__setattr__(expr, _EVALUATION_CACHE_ATTR, cache) + except (AttributeError, TypeError) as exc: + raise TypeError( + f"Term implementation {type(expr).__qualname__} does not support " + "memoized evaluation" + ) from exc + return cache + + @evaluate.register(Term) def _evaluate_term(expr: Term, **kwargs): - args = tuple(evaluate(arg) for arg in expr.args) - kwargs = {k: evaluate(v) for k, v in expr.kwargs.items()} - return expr.op(*args, **kwargs) + cache_key = _current_cache_key() + if cache_key is None: + args = tuple(evaluate(arg) for arg in expr.args) + kwargs = {k: evaluate(v) for k, v in expr.kwargs.items()} + return expr.op(*args, **kwargs) + + cache = _term_cache(expr) + result = cache.get(cache_key, _CACHE_MISSING) + if result is _CACHE_PENDING: + raise RuntimeError("cyclic memoized evaluation of a Term") + if result is not _CACHE_MISSING: + return result + + cache[cache_key] = _CACHE_PENDING + try: + args = tuple(evaluate(arg) for arg in expr.args) + kwargs = {k: evaluate(v) for k, v in expr.kwargs.items()} + result = expr.op(*args, **kwargs) + except BaseException: + cache.pop(cache_key, None) + raise + + cache[cache_key] = result + return result + + +def get[T](intp: Interpretation, term: Expr[T]) -> Expr[T]: + """Evaluate ``term`` under ``intp``, reusing cached results when enabled. + + This is equivalent to ``handler(intp)(evaluate)(term)``. If ``intp`` was + created by :func:`memoize`, results are cached on each visited term. + """ + with handler(intp): + return evaluate(term) @evaluate.register(Operation) @@ -248,6 +361,20 @@ def _simple_type(tp: type) -> type: return typing.get_origin(tp) or tp +def _typeof_apply(op, *args, **kwargs): + from effectful.internals.unification import Box + + return Box(op.__type_rule__(*args, **kwargs)) + + +_TYPEOF_INTERPRETATION = memoize({apply: _typeof_apply}) + + +def _typeof(term: Expr): + """Evaluate the cached type analysis without unwrapping its result.""" + return evaluate(term, intp=_TYPEOF_INTERPRETATION) + + def typeof[T](term: Expr[T]) -> type[T]: """Return the type of an expression. @@ -270,17 +397,60 @@ def typeof[T](term: Expr[T]) -> type[T]: """ - from effectful.internals.runtime import interpreter from effectful.internals.unification import Box - def _apply(op, *args, **kwargs): - return Box(op.__type_rule__(*args, **kwargs)) + type_or_value = _typeof(term) + if isinstance(type_or_value, Box): + return _simple_type(type_or_value.value) + return typing.cast(type[T], type(type_or_value)) + + +@dataclasses.dataclass(frozen=True) +class _FreeVariables: + value: frozenset[Operation] + + +def _collect_free_variables(expr) -> frozenset[Operation]: + if isinstance(expr, _FreeVariables): + return expr.value + elif dataclasses.is_dataclass(expr) and not isinstance(expr, type): + return frozenset().union( + *( + _collect_free_variables(getattr(expr, field.name)) + for field in dataclasses.fields(expr) + ) + ) + elif isinstance(expr, collections.abc.Mapping): + return frozenset().union( + *( + _collect_free_variables(item) + for key, value in expr.items() + for item in (key, value) + ) + ) + elif isinstance(expr, collections.abc.Sequence) and not isinstance( + expr, str | bytes + ): + return frozenset().union(*map(_collect_free_variables, expr)) + elif isinstance( + expr, + collections.abc.ItemsView + | collections.abc.KeysView + | collections.abc.ValuesView, + ): + return frozenset().union(*map(_collect_free_variables, expr)) + return frozenset() + + +def _fvsof_apply(op, *args, **kwargs): + fvs = {op} | set(_collect_free_variables((args, kwargs))) + bindings = op.__fvs_rule__(*args, **kwargs) + bound_vars = set().union(*(*bindings.args, *bindings.kwargs.values())) + assert all(isinstance(bound_var, Operation) for bound_var in bound_vars) + return _FreeVariables(frozenset(fvs - bound_vars)) - with interpreter({apply: _apply}): - type_or_value = evaluate(term) - if isinstance(type_or_value, Box): - return _simple_type(type_or_value.value) - return typing.cast(type[T], type(type_or_value)) + +_FVSOF_INTERPRETATION = memoize({apply: _fvsof_apply}) def fvsof[S](term: Expr[S]) -> collections.abc.Set[Operation]: @@ -295,20 +465,5 @@ def fvsof[S](term: Expr[S]) -> collections.abc.Set[Operation]: >>> assert f in fvs >>> assert len(fvs) == 1 """ - from effectful.internals.runtime import interpreter - - _fvs: set[Operation] = set() - - def _update_fvs(op, *args, **kwargs): - _fvs.add(op) - bindings = op.__fvs_rule__(*args, **kwargs) - for bound_var in set().union(*(*bindings.args, *bindings.kwargs.values())): - assert isinstance(bound_var, Operation) - if bound_var in _fvs: - _fvs.remove(bound_var) - return defdata(op, *args, **kwargs) - - with interpreter({apply: _update_fvs}): - evaluate(term) - - return _fvs + analyzed = evaluate(term, intp=_FVSOF_INTERPRETATION) + return _collect_free_variables(analyzed) diff --git a/effectful/ops/syntax.py b/effectful/ops/syntax.py index 764016752..0b27e1847 100644 --- a/effectful/ops/syntax.py +++ b/effectful/ops/syntax.py @@ -484,9 +484,8 @@ def __call__(self: collections.abc.Callable[P, T], *args: P.args, **kwargs: P.kw When an Operation whose return type is `Callable` is passed to :func:`defdata`, it is reconstructed as a :class:`_CallableTerm`, which implements the :func:`__call__` method. """ - from effectful.internals.product_n import _unpack, productN from effectful.internals.runtime import interpreter - from effectful.ops.semantics import _simple_type, apply, evaluate + from effectful.ops.semantics import _simple_type, _typeof, apply, evaluate # If this operation binds variables, we need to rename them in the # appropriate parts of the child term. @@ -497,38 +496,20 @@ def __call__(self: collections.abc.Callable[P, T], *args: P.args, **kwargs: P.kw for var in bound_vars } - # Analysis for type computation and term reconstruction - typ = defop(object, name="typ") - cast = defop(object, name="cast") - - def apply_type(op, *args, **kwargs): - from effectful.internals.unification import Box - - assert isinstance(op, Operation) - tp = op.__type_rule__(*args, **kwargs) - return Box(tp) - - def apply_cast(op, *args, **kwargs): - assert isinstance(op, Operation) - full_type = typ() - dispatch_type = _simple_type(full_type.value) - return __dispatch(dispatch_type)(dispatch_type, op, *args, **kwargs) - - analysis = productN({typ: {apply: apply_type}, cast: {apply: apply_cast}}) - def evaluate_with_renaming(expr, ctx): """Evaluate an expression with renaming applied.""" renaming_ctx = { old_var: new_var for old_var, new_var in renaming.items() if old_var in ctx } + if not renaming_ctx: + return expr + # Note: coproduct cannot be used to compose these interpretations # because evaluate will only do operation replacement when the handler # is operation typed, which coproduct does not satisfy. - with interpreter(analysis | renaming_ctx): - result = evaluate(expr) - - return result + with interpreter({apply: defdata} | renaming_ctx): + return evaluate(expr) renamed_args = op.__signature__.bind(*args, **kwargs) renamed_args.apply_defaults() @@ -542,11 +523,12 @@ def evaluate_with_renaming(expr, ctx): for (k, v) in renamed_args.kwargs.items() } - # Build the final term with type analysis - with interpreter(analysis): - result = op(*args_, **kwargs_) - - return _unpack(result, cast) + # Build the final term using the cached type analysis of its children. + typed_args = tuple(_typeof(arg) for arg in args_) + typed_kwargs = {k: _typeof(v) for k, v in kwargs_.items()} + full_type = op.__type_rule__(*typed_args, **typed_kwargs) + dispatch_type = _simple_type(full_type) + return __dispatch(dispatch_type)(dispatch_type, op, *args_, **kwargs_) def _construct_dataclass_term[T]( diff --git a/tests/test_ops_semantics.py b/tests/test_ops_semantics.py index 75d040162..478ba17c2 100644 --- a/tests/test_ops_semantics.py +++ b/tests/test_ops_semantics.py @@ -8,7 +8,17 @@ import pytest -from effectful.ops.semantics import coproduct, evaluate, fvsof, fwd, handler, typeof +from effectful.ops.semantics import ( + apply, + coproduct, + evaluate, + fvsof, + fwd, + get, + handler, + memoize, + typeof, +) from effectful.ops.syntax import ObjectInterpretation, Scoped, deffn, defop, implements from effectful.ops.types import Interpretation, NotHandled, Operation, Term @@ -457,6 +467,85 @@ def Nested(*args, **kwargs): assert evaluate(t) == Nested([{"a": 2}, 1, (1, 2)], 1, arg1={"b": 1}) +def test_memoized_interpretation(): + @defop + def node(x: object) -> object: + raise NotHandled + + term = node(node(1)) + calls = 0 + + def analyze(op, *args, **kwargs): + nonlocal calls + calls += 1 + return (op.__name__, args, kwargs) + + intp = memoize({apply: analyze}) + expected = ("node", (("node", (1,), {}),), {}) + + assert get(intp, term) == expected + assert calls == 2 + + # The root cache is checked before its children are traversed, including + # when evaluation is expressed directly through a handler. + with handler(intp): + assert evaluate(term) == expected + assert calls == 2 + + # Child results are cached independently and can be reused directly. + assert get(intp, term.args[0]) == expected[1][0] + assert calls == 2 + + # A separately memoized interpretation has a separate cache namespace. + other_intp = memoize({apply: analyze}) + assert get(other_intp, term) == expected + assert calls == 4 + + +def test_memoized_interpretation_does_not_cache_failures(): + @defop + def node() -> object: + raise NotHandled + + term = node() + calls = 0 + + def analyze(op, *args, **kwargs): + nonlocal calls + calls += 1 + if calls == 1: + raise ValueError("failed analysis") + return "success" + + intp = memoize({apply: analyze}) + with pytest.raises(ValueError, match="failed analysis"): + get(intp, term) + + assert get(intp, term) == "success" + assert calls == 2 + assert get(intp, term) == "success" + assert calls == 2 + + +def test_fvsof_is_memoized(): + x = defop(int, name="x") + + @defop + def identity(value: int) -> int: + raise NotHandled + + term = identity(x()) + assert not hasattr(term, "__effectful_evaluation_cache__") + + assert fvsof(term) == {identity, x} + cache = term.__effectful_evaluation_cache__ + assert len(cache) == 1 + + assert fvsof(term) == {identity, x} + assert term.__effectful_evaluation_cache__ is cache + assert len(cache) == 1 + + def test_ctxof(): x = defop(object) y = defop(object) @@ -503,6 +592,23 @@ def g(x: str, y: bool) -> str: evaluate(0, intp={f: lambda x: x + 1, g: lambda x, y: x + str(y)}) +def test_typeof_is_memoized(): + @defop + def identity(x: int) -> int: + raise NotHandled + + term = identity(1) + assert not hasattr(term, "__effectful_evaluation_cache__") + + assert typeof(term) is int + cache = term.__effectful_evaluation_cache__ + assert len(cache) == 1 + + assert typeof(term) is int + assert term.__effectful_evaluation_cache__ is cache + assert len(cache) == 1 + + def test_typeof_basic(): """Test typeof with basic operations that have simple return types.""" From 8475f54c6e8b935fc3d872a20584653eb63d406a Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Fri, 17 Jul 2026 11:24:49 -0400 Subject: [PATCH 04/23] simplify --- effectful/ops/semantics.py | 53 ++++++++----------------------------- tests/test_ops_semantics.py | 4 +++ 2 files changed, 15 insertions(+), 42 deletions(-) diff --git a/effectful/ops/semantics.py b/effectful/ops/semantics.py index 898260ac8..dc0def602 100644 --- a/effectful/ops/semantics.py +++ b/effectful/ops/semantics.py @@ -21,56 +21,25 @@ @defop -def _get_cache_key() -> object | None: - """Return the cache key for the current memoized interpretation. +def _get_cache_key() -> Operation: + """Return the operation identifying the current memoized interpretation. This is queried directly by :func:`evaluate`, rather than dispatched as an ordinary operation, so that interpretations of :data:`apply` do not see it. """ - return None + raise RuntimeError("_get_cache_key must be handled directly") -class MemoizedInterpretation(collections.abc.Mapping): - """An interpretation whose evaluations of terms are memoized. - - Results are stored on each term and keyed by this interpretation's private - identity. Memoization is therefore shared by every installation of this - object, while wrapping an interpretation a second time creates a fresh - cache namespace. - - Memoized interpretations should be deterministic and independent of - enclosing handlers. In particular, handlers that use :func:`fwd` to depend - on an enclosing interpretation are generally not safe to memoize. - """ - - def __init__(self, intp: Interpretation): - self._intp = intp - self._cache_key = object() - - def __iter__(self): - yield from self._intp - if _get_cache_key not in self._intp: - yield _get_cache_key - - def __len__(self): - return len(self._intp) + (_get_cache_key not in self._intp) - - def __getitem__(self, op: Operation): - if op is _get_cache_key: - return lambda: self._cache_key - return self._intp[op] - - -def memoize(intp: Interpretation) -> MemoizedInterpretation: +def memoize(intp: Interpretation) -> Interpretation: """Flag ``intp`` for term-local memoization. - Calling ``memoize`` on an already memoized interpretation is idempotent. - The interpretation must be deterministic and independent of enclosing - handlers for cached evaluation to preserve its semantics. + A fresh operation identifies each resulting interpretation and is used as + the key in term-local caches. The interpretation must be deterministic and + independent of enclosing handlers for cached evaluation to preserve its + semantics. """ - if isinstance(intp, MemoizedInterpretation): - return intp - return MemoizedInterpretation(intp) + cache_key = defop(object, name="cache_key") + return coproduct(intp, {_get_cache_key: lambda: cache_key}) @defop @@ -224,7 +193,7 @@ def _evaluate_object[T](expr: T, **kwargs) -> T: _CACHE_PENDING = object() -def _current_cache_key() -> object | None: +def _current_cache_key() -> Operation | None: """Return the current interpretation's cache key without effect dispatch.""" from effectful.internals.runtime import get_interpretation diff --git a/tests/test_ops_semantics.py b/tests/test_ops_semantics.py index 478ba17c2..eb850c4e9 100644 --- a/tests/test_ops_semantics.py +++ b/tests/test_ops_semantics.py @@ -485,6 +485,10 @@ def analyze(op, *args, **kwargs): assert get(intp, term) == expected assert calls == 2 + assert all( + isinstance(cache_key, Operation) + for cache_key in term.__effectful_evaluation_cache__ + ) # The root cache is checked before its children are traversed, including # when evaluation is expressed directly through a handler. From 3d63173beea8a5749cd73ee0f5e352db715a6b75 Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Fri, 17 Jul 2026 11:26:37 -0400 Subject: [PATCH 05/23] simplify --- effectful/ops/semantics.py | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/effectful/ops/semantics.py b/effectful/ops/semantics.py index dc0def602..8c16dad41 100644 --- a/effectful/ops/semantics.py +++ b/effectful/ops/semantics.py @@ -189,8 +189,6 @@ def _evaluate_object[T](expr: T, **kwargs) -> T: _EVALUATION_CACHE_ATTR = "__effectful_evaluation_cache__" -_CACHE_MISSING = object() -_CACHE_PENDING = object() def _current_cache_key() -> Operation | None: @@ -226,21 +224,12 @@ def _evaluate_term(expr: Term, **kwargs): return expr.op(*args, **kwargs) cache = _term_cache(expr) - result = cache.get(cache_key, _CACHE_MISSING) - if result is _CACHE_PENDING: - raise RuntimeError("cyclic memoized evaluation of a Term") - if result is not _CACHE_MISSING: - return result - - cache[cache_key] = _CACHE_PENDING - try: - args = tuple(evaluate(arg) for arg in expr.args) - kwargs = {k: evaluate(v) for k, v in expr.kwargs.items()} - result = expr.op(*args, **kwargs) - except BaseException: - cache.pop(cache_key, None) - raise + if cache_key in cache: + return cache[cache_key] + args = tuple(evaluate(arg) for arg in expr.args) + kwargs = {k: evaluate(v) for k, v in expr.kwargs.items()} + result = expr.op(*args, **kwargs) cache[cache_key] = result return result From 02f1c7423a8da59d9cba1d67b729c6ada1640e63 Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Fri, 17 Jul 2026 11:28:42 -0400 Subject: [PATCH 06/23] simplify --- effectful/ops/semantics.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/effectful/ops/semantics.py b/effectful/ops/semantics.py index 8c16dad41..04efad6e5 100644 --- a/effectful/ops/semantics.py +++ b/effectful/ops/semantics.py @@ -192,11 +192,12 @@ def _evaluate_object[T](expr: T, **kwargs) -> T: def _current_cache_key() -> Operation | None: - """Return the current interpretation's cache key without effect dispatch.""" + """Return the current interpretation's cache key, if it has one.""" from effectful.internals.runtime import get_interpretation - impl = get_interpretation().get(_get_cache_key) - return None if impl is None else impl() + if _get_cache_key not in get_interpretation(): + return None + return _get_cache_key() def _term_cache(expr: Term) -> dict[object, object]: From b9b5fa4b9dfc5521fe6fb4b829f32c3f3fe92949 Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Fri, 17 Jul 2026 11:31:24 -0400 Subject: [PATCH 07/23] simplify --- effectful/ops/semantics.py | 10 ---------- tests/test_ops_semantics.py | 13 ++++++------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/effectful/ops/semantics.py b/effectful/ops/semantics.py index 04efad6e5..645d49fcd 100644 --- a/effectful/ops/semantics.py +++ b/effectful/ops/semantics.py @@ -235,16 +235,6 @@ def _evaluate_term(expr: Term, **kwargs): return result -def get[T](intp: Interpretation, term: Expr[T]) -> Expr[T]: - """Evaluate ``term`` under ``intp``, reusing cached results when enabled. - - This is equivalent to ``handler(intp)(evaluate)(term)``. If ``intp`` was - created by :func:`memoize`, results are cached on each visited term. - """ - with handler(intp): - return evaluate(term) - - @evaluate.register(Operation) def _evaluate_operation(expr: Operation, **kwargs) -> Operation: from effectful.internals.runtime import get_interpretation diff --git a/tests/test_ops_semantics.py b/tests/test_ops_semantics.py index eb850c4e9..50c5d2616 100644 --- a/tests/test_ops_semantics.py +++ b/tests/test_ops_semantics.py @@ -14,7 +14,6 @@ evaluate, fvsof, fwd, - get, handler, memoize, typeof, @@ -483,7 +482,7 @@ def analyze(op, *args, **kwargs): intp = memoize({apply: analyze}) expected = ("node", (("node", (1,), {}),), {}) - assert get(intp, term) == expected + assert handler(intp)(evaluate)(term) == expected assert calls == 2 assert all( isinstance(cache_key, Operation) @@ -497,12 +496,12 @@ def analyze(op, *args, **kwargs): assert calls == 2 # Child results are cached independently and can be reused directly. - assert get(intp, term.args[0]) == expected[1][0] + assert handler(intp)(evaluate)(term.args[0]) == expected[1][0] assert calls == 2 # A separately memoized interpretation has a separate cache namespace. other_intp = memoize({apply: analyze}) - assert get(other_intp, term) == expected + assert handler(other_intp)(evaluate)(term) == expected assert calls == 4 @@ -523,11 +522,11 @@ def analyze(op, *args, **kwargs): intp = memoize({apply: analyze}) with pytest.raises(ValueError, match="failed analysis"): - get(intp, term) + handler(intp)(evaluate)(term) - assert get(intp, term) == "success" + assert handler(intp)(evaluate)(term) == "success" assert calls == 2 - assert get(intp, term) == "success" + assert handler(intp)(evaluate)(term) == "success" assert calls == 2 From 782ce7bcee14b36df231b1451c370cc60ea28b28 Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Fri, 17 Jul 2026 15:19:58 -0400 Subject: [PATCH 08/23] revert fvsof --- effectful/ops/semantics.py | 73 +++++++++++-------------------------- tests/test_ops_semantics.py | 36 ------------------ 2 files changed, 22 insertions(+), 87 deletions(-) diff --git a/effectful/ops/semantics.py b/effectful/ops/semantics.py index 645d49fcd..de3712fc9 100644 --- a/effectful/ops/semantics.py +++ b/effectful/ops/semantics.py @@ -8,7 +8,11 @@ from collections.abc import Callable from typing import Any -from effectful.ops.syntax import _CustomSingleDispatchCallable, defop +from effectful.ops.syntax import ( + _CustomSingleDispatchCallable, + defdata, + defop, +) from effectful.ops.types import ( Expr, Interpretation, @@ -354,54 +358,6 @@ def typeof[T](term: Expr[T]) -> type[T]: return typing.cast(type[T], type(type_or_value)) -@dataclasses.dataclass(frozen=True) -class _FreeVariables: - value: frozenset[Operation] - - -def _collect_free_variables(expr) -> frozenset[Operation]: - if isinstance(expr, _FreeVariables): - return expr.value - elif dataclasses.is_dataclass(expr) and not isinstance(expr, type): - return frozenset().union( - *( - _collect_free_variables(getattr(expr, field.name)) - for field in dataclasses.fields(expr) - ) - ) - elif isinstance(expr, collections.abc.Mapping): - return frozenset().union( - *( - _collect_free_variables(item) - for key, value in expr.items() - for item in (key, value) - ) - ) - elif isinstance(expr, collections.abc.Sequence) and not isinstance( - expr, str | bytes - ): - return frozenset().union(*map(_collect_free_variables, expr)) - elif isinstance( - expr, - collections.abc.ItemsView - | collections.abc.KeysView - | collections.abc.ValuesView, - ): - return frozenset().union(*map(_collect_free_variables, expr)) - return frozenset() - - -def _fvsof_apply(op, *args, **kwargs): - fvs = {op} | set(_collect_free_variables((args, kwargs))) - bindings = op.__fvs_rule__(*args, **kwargs) - bound_vars = set().union(*(*bindings.args, *bindings.kwargs.values())) - assert all(isinstance(bound_var, Operation) for bound_var in bound_vars) - return _FreeVariables(frozenset(fvs - bound_vars)) - - -_FVSOF_INTERPRETATION = memoize({apply: _fvsof_apply}) - - def fvsof[S](term: Expr[S]) -> collections.abc.Set[Operation]: """Return the free variables of an expression. @@ -414,5 +370,20 @@ def fvsof[S](term: Expr[S]) -> collections.abc.Set[Operation]: >>> assert f in fvs >>> assert len(fvs) == 1 """ - analyzed = evaluate(term, intp=_FVSOF_INTERPRETATION) - return _collect_free_variables(analyzed) + from effectful.internals.runtime import interpreter + + _fvs: set[Operation] = set() + + def _update_fvs(op, *args, **kwargs): + _fvs.add(op) + bindings = op.__fvs_rule__(*args, **kwargs) + for bound_var in set().union(*(*bindings.args, *bindings.kwargs.values())): + assert isinstance(bound_var, Operation) + if bound_var in _fvs: + _fvs.remove(bound_var) + return defdata(op, *args, **kwargs) + + with interpreter({apply: _update_fvs}): + evaluate(term) + + return _fvs diff --git a/tests/test_ops_semantics.py b/tests/test_ops_semantics.py index 50c5d2616..192f295e2 100644 --- a/tests/test_ops_semantics.py +++ b/tests/test_ops_semantics.py @@ -530,25 +530,6 @@ def analyze(op, *args, **kwargs): assert calls == 2 -def test_fvsof_is_memoized(): - x = defop(int, name="x") - - @defop - def identity(value: int) -> int: - raise NotHandled - - term = identity(x()) - assert not hasattr(term, "__effectful_evaluation_cache__") - - assert fvsof(term) == {identity, x} - cache = term.__effectful_evaluation_cache__ - assert len(cache) == 1 - - assert fvsof(term) == {identity, x} - assert term.__effectful_evaluation_cache__ is cache - assert len(cache) == 1 - - def test_ctxof(): x = defop(object) y = defop(object) @@ -595,23 +576,6 @@ def g(x: str, y: bool) -> str: evaluate(0, intp={f: lambda x: x + 1, g: lambda x, y: x + str(y)}) -def test_typeof_is_memoized(): - @defop - def identity(x: int) -> int: - raise NotHandled - - term = identity(1) - assert not hasattr(term, "__effectful_evaluation_cache__") - - assert typeof(term) is int - cache = term.__effectful_evaluation_cache__ - assert len(cache) == 1 - - assert typeof(term) is int - assert term.__effectful_evaluation_cache__ is cache - assert len(cache) == 1 - - def test_typeof_basic(): """Test typeof with basic operations that have simple return types.""" From a6cb80b2edbee9889f5e4031df2c9a2d5fae3504 Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Fri, 17 Jul 2026 15:30:06 -0400 Subject: [PATCH 09/23] simplify --- effectful/ops/semantics.py | 20 +++++++++----------- tests/test_ops_semantics.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/effectful/ops/semantics.py b/effectful/ops/semantics.py index de3712fc9..84a3515f3 100644 --- a/effectful/ops/semantics.py +++ b/effectful/ops/semantics.py @@ -204,19 +204,16 @@ def _current_cache_key() -> Operation | None: return _get_cache_key() -def _term_cache(expr: Term) -> dict[object, object]: - """Return the evaluation cache owned by ``expr``, creating it if needed.""" +def _term_cache(expr: Term) -> dict[object, object] | None: + """Return the cache owned by ``expr``, or ``None`` if it cannot store one.""" try: - return object.__getattribute__(expr, _EVALUATION_CACHE_ATTR) + return getattr(expr, _EVALUATION_CACHE_ATTR) except AttributeError: cache: dict[object, object] = {} try: - object.__setattr__(expr, _EVALUATION_CACHE_ATTR, cache) - except (AttributeError, TypeError) as exc: - raise TypeError( - f"Term implementation {type(expr).__qualname__} does not support " - "memoized evaluation" - ) from exc + setattr(expr, _EVALUATION_CACHE_ATTR, cache) + except (AttributeError, TypeError): + return None return cache @@ -229,13 +226,14 @@ def _evaluate_term(expr: Term, **kwargs): return expr.op(*args, **kwargs) cache = _term_cache(expr) - if cache_key in cache: + if cache is not None and cache_key in cache: return cache[cache_key] args = tuple(evaluate(arg) for arg in expr.args) kwargs = {k: evaluate(v) for k, v in expr.kwargs.items()} result = expr.op(*args, **kwargs) - cache[cache_key] = result + if cache is not None: + cache[cache_key] = result return result diff --git a/tests/test_ops_semantics.py b/tests/test_ops_semantics.py index 192f295e2..095a19a7b 100644 --- a/tests/test_ops_semantics.py +++ b/tests/test_ops_semantics.py @@ -530,6 +530,34 @@ def analyze(op, *args, **kwargs): assert calls == 2 +def test_memoized_interpretation_skips_terms_without_attribute_storage(): + @defop + def node() -> object: + raise NotHandled + + @Term.register + class SlottedTerm: + __slots__ = ("op", "args", "kwargs") + + def __init__(self): + self.op = node + self.args = () + self.kwargs = {} + + calls = 0 + + def analyze(op, *args, **kwargs): + nonlocal calls + calls += 1 + return "result" + + term = SlottedTerm() + intp = memoize({apply: analyze}) + assert handler(intp)(evaluate)(term) == "result" + assert handler(intp)(evaluate)(term) == "result" + assert calls == 2 + + def test_ctxof(): x = defop(object) y = defop(object) From 83e3cd5fa0eac9cf0fde8890c1b03cbfd0344e1d Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Fri, 17 Jul 2026 16:06:30 -0400 Subject: [PATCH 10/23] use object identity for caching --- effectful/internals/runtime.py | 2 +- effectful/ops/semantics.py | 41 ++++++++++++++-------------------- tests/test_ops_semantics.py | 29 +++++++++++++++--------- 3 files changed, 37 insertions(+), 35 deletions(-) diff --git a/effectful/internals/runtime.py b/effectful/internals/runtime.py index 4c9ebd7b1..26deb1f4d 100644 --- a/effectful/internals/runtime.py +++ b/effectful/internals/runtime.py @@ -27,7 +27,7 @@ def interpreter(intp: "Interpretation"): r = get_runtime() old_intp = r.interpretation try: - old_intp, r.interpretation = r.interpretation, dict(intp) + old_intp, r.interpretation = r.interpretation, intp yield intp finally: r.interpretation = old_intp diff --git a/effectful/ops/semantics.py b/effectful/ops/semantics.py index 84a3515f3..5cac20d8a 100644 --- a/effectful/ops/semantics.py +++ b/effectful/ops/semantics.py @@ -25,13 +25,9 @@ @defop -def _get_cache_key() -> Operation: - """Return the operation identifying the current memoized interpretation. - - This is queried directly by :func:`evaluate`, rather than dispatched as an - ordinary operation, so that interpretations of :data:`apply` do not see it. - """ - raise RuntimeError("_get_cache_key must be handled directly") +def _is_memoized() -> bool: + """Return whether evaluation under the current interpretation is memoized.""" + return False def memoize(intp: Interpretation) -> Interpretation: @@ -42,8 +38,7 @@ def memoize(intp: Interpretation) -> Interpretation: independent of enclosing handlers for cached evaluation to preserve its semantics. """ - cache_key = defop(object, name="cache_key") - return coproduct(intp, {_get_cache_key: lambda: cache_key}) + return coproduct(intp, {_is_memoized: lambda: True}) @defop @@ -195,13 +190,14 @@ def _evaluate_object[T](expr: T, **kwargs) -> T: _EVALUATION_CACHE_ATTR = "__effectful_evaluation_cache__" -def _current_cache_key() -> Operation | None: - """Return the current interpretation's cache key, if it has one.""" +def _current_interpretation_cache_id() -> int | None: + """Return the current memoized interpretation's object identity.""" from effectful.internals.runtime import get_interpretation - if _get_cache_key not in get_interpretation(): + intp = get_interpretation() + if _is_memoized not in intp: return None - return _get_cache_key() + return id(intp) def _term_cache(expr: Term) -> dict[object, object] | None: @@ -219,21 +215,18 @@ def _term_cache(expr: Term) -> dict[object, object] | None: @evaluate.register(Term) def _evaluate_term(expr: Term, **kwargs): - cache_key = _current_cache_key() - if cache_key is None: - args = tuple(evaluate(arg) for arg in expr.args) - kwargs = {k: evaluate(v) for k, v in expr.kwargs.items()} - return expr.op(*args, **kwargs) - - cache = _term_cache(expr) - if cache is not None and cache_key in cache: - return cache[cache_key] + cache = None + cache_id = _current_interpretation_cache_id() + if cache_id is not None: + cache = _term_cache(expr) + if cache is not None and cache_id in cache: + return cache[cache_id] args = tuple(evaluate(arg) for arg in expr.args) kwargs = {k: evaluate(v) for k, v in expr.kwargs.items()} result = expr.op(*args, **kwargs) - if cache is not None: - cache[cache_key] = result + if cache is not None and cache_id is not None: + cache[cache_id] = result return result diff --git a/tests/test_ops_semantics.py b/tests/test_ops_semantics.py index 095a19a7b..110bedef6 100644 --- a/tests/test_ops_semantics.py +++ b/tests/test_ops_semantics.py @@ -467,6 +467,8 @@ def Nested(*args, **kwargs): def test_memoized_interpretation(): + from effectful.internals.runtime import interpreter + @defop def node(x: object) -> object: raise NotHandled @@ -482,30 +484,37 @@ def analyze(op, *args, **kwargs): intp = memoize({apply: analyze}) expected = ("node", (("node", (1,), {}),), {}) - assert handler(intp)(evaluate)(term) == expected + assert interpreter(intp)(evaluate)(term) == expected assert calls == 2 assert all( - isinstance(cache_key, Operation) - for cache_key in term.__effectful_evaluation_cache__ + isinstance(cache_key, int) for cache_key in term.__effectful_evaluation_cache__ ) # The root cache is checked before its children are traversed, including # when evaluation is expressed directly through a handler. - with handler(intp): + with interpreter(intp): assert evaluate(term) == expected assert calls == 2 # Child results are cached independently and can be reused directly. - assert handler(intp)(evaluate)(term.args[0]) == expected[1][0] + assert interpreter(intp)(evaluate)(term.args[0]) == expected[1][0] assert calls == 2 + # A composition has a distinct identity even when its added handler is not + # used while evaluating this term. + combined_intp = coproduct(intp, {plus_1: lambda x: x}) + assert interpreter(combined_intp)(evaluate)(term) == expected + assert calls == 4 + # A separately memoized interpretation has a separate cache namespace. other_intp = memoize({apply: analyze}) - assert handler(other_intp)(evaluate)(term) == expected - assert calls == 4 + assert interpreter(other_intp)(evaluate)(term) == expected + assert calls == 6 def test_memoized_interpretation_does_not_cache_failures(): + from effectful.internals.runtime import interpreter + @defop def node() -> object: raise NotHandled @@ -522,11 +531,11 @@ def analyze(op, *args, **kwargs): intp = memoize({apply: analyze}) with pytest.raises(ValueError, match="failed analysis"): - handler(intp)(evaluate)(term) + interpreter(intp)(evaluate)(term) - assert handler(intp)(evaluate)(term) == "success" + assert interpreter(intp)(evaluate)(term) == "success" assert calls == 2 - assert handler(intp)(evaluate)(term) == "success" + assert interpreter(intp)(evaluate)(term) == "success" assert calls == 2 From 75827783bdb654ca3cef90b5d4036057620526cc Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Mon, 20 Jul 2026 15:59:15 -0400 Subject: [PATCH 11/23] make memoize private --- effectful/ops/semantics.py | 4 ++-- tests/test_ops_semantics.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/effectful/ops/semantics.py b/effectful/ops/semantics.py index 5cac20d8a..a11b563dc 100644 --- a/effectful/ops/semantics.py +++ b/effectful/ops/semantics.py @@ -30,7 +30,7 @@ def _is_memoized() -> bool: return False -def memoize(intp: Interpretation) -> Interpretation: +def _memoize(intp: Interpretation) -> Interpretation: """Flag ``intp`` for term-local memoization. A fresh operation identifies each resulting interpretation and is used as @@ -311,7 +311,7 @@ def _typeof_apply(op, *args, **kwargs): return Box(op.__type_rule__(*args, **kwargs)) -_TYPEOF_INTERPRETATION = memoize({apply: _typeof_apply}) +_TYPEOF_INTERPRETATION = _memoize({apply: _typeof_apply}) def _typeof(term: Expr): diff --git a/tests/test_ops_semantics.py b/tests/test_ops_semantics.py index 110bedef6..40ee79a10 100644 --- a/tests/test_ops_semantics.py +++ b/tests/test_ops_semantics.py @@ -9,13 +9,13 @@ import pytest from effectful.ops.semantics import ( + _memoize, apply, coproduct, evaluate, fvsof, fwd, handler, - memoize, typeof, ) from effectful.ops.syntax import ObjectInterpretation, Scoped, deffn, defop, implements @@ -481,7 +481,7 @@ def analyze(op, *args, **kwargs): calls += 1 return (op.__name__, args, kwargs) - intp = memoize({apply: analyze}) + intp = _memoize({apply: analyze}) expected = ("node", (("node", (1,), {}),), {}) assert interpreter(intp)(evaluate)(term) == expected @@ -507,7 +507,7 @@ def analyze(op, *args, **kwargs): assert calls == 4 # A separately memoized interpretation has a separate cache namespace. - other_intp = memoize({apply: analyze}) + other_intp = _memoize({apply: analyze}) assert interpreter(other_intp)(evaluate)(term) == expected assert calls == 6 @@ -529,7 +529,7 @@ def analyze(op, *args, **kwargs): raise ValueError("failed analysis") return "success" - intp = memoize({apply: analyze}) + intp = _memoize({apply: analyze}) with pytest.raises(ValueError, match="failed analysis"): interpreter(intp)(evaluate)(term) @@ -561,7 +561,7 @@ def analyze(op, *args, **kwargs): return "result" term = SlottedTerm() - intp = memoize({apply: analyze}) + intp = _memoize({apply: analyze}) assert handler(intp)(evaluate)(term) == "result" assert handler(intp)(evaluate)(term) == "result" assert calls == 2 From 3bed5f10080be3d4f0e8522bf3336f2e23df28bb Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Mon, 20 Jul 2026 15:59:30 -0400 Subject: [PATCH 12/23] use frozenset of intp for cache key --- effectful/ops/semantics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/effectful/ops/semantics.py b/effectful/ops/semantics.py index a11b563dc..206a4a620 100644 --- a/effectful/ops/semantics.py +++ b/effectful/ops/semantics.py @@ -190,14 +190,14 @@ def _evaluate_object[T](expr: T, **kwargs) -> T: _EVALUATION_CACHE_ATTR = "__effectful_evaluation_cache__" -def _current_interpretation_cache_id() -> int | None: +def _current_interpretation_cache_id() -> object | None: """Return the current memoized interpretation's object identity.""" from effectful.internals.runtime import get_interpretation intp = get_interpretation() if _is_memoized not in intp: return None - return id(intp) + return frozenset(intp.items()) def _term_cache(expr: Term) -> dict[object, object] | None: From 2742dd4bb58652aac3e28cc068580cf90a2d5511 Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Mon, 20 Jul 2026 15:59:41 -0400 Subject: [PATCH 13/23] drop unused productN --- effectful/internals/product_n.py | 214 ------------------------------ tests/test_internals_product_n.py | 160 ---------------------- 2 files changed, 374 deletions(-) delete mode 100644 effectful/internals/product_n.py delete mode 100644 tests/test_internals_product_n.py diff --git a/effectful/internals/product_n.py b/effectful/internals/product_n.py deleted file mode 100644 index 4b8bd2a81..000000000 --- a/effectful/internals/product_n.py +++ /dev/null @@ -1,214 +0,0 @@ -import collections.abc -import dataclasses -import functools -import types -from collections.abc import Callable, Mapping -from typing import Any - -from effectful.ops.semantics import apply, coproduct, handler -from effectful.ops.syntax import defop -from effectful.ops.types import ( - Interpretation, - NotHandled, # noqa: F401 - Operation, -) - - -@dataclasses.dataclass -class CallByNeed[**P, T]: - func: Callable[P, T] - args: Any # P.args - kwargs: Any # P.kwargs - value: T | None = None - initialized: bool = False - - def __init__(self, func, *args, **kwargs): - self.func = func - self.args = args - self.kwargs = kwargs - - def __call__(self): - if not self.initialized: - self.value = self.func(*self.args, **self.kwargs) - self.initialized = True - return self.value - - -@defop -def argsof(op: Operation) -> tuple[list, dict]: - raise RuntimeError("Prompt argsof not bound.") - - -class Product: - values: object - - def __init__(self, values): - self.values = values - - -def _pack(intp): - from effectful.internals.runtime import interpreter - - return Product(interpreter(intp)(lambda x: x())) - - -def _unpack(x, prompt): - if isinstance(x, Product): - return x.values(prompt) - return x - - -def map_structure(func, expr): - if isinstance(expr, collections.abc.Mapping): - if isinstance(expr, collections.defaultdict): - return type(expr)( - expr.default_factory, map_structure(func, tuple(expr.items())) - ) - elif isinstance(expr, types.MappingProxyType): - return type(expr)(dict(map_structure(func, tuple(expr.items())))) - else: - return type(expr)(map_structure(func, tuple(expr.items()))) - elif isinstance(expr, collections.abc.Sequence): - if isinstance(expr, str | bytes): - return expr - elif ( - isinstance(expr, tuple) - and hasattr(expr, "_fields") - and all(hasattr(expr, field) for field in getattr(expr, "_fields")) - ): # namedtuple - return type(expr)( - **{ - field: map_structure(func, getattr(expr, field)) - for field in expr._fields - } - ) - else: - return type(expr)(map_structure(func, item) for item in expr) - elif isinstance(expr, collections.abc.Set): - if isinstance(expr, collections.abc.ItemsView | collections.abc.KeysView): - return {map_structure(func, item) for item in expr} - else: - return type(expr)(map_structure(func, item) for item in expr) - elif isinstance(expr, collections.abc.ValuesView): - return [map_structure(func, item) for item in expr] - elif dataclasses.is_dataclass(expr) and not isinstance(expr, type): - return dataclasses.replace( - expr, - **{ - field.name: map_structure(func, getattr(expr, field.name)) - for field in dataclasses.fields(expr) - }, - ) - else: - return func(expr) - - -def productN(intps: Mapping[Operation, Interpretation]) -> Interpretation: - # The resulting interpretation supports ops that exist in at least one input - # interpretation - result_ops = set(op for intp in intps.values() for op in intp) - if result_ops is None: - return {} - - renaming = {(prompt, op): defop(op) for prompt in intps for op in result_ops} - - # We enforce isolation between the named interpretations by giving every - # operation a fresh name and giving each operation a translation from - # the fresh names back to the names from their interpretation. - # - # E.g. { a: { f, g }, b: { f, h } } => - # { handler({f: f_a, g: g_a, h: h_default})(f_a), handler({f: f_a, g: g_a})(g_a), - # handler({f: f_b, h: h_b})(f_b), handler({f: f_b, h: h_b})(h_b) } - translation_intps: dict[Operation, Interpretation] = { - prompt: {op: renaming[(prompt, op)] for op in result_ops} for prompt in intps - } - - # For every prompt, build an isolated interpretation that binds all operations. - isolated_intps = { - prompt: { - renaming[(prompt, op)]: handler(translation_intps[prompt])(func) - for op, func in intp.items() - } - for prompt, intp in intps.items() - } - - def product_op(op, *args, **kwargs): - """Compute the product of operation `op` in named interpretations - `intps`. The product operation consumes product arguments and - returns product results. These products are represented as - interpretations. - - """ - assert isinstance(op, Operation) - - result_intp = {} - - def argsof_direct_call(prompt): - return result_intp[prompt].args, result_intp[prompt].kwargs - - def argsof_apply(prompt): - return result_intp[prompt].args[2:], result_intp[prompt].kwargs - - # Every prompt gets an argsof implementation. The implementation is - # either for a direct call to a handler or for a call to an apply - # handler. - argsof_prompts = {} - - for prompt, intp in intps.items(): - # Args and kwargs are expected to be either interpretations with - # bindings for each named analysis in intps or concrete values. - # `get_for_intp` extracts the value that corresponds to this - # analysis. - # - # TODO: `get_for_intp` has to guess whether a dict value is an - # interpretation or not. This is probably a latent bug. - intp_args, intp_kwargs = map_structure( - lambda x: _unpack(x, prompt), (args, kwargs) - ) - - # Making result a CallByNeed has two functions. It avoids some - # work when the result is not requested and it delays evaluation - # so that when the result is requested in `get_for_intp`, it - # evaluates in a context that binds the results of the other - # named interpretations. - isolated_intp = isolated_intps[prompt] - renamed_op = renaming[(prompt, op)] - if op in intp: - result = CallByNeed( - handler(isolated_intp)(renamed_op), *intp_args, **intp_kwargs - ) - argsof_impl = argsof_direct_call - elif apply in intp: - result = CallByNeed( - handler(isolated_intp)(renaming[(prompt, apply)]), - renamed_op, - *intp_args, - **intp_kwargs, - ) - argsof_impl = argsof_apply - else: - # TODO: If an intp does not handle an operation and has no apply - # handler, use the default rule. In the future, we would like to - # instead defer to the enclosing interpretation. This is - # difficult right now, because the output interpretation handles - # all operations with product handlers which would have to be - # skipped over. - result = CallByNeed( - handler(coproduct(isolated_intp, translation_intps[prompt]))( - op.__default_rule__ - ), - *intp_args, - **intp_kwargs, - ) - argsof_impl = argsof_direct_call - - result_intp[prompt] = result - argsof_prompts[prompt] = argsof_impl - - result_intp[argsof] = lambda prompt: argsof_prompts[prompt](prompt) - return _pack(result_intp) - - product_intp: Interpretation = { - op: functools.partial(product_op, op) for op in result_ops - } - return product_intp diff --git a/tests/test_internals_product_n.py b/tests/test_internals_product_n.py deleted file mode 100644 index 331019824..000000000 --- a/tests/test_internals_product_n.py +++ /dev/null @@ -1,160 +0,0 @@ -from effectful.internals.product_n import argsof, productN -from effectful.internals.unification import Box -from effectful.ops.semantics import apply, coproduct, evaluate, handler -from effectful.ops.syntax import defop -from effectful.ops.types import Interpretation, NotHandled - - -def test_simul_analysis(): - @defop - def plus1(x: int) -> int: - raise NotHandled - - @defop - def plus2(x: int) -> int: - raise NotHandled - - @defop - def times(x: int, y: int) -> int: - raise NotHandled - - x, y = defop(int, name="x"), defop(int, name="y") - - typ = defop(Interpretation, name="typ") - value = defop(Interpretation, name="value") - - type_rules = { - plus1: lambda x: int, - plus2: lambda x: int, - times: lambda x, y: int, - x: lambda: int, - y: lambda: int, - } - - def plus1_value(x): - return x + 1 - - def plus2_value(x): - return plus1(plus1(x)) - - def times_value(x, y): - t = typ() - arg = argsof(typ)[0][0] - if t is int and arg is int: - return x * y - raise TypeError("unexpected type!") - - value_rules = { - plus1: plus1_value, - plus2: plus2_value, - times: times_value, - x: lambda: 3, - y: lambda: 4, - } - - analysisN = productN({typ: type_rules, value: value_rules}) - - def f1(): - v1 = x() # {typ: lambda: int, val: lambda: 3} - v2 = y() # {typ: lambda: int, val: lambda: 4} - v3 = plus2(v1) # {typ: lambda: int, val: lambda: 5} - v4 = times(v2, v3) # {typ: lambda: int, val: lambda: 20} - v5 = plus1(v4) # {typ: lambda: int, val: lambda: 21} - return v5 # {typ: lambda: int, val: lambda: 21} - - with handler(analysisN): - i = f1() - t = i.values(typ) - v = i.values(value) - assert t is int - assert v == 21 - - -def test_simul_analysis_apply(): - @defop - def plus1[T](x: T) -> T: - raise NotHandled - - @defop - def plus2[T](x: T) -> T: - raise NotHandled - - @defop - def times[T](x: T, y: T) -> T: - raise NotHandled - - x, y = defop(int, name="x"), defop(int, name="y") - - typ = defop(Interpretation, name="typ") - value = defop(Interpretation, name="value") - - def apply_type(op, *a, **k): - return Box(op.__type_rule__(*a, **k)) - - type_rules = {apply: apply_type} - - def plus1_value(x): - return x + 1 - - def plus2_value(x): - return plus1(plus1(x)) - - def times_value(x, y): - t = typ().value - arg = argsof(typ)[0][0].value - if t is int and arg is int: - return x * y - raise TypeError("unexpected type!") - - value_rules = { - plus1: plus1_value, - plus2: plus2_value, - times: times_value, - x: lambda: 3, - y: lambda: 4, - } - - analysisN = productN({typ: type_rules, value: value_rules}) - - def f1(): - v1 = x() # {typ: lambda: int, val: lambda: 3} - v2 = y() # {typ: lambda: int, val: lambda: 4} - v3 = plus2(v1) # {typ: lambda: int, val: lambda: 5} - v4 = times(v2, v3) # {typ: lambda: int, val: lambda: 20} - v5 = plus1(v4) # {typ: lambda: int, val: lambda: 21} - return v5 # {typ: lambda: int, val: lambda: 21} - - with handler(analysisN): - i = f1() - t = i.values(typ).value - v = i.values(value) - assert t is int - assert v == 21 - - -def test_productN_distributive(): - """Test that productN distributes over coproducts.""" - - @defop - def add[T](x: T, y: T) -> T: - raise NotHandled - - x = defop(object, name="x") - i = defop(object, name="i") - s = defop(object, name="s") - - intp1 = {add: lambda x, y: x + y} - intp2 = {x: lambda: 1} - intp3 = {x: lambda: "a"} - - term = add(x(), x()) - - prod_intp1 = productN({i: coproduct(intp2, intp1), s: coproduct(intp3, intp1)}) - prod_intp2 = coproduct( - productN({i: intp2, s: intp3}), productN({i: intp1, s: intp1}) - ) - result1 = evaluate(term, intp=prod_intp1) - result2 = evaluate(term, intp=prod_intp2) - - assert result1.values(i) == result2.values(i) == 2 - assert result1.values(s) == result2.values(s) == "aa" From 6e80642435bf5d835e9982b3b22a8efeeef76f1b Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Mon, 20 Jul 2026 16:02:52 -0400 Subject: [PATCH 14/23] remove internal detail from test --- tests/test_ops_semantics.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_ops_semantics.py b/tests/test_ops_semantics.py index 40ee79a10..1be6f994e 100644 --- a/tests/test_ops_semantics.py +++ b/tests/test_ops_semantics.py @@ -486,9 +486,6 @@ def analyze(op, *args, **kwargs): assert interpreter(intp)(evaluate)(term) == expected assert calls == 2 - assert all( - isinstance(cache_key, int) for cache_key in term.__effectful_evaluation_cache__ - ) # The root cache is checked before its children are traversed, including # when evaluation is expressed directly through a handler. From e731330aab180e8a0098ebddb64b0b018d950dac Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Wed, 22 Jul 2026 13:29:14 -0400 Subject: [PATCH 15/23] Revert "drop unused productN" This reverts commit 2742dd4bb58652aac3e28cc068580cf90a2d5511. --- effectful/internals/product_n.py | 214 ++++++++++++++++++++++++++++++ tests/test_internals_product_n.py | 160 ++++++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 effectful/internals/product_n.py create mode 100644 tests/test_internals_product_n.py diff --git a/effectful/internals/product_n.py b/effectful/internals/product_n.py new file mode 100644 index 000000000..4b8bd2a81 --- /dev/null +++ b/effectful/internals/product_n.py @@ -0,0 +1,214 @@ +import collections.abc +import dataclasses +import functools +import types +from collections.abc import Callable, Mapping +from typing import Any + +from effectful.ops.semantics import apply, coproduct, handler +from effectful.ops.syntax import defop +from effectful.ops.types import ( + Interpretation, + NotHandled, # noqa: F401 + Operation, +) + + +@dataclasses.dataclass +class CallByNeed[**P, T]: + func: Callable[P, T] + args: Any # P.args + kwargs: Any # P.kwargs + value: T | None = None + initialized: bool = False + + def __init__(self, func, *args, **kwargs): + self.func = func + self.args = args + self.kwargs = kwargs + + def __call__(self): + if not self.initialized: + self.value = self.func(*self.args, **self.kwargs) + self.initialized = True + return self.value + + +@defop +def argsof(op: Operation) -> tuple[list, dict]: + raise RuntimeError("Prompt argsof not bound.") + + +class Product: + values: object + + def __init__(self, values): + self.values = values + + +def _pack(intp): + from effectful.internals.runtime import interpreter + + return Product(interpreter(intp)(lambda x: x())) + + +def _unpack(x, prompt): + if isinstance(x, Product): + return x.values(prompt) + return x + + +def map_structure(func, expr): + if isinstance(expr, collections.abc.Mapping): + if isinstance(expr, collections.defaultdict): + return type(expr)( + expr.default_factory, map_structure(func, tuple(expr.items())) + ) + elif isinstance(expr, types.MappingProxyType): + return type(expr)(dict(map_structure(func, tuple(expr.items())))) + else: + return type(expr)(map_structure(func, tuple(expr.items()))) + elif isinstance(expr, collections.abc.Sequence): + if isinstance(expr, str | bytes): + return expr + elif ( + isinstance(expr, tuple) + and hasattr(expr, "_fields") + and all(hasattr(expr, field) for field in getattr(expr, "_fields")) + ): # namedtuple + return type(expr)( + **{ + field: map_structure(func, getattr(expr, field)) + for field in expr._fields + } + ) + else: + return type(expr)(map_structure(func, item) for item in expr) + elif isinstance(expr, collections.abc.Set): + if isinstance(expr, collections.abc.ItemsView | collections.abc.KeysView): + return {map_structure(func, item) for item in expr} + else: + return type(expr)(map_structure(func, item) for item in expr) + elif isinstance(expr, collections.abc.ValuesView): + return [map_structure(func, item) for item in expr] + elif dataclasses.is_dataclass(expr) and not isinstance(expr, type): + return dataclasses.replace( + expr, + **{ + field.name: map_structure(func, getattr(expr, field.name)) + for field in dataclasses.fields(expr) + }, + ) + else: + return func(expr) + + +def productN(intps: Mapping[Operation, Interpretation]) -> Interpretation: + # The resulting interpretation supports ops that exist in at least one input + # interpretation + result_ops = set(op for intp in intps.values() for op in intp) + if result_ops is None: + return {} + + renaming = {(prompt, op): defop(op) for prompt in intps for op in result_ops} + + # We enforce isolation between the named interpretations by giving every + # operation a fresh name and giving each operation a translation from + # the fresh names back to the names from their interpretation. + # + # E.g. { a: { f, g }, b: { f, h } } => + # { handler({f: f_a, g: g_a, h: h_default})(f_a), handler({f: f_a, g: g_a})(g_a), + # handler({f: f_b, h: h_b})(f_b), handler({f: f_b, h: h_b})(h_b) } + translation_intps: dict[Operation, Interpretation] = { + prompt: {op: renaming[(prompt, op)] for op in result_ops} for prompt in intps + } + + # For every prompt, build an isolated interpretation that binds all operations. + isolated_intps = { + prompt: { + renaming[(prompt, op)]: handler(translation_intps[prompt])(func) + for op, func in intp.items() + } + for prompt, intp in intps.items() + } + + def product_op(op, *args, **kwargs): + """Compute the product of operation `op` in named interpretations + `intps`. The product operation consumes product arguments and + returns product results. These products are represented as + interpretations. + + """ + assert isinstance(op, Operation) + + result_intp = {} + + def argsof_direct_call(prompt): + return result_intp[prompt].args, result_intp[prompt].kwargs + + def argsof_apply(prompt): + return result_intp[prompt].args[2:], result_intp[prompt].kwargs + + # Every prompt gets an argsof implementation. The implementation is + # either for a direct call to a handler or for a call to an apply + # handler. + argsof_prompts = {} + + for prompt, intp in intps.items(): + # Args and kwargs are expected to be either interpretations with + # bindings for each named analysis in intps or concrete values. + # `get_for_intp` extracts the value that corresponds to this + # analysis. + # + # TODO: `get_for_intp` has to guess whether a dict value is an + # interpretation or not. This is probably a latent bug. + intp_args, intp_kwargs = map_structure( + lambda x: _unpack(x, prompt), (args, kwargs) + ) + + # Making result a CallByNeed has two functions. It avoids some + # work when the result is not requested and it delays evaluation + # so that when the result is requested in `get_for_intp`, it + # evaluates in a context that binds the results of the other + # named interpretations. + isolated_intp = isolated_intps[prompt] + renamed_op = renaming[(prompt, op)] + if op in intp: + result = CallByNeed( + handler(isolated_intp)(renamed_op), *intp_args, **intp_kwargs + ) + argsof_impl = argsof_direct_call + elif apply in intp: + result = CallByNeed( + handler(isolated_intp)(renaming[(prompt, apply)]), + renamed_op, + *intp_args, + **intp_kwargs, + ) + argsof_impl = argsof_apply + else: + # TODO: If an intp does not handle an operation and has no apply + # handler, use the default rule. In the future, we would like to + # instead defer to the enclosing interpretation. This is + # difficult right now, because the output interpretation handles + # all operations with product handlers which would have to be + # skipped over. + result = CallByNeed( + handler(coproduct(isolated_intp, translation_intps[prompt]))( + op.__default_rule__ + ), + *intp_args, + **intp_kwargs, + ) + argsof_impl = argsof_direct_call + + result_intp[prompt] = result + argsof_prompts[prompt] = argsof_impl + + result_intp[argsof] = lambda prompt: argsof_prompts[prompt](prompt) + return _pack(result_intp) + + product_intp: Interpretation = { + op: functools.partial(product_op, op) for op in result_ops + } + return product_intp diff --git a/tests/test_internals_product_n.py b/tests/test_internals_product_n.py new file mode 100644 index 000000000..331019824 --- /dev/null +++ b/tests/test_internals_product_n.py @@ -0,0 +1,160 @@ +from effectful.internals.product_n import argsof, productN +from effectful.internals.unification import Box +from effectful.ops.semantics import apply, coproduct, evaluate, handler +from effectful.ops.syntax import defop +from effectful.ops.types import Interpretation, NotHandled + + +def test_simul_analysis(): + @defop + def plus1(x: int) -> int: + raise NotHandled + + @defop + def plus2(x: int) -> int: + raise NotHandled + + @defop + def times(x: int, y: int) -> int: + raise NotHandled + + x, y = defop(int, name="x"), defop(int, name="y") + + typ = defop(Interpretation, name="typ") + value = defop(Interpretation, name="value") + + type_rules = { + plus1: lambda x: int, + plus2: lambda x: int, + times: lambda x, y: int, + x: lambda: int, + y: lambda: int, + } + + def plus1_value(x): + return x + 1 + + def plus2_value(x): + return plus1(plus1(x)) + + def times_value(x, y): + t = typ() + arg = argsof(typ)[0][0] + if t is int and arg is int: + return x * y + raise TypeError("unexpected type!") + + value_rules = { + plus1: plus1_value, + plus2: plus2_value, + times: times_value, + x: lambda: 3, + y: lambda: 4, + } + + analysisN = productN({typ: type_rules, value: value_rules}) + + def f1(): + v1 = x() # {typ: lambda: int, val: lambda: 3} + v2 = y() # {typ: lambda: int, val: lambda: 4} + v3 = plus2(v1) # {typ: lambda: int, val: lambda: 5} + v4 = times(v2, v3) # {typ: lambda: int, val: lambda: 20} + v5 = plus1(v4) # {typ: lambda: int, val: lambda: 21} + return v5 # {typ: lambda: int, val: lambda: 21} + + with handler(analysisN): + i = f1() + t = i.values(typ) + v = i.values(value) + assert t is int + assert v == 21 + + +def test_simul_analysis_apply(): + @defop + def plus1[T](x: T) -> T: + raise NotHandled + + @defop + def plus2[T](x: T) -> T: + raise NotHandled + + @defop + def times[T](x: T, y: T) -> T: + raise NotHandled + + x, y = defop(int, name="x"), defop(int, name="y") + + typ = defop(Interpretation, name="typ") + value = defop(Interpretation, name="value") + + def apply_type(op, *a, **k): + return Box(op.__type_rule__(*a, **k)) + + type_rules = {apply: apply_type} + + def plus1_value(x): + return x + 1 + + def plus2_value(x): + return plus1(plus1(x)) + + def times_value(x, y): + t = typ().value + arg = argsof(typ)[0][0].value + if t is int and arg is int: + return x * y + raise TypeError("unexpected type!") + + value_rules = { + plus1: plus1_value, + plus2: plus2_value, + times: times_value, + x: lambda: 3, + y: lambda: 4, + } + + analysisN = productN({typ: type_rules, value: value_rules}) + + def f1(): + v1 = x() # {typ: lambda: int, val: lambda: 3} + v2 = y() # {typ: lambda: int, val: lambda: 4} + v3 = plus2(v1) # {typ: lambda: int, val: lambda: 5} + v4 = times(v2, v3) # {typ: lambda: int, val: lambda: 20} + v5 = plus1(v4) # {typ: lambda: int, val: lambda: 21} + return v5 # {typ: lambda: int, val: lambda: 21} + + with handler(analysisN): + i = f1() + t = i.values(typ).value + v = i.values(value) + assert t is int + assert v == 21 + + +def test_productN_distributive(): + """Test that productN distributes over coproducts.""" + + @defop + def add[T](x: T, y: T) -> T: + raise NotHandled + + x = defop(object, name="x") + i = defop(object, name="i") + s = defop(object, name="s") + + intp1 = {add: lambda x, y: x + y} + intp2 = {x: lambda: 1} + intp3 = {x: lambda: "a"} + + term = add(x(), x()) + + prod_intp1 = productN({i: coproduct(intp2, intp1), s: coproduct(intp3, intp1)}) + prod_intp2 = coproduct( + productN({i: intp2, s: intp3}), productN({i: intp1, s: intp1}) + ) + result1 = evaluate(term, intp=prod_intp1) + result2 = evaluate(term, intp=prod_intp2) + + assert result1.values(i) == result2.values(i) == 2 + assert result1.values(s) == result2.values(s) == "aa" From 063814b65135a47caaa4e0f6b8dcea9fbe0149eb Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Thu, 23 Jul 2026 16:00:21 -0400 Subject: [PATCH 16/23] switch to pureinterpretation --- effectful/ops/semantics.py | 66 +++++------------------ effectful/ops/syntax.py | 37 +++++++++++++ effectful/ops/types.py | 105 ++++++++++++++++++++----------------- 3 files changed, 109 insertions(+), 99 deletions(-) diff --git a/effectful/ops/semantics.py b/effectful/ops/semantics.py index 206a4a620..232864180 100644 --- a/effectful/ops/semantics.py +++ b/effectful/ops/semantics.py @@ -10,6 +10,7 @@ from effectful.ops.syntax import ( _CustomSingleDispatchCallable, + assume_pure, defdata, defop, ) @@ -18,29 +19,13 @@ Interpretation, NotHandled, # noqa: F401 Operation, + PureInterpretation, Term, ) apply = Operation.__apply__ -@defop -def _is_memoized() -> bool: - """Return whether evaluation under the current interpretation is memoized.""" - return False - - -def _memoize(intp: Interpretation) -> Interpretation: - """Flag ``intp`` for term-local memoization. - - A fresh operation identifies each resulting interpretation and is used as - the key in term-local caches. The interpretation must be deterministic and - independent of enclosing handlers for cached evaluation to preserve its - semantics. - """ - return coproduct(intp, {_is_memoized: lambda: True}) - - @defop def fwd(*args, **kwargs) -> Any: """Forward execution to the next most enclosing handler. @@ -187,46 +172,23 @@ def _evaluate_object[T](expr: T, **kwargs) -> T: return expr -_EVALUATION_CACHE_ATTR = "__effectful_evaluation_cache__" - - -def _current_interpretation_cache_id() -> object | None: - """Return the current memoized interpretation's object identity.""" - from effectful.internals.runtime import get_interpretation - - intp = get_interpretation() - if _is_memoized not in intp: - return None - return frozenset(intp.items()) - - -def _term_cache(expr: Term) -> dict[object, object] | None: - """Return the cache owned by ``expr``, or ``None`` if it cannot store one.""" - try: - return getattr(expr, _EVALUATION_CACHE_ATTR) - except AttributeError: - cache: dict[object, object] = {} - try: - setattr(expr, _EVALUATION_CACHE_ATTR, cache) - except (AttributeError, TypeError): - return None - return cache - - @evaluate.register(Term) def _evaluate_term(expr: Term, **kwargs): - cache = None - cache_id = _current_interpretation_cache_id() - if cache_id is not None: - cache = _term_cache(expr) - if cache is not None and cache_id in cache: - return cache[cache_id] + from effectful.internals.runtime import get_interpretation + + current_intp = get_interpretation() + if isinstance(current_intp, PureInterpretation): + cache = expr._term_cache + if current_intp in cache: + return cache[current_intp] + else: + cache = None args = tuple(evaluate(arg) for arg in expr.args) kwargs = {k: evaluate(v) for k, v in expr.kwargs.items()} result = expr.op(*args, **kwargs) - if cache is not None and cache_id is not None: - cache[cache_id] = result + if cache is not None: + cache[current_intp] = result return result @@ -311,7 +273,7 @@ def _typeof_apply(op, *args, **kwargs): return Box(op.__type_rule__(*args, **kwargs)) -_TYPEOF_INTERPRETATION = _memoize({apply: _typeof_apply}) +_TYPEOF_INTERPRETATION = assume_pure({apply: _typeof_apply}) def _typeof(term: Expr): diff --git a/effectful/ops/syntax.py b/effectful/ops/syntax.py index 0b27e1847..bc927562b 100644 --- a/effectful/ops/syntax.py +++ b/effectful/ops/syntax.py @@ -4,6 +4,7 @@ import inspect import numbers import operator +import types import typing from collections.abc import Callable, Iterable, Mapping from typing import Annotated, Any @@ -11,8 +12,10 @@ from effectful.ops.types import ( Annotation, Expr, + Interpretation, NotHandled, Operation, + PureInterpretation, Term, _CustomSingleDispatchCallable, ) @@ -834,6 +837,40 @@ def _(x: object, other) -> bool: return x == other +class _PureInterpretation[T, V](collections.abc.Mapping): + __effectful_pure__: typing.Literal[True] = True + + def __init__(self, intp: Interpretation[T, V]): + self._implementations = types.MappingProxyType(dict(intp)) + + def __getitem__(self, op): + return self._implementations[op] + + def __iter__(self): + return iter(self._implementations) + + def __len__(self): + return len(self._implementations) + + def __hash__(self): + return hash(frozenset(self._implementations.items())) + + def __eq__(self, other): + return isinstance(other, PureInterpretation) and frozenset( + self._implementations.items() + ) == frozenset(other._implementations.items()) + + +def assume_pure[T, V](intp: Interpretation[T, V]) -> PureInterpretation[T, V]: + """Cast an Interpretation into a PureInterpretation. + + Pure interpretations have no visible side effects. Values of terms evaluated + under pure interpretations are cached. + + """ + return _PureInterpretation(intp) + + class ObjectInterpretation[T, V](collections.abc.Mapping): """A helper superclass for defining an ``Interpretation`` of many :class:`~effectful.ops.types.Operation` instances with shared state or behavior. diff --git a/effectful/ops/types.py b/effectful/ops/types.py index 65e31de38..5d7ee1cbc 100644 --- a/effectful/ops/types.py +++ b/effectful/ops/types.py @@ -4,10 +4,12 @@ import inspect import types import typing +import weakref from collections.abc import Callable, Mapping, Sequence from typing import ( Any, Concatenate, + Literal, Protocol, _ProtocolMeta, overload, @@ -590,6 +592,58 @@ def __call__[**Q, V]( assert isinstance(Operation.define, _OperationDefine) +class _InterpretationMeta(_ProtocolMeta): + def __instancecheck__(cls, instance): + return isinstance(instance, collections.abc.Mapping) and all( + isinstance(k, Operation) and callable(v) for k, v in instance.items() + ) + + +@runtime_checkable +class Interpretation[T, V](typing.Protocol, metaclass=_InterpretationMeta): + """An interpretation is a mapping from operations to their implementations.""" + + def keys(self): + raise NotImplementedError + + def values(self): + raise NotImplementedError + + def items(self): + raise NotImplementedError + + @overload + def get(self, key: Operation[..., T], /) -> Callable[..., V] | None: + raise NotImplementedError + + @overload + def get( + self, key: Operation[..., T], default: Callable[..., V], / + ) -> Callable[..., V]: + raise NotImplementedError + + @overload + def get[S](self, key: Operation[..., T], default: S, /) -> Callable[..., V] | S: + raise NotImplementedError + + def __getitem__(self, key: Operation[..., T]) -> Callable[..., V]: + raise NotImplementedError + + def __contains__(self, key: Operation[..., T]) -> bool: + raise NotImplementedError + + def __iter__(self): + raise NotImplementedError + + def __len__(self) -> int: + raise NotImplementedError + + +@runtime_checkable +class PureInterpretation[T, V](Interpretation[T, V], typing.Protocol): + __effectful_pure__: Literal[True] + + class Term[T](abc.ABC): """A term in an effectful computation is a is a tree of :class:`Operation` applied to values. @@ -616,6 +670,10 @@ def kwargs(self) -> Mapping[str, "Expr[Any]"]: """Abstract property for the keyword arguments.""" raise NotImplementedError + @functools.cached_property + def _term_cache(self) -> weakref.WeakKeyDictionary[PureInterpretation, Any]: + return weakref.WeakKeyDictionary() + def __repr__(self) -> str: return f"{self.__class__.__name__}({self.op!r}, {self.args!r}, {self.kwargs!r})" @@ -702,53 +760,6 @@ def pretty_term(value: Term, ctx): type Expr[T] = T | Term[T] -class _InterpretationMeta(_ProtocolMeta): - def __instancecheck__(cls, instance): - return isinstance(instance, collections.abc.Mapping) and all( - isinstance(k, Operation) and callable(v) for k, v in instance.items() - ) - - -@runtime_checkable -class Interpretation[T, V](typing.Protocol, metaclass=_InterpretationMeta): - """An interpretation is a mapping from operations to their implementations.""" - - def keys(self): - raise NotImplementedError - - def values(self): - raise NotImplementedError - - def items(self): - raise NotImplementedError - - @overload - def get(self, key: Operation[..., T], /) -> Callable[..., V] | None: - raise NotImplementedError - - @overload - def get( - self, key: Operation[..., T], default: Callable[..., V], / - ) -> Callable[..., V]: - raise NotImplementedError - - @overload - def get[S](self, key: Operation[..., T], default: S, /) -> Callable[..., V] | S: - raise NotImplementedError - - def __getitem__(self, key: Operation[..., T]) -> Callable[..., V]: - raise NotImplementedError - - def __contains__(self, key: Operation[..., T]) -> bool: - raise NotImplementedError - - def __iter__(self): - raise NotImplementedError - - def __len__(self) -> int: - raise NotImplementedError - - class Annotation(abc.ABC): @classmethod @abc.abstractmethod From 57a880fcf16eb9f750e9f887c8f02a43f9f0c063 Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Thu, 23 Jul 2026 16:08:14 -0400 Subject: [PATCH 17/23] fix instance check and tests --- effectful/ops/types.py | 12 ++++++++-- tests/test_ops_semantics.py | 48 ++++++++++--------------------------- tests/test_ops_types.py | 36 +++++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 38 deletions(-) diff --git a/effectful/ops/types.py b/effectful/ops/types.py index 5d7ee1cbc..66b90bd84 100644 --- a/effectful/ops/types.py +++ b/effectful/ops/types.py @@ -594,8 +594,16 @@ def __call__[**Q, V]( class _InterpretationMeta(_ProtocolMeta): def __instancecheck__(cls, instance): - return isinstance(instance, collections.abc.Mapping) and all( - isinstance(k, Operation) and callable(v) for k, v in instance.items() + if cls is Interpretation: + return isinstance(instance, collections.abc.Mapping) and all( + isinstance(k, Operation) and callable(v) + for k, v in instance.items() + ) + + # Let Protocol perform the structural check for refined interpretation + # protocols, including any members introduced by the refinement. + return isinstance(instance, Interpretation) and super().__instancecheck__( + instance ) diff --git a/tests/test_ops_semantics.py b/tests/test_ops_semantics.py index 1be6f994e..9d34c4f78 100644 --- a/tests/test_ops_semantics.py +++ b/tests/test_ops_semantics.py @@ -9,7 +9,6 @@ import pytest from effectful.ops.semantics import ( - _memoize, apply, coproduct, evaluate, @@ -18,7 +17,14 @@ handler, typeof, ) -from effectful.ops.syntax import ObjectInterpretation, Scoped, deffn, defop, implements +from effectful.ops.syntax import ( + ObjectInterpretation, + Scoped, + assume_pure, + deffn, + defop, + implements, +) from effectful.ops.types import Interpretation, NotHandled, Operation, Term logger = logging.getLogger(__name__) @@ -481,7 +487,7 @@ def analyze(op, *args, **kwargs): calls += 1 return (op.__name__, args, kwargs) - intp = _memoize({apply: analyze}) + intp = assume_pure({apply: analyze}) expected = ("node", (("node", (1,), {}),), {}) assert interpreter(intp)(evaluate)(term) == expected @@ -503,10 +509,10 @@ def analyze(op, *args, **kwargs): assert interpreter(combined_intp)(evaluate)(term) == expected assert calls == 4 - # A separately memoized interpretation has a separate cache namespace. - other_intp = _memoize({apply: analyze}) + # An identical memoized interpretation the same cache namespace. + other_intp = assume_pure({apply: analyze}) assert interpreter(other_intp)(evaluate)(term) == expected - assert calls == 6 + assert calls == 4 def test_memoized_interpretation_does_not_cache_failures(): @@ -526,7 +532,7 @@ def analyze(op, *args, **kwargs): raise ValueError("failed analysis") return "success" - intp = _memoize({apply: analyze}) + intp = assume_pure({apply: analyze}) with pytest.raises(ValueError, match="failed analysis"): interpreter(intp)(evaluate)(term) @@ -536,34 +542,6 @@ def analyze(op, *args, **kwargs): assert calls == 2 -def test_memoized_interpretation_skips_terms_without_attribute_storage(): - @defop - def node() -> object: - raise NotHandled - - @Term.register - class SlottedTerm: - __slots__ = ("op", "args", "kwargs") - - def __init__(self): - self.op = node - self.args = () - self.kwargs = {} - - calls = 0 - - def analyze(op, *args, **kwargs): - nonlocal calls - calls += 1 - return "result" - - term = SlottedTerm() - intp = _memoize({apply: analyze}) - assert handler(intp)(evaluate)(term) == "result" - assert handler(intp)(evaluate)(term) == "result" - assert calls == 2 - - def test_ctxof(): x = defop(object) y = defop(object) diff --git a/tests/test_ops_types.py b/tests/test_ops_types.py index 3b9187c5a..073eb676e 100644 --- a/tests/test_ops_types.py +++ b/tests/test_ops_types.py @@ -3,7 +3,7 @@ from effectful.ops.semantics import typeof from effectful.ops.syntax import defop -from effectful.ops.types import Interpretation, NotHandled +from effectful.ops.types import Interpretation, NotHandled, PureInterpretation def test_interpretation_isinstance(): @@ -16,6 +16,40 @@ def test_interpretation_isinstance(): assert not isinstance({"a": lambda: 0, "b": lambda: "hello"}, Interpretation) +def test_pure_interpretation_isinstance_requires_marker(): + a = defop(int) + intp = {a: lambda: 0} + + assert isinstance(intp, Interpretation) + assert not isinstance(intp, PureInterpretation) + + class PureDict(dict): + __effectful_pure__: typing.Literal[True] = True + + pure_intp = PureDict(intp) + assert isinstance(pure_intp, Interpretation) + assert isinstance(pure_intp, PureInterpretation) + + assert not isinstance(PureDict({"not-an-operation": lambda: 0}), PureInterpretation) + + +def test_interpretation_refinement_isinstance_checks_new_members(): + @typing.runtime_checkable + class NamedInterpretation[T, V](Interpretation[T, V], typing.Protocol): + interpretation_name: str + + op = defop(int) + + class NamedDict(dict): + interpretation_name = "test" + + assert not isinstance({op: lambda: 0}, NamedInterpretation) + assert isinstance(NamedDict({op: lambda: 0}), NamedInterpretation) + assert not isinstance( + NamedDict({"not-an-operation": lambda: 0}), NamedInterpretation + ) + + def test_instance_method_signature_excludes_self(): """Instance-bound operations should not have 'self' in their signature. From dd04ae9535738139ed63b25521cbad7b26f694b8 Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Thu, 23 Jul 2026 16:08:42 -0400 Subject: [PATCH 18/23] format --- effectful/ops/types.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/effectful/ops/types.py b/effectful/ops/types.py index 66b90bd84..4c0c1b001 100644 --- a/effectful/ops/types.py +++ b/effectful/ops/types.py @@ -596,8 +596,7 @@ class _InterpretationMeta(_ProtocolMeta): def __instancecheck__(cls, instance): if cls is Interpretation: return isinstance(instance, collections.abc.Mapping) and all( - isinstance(k, Operation) and callable(v) - for k, v in instance.items() + isinstance(k, Operation) and callable(v) for k, v in instance.items() ) # Let Protocol perform the structural check for refined interpretation From 7fc96102ff2b9e91e0ce103ae87c5fd407bff804 Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Thu, 23 Jul 2026 16:26:25 -0400 Subject: [PATCH 19/23] switch back from property --- effectful/ops/semantics.py | 25 +++++++++++++++++++++++-- effectful/ops/types.py | 5 ----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/effectful/ops/semantics.py b/effectful/ops/semantics.py index 232864180..a17f4b3fd 100644 --- a/effectful/ops/semantics.py +++ b/effectful/ops/semantics.py @@ -5,6 +5,7 @@ import operator import types import typing +import weakref from collections.abc import Callable from typing import Any @@ -172,14 +173,34 @@ def _evaluate_object[T](expr: T, **kwargs) -> T: return expr +_EVALUATION_CACHE_ATTR = "__effectful_evaluation_cache__" + + +def _term_cache( + expr: Term, +) -> weakref.WeakKeyDictionary[PureInterpretation, Any] | None: + """Return the cache owned by ``expr``, or ``None`` if it cannot store one.""" + try: + return getattr(expr, _EVALUATION_CACHE_ATTR) + except AttributeError: + cache: weakref.WeakKeyDictionary[PureInterpretation, Any] = ( + weakref.WeakKeyDictionary() + ) + try: + setattr(expr, _EVALUATION_CACHE_ATTR, cache) + except (AttributeError, TypeError): + return None + return cache + + @evaluate.register(Term) def _evaluate_term(expr: Term, **kwargs): from effectful.internals.runtime import get_interpretation current_intp = get_interpretation() if isinstance(current_intp, PureInterpretation): - cache = expr._term_cache - if current_intp in cache: + cache = _term_cache(expr) + if cache is not None and current_intp in cache: return cache[current_intp] else: cache = None diff --git a/effectful/ops/types.py b/effectful/ops/types.py index 4c0c1b001..61d2aab0e 100644 --- a/effectful/ops/types.py +++ b/effectful/ops/types.py @@ -4,7 +4,6 @@ import inspect import types import typing -import weakref from collections.abc import Callable, Mapping, Sequence from typing import ( Any, @@ -677,10 +676,6 @@ def kwargs(self) -> Mapping[str, "Expr[Any]"]: """Abstract property for the keyword arguments.""" raise NotImplementedError - @functools.cached_property - def _term_cache(self) -> weakref.WeakKeyDictionary[PureInterpretation, Any]: - return weakref.WeakKeyDictionary() - def __repr__(self) -> str: return f"{self.__class__.__name__}({self.op!r}, {self.args!r}, {self.kwargs!r})" From aaef6ec98e0096ce5842f189bf339b8a3c19cafa Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Fri, 24 Jul 2026 09:58:53 -0400 Subject: [PATCH 20/23] reset types.py --- effectful/ops/types.py | 107 ++++++++++++++++++----------------------- 1 file changed, 47 insertions(+), 60 deletions(-) diff --git a/effectful/ops/types.py b/effectful/ops/types.py index 61d2aab0e..65e31de38 100644 --- a/effectful/ops/types.py +++ b/effectful/ops/types.py @@ -8,7 +8,6 @@ from typing import ( Any, Concatenate, - Literal, Protocol, _ProtocolMeta, overload, @@ -591,65 +590,6 @@ def __call__[**Q, V]( assert isinstance(Operation.define, _OperationDefine) -class _InterpretationMeta(_ProtocolMeta): - def __instancecheck__(cls, instance): - if cls is Interpretation: - return isinstance(instance, collections.abc.Mapping) and all( - isinstance(k, Operation) and callable(v) for k, v in instance.items() - ) - - # Let Protocol perform the structural check for refined interpretation - # protocols, including any members introduced by the refinement. - return isinstance(instance, Interpretation) and super().__instancecheck__( - instance - ) - - -@runtime_checkable -class Interpretation[T, V](typing.Protocol, metaclass=_InterpretationMeta): - """An interpretation is a mapping from operations to their implementations.""" - - def keys(self): - raise NotImplementedError - - def values(self): - raise NotImplementedError - - def items(self): - raise NotImplementedError - - @overload - def get(self, key: Operation[..., T], /) -> Callable[..., V] | None: - raise NotImplementedError - - @overload - def get( - self, key: Operation[..., T], default: Callable[..., V], / - ) -> Callable[..., V]: - raise NotImplementedError - - @overload - def get[S](self, key: Operation[..., T], default: S, /) -> Callable[..., V] | S: - raise NotImplementedError - - def __getitem__(self, key: Operation[..., T]) -> Callable[..., V]: - raise NotImplementedError - - def __contains__(self, key: Operation[..., T]) -> bool: - raise NotImplementedError - - def __iter__(self): - raise NotImplementedError - - def __len__(self) -> int: - raise NotImplementedError - - -@runtime_checkable -class PureInterpretation[T, V](Interpretation[T, V], typing.Protocol): - __effectful_pure__: Literal[True] - - class Term[T](abc.ABC): """A term in an effectful computation is a is a tree of :class:`Operation` applied to values. @@ -762,6 +702,53 @@ def pretty_term(value: Term, ctx): type Expr[T] = T | Term[T] +class _InterpretationMeta(_ProtocolMeta): + def __instancecheck__(cls, instance): + return isinstance(instance, collections.abc.Mapping) and all( + isinstance(k, Operation) and callable(v) for k, v in instance.items() + ) + + +@runtime_checkable +class Interpretation[T, V](typing.Protocol, metaclass=_InterpretationMeta): + """An interpretation is a mapping from operations to their implementations.""" + + def keys(self): + raise NotImplementedError + + def values(self): + raise NotImplementedError + + def items(self): + raise NotImplementedError + + @overload + def get(self, key: Operation[..., T], /) -> Callable[..., V] | None: + raise NotImplementedError + + @overload + def get( + self, key: Operation[..., T], default: Callable[..., V], / + ) -> Callable[..., V]: + raise NotImplementedError + + @overload + def get[S](self, key: Operation[..., T], default: S, /) -> Callable[..., V] | S: + raise NotImplementedError + + def __getitem__(self, key: Operation[..., T]) -> Callable[..., V]: + raise NotImplementedError + + def __contains__(self, key: Operation[..., T]) -> bool: + raise NotImplementedError + + def __iter__(self): + raise NotImplementedError + + def __len__(self) -> int: + raise NotImplementedError + + class Annotation(abc.ABC): @classmethod @abc.abstractmethod From 6801c811b6f817653a9a00ddfb38790cc9eec3c6 Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Fri, 24 Jul 2026 10:09:51 -0400 Subject: [PATCH 21/23] make PureInterpretation and ObjectInterpretation subclass --- effectful/ops/semantics.py | 13 ++++++--- effectful/ops/syntax.py | 47 +++++++------------------------- tests/test_ops_semantics.py | 54 ++++++++++++++++++++----------------- tests/test_ops_types.py | 36 +------------------------ 4 files changed, 50 insertions(+), 100 deletions(-) diff --git a/effectful/ops/semantics.py b/effectful/ops/semantics.py index a17f4b3fd..fb66b8f2a 100644 --- a/effectful/ops/semantics.py +++ b/effectful/ops/semantics.py @@ -10,17 +10,17 @@ from typing import Any from effectful.ops.syntax import ( + PureInterpretation, _CustomSingleDispatchCallable, - assume_pure, defdata, defop, + implements, ) from effectful.ops.types import ( Expr, Interpretation, NotHandled, # noqa: F401 Operation, - PureInterpretation, Term, ) @@ -294,12 +294,17 @@ def _typeof_apply(op, *args, **kwargs): return Box(op.__type_rule__(*args, **kwargs)) -_TYPEOF_INTERPRETATION = assume_pure({apply: _typeof_apply}) +class _TypeofIntp(PureInterpretation): + @implements(apply) + def _(self, op, *args, **kwargs): + from effectful.internals.unification import Box + + return Box(op.__type_rule__(*args, **kwargs)) def _typeof(term: Expr): """Evaluate the cached type analysis without unwrapping its result.""" - return evaluate(term, intp=_TYPEOF_INTERPRETATION) + return evaluate(term, intp=_TypeofIntp()) def typeof[T](term: Expr[T]) -> type[T]: diff --git a/effectful/ops/syntax.py b/effectful/ops/syntax.py index bc927562b..fefb01768 100644 --- a/effectful/ops/syntax.py +++ b/effectful/ops/syntax.py @@ -4,7 +4,6 @@ import inspect import numbers import operator -import types import typing from collections.abc import Callable, Iterable, Mapping from typing import Annotated, Any @@ -12,10 +11,8 @@ from effectful.ops.types import ( Annotation, Expr, - Interpretation, NotHandled, Operation, - PureInterpretation, Term, _CustomSingleDispatchCallable, ) @@ -837,40 +834,6 @@ def _(x: object, other) -> bool: return x == other -class _PureInterpretation[T, V](collections.abc.Mapping): - __effectful_pure__: typing.Literal[True] = True - - def __init__(self, intp: Interpretation[T, V]): - self._implementations = types.MappingProxyType(dict(intp)) - - def __getitem__(self, op): - return self._implementations[op] - - def __iter__(self): - return iter(self._implementations) - - def __len__(self): - return len(self._implementations) - - def __hash__(self): - return hash(frozenset(self._implementations.items())) - - def __eq__(self, other): - return isinstance(other, PureInterpretation) and frozenset( - self._implementations.items() - ) == frozenset(other._implementations.items()) - - -def assume_pure[T, V](intp: Interpretation[T, V]) -> PureInterpretation[T, V]: - """Cast an Interpretation into a PureInterpretation. - - Pure interpretations have no visible side effects. Values of terms evaluated - under pure interpretations are cached. - - """ - return _PureInterpretation(intp) - - class ObjectInterpretation[T, V](collections.abc.Mapping): """A helper superclass for defining an ``Interpretation`` of many :class:`~effectful.ops.types.Operation` instances with shared state or behavior. @@ -948,6 +911,16 @@ def __getitem__(self, item: Operation[..., T]) -> Callable[..., V]: return self.implementations[item].__get__(self, type(self)) +class PureInterpretation[T, V](ObjectInterpretation[T, V]): + def __hash__(self): + return hash(frozenset(self.implementations.items())) + + def __eq__(self, other): + return isinstance(other, PureInterpretation) and frozenset( + self.implementations.items() + ) == frozenset(other.implementations.items()) + + class _ImplementedOperation[**P, **Q, T, V]: impl: Callable[Q, V] | None op: Operation[P, T] diff --git a/tests/test_ops_semantics.py b/tests/test_ops_semantics.py index 9d34c4f78..2875387d1 100644 --- a/tests/test_ops_semantics.py +++ b/tests/test_ops_semantics.py @@ -19,8 +19,8 @@ ) from effectful.ops.syntax import ( ObjectInterpretation, + PureInterpretation, Scoped, - assume_pure, deffn, defop, implements, @@ -480,39 +480,42 @@ def node(x: object) -> object: raise NotHandled term = node(node(1)) - calls = 0 - def analyze(op, *args, **kwargs): - nonlocal calls - calls += 1 - return (op.__name__, args, kwargs) + class Intp(PureInterpretation): + def __init__(self): + self.calls = 0 - intp = assume_pure({apply: analyze}) + @implements(apply) + def _(self, op, *args, **kwargs): + self.calls += 1 + return (op.__name__, args, kwargs) + + intp = Intp() expected = ("node", (("node", (1,), {}),), {}) assert interpreter(intp)(evaluate)(term) == expected - assert calls == 2 + assert intp.calls == 2 # The root cache is checked before its children are traversed, including # when evaluation is expressed directly through a handler. with interpreter(intp): assert evaluate(term) == expected - assert calls == 2 + assert intp.calls == 2 # Child results are cached independently and can be reused directly. assert interpreter(intp)(evaluate)(term.args[0]) == expected[1][0] - assert calls == 2 + assert intp.calls == 2 # A composition has a distinct identity even when its added handler is not # used while evaluating this term. combined_intp = coproduct(intp, {plus_1: lambda x: x}) assert interpreter(combined_intp)(evaluate)(term) == expected - assert calls == 4 + assert intp.calls == 4 - # An identical memoized interpretation the same cache namespace. - other_intp = assume_pure({apply: analyze}) + # An identical memoized interpretation is in the same cache namespace. + other_intp = Intp() assert interpreter(other_intp)(evaluate)(term) == expected - assert calls == 4 + assert intp.calls == 4 def test_memoized_interpretation_does_not_cache_failures(): @@ -523,23 +526,26 @@ def node() -> object: raise NotHandled term = node() - calls = 0 - def analyze(op, *args, **kwargs): - nonlocal calls - calls += 1 - if calls == 1: - raise ValueError("failed analysis") - return "success" + class Intp(PureInterpretation): + def __init__(self): + self.calls = 0 + + @implements(apply) + def _(self, op, *args, **kwargs): + self.calls += 1 + if self.calls == 1: + raise ValueError("failed analysis") + return "success" - intp = assume_pure({apply: analyze}) + intp = Intp() with pytest.raises(ValueError, match="failed analysis"): interpreter(intp)(evaluate)(term) assert interpreter(intp)(evaluate)(term) == "success" - assert calls == 2 + assert intp.calls == 2 assert interpreter(intp)(evaluate)(term) == "success" - assert calls == 2 + assert intp.calls == 2 def test_ctxof(): diff --git a/tests/test_ops_types.py b/tests/test_ops_types.py index 073eb676e..3b9187c5a 100644 --- a/tests/test_ops_types.py +++ b/tests/test_ops_types.py @@ -3,7 +3,7 @@ from effectful.ops.semantics import typeof from effectful.ops.syntax import defop -from effectful.ops.types import Interpretation, NotHandled, PureInterpretation +from effectful.ops.types import Interpretation, NotHandled def test_interpretation_isinstance(): @@ -16,40 +16,6 @@ def test_interpretation_isinstance(): assert not isinstance({"a": lambda: 0, "b": lambda: "hello"}, Interpretation) -def test_pure_interpretation_isinstance_requires_marker(): - a = defop(int) - intp = {a: lambda: 0} - - assert isinstance(intp, Interpretation) - assert not isinstance(intp, PureInterpretation) - - class PureDict(dict): - __effectful_pure__: typing.Literal[True] = True - - pure_intp = PureDict(intp) - assert isinstance(pure_intp, Interpretation) - assert isinstance(pure_intp, PureInterpretation) - - assert not isinstance(PureDict({"not-an-operation": lambda: 0}), PureInterpretation) - - -def test_interpretation_refinement_isinstance_checks_new_members(): - @typing.runtime_checkable - class NamedInterpretation[T, V](Interpretation[T, V], typing.Protocol): - interpretation_name: str - - op = defop(int) - - class NamedDict(dict): - interpretation_name = "test" - - assert not isinstance({op: lambda: 0}, NamedInterpretation) - assert isinstance(NamedDict({op: lambda: 0}), NamedInterpretation) - assert not isinstance( - NamedDict({"not-an-operation": lambda: 0}), NamedInterpretation - ) - - def test_instance_method_signature_excludes_self(): """Instance-bound operations should not have 'self' in their signature. From 17a0ce78379db455ef726e46d086f270a04f2437 Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Fri, 24 Jul 2026 10:14:15 -0400 Subject: [PATCH 22/23] drop unused code --- effectful/ops/semantics.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/effectful/ops/semantics.py b/effectful/ops/semantics.py index fb66b8f2a..26ddb62eb 100644 --- a/effectful/ops/semantics.py +++ b/effectful/ops/semantics.py @@ -288,12 +288,6 @@ def _simple_type(tp: type) -> type: return typing.get_origin(tp) or tp -def _typeof_apply(op, *args, **kwargs): - from effectful.internals.unification import Box - - return Box(op.__type_rule__(*args, **kwargs)) - - class _TypeofIntp(PureInterpretation): @implements(apply) def _(self, op, *args, **kwargs): From 774d0215f73dec9ca9b070954928a885908cab44 Mon Sep 17 00:00:00 2001 From: Jack Feser Date: Fri, 24 Jul 2026 14:51:40 -0400 Subject: [PATCH 23/23] use singleton intp object --- effectful/ops/semantics.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/effectful/ops/semantics.py b/effectful/ops/semantics.py index 26ddb62eb..43a3135ac 100644 --- a/effectful/ops/semantics.py +++ b/effectful/ops/semantics.py @@ -296,9 +296,12 @@ def _(self, op, *args, **kwargs): return Box(op.__type_rule__(*args, **kwargs)) +_TYPEOF_INTP = _TypeofIntp() + + def _typeof(term: Expr): """Evaluate the cached type analysis without unwrapping its result.""" - return evaluate(term, intp=_TypeofIntp()) + return evaluate(term, intp=_TYPEOF_INTP) def typeof[T](term: Expr[T]) -> type[T]: