Skip to content

Commit a16f19b

Browse files
authored
Add is_transparent method and TransparentModelEvaluationContext for stable cache keys (#194)
Evaluators that don't modify return values can now override is_transparent() to return True, which causes make_evaluation_context() to create TransparentModelEvaluationContext layers. cache_key() strips these layers so that wrapping a model with different transparent evaluators does not change its cache identity or dependency graph node identity. The is_transparent() method accepts the ModelEvaluationContext, allowing evaluators to be conditionally transparent based on context. Closes #192 Signed-off-by: Pascal Tomecek <pascal.tomecek@cubistsystematic.com>
1 parent cde4fbb commit a16f19b

4 files changed

Lines changed: 239 additions & 7 deletions

File tree

ccflow/callable.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"FlowOptionsDeps",
4242
"FlowOptionsOverride",
4343
"ModelEvaluationContext",
44+
"TransparentModelEvaluationContext",
4445
"EvaluatorBase",
4546
"Evaluator",
4647
"WrapperModel",
@@ -262,7 +263,7 @@ def get_evaluation_context(model: CallableModelType, context: ContextType, as_di
262263
if as_dict:
263264
return dict(model=evaluator, context=evaluation_context)
264265
else:
265-
return ModelEvaluationContext(model=evaluator, context=evaluation_context)
266+
return evaluator.make_evaluation_context(evaluation_context)
266267

267268
# The decorator implementation
268269
def wrapper(model, context=Signature.empty, *, _options: Optional[FlowOptions] = None, **kwargs):
@@ -510,10 +511,47 @@ def __deps__(self, context: ModelEvaluationContext) -> GraphDepList:
510511
def __exit__(self):
511512
pass
512513

514+
def is_transparent(self, context: ModelEvaluationContext) -> bool:
515+
"""Whether this evaluator does NOT modify the return value for the given context.
516+
517+
Transparent evaluators may add side effects (logging, caching, timing,
518+
dependency ordering) but always return the same value as ``context()``.
519+
This allows cache key computation and dependency graph deduplication to
520+
skip these layers.
521+
522+
Override this method to return ``True`` for evaluators that are always
523+
transparent, or implement context-dependent logic for evaluators that
524+
are only sometimes transparent.
525+
"""
526+
return False
527+
528+
def make_evaluation_context(self, context: ModelEvaluationContext, **kwargs) -> ModelEvaluationContext:
529+
"""Create a ModelEvaluationContext wrapping this evaluator around the given context.
530+
531+
Returns a ``TransparentModelEvaluationContext`` when ``is_transparent(context)``
532+
is ``True``, signaling that this layer can be skipped for cache key computation.
533+
"""
534+
if self.is_transparent(context):
535+
return TransparentModelEvaluationContext(model=self, context=context, **kwargs)
536+
return ModelEvaluationContext(model=self, context=context, **kwargs)
537+
538+
539+
class TransparentModelEvaluationContext(ModelEvaluationContext):
540+
"""A ModelEvaluationContext layer that is safe to skip for cache key computation.
541+
542+
Created by ``EvaluatorBase.make_evaluation_context()`` when the evaluator's
543+
``is_transparent()`` returns ``True``. Signals that this evaluator layer does
544+
not modify the return value and can be ignored when computing cache keys or
545+
deduplicating dependency graph nodes.
546+
"""
547+
513548

514549
class Evaluator(EvaluatorBase):
515550
"""A higher-order model that evaluates a function on a CallableModel and a Context."""
516551

552+
def is_transparent(self, context: ModelEvaluationContext) -> bool:
553+
return True
554+
517555
@override
518556
def __call__(self, context: ModelEvaluationContext) -> ResultType:
519557
return context()

ccflow/evaluators/common.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@
1212
from typing_extensions import override
1313

1414
from ..base import BaseModel, make_lazy_result
15-
from ..callable import CallableModel, ContextBase, EvaluatorBase, ModelEvaluationContext, ResultType
15+
from ..callable import (
16+
CallableModel,
17+
ContextBase,
18+
EvaluatorBase,
19+
ModelEvaluationContext,
20+
ResultType,
21+
TransparentModelEvaluationContext,
22+
)
1623

1724
__all__ = [
1825
"cache_key",
@@ -53,16 +60,25 @@ def combine_evaluators(first: Optional[EvaluatorBase], second: Optional[Evaluato
5360

5461

5562
class MultiEvaluator(EvaluatorBase):
56-
"""An evaluator that combines multiple evaluators."""
63+
"""An evaluator that combines multiple evaluators.
64+
65+
Each child evaluator is wrapped in a ModelEvaluationContext using its own
66+
``make_evaluation_context()`` method, so transparent children produce
67+
``TransparentModelEvaluationContext`` layers that can be skipped during
68+
cache key computation.
69+
"""
5770

5871
evaluators: List[EvaluatorBase] = Field(
5972
description="The list of evaluators to combine. The first evaluator in the list will be called first during evaluation."
6073
)
6174

75+
def is_transparent(self, context: ModelEvaluationContext) -> bool:
76+
return all(e.is_transparent(context) for e in self.evaluators)
77+
6278
@override
6379
def __call__(self, context: ModelEvaluationContext) -> ResultType:
6480
for evaluator in self.evaluators:
65-
context = ModelEvaluationContext(model=evaluator, context=context, options=context.options)
81+
context = evaluator.make_evaluation_context(context, options=context.options)
6682
return context()
6783

6884

@@ -71,6 +87,9 @@ class FallbackEvaluator(EvaluatorBase):
7187

7288
evaluators: List[EvaluatorBase] = Field(description="The list of evaluators to try (in order).")
7389

90+
def is_transparent(self, context: ModelEvaluationContext) -> bool:
91+
return all(e.is_transparent(context) for e in self.evaluators)
92+
7493
@override
7594
def __call__(self, context: ModelEvaluationContext) -> ResultType:
7695
for evaluator in self.evaluators:
@@ -120,6 +139,9 @@ class LoggingEvaluator(EvaluatorBase):
120139
log_result: bool = Field(False, description="Whether to log the result of the evaluation")
121140
format_config: FormatConfig = Field(FormatConfig(), description="Configuration for formatting the result of the evaluation if log_result=True")
122141

142+
def is_transparent(self, context: ModelEvaluationContext) -> bool:
143+
return True
144+
123145
@field_validator("log_level", mode="before")
124146
@classmethod
125147
def _validate_log_level(cls, v: Union[int, str]) -> int:
@@ -195,12 +217,30 @@ def _format_result(self, result: ResultType) -> str:
195217

196218

197219
def cache_key(flow_obj: Union[ModelEvaluationContext, ContextBase, CallableModel]) -> bytes:
198-
"""Returns a key suitable for use in caching.
220+
"""Returns a key suitable for use in caching and dependency graph deduplication.
221+
222+
For ``ModelEvaluationContext`` inputs, strips ``TransparentModelEvaluationContext``
223+
layers (evaluators that don't modify the return value) so that the key depends
224+
only on the underlying model, context, fn, options, and any non-transparent
225+
evaluators in the chain.
199226
200227
Args:
201228
flow_obj: The object to be tokenized to form the cache key.
202229
"""
203-
if isinstance(flow_obj, (ModelEvaluationContext, ContextBase, CallableModel)):
230+
if isinstance(flow_obj, ModelEvaluationContext):
231+
fn = flow_obj.fn
232+
non_transparent = []
233+
while isinstance(flow_obj.context, ModelEvaluationContext):
234+
fn = flow_obj.fn if flow_obj.fn != "__call__" else fn
235+
if not isinstance(flow_obj, TransparentModelEvaluationContext):
236+
non_transparent.append(flow_obj.model)
237+
flow_obj = flow_obj.context
238+
d = flow_obj.model_dump(mode="python")
239+
d["fn"] = fn if fn != "__call__" else flow_obj.fn
240+
if non_transparent:
241+
d["_evaluators"] = [e.model_dump(mode="python") for e in non_transparent]
242+
return dask.base.tokenize(d).encode("utf-8")
243+
elif isinstance(flow_obj, (ContextBase, CallableModel)):
204244
return dask.base.tokenize(flow_obj.model_dump(mode="python")).encode("utf-8")
205245
else:
206246
raise TypeError(f"object of type {type(flow_obj)} cannot be serialized by this function!")
@@ -213,8 +253,14 @@ class MemoryCacheEvaluator(EvaluatorBase):
213253
_cache: Dict[bytes, ResultType] = PrivateAttr({})
214254
_ids: Dict[bytes, ModelEvaluationContext] = PrivateAttr({})
215255

256+
def is_transparent(self, context: ModelEvaluationContext) -> bool:
257+
return True
258+
216259
def key(self, context: ModelEvaluationContext):
217-
"""Function to convert a ModelEvaluationContext to a key"""
260+
"""Function to convert a ModelEvaluationContext to a cache key.
261+
262+
Delegates to ``cache_key()`` which strips transparent evaluator layers.
263+
"""
218264
return cache_key(context)
219265

220266
@property
@@ -289,6 +335,9 @@ class GraphEvaluator(EvaluatorBase):
289335

290336
_is_evaluating: bool = PrivateAttr(False)
291337

338+
def is_transparent(self, context: ModelEvaluationContext) -> bool:
339+
return True
340+
292341
@override
293342
def __call__(self, context: ModelEvaluationContext) -> ResultType:
294343
import graphlib

ccflow/tests/evaluators/test_common.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
DateContext,
1010
DateRangeContext,
1111
Evaluator,
12+
EvaluatorBase,
1213
FlowOptionsOverride,
1314
ModelEvaluationContext,
1415
NullContext,
16+
TransparentModelEvaluationContext,
1517
)
1618
from ccflow.evaluators import (
1719
FallbackEvaluator,
@@ -257,6 +259,73 @@ def test_model_evaluation_context(self):
257259
assert cache_key(mec1) == cache_key(mec2)
258260
assert cache_key(mec3) != cache_key(mec1)
259261

262+
def test_transparent_mec_stripped(self):
263+
"""TransparentModelEvaluationContext layers are stripped from cache keys."""
264+
m = MyDateCallable(offset=1)
265+
ctx = DateContext(date=date(2022, 1, 1))
266+
inner = ModelEvaluationContext(model=m, context=ctx)
267+
wrapped = TransparentModelEvaluationContext(model=LoggingEvaluator(), context=inner)
268+
assert cache_key(inner) == cache_key(wrapped)
269+
270+
def test_opaque_mec_preserved(self):
271+
"""Non-transparent MEC layers produce different cache keys."""
272+
273+
class OpaqueEval(EvaluatorBase):
274+
def __call__(self, context: ModelEvaluationContext):
275+
return context()
276+
277+
m = MyDateCallable(offset=1)
278+
ctx = DateContext(date=date(2022, 1, 1))
279+
inner = ModelEvaluationContext(model=m, context=ctx)
280+
wrapped = ModelEvaluationContext(model=OpaqueEval(), context=inner)
281+
assert cache_key(inner) != cache_key(wrapped)
282+
283+
def test_stacked_transparent_stripped(self):
284+
"""Multiple stacked TransparentMEC layers are all stripped."""
285+
m = MyDateCallable(offset=1)
286+
ctx = DateContext(date=date(2022, 1, 1))
287+
inner = ModelEvaluationContext(model=m, context=ctx)
288+
layer1 = TransparentModelEvaluationContext(model=LoggingEvaluator(), context=inner)
289+
layer2 = TransparentModelEvaluationContext(model=MemoryCacheEvaluator(), context=layer1)
290+
assert cache_key(inner) == cache_key(layer2)
291+
292+
def test_sandwich_transparent_between_opaque(self):
293+
"""Transparent layer sandwiched between opaque layers is stripped, opaques preserved."""
294+
295+
class OpaqueEval(EvaluatorBase):
296+
tag: str = "default"
297+
298+
def __call__(self, context: ModelEvaluationContext):
299+
return context()
300+
301+
m = MyDateCallable(offset=1)
302+
ctx = DateContext(date=date(2022, 1, 1))
303+
inner = ModelEvaluationContext(model=m, context=ctx)
304+
opaque1 = ModelEvaluationContext(model=OpaqueEval(tag="inner"), context=inner)
305+
transparent = TransparentModelEvaluationContext(model=LoggingEvaluator(), context=opaque1)
306+
opaque2 = ModelEvaluationContext(model=OpaqueEval(tag="outer"), context=transparent)
307+
# Both opaque evaluators should be in the key; the transparent one should not
308+
assert cache_key(opaque2) != cache_key(inner)
309+
# Same sandwich should give consistent keys
310+
opaque2b = ModelEvaluationContext(
311+
model=OpaqueEval(tag="outer"),
312+
context=TransparentModelEvaluationContext(
313+
model=LoggingEvaluator(), context=ModelEvaluationContext(model=OpaqueEval(tag="inner"), context=inner)
314+
),
315+
)
316+
assert cache_key(opaque2) == cache_key(opaque2b)
317+
318+
def test_fn_deps_preserved_through_transparent(self):
319+
"""fn='__deps__' is preserved when walking through transparent layers."""
320+
m = MyDateCallable(offset=1)
321+
ctx = DateContext(date=date(2022, 1, 1))
322+
inner = ModelEvaluationContext(model=m, context=ctx, fn="__deps__")
323+
wrapped = TransparentModelEvaluationContext(model=LoggingEvaluator(), context=inner)
324+
# Both should produce the same key, and it should differ from __call__
325+
assert cache_key(inner) == cache_key(wrapped)
326+
call_inner = ModelEvaluationContext(model=m, context=ctx, fn="__call__")
327+
assert cache_key(inner) != cache_key(call_inner)
328+
260329

261330
class TestMemoryCacheEvaluator(TestCase):
262331
def test_basic(self):
@@ -355,6 +424,74 @@ def test_decorator_volatile(self):
355424
self.assertGreater(out2, out1)
356425
self.assertEqual(len(captured.records), 2)
357426

427+
def test_cache_key_stable_across_evaluators(self):
428+
"""Cache keys should not change when wrapping with non-caching evaluators (e.g. LoggingEvaluator)."""
429+
m1 = MyDateCallable(offset=1)
430+
cache = MemoryCacheEvaluator()
431+
ctx = DateContext(date=date(2022, 1, 1))
432+
433+
# First call: cache evaluator only
434+
with FlowOptionsOverride(options={"evaluator": cache, "cacheable": True}):
435+
out1 = m1(ctx)
436+
self.assertEqual(len(cache.cache), 1)
437+
438+
# Second call: LoggingEvaluator + same cache evaluator
439+
wrapped = combine_evaluators(LoggingEvaluator(), cache)
440+
with FlowOptionsOverride(options={"evaluator": wrapped, "cacheable": True}):
441+
out2 = m1(ctx)
442+
# Should still be only 1 cache entry (same key, cache hit)
443+
self.assertEqual(len(cache.cache), 1)
444+
self.assertEqual(out1, out2)
445+
446+
def test_cache_key_differs_with_nontransparent_evaluator(self):
447+
"""Cache keys should differ when a non-transparent evaluator is in the chain."""
448+
449+
class OpaqueEvaluator(EvaluatorBase):
450+
"""A dummy evaluator that is NOT transparent."""
451+
452+
def __call__(self, context: ModelEvaluationContext):
453+
return context()
454+
455+
m1 = MyDateCallable(offset=1)
456+
cache = MemoryCacheEvaluator()
457+
ctx = DateContext(date=date(2022, 1, 1))
458+
459+
# First call: cache evaluator only
460+
with FlowOptionsOverride(options={"evaluator": cache, "cacheable": True}):
461+
m1(ctx)
462+
self.assertEqual(len(cache.cache), 1)
463+
464+
# Second call: OpaqueEvaluator + same cache evaluator
465+
wrapped = combine_evaluators(OpaqueEvaluator(), cache)
466+
with FlowOptionsOverride(options={"evaluator": wrapped, "cacheable": True}):
467+
m1(ctx)
468+
# OpaqueEvaluator is not transparent, so cache key should differ
469+
self.assertEqual(len(cache.cache), 2)
470+
471+
def test_cache_key_differs_with_fallback_opaque_child(self):
472+
"""FallbackEvaluator with opaque child should produce different cache key."""
473+
474+
class OpaqueEvaluator(EvaluatorBase):
475+
def __call__(self, context: ModelEvaluationContext):
476+
return context()
477+
478+
m1 = MyDateCallable(offset=1)
479+
cache = MemoryCacheEvaluator()
480+
ctx = DateContext(date=date(2022, 1, 1))
481+
482+
# First call: cache evaluator only
483+
with FlowOptionsOverride(options={"evaluator": cache, "cacheable": True}):
484+
m1(ctx)
485+
self.assertEqual(len(cache.cache), 1)
486+
487+
# Second call: FallbackEvaluator(OpaqueEvaluator) + cache
488+
fallback = FallbackEvaluator(evaluators=[OpaqueEvaluator()])
489+
wrapped = combine_evaluators(fallback, cache)
490+
with FlowOptionsOverride(options={"evaluator": wrapped, "cacheable": True}):
491+
m1(ctx)
492+
# FallbackEvaluator is not transparent, so cache key should differ
493+
self.assertEqual(len(cache.cache), 2)
494+
358495

359496
class TestGraphDeps(TestCase):
360497
def test_graph_deps_diamond(self):

docs/wiki/Workflows.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,13 +589,21 @@ An evaluator is basically another form of callable model, with a few caveats
589589
The `ModelEvaluationContext` has fields for the model, the context, the function to evaluate (i.e. `__call__`), and the `FlowOptions`.
590590
It too, has a `__call__` method that will evaluate the function on the model with the provided context (but ignoring any options).
591591

592+
Evaluators that do not modify the return value (e.g. logging, caching, timing) should override the `is_transparent` method to return `True`.
593+
This allows `cache_key()` to skip these layers when computing cache keys, so that wrapping a model with different transparent evaluators does not change its cache identity.
594+
Evaluators that transform the result should inherit from `EvaluatorBase` directly and leave `is_transparent` as the default (`False`).
595+
592596
Below we illustrate how to write a really simple evaluator that just prints the options and delegates to the `ModelEvaluationContext` to get the normal result.
597+
Since it does not modify the return value, it overrides `is_transparent` to return `True`.
593598

594599
```python
595600
from ccflow import EvaluatorBase, ModelEvaluationContext, ResultType
596601

597602
class MyEvaluator(EvaluatorBase):
598603

604+
def is_transparent(self, context: ModelEvaluationContext) -> bool:
605+
return True
606+
599607
def __call__(self, context: ModelEvaluationContext) -> ResultType:
600608
print("Custom evaluator with options:", context.options)
601609
return context()

0 commit comments

Comments
 (0)