query-cascade 0.3.2__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.2 → query_cascade-0.3.3}/PKG-INFO +1 -1
  2. {query_cascade-0.3.2 → query_cascade-0.3.3}/pyproject.toml +1 -1
  3. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/cascade/_evaluator.py +63 -10
  4. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/cascade/_runtime.py +5 -0
  5. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/cascade/_state.py +1 -0
  6. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/cascade/_store.py +13 -1
  7. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/cascade/engine.py +9 -4
  8. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/query_cascade.egg-info/PKG-INFO +1 -1
  9. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/query_cascade.egg-info/SOURCES.txt +1 -0
  10. query_cascade-0.3.3/tests/test_fixed_point.py +38 -0
  11. {query_cascade-0.3.2 → query_cascade-0.3.3}/README.md +0 -0
  12. {query_cascade-0.3.2 → query_cascade-0.3.3}/setup.cfg +0 -0
  13. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/cascade/__init__.py +0 -0
  14. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/cascade/_canonical.py +0 -0
  15. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/cascade/_disk_cache.py +0 -0
  16. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/cascade/_errors.py +0 -0
  17. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/cascade/_graph_export.py +0 -0
  18. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/cascade/_persistence.py +0 -0
  19. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/cascade/_scheduler.py +0 -0
  20. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/cascade/_serde.py +0 -0
  21. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/cascade/_synthetic_graph.py +0 -0
  22. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/query_cascade.egg-info/dependency_links.txt +0 -0
  23. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/query_cascade.egg-info/requires.txt +0 -0
  24. {query_cascade-0.3.2 → query_cascade-0.3.3}/src/query_cascade.egg-info/top_level.txt +0 -0
  25. {query_cascade-0.3.2 → query_cascade-0.3.3}/tests/test_dependency_parallelism.py +0 -0
  26. {query_cascade-0.3.2 → query_cascade-0.3.3}/tests/test_disk_cache.py +0 -0
  27. {query_cascade-0.3.2 → query_cascade-0.3.3}/tests/test_engine.py +0 -0
  28. {query_cascade-0.3.2 → query_cascade-0.3.3}/tests/test_engine_compute_many.py +0 -0
  29. {query_cascade-0.3.2 → query_cascade-0.3.3}/tests/test_engine_concurrency.py +0 -0
  30. {query_cascade-0.3.2 → query_cascade-0.3.3}/tests/test_engine_persistence_and_tracing.py +0 -0
  31. {query_cascade-0.3.2 → query_cascade-0.3.3}/tests/test_engine_private.py +0 -0
  32. {query_cascade-0.3.2 → query_cascade-0.3.3}/tests/test_engine_private2.py +0 -0
  33. {query_cascade-0.3.2 → query_cascade-0.3.3}/tests/test_evaluator_and_synthetic_coverage.py +0 -0
  34. {query_cascade-0.3.2 → query_cascade-0.3.3}/tests/test_examples.py +0 -0
  35. {query_cascade-0.3.2 → query_cascade-0.3.3}/tests/test_internal_invariants.py +0 -0
  36. {query_cascade-0.3.2 → query_cascade-0.3.3}/tests/test_performance.py +0 -0
  37. {query_cascade-0.3.2 → query_cascade-0.3.3}/tests/test_repro_touch_memo_keyerror.py +0 -0
  38. {query_cascade-0.3.2 → query_cascade-0.3.3}/tests/test_scale_behavior.py +0 -0
  39. {query_cascade-0.3.2 → query_cascade-0.3.3}/tests/test_serde.py +0 -0
  40. {query_cascade-0.3.2 → query_cascade-0.3.3}/tests/test_stateful_engine_invariants.py +0 -0
  41. {query_cascade-0.3.2 → 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.2
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.2"
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.2
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
@@ -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