query-cascade 0.3.1__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {query_cascade-0.3.1 → query_cascade-0.3.3}/PKG-INFO +1 -1
  2. {query_cascade-0.3.1 → query_cascade-0.3.3}/pyproject.toml +1 -1
  3. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/cascade/_evaluator.py +63 -10
  4. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/cascade/_runtime.py +5 -0
  5. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/cascade/_state.py +1 -0
  6. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/cascade/_store.py +13 -1
  7. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/cascade/engine.py +9 -4
  8. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/query_cascade.egg-info/PKG-INFO +1 -1
  9. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/query_cascade.egg-info/SOURCES.txt +1 -0
  10. {query_cascade-0.3.1 → query_cascade-0.3.3}/tests/test_disk_cache.py +169 -0
  11. query_cascade-0.3.3/tests/test_fixed_point.py +38 -0
  12. {query_cascade-0.3.1 → query_cascade-0.3.3}/README.md +0 -0
  13. {query_cascade-0.3.1 → query_cascade-0.3.3}/setup.cfg +0 -0
  14. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/cascade/__init__.py +0 -0
  15. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/cascade/_canonical.py +0 -0
  16. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/cascade/_disk_cache.py +0 -0
  17. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/cascade/_errors.py +0 -0
  18. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/cascade/_graph_export.py +0 -0
  19. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/cascade/_persistence.py +0 -0
  20. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/cascade/_scheduler.py +0 -0
  21. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/cascade/_serde.py +0 -0
  22. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/cascade/_synthetic_graph.py +0 -0
  23. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/query_cascade.egg-info/dependency_links.txt +0 -0
  24. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/query_cascade.egg-info/requires.txt +0 -0
  25. {query_cascade-0.3.1 → query_cascade-0.3.3}/src/query_cascade.egg-info/top_level.txt +0 -0
  26. {query_cascade-0.3.1 → query_cascade-0.3.3}/tests/test_dependency_parallelism.py +0 -0
  27. {query_cascade-0.3.1 → query_cascade-0.3.3}/tests/test_engine.py +0 -0
  28. {query_cascade-0.3.1 → query_cascade-0.3.3}/tests/test_engine_compute_many.py +0 -0
  29. {query_cascade-0.3.1 → query_cascade-0.3.3}/tests/test_engine_concurrency.py +0 -0
  30. {query_cascade-0.3.1 → query_cascade-0.3.3}/tests/test_engine_persistence_and_tracing.py +0 -0
  31. {query_cascade-0.3.1 → query_cascade-0.3.3}/tests/test_engine_private.py +0 -0
  32. {query_cascade-0.3.1 → query_cascade-0.3.3}/tests/test_engine_private2.py +0 -0
  33. {query_cascade-0.3.1 → query_cascade-0.3.3}/tests/test_evaluator_and_synthetic_coverage.py +0 -0
  34. {query_cascade-0.3.1 → query_cascade-0.3.3}/tests/test_examples.py +0 -0
  35. {query_cascade-0.3.1 → query_cascade-0.3.3}/tests/test_internal_invariants.py +0 -0
  36. {query_cascade-0.3.1 → query_cascade-0.3.3}/tests/test_performance.py +0 -0
  37. {query_cascade-0.3.1 → query_cascade-0.3.3}/tests/test_repro_touch_memo_keyerror.py +0 -0
  38. {query_cascade-0.3.1 → query_cascade-0.3.3}/tests/test_scale_behavior.py +0 -0
  39. {query_cascade-0.3.1 → query_cascade-0.3.3}/tests/test_serde.py +0 -0
  40. {query_cascade-0.3.1 → query_cascade-0.3.3}/tests/test_stateful_engine_invariants.py +0 -0
  41. {query_cascade-0.3.1 → query_cascade-0.3.3}/tests/test_subgraph_stats_export.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: query-cascade
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Minimal demand-driven query framework for incremental computation.
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "query-cascade"
7
- version = "0.3.1"
7
+ version = "0.3.3"
8
8
  description = "Minimal demand-driven query framework for incremental computation."
9
9
  requires-python = ">=3.12"
10
10
  readme = "README.md"
@@ -7,7 +7,7 @@ from typing import Any, Callable, Mapping, Sequence
7
7
  from . import _canonical, _disk_cache
8
8
  from ._disk_cache import DiskCache
9
9
  from ._errors import CycleError, PersistentCacheError, QueryCancelled
10
- from ._runtime import RuntimeFrame, RuntimeState
10
+ from ._runtime import UNSET, RuntimeFrame, RuntimeState
11
11
  from ._state import InputVersion, MemoEntry, QueryKey, Snapshot
12
12
  from ._store import GraphStore
13
13
 
@@ -146,10 +146,24 @@ class Evaluator:
146
146
  )
147
147
 
148
148
  key: QueryKey = ("query", query_id, args)
149
- if any(frame.key == key for frame in runtime.stack):
150
- cycle_start = [frame.key for frame in runtime.stack] + [key]
151
- cycle = " -> ".join(self._store.key_to_str(k) for k in cycle_start)
152
- raise CycleError(f"cycle detected: {cycle}")
149
+ for i, frame in enumerate(runtime.stack):
150
+ if frame.key == key:
151
+ if frame.cycle_guess is UNSET:
152
+ has_fp, fp_val = self._store.lookup_query_fixed_point(query_id)
153
+ if not has_fp:
154
+ cycle_start = [f.key for f in runtime.stack[i:]] + [key]
155
+ cycle = " -> ".join(self._store.key_to_str(k) for k in cycle_start)
156
+ raise CycleError(f"cycle detected: {cycle}")
157
+ frame.cycle_guess = fp_val
158
+
159
+ frame.is_cycle_root = True
160
+ for f in runtime.stack[i:]:
161
+ frame.cycle_nodes.add(f.key)
162
+
163
+ # Record the back-edge dependency so the calling node is properly invalidated later
164
+ if len(runtime.stack) > 0:
165
+ runtime.stack[-1].deps[key] = runtime.snapshot.revision
166
+ return frame.cycle_guess
153
167
  self.check_cancelled(runtime.cancel_epoch)
154
168
  entry, replay_needed = self.compute_or_get_memo(key, fn, runtime)
155
169
  self.record_dependency(key, entry.changed_at)
@@ -235,6 +249,11 @@ class Evaluator:
235
249
  return memo.changed_at
236
250
 
237
251
  runtime = self._runtime_var.get()
252
+ if runtime is not None:
253
+ for frame in runtime.stack:
254
+ if frame.key == key:
255
+ return snapshot.revision
256
+
238
257
  if runtime is None:
239
258
  self._run_in_runtime(
240
259
  RuntimeState(
@@ -280,17 +299,50 @@ class Evaluator:
280
299
  runtime.stack.append(frame)
281
300
  start = self._store.monotonic_seconds()
282
301
  self._store.trace_event("recompute_start", key)
283
- try:
284
- self.check_cancelled(runtime.cancel_epoch)
285
- result = fn(*key[2])
286
- self.check_cancelled(runtime.cancel_epoch)
287
- finally:
302
+
303
+ with self._store.lock:
304
+ old_memo = self._store.memos.get(key)
305
+ if old_memo is not None and old_memo.cycle_nodes:
306
+ for k in old_memo.cycle_nodes:
307
+ if k != key:
308
+ self._store.drop_memo_locked(k)
309
+
310
+ while True:
311
+ try:
312
+ self.check_cancelled(runtime.cancel_epoch)
313
+ result = fn(*key[2])
314
+ self.check_cancelled(runtime.cancel_epoch)
315
+ except BaseException:
316
+ runtime.stack.pop()
317
+ raise
318
+
319
+ if frame.is_cycle_root:
320
+ if self._store.stable_hash(result) != self._store.stable_hash(frame.cycle_guess):
321
+ frame.cycle_guess = result
322
+ frame.is_cycle_root = False
323
+ with self._store.lock:
324
+ self._store.revision += 1
325
+ runtime.snapshot = Snapshot(revision=self._store.revision)
326
+ for k in frame.cycle_nodes:
327
+ if k != key:
328
+ self._store.drop_memo_locked(k)
329
+ frame.deps.clear()
330
+ frame.effects.clear()
331
+ continue
332
+
288
333
  runtime.stack.pop()
334
+ break
335
+
289
336
  elapsed = self._store.monotonic_seconds() - start
290
337
  duration_ms = elapsed * 1000.0
291
338
 
292
339
  frozen_effects = {name: tuple(items) for name, items in frame.effects.items()}
293
340
  value_hash = self._store.stable_hash(result)
341
+
342
+ if frame.cycle_nodes:
343
+ for k in frame.cycle_nodes:
344
+ frame.deps.pop(k, None)
345
+
294
346
  with self._store.lock:
295
347
  previous = self._store.memos.get(key)
296
348
  if previous is not None and previous.value_hash == value_hash:
@@ -307,6 +359,7 @@ class Evaluator:
307
359
  deps=frame.deps,
308
360
  effects=frozen_effects,
309
361
  last_access=self._store.next_access_id,
362
+ cycle_nodes=tuple(frame.cycle_nodes),
310
363
  )
311
364
  self._store.drop_memo_locked(key)
312
365
  self._store.memos[key] = memo
@@ -7,11 +7,16 @@ from typing import Any
7
7
  from ._state import QueryKey, Snapshot
8
8
 
9
9
 
10
+ UNSET = object()
11
+
10
12
  @dataclass
11
13
  class RuntimeFrame:
12
14
  key: QueryKey
13
15
  deps: dict[QueryKey, int] = field(default_factory=dict)
14
16
  effects: dict[str, list[Any]] = field(default_factory=lambda: defaultdict(list))
17
+ cycle_guess: Any = UNSET
18
+ is_cycle_root: bool = False
19
+ cycle_nodes: set[QueryKey] = field(default_factory=set)
15
20
 
16
21
 
17
22
  @dataclass
@@ -31,6 +31,7 @@ class MemoEntry:
31
31
  deps: tuple[Dependency, ...]
32
32
  effects: dict[str, tuple[Any, ...]]
33
33
  last_access: int
34
+ cycle_nodes: tuple[QueryKey, ...] = ()
34
35
 
35
36
 
36
37
  @dataclass(frozen=True)
@@ -44,6 +44,7 @@ class GraphStore:
44
44
 
45
45
  self.inputs: dict[InputKey, list[InputVersion]] = {}
46
46
  self.queries: dict[str, Callable[..., Any]] = {}
47
+ self.query_fixed_points: dict[str, Any] = {}
47
48
  self.input_fns: dict[str, Callable[..., Any]] = {}
48
49
  self.memos: dict[QueryKey, MemoEntry] = {}
49
50
  self.dependents: dict[QueryKey, set[QueryKey]] = defaultdict(set)
@@ -101,14 +102,23 @@ class GraphStore:
101
102
  sk = self.key_to_str(key)
102
103
  self._stats_by_key[sk] = self._stats_by_key.get(sk, 0.0) + seconds
103
104
 
104
- def register_query(self, query_id: str, fn: Callable[..., Any]) -> None:
105
+ def register_query(self, query_id: str, fn: Callable[..., Any], *, fixed_point: Any = None, has_fixed_point: bool = False) -> None:
105
106
  with self.lock:
106
107
  self.queries[query_id] = fn
108
+ if has_fixed_point:
109
+ self.query_fixed_points[query_id] = fixed_point
107
110
 
108
111
  def lookup_query(self, query_id: str) -> Callable[..., Any]:
109
112
  with self.lock:
110
113
  return self.queries[query_id]
111
114
 
115
+ def lookup_query_fixed_point(self, query_id: str) -> tuple[bool, Any]:
116
+ """Returns (has_fixed_point, fixed_point_value)."""
117
+ with self.lock:
118
+ if query_id in self.query_fixed_points:
119
+ return True, self.query_fixed_points[query_id]
120
+ return False, None
121
+
112
122
  def register_input(self, input_id: str, fn: Callable[..., Any]) -> None:
113
123
  with self.lock:
114
124
  self.input_fns[input_id] = fn
@@ -419,6 +429,7 @@ class GraphStore:
419
429
  deps: dict[QueryKey, int],
420
430
  effects: dict[str, tuple[Any, ...]],
421
431
  last_access: int,
432
+ cycle_nodes: tuple[QueryKey, ...] = (),
422
433
  ) -> MemoEntry:
423
434
  return MemoEntry(
424
435
  value=value,
@@ -428,4 +439,5 @@ class GraphStore:
428
439
  deps=tuple(Dependency(dep_key, observed) for dep_key, observed in deps.items()),
429
440
  effects=effects,
430
441
  last_access=last_access,
442
+ cycle_nodes=cycle_nodes,
431
443
  )
@@ -218,10 +218,15 @@ class Engine:
218
218
  self._store.register_input(handle.id, fn)
219
219
  return handle
220
220
 
221
- def query(self, fn: Callable[..., Any]) -> _QueryHandle:
222
- handle = _QueryHandle(self, fn)
223
- self._store.register_query(handle.id, fn)
224
- return handle
221
+ def query(self, fn: Callable[..., Any] | None = None, *, fixed_point: Any = _UNSET) -> Any:
222
+ def decorator(f: Callable[..., Any]) -> _QueryHandle:
223
+ handle = _QueryHandle(self, f)
224
+ has_fixed_point = fixed_point is not _UNSET
225
+ self._store.register_query(handle.id, f, fixed_point=fixed_point, has_fixed_point=has_fixed_point)
226
+ return handle
227
+ if fn is not None:
228
+ return decorator(fn)
229
+ return decorator
225
230
 
226
231
  def accumulator(self, name: str) -> Accumulator:
227
232
  return Accumulator(self, name=name)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: query-cascade
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Minimal demand-driven query framework for incremental computation.
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -29,6 +29,7 @@ tests/test_engine_private.py
29
29
  tests/test_engine_private2.py
30
30
  tests/test_evaluator_and_synthetic_coverage.py
31
31
  tests/test_examples.py
32
+ tests/test_fixed_point.py
32
33
  tests/test_internal_invariants.py
33
34
  tests/test_performance.py
34
35
  tests/test_repro_touch_memo_keyerror.py
@@ -674,3 +674,172 @@ def test_disk_cache_prune_vacuum_edge_cases(tmp_path: Path) -> None:
674
674
  engine.prune([("query", "missing:fid", ())], vacuum_disk=True)
675
675
 
676
676
  engine.shutdown()
677
+
678
+
679
+ def test_dynamic_graph_topology_cross_session(tmp_path: Path) -> None:
680
+ data = tmp_path / "cond.txt"
681
+ data.write_text("A")
682
+ cache = tmp_path / "cache"
683
+ runs: dict[str, int] = {}
684
+
685
+ def build() -> tuple[Engine, Any]:
686
+ engine = Engine(cache_dir=cache)
687
+
688
+ @engine.input
689
+ def condition() -> str:
690
+ return data.read_text().strip()
691
+
692
+ @engine.query
693
+ def branch_a() -> str:
694
+ runs["branch_a"] = runs.get("branch_a", 0) + 1
695
+ return "A_out"
696
+
697
+ @engine.query
698
+ def branch_b() -> str:
699
+ runs["branch_b"] = runs.get("branch_b", 0) + 1
700
+ return "B_out"
701
+
702
+ @engine.query
703
+ def root() -> str:
704
+ runs["root"] = runs.get("root", 0) + 1
705
+ if condition() == "A":
706
+ return branch_a()
707
+ else:
708
+ return branch_b()
709
+
710
+ return engine, root
711
+
712
+ # Session 1: cond is A
713
+ engine_1, root_1 = build()
714
+ assert root_1() == "A_out"
715
+ assert runs == {"root": 1, "branch_a": 1}
716
+ engine_1.shutdown()
717
+
718
+ # Session 2: cond is still A, should hit
719
+ runs.clear()
720
+ engine_2, root_2 = build()
721
+ assert root_2() == "A_out"
722
+ assert runs == {}
723
+ engine_2.shutdown()
724
+
725
+ # Session 3: cond is B, must recompute root and branch_b
726
+ data.write_text("B")
727
+ runs.clear()
728
+ engine_3, root_3 = build()
729
+ assert root_3() == "B_out"
730
+ assert runs == {"root": 1, "branch_b": 1}
731
+ engine_3.shutdown()
732
+
733
+ # Session 4: cond is B, should hit
734
+ runs.clear()
735
+ engine_4, root_4 = build()
736
+ assert root_4() == "B_out"
737
+ assert runs == {}
738
+ engine_4.shutdown()
739
+
740
+ # Session 5: cond is A again. root's fingerprint was overwritten by Session 3 (which depended on B),
741
+ # so root must recompute. But branch_a will hit the cache from Session 1!
742
+ data.write_text("A")
743
+ runs.clear()
744
+ engine_5, root_5 = build()
745
+ assert root_5() == "A_out"
746
+ assert runs == {"root": 1}
747
+ engine_5.shutdown()
748
+
749
+
750
+ def test_errors_are_not_cached_across_sessions(tmp_path: Path) -> None:
751
+ data = tmp_path / "state.txt"
752
+ data.write_text("fail")
753
+ cache = tmp_path / "cache"
754
+ runs: dict[str, int] = {}
755
+
756
+ def build() -> tuple[Engine, Any]:
757
+ engine = Engine(cache_dir=cache)
758
+
759
+ @engine.input
760
+ def state() -> str:
761
+ return data.read_text().strip()
762
+
763
+ @engine.query
764
+ def process() -> str:
765
+ runs["process"] = runs.get("process", 0) + 1
766
+ val = state()
767
+ if val == "fail":
768
+ raise ValueError("Oops")
769
+ return "Success"
770
+
771
+ return engine, process
772
+
773
+ # Session 1: fails
774
+ engine_1, process_1 = build()
775
+ with pytest.raises(ValueError, match="Oops"):
776
+ process_1()
777
+ assert runs == {"process": 1}
778
+ engine_1.shutdown()
779
+
780
+ # Session 2: still failing, must re-run because errors aren't cached
781
+ runs.clear()
782
+ engine_2, process_2 = build()
783
+ with pytest.raises(ValueError, match="Oops"):
784
+ process_2()
785
+ assert runs == {"process": 1}
786
+ engine_2.shutdown()
787
+
788
+ # Session 3: state fixed, runs and caches
789
+ data.write_text("ok")
790
+ runs.clear()
791
+ engine_3, process_3 = build()
792
+ assert process_3() == "Success"
793
+ assert runs == {"process": 1}
794
+ engine_3.shutdown()
795
+
796
+ # Session 4: state still ok, hits cache
797
+ runs.clear()
798
+ engine_4, process_4 = build()
799
+ assert process_4() == "Success"
800
+ assert runs == {}
801
+ engine_4.shutdown()
802
+
803
+
804
+ def test_deep_dependency_chain_cross_session(tmp_path: Path) -> None:
805
+ data = tmp_path / "val.txt"
806
+ data.write_text("0")
807
+ cache = tmp_path / "cache"
808
+ runs: dict[str, int] = {}
809
+
810
+ def build() -> tuple[Engine, Any]:
811
+ engine = Engine(cache_dir=cache)
812
+
813
+ @engine.input
814
+ def base() -> int:
815
+ return int(data.read_text().strip())
816
+
817
+ @engine.query
818
+ def chain(idx: int) -> int:
819
+ runs[f"q_{idx}"] = runs.get(f"q_{idx}", 0) + 1
820
+ if idx == 0:
821
+ return base() + 1
822
+ return chain(idx - 1) + 1
823
+
824
+ return engine, chain
825
+
826
+ # Session 1: compute the chain
827
+ engine_1, chain_1 = build()
828
+ assert chain_1(49) == 50
829
+ assert len(runs) == 50
830
+ engine_1.shutdown()
831
+
832
+ # Session 2: hit the chain
833
+ runs.clear()
834
+ engine_2, chain_2 = build()
835
+ assert chain_2(49) == 50
836
+ assert len(runs) == 0
837
+ engine_2.shutdown()
838
+
839
+ # Session 3: update base, recomputes all
840
+ data.write_text("10")
841
+ runs.clear()
842
+ engine_3, chain_3 = build()
843
+ assert chain_3(49) == 60
844
+ assert len(runs) == 50
845
+ engine_3.shutdown()
@@ -0,0 +1,38 @@
1
+ import pytest
2
+ from cascade.engine import Engine, CycleError
3
+
4
+ def test_fixed_point_basic():
5
+ engine = Engine()
6
+
7
+ @engine.input
8
+ def input_val():
9
+ return 1
10
+
11
+ @engine.query(fixed_point=0)
12
+ def cell_a():
13
+ return cell_b() + input_val()
14
+
15
+ @engine.query
16
+ def cell_b():
17
+ return cell_a() // 2
18
+
19
+ assert cell_a() == 1
20
+ assert cell_b() == 0
21
+
22
+ input_val.set(value=4)
23
+ assert cell_a() == 7
24
+ assert cell_b() == 3
25
+
26
+ def test_fixed_point_no_default_raises():
27
+ engine = Engine()
28
+
29
+ @engine.query
30
+ def cell_a():
31
+ return cell_b()
32
+
33
+ @engine.query
34
+ def cell_b():
35
+ return cell_a()
36
+
37
+ with pytest.raises(CycleError):
38
+ cell_a()
File without changes
File without changes