From 6b652de616261c48b35672303bd2857bb995485c Mon Sep 17 00:00:00 2001 From: Pascal Tomecek <40371786+ptomecek@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:06:04 -0400 Subject: [PATCH] Add effective_cache_key(model, context) helper Expose a public helper that returns the effective identity key for evaluating a model on a context, given the model and context directly rather than a ModelEvaluationContext. cache_key(..., effective=True) already derives effective identity, but only for a ModelEvaluationContext. Callers that hold a bare model plus a context (for example keyed persistent caches) otherwise have to reconstruct a ModelEvaluationContext and compare structural versus effective keys to detect opt-out models. This helper wraps the existing internal derivation instead: models that declare an effective identity are keyed by it, and models that do not fall back byte-for-byte to cache_key(model). Signed-off-by: Pascal Tomecek <40371786+ptomecek@users.noreply.github.com> --- ccflow/evaluators/common.py | 26 ++++++++++++++ ccflow/tests/evaluators/test_common.py | 49 ++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/ccflow/evaluators/common.py b/ccflow/evaluators/common.py index c2e52406..f1dcfbac 100644 --- a/ccflow/evaluators/common.py +++ b/ccflow/evaluators/common.py @@ -32,6 +32,7 @@ "MultiEvaluator", "cache_key", "combine_evaluators", + "effective_cache_key", "get_dependency_graph", ] @@ -336,6 +337,31 @@ def cache_key(flow_obj: ModelEvaluationContext | ContextBase | CallableModel, *, raise TypeError(f"object of type {type(flow_obj)} cannot be serialized by this function!") +def effective_cache_key(model: CallableModel, context: ContextBase) -> bytes: + """Returns the effective identity key for evaluating ``model`` on ``context``. + + This is the ``(model, context)`` counterpart to ``cache_key(mec, effective=True)``, for callers + that hold a model and a context directly rather than a ``ModelEvaluationContext``. Models that + declare an effective identity (by returning a non-``None`` identity payload) are keyed by it; + models that do not fall back to the structural ``cache_key(model)``, so the result is byte-for-byte + identical to ``cache_key(model)`` for such models. + + Unlike ``cache_key(mec, effective=True)``, this returns the model-level effective key without the + surrounding evaluation-context envelope (``fn`` and ``options``), and it uses the structural + ``cache_key(model)`` (rather than a context-dependent key) for models that do not opt in. + + Args: + model: The model whose identity is being tokenized. + context: The context the model would be evaluated on; it is passed to the model's identity + payload. Models that do not opt into effective identity ignore it. + """ + try: + key = _effective_model_key(model, context, {}, set()) + except _EffectiveEvaluationKeyUnavailable: + key = None + return key if key is not None else cache_key(model) + + class MemoryCacheEvaluator(EvaluatorBase): """Evaluator that caches results in memory.""" diff --git a/ccflow/tests/evaluators/test_common.py b/ccflow/tests/evaluators/test_common.py index fc837427..52990e6e 100644 --- a/ccflow/tests/evaluators/test_common.py +++ b/ccflow/tests/evaluators/test_common.py @@ -26,6 +26,7 @@ MultiEvaluator, cache_key, combine_evaluators, + effective_cache_key, get_dependency_graph, ) @@ -739,3 +740,51 @@ def test_graph_evaluator_circular(self): evaluator = GraphEvaluator() with FlowOptionsOverride(options={"evaluator": evaluator}): self.assertRaises(Exception, root, context) # noqa: B017 + + +class _OffsetIdentityCallable(MyDateCallable): + """Opt-in model whose effective identity depends only on ``offset``, not on the context date.""" + + def _evaluation_identity_payload(self, context): + return {"offset": self.offset} + + +class TestEffectiveCacheKey(TestCase): + def test_opt_out_matches_structural_cache_key(self): + """Models that do not opt in fall back byte-for-byte to cache_key(model).""" + model = MyDateCallable(offset=1) + context = DateContext(date=date(2022, 1, 1)) + self.assertEqual(effective_cache_key(model, context), cache_key(model)) + + def test_opt_out_is_context_independent(self): + model = MyDateCallable(offset=1) + c1 = DateContext(date=date(2022, 1, 1)) + c2 = DateContext(date=date(2022, 1, 2)) + self.assertEqual(effective_cache_key(model, c1), effective_cache_key(model, c2)) + + def test_opt_in_uses_identity_payload(self): + """An opt-in model is keyed by its identity payload, not the structural key.""" + model = _OffsetIdentityCallable(offset=1) + c1 = DateContext(date=date(2022, 1, 1)) + c2 = DateContext(date=date(2022, 1, 2)) + # The payload ignores the date, so the two contexts collapse to one key ... + self.assertEqual(effective_cache_key(model, c1), effective_cache_key(model, c2)) + # ... and that key differs from the structural cache_key(model). + self.assertNotEqual(effective_cache_key(model, c1), cache_key(model)) + + def test_opt_in_distinguishes_payload_relevant_change(self): + context = DateContext(date=date(2022, 1, 1)) + self.assertNotEqual( + effective_cache_key(_OffsetIdentityCallable(offset=1), context), + effective_cache_key(_OffsetIdentityCallable(offset=2), context), + ) + + def test_identity_payload_errors_propagate(self): + """A failing identity payload surfaces rather than being hidden by the structural fallback.""" + + class _BadIdentityCallable(MyDateCallable): + def _evaluation_identity_payload(self, context): + raise ValueError("identity broke") + + with self.assertRaisesRegex(ValueError, "identity broke"): + effective_cache_key(_BadIdentityCallable(offset=1), DateContext(date=date(2022, 1, 1)))