|
9 | 9 | DateContext, |
10 | 10 | DateRangeContext, |
11 | 11 | Evaluator, |
| 12 | + EvaluatorBase, |
12 | 13 | FlowOptionsOverride, |
13 | 14 | ModelEvaluationContext, |
14 | 15 | NullContext, |
| 16 | + TransparentModelEvaluationContext, |
15 | 17 | ) |
16 | 18 | from ccflow.evaluators import ( |
17 | 19 | FallbackEvaluator, |
@@ -257,6 +259,73 @@ def test_model_evaluation_context(self): |
257 | 259 | assert cache_key(mec1) == cache_key(mec2) |
258 | 260 | assert cache_key(mec3) != cache_key(mec1) |
259 | 261 |
|
| 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 | + |
260 | 329 |
|
261 | 330 | class TestMemoryCacheEvaluator(TestCase): |
262 | 331 | def test_basic(self): |
@@ -355,6 +424,74 @@ def test_decorator_volatile(self): |
355 | 424 | self.assertGreater(out2, out1) |
356 | 425 | self.assertEqual(len(captured.records), 2) |
357 | 426 |
|
| 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 | + |
358 | 495 |
|
359 | 496 | class TestGraphDeps(TestCase): |
360 | 497 | def test_graph_deps_diamond(self): |
|
0 commit comments