query-cascade 0.2.2__tar.gz → 0.2.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 (35) hide show
  1. {query_cascade-0.2.2 → query_cascade-0.2.3}/PKG-INFO +3 -2
  2. {query_cascade-0.2.2 → query_cascade-0.2.3}/README.md +2 -1
  3. {query_cascade-0.2.2 → query_cascade-0.2.3}/pyproject.toml +1 -1
  4. {query_cascade-0.2.2 → query_cascade-0.2.3}/src/cascade/engine.py +80 -3
  5. {query_cascade-0.2.2 → query_cascade-0.2.3}/src/query_cascade.egg-info/PKG-INFO +3 -2
  6. {query_cascade-0.2.2 → query_cascade-0.2.3}/tests/test_engine_compute_many.py +73 -0
  7. {query_cascade-0.2.2 → query_cascade-0.2.3}/setup.cfg +0 -0
  8. {query_cascade-0.2.2 → query_cascade-0.2.3}/src/cascade/__init__.py +0 -0
  9. {query_cascade-0.2.2 → query_cascade-0.2.3}/src/cascade/_errors.py +0 -0
  10. {query_cascade-0.2.2 → query_cascade-0.2.3}/src/cascade/_evaluator.py +0 -0
  11. {query_cascade-0.2.2 → query_cascade-0.2.3}/src/cascade/_graph_export.py +0 -0
  12. {query_cascade-0.2.2 → query_cascade-0.2.3}/src/cascade/_persistence.py +0 -0
  13. {query_cascade-0.2.2 → query_cascade-0.2.3}/src/cascade/_runtime.py +0 -0
  14. {query_cascade-0.2.2 → query_cascade-0.2.3}/src/cascade/_scheduler.py +0 -0
  15. {query_cascade-0.2.2 → query_cascade-0.2.3}/src/cascade/_serde.py +0 -0
  16. {query_cascade-0.2.2 → query_cascade-0.2.3}/src/cascade/_state.py +0 -0
  17. {query_cascade-0.2.2 → query_cascade-0.2.3}/src/cascade/_store.py +0 -0
  18. {query_cascade-0.2.2 → query_cascade-0.2.3}/src/cascade/_synthetic_graph.py +0 -0
  19. {query_cascade-0.2.2 → query_cascade-0.2.3}/src/query_cascade.egg-info/SOURCES.txt +0 -0
  20. {query_cascade-0.2.2 → query_cascade-0.2.3}/src/query_cascade.egg-info/dependency_links.txt +0 -0
  21. {query_cascade-0.2.2 → query_cascade-0.2.3}/src/query_cascade.egg-info/requires.txt +0 -0
  22. {query_cascade-0.2.2 → query_cascade-0.2.3}/src/query_cascade.egg-info/top_level.txt +0 -0
  23. {query_cascade-0.2.2 → query_cascade-0.2.3}/tests/test_dependency_parallelism.py +0 -0
  24. {query_cascade-0.2.2 → query_cascade-0.2.3}/tests/test_engine.py +0 -0
  25. {query_cascade-0.2.2 → query_cascade-0.2.3}/tests/test_engine_concurrency.py +0 -0
  26. {query_cascade-0.2.2 → query_cascade-0.2.3}/tests/test_engine_persistence_and_tracing.py +0 -0
  27. {query_cascade-0.2.2 → query_cascade-0.2.3}/tests/test_evaluator_and_synthetic_coverage.py +0 -0
  28. {query_cascade-0.2.2 → query_cascade-0.2.3}/tests/test_examples.py +0 -0
  29. {query_cascade-0.2.2 → query_cascade-0.2.3}/tests/test_internal_invariants.py +0 -0
  30. {query_cascade-0.2.2 → query_cascade-0.2.3}/tests/test_performance.py +0 -0
  31. {query_cascade-0.2.2 → query_cascade-0.2.3}/tests/test_repro_touch_memo_keyerror.py +0 -0
  32. {query_cascade-0.2.2 → query_cascade-0.2.3}/tests/test_scale_behavior.py +0 -0
  33. {query_cascade-0.2.2 → query_cascade-0.2.3}/tests/test_serde.py +0 -0
  34. {query_cascade-0.2.2 → query_cascade-0.2.3}/tests/test_stateful_engine_invariants.py +0 -0
  35. {query_cascade-0.2.2 → query_cascade-0.2.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.2.2
3
+ Version: 0.2.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
@@ -212,7 +212,7 @@ Properties and methods:
212
212
  - **`accumulator(name: str) → Accumulator`** — Return a named **`Accumulator`** (see below). Names are keys in the optional **`effects`** map passed to synchronous **`QueryHandle`** calls and **`submit`**.
213
213
  - **`snapshot() → Snapshot`** — Capture **`Snapshot(revision=engine.revision)`** for use as **`snapshot=`** on reads. Reads through that snapshot see input and memo state as of that revision; live **`set`** calls do not disturb snapshot reads.
214
214
  - **`submit(query, *args, *, snapshot=None, effects=None, executor=None) → concurrent.futures.Future`** — Run **`query(*args)`** on **`executor`**, or on a **lazily created** per-engine **`ThreadPoolExecutor`** when **`executor` is `None`**. The engine captures **`cancel_epoch`** at schedule time; if inputs move on before the task runs, the future may complete with **`QueryCancelled`**. **`snapshot`** defaults to a fresh **`snapshot()`** at submit time when omitted.
215
- - **`compute_many(calls, *, workers=None, snapshot=None) → list[Any]`** — Run many queries in parallel with a work-stealing scheduler. **`calls`** is a sequence of **`(query_handle, args_tuple)`**. Result order matches **`calls`**. **`workers`** defaults to a sensible value from **`len(calls)`** (capped); **`workers=0`** falls back to that same default. **`snapshot`** defaults to **`snapshot()`** when omitted. There is no **`effects`** parameter on this path—use **`QueryHandle.__call__`** or **`submit`** if you need to collect accumulator output in a dict.
215
+ - **`compute_many(calls, *, workers=None, snapshot=None, effects=None) → list[Any]`** — Run many queries in parallel with a work-stealing scheduler. **`calls`** is a sequence of **`(query_handle, args_tuple)`**. Result order matches **`calls`**. **`workers`** defaults to a sensible value from **`len(calls)`** (capped); **`workers=0`** falls back to that same default. **`snapshot`** defaults to **`snapshot()`** when omitted. When **`effects`** is a dict, accumulator output is collected and appended into **`effects[name]`** deterministically in **call order** (not completion order).
216
216
  - **`shutdown(*, wait: bool = True, cancel_futures: bool = False)`** — Shut down the **default** thread pool created by **`submit`**, if any. Call when discarding the engine if you need threads torn down promptly (for example in tests).
217
217
  - **`traces() → list[TraceEvent]`** — Copy of recent trace events (subject to **`trace_limit`**).
218
218
  - **`clear_traces()`** — Drop all buffered trace events.
@@ -452,6 +452,7 @@ Answer Yes/No:
452
452
  | `dynamic_macro_expansion.py` | Query that **changes downstream dependencies** at runtime |
453
453
  | `snapshot_isolation.py` | Snapshot reads while live inputs change |
454
454
  | `concurrent_background_work.py` | Dedup under concurrency + cancellation after input changes |
455
+ | `compute_many_with_accumulators.py` | `compute_many(..., effects=...)` accumulator collection |
455
456
  | `persistence_and_inspection.py` | Save/load and graph summaries |
456
457
  | `gil_parallel_speedup.py` | Threaded CPU benchmark: GIL vs free-threaded |
457
458
 
@@ -199,7 +199,7 @@ Properties and methods:
199
199
  - **`accumulator(name: str) → Accumulator`** — Return a named **`Accumulator`** (see below). Names are keys in the optional **`effects`** map passed to synchronous **`QueryHandle`** calls and **`submit`**.
200
200
  - **`snapshot() → Snapshot`** — Capture **`Snapshot(revision=engine.revision)`** for use as **`snapshot=`** on reads. Reads through that snapshot see input and memo state as of that revision; live **`set`** calls do not disturb snapshot reads.
201
201
  - **`submit(query, *args, *, snapshot=None, effects=None, executor=None) → concurrent.futures.Future`** — Run **`query(*args)`** on **`executor`**, or on a **lazily created** per-engine **`ThreadPoolExecutor`** when **`executor` is `None`**. The engine captures **`cancel_epoch`** at schedule time; if inputs move on before the task runs, the future may complete with **`QueryCancelled`**. **`snapshot`** defaults to a fresh **`snapshot()`** at submit time when omitted.
202
- - **`compute_many(calls, *, workers=None, snapshot=None) → list[Any]`** — Run many queries in parallel with a work-stealing scheduler. **`calls`** is a sequence of **`(query_handle, args_tuple)`**. Result order matches **`calls`**. **`workers`** defaults to a sensible value from **`len(calls)`** (capped); **`workers=0`** falls back to that same default. **`snapshot`** defaults to **`snapshot()`** when omitted. There is no **`effects`** parameter on this path—use **`QueryHandle.__call__`** or **`submit`** if you need to collect accumulator output in a dict.
202
+ - **`compute_many(calls, *, workers=None, snapshot=None, effects=None) → list[Any]`** — Run many queries in parallel with a work-stealing scheduler. **`calls`** is a sequence of **`(query_handle, args_tuple)`**. Result order matches **`calls`**. **`workers`** defaults to a sensible value from **`len(calls)`** (capped); **`workers=0`** falls back to that same default. **`snapshot`** defaults to **`snapshot()`** when omitted. When **`effects`** is a dict, accumulator output is collected and appended into **`effects[name]`** deterministically in **call order** (not completion order).
203
203
  - **`shutdown(*, wait: bool = True, cancel_futures: bool = False)`** — Shut down the **default** thread pool created by **`submit`**, if any. Call when discarding the engine if you need threads torn down promptly (for example in tests).
204
204
  - **`traces() → list[TraceEvent]`** — Copy of recent trace events (subject to **`trace_limit`**).
205
205
  - **`clear_traces()`** — Drop all buffered trace events.
@@ -439,6 +439,7 @@ Answer Yes/No:
439
439
  | `dynamic_macro_expansion.py` | Query that **changes downstream dependencies** at runtime |
440
440
  | `snapshot_isolation.py` | Snapshot reads while live inputs change |
441
441
  | `concurrent_background_work.py` | Dedup under concurrency + cancellation after input changes |
442
+ | `compute_many_with_accumulators.py` | `compute_many(..., effects=...)` accumulator collection |
442
443
  | `persistence_and_inspection.py` | Save/load and graph summaries |
443
444
  | `gil_parallel_speedup.py` | Threaded CPU benchmark: GIL vs free-threaded |
444
445
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "query-cascade"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Minimal demand-driven query framework for incremental computation."
9
9
  requires-python = ">=3.12"
10
10
  readme = "README.md"
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import concurrent.futures
4
4
  import os
5
5
  import threading
6
- from typing import Any, Callable, Iterable, Literal, Mapping, Sequence
6
+ from typing import Any, Callable, Iterable, Iterator, Literal, Mapping, Sequence
7
7
 
8
8
  from ._errors import CancellationError, CycleError, QueryCancelled
9
9
  from ._evaluator import Evaluator
@@ -262,18 +262,95 @@ class Engine:
262
262
  *,
263
263
  workers: int | None = None,
264
264
  snapshot: Snapshot | None = None,
265
+ effects: dict[str, list[Any]] | None = None,
265
266
  ) -> list[Any]:
266
267
  if not calls:
267
268
  return []
268
269
  run_snapshot = snapshot or self.snapshot()
269
270
  worker_count = workers or min(32, max(1, len(calls)))
270
271
  scheduler = WorkStealingExecutor(worker_count)
272
+ per_call_effects: list[dict[str, list[Any]] | None]
273
+ if effects is None:
274
+ per_call_effects = [None] * len(calls)
275
+ else:
276
+ per_call_effects = [{} for _ in range(len(calls))]
271
277
  for idx, (query, args) in enumerate(calls):
272
278
  scheduler.submit_indexed(
273
279
  idx,
274
- lambda q=query, a=args: self._query_call(q.id, q.raw, a, snapshot=run_snapshot),
280
+ lambda q=query, a=args, e=per_call_effects[idx]: self._query_call(
281
+ q.id,
282
+ q.raw,
283
+ a,
284
+ snapshot=run_snapshot,
285
+ effects=e,
286
+ ),
275
287
  )
276
- return scheduler.run(len(calls))
288
+ results = scheduler.run(len(calls))
289
+ if effects is not None:
290
+ for call_effects in per_call_effects:
291
+ if not call_effects:
292
+ continue
293
+ for name, items in call_effects.items():
294
+ if not items:
295
+ continue
296
+ effects.setdefault(name, []).extend(items)
297
+ return results
298
+
299
+ def compute_many_stream(
300
+ self,
301
+ calls: Sequence[tuple[_QueryHandle, tuple[Any, ...]]],
302
+ *,
303
+ workers: int | None = None,
304
+ snapshot: Snapshot | None = None,
305
+ effects: dict[str, list[Any]] | None = None,
306
+ ) -> Iterator[tuple[int, Any, dict[str, list[Any]]]]:
307
+ """Yield completed results as each call finishes.
308
+
309
+ Yields ``(index, value, call_effects)`` where ``index`` matches the position in
310
+ ``calls`` and ``call_effects`` contains accumulator output for that call when
311
+ ``effects`` was provided (otherwise an empty dict).
312
+
313
+ Unlike :meth:`compute_many`, this API yields items in *completion order*.
314
+ If ``effects`` is provided, it is populated after all calls complete, merged
315
+ deterministically in call order (matching :meth:`compute_many`).
316
+ """
317
+ if not calls:
318
+ return
319
+
320
+ run_snapshot = snapshot or self.snapshot()
321
+ worker_count = workers or min(32, max(1, len(calls)))
322
+
323
+ per_call_effects: list[dict[str, list[Any]]]
324
+ per_call_effects = [{} for _ in range(len(calls))]
325
+
326
+ with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as pool:
327
+ future_to_index: dict[concurrent.futures.Future[Any], int] = {}
328
+ for idx, (query, args) in enumerate(calls):
329
+ fut = pool.submit(
330
+ self._query_call,
331
+ query.id,
332
+ query.raw,
333
+ args,
334
+ snapshot=run_snapshot,
335
+ effects=per_call_effects[idx] if effects is not None else None,
336
+ )
337
+ future_to_index[fut] = idx
338
+
339
+ for fut in concurrent.futures.as_completed(future_to_index):
340
+ idx = future_to_index[fut]
341
+ value = fut.result()
342
+ call_effects = per_call_effects[idx] if effects is not None else {}
343
+ item = (idx, value, call_effects)
344
+ yield item
345
+
346
+ if effects is not None:
347
+ for call_effects in per_call_effects:
348
+ if not call_effects:
349
+ continue
350
+ for name, items in call_effects.items():
351
+ if not items:
352
+ continue
353
+ effects.setdefault(name, []).extend(items)
277
354
 
278
355
  def traces(self) -> list[TraceEvent]:
279
356
  return self._store.traces()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: query-cascade
3
- Version: 0.2.2
3
+ Version: 0.2.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
@@ -212,7 +212,7 @@ Properties and methods:
212
212
  - **`accumulator(name: str) → Accumulator`** — Return a named **`Accumulator`** (see below). Names are keys in the optional **`effects`** map passed to synchronous **`QueryHandle`** calls and **`submit`**.
213
213
  - **`snapshot() → Snapshot`** — Capture **`Snapshot(revision=engine.revision)`** for use as **`snapshot=`** on reads. Reads through that snapshot see input and memo state as of that revision; live **`set`** calls do not disturb snapshot reads.
214
214
  - **`submit(query, *args, *, snapshot=None, effects=None, executor=None) → concurrent.futures.Future`** — Run **`query(*args)`** on **`executor`**, or on a **lazily created** per-engine **`ThreadPoolExecutor`** when **`executor` is `None`**. The engine captures **`cancel_epoch`** at schedule time; if inputs move on before the task runs, the future may complete with **`QueryCancelled`**. **`snapshot`** defaults to a fresh **`snapshot()`** at submit time when omitted.
215
- - **`compute_many(calls, *, workers=None, snapshot=None) → list[Any]`** — Run many queries in parallel with a work-stealing scheduler. **`calls`** is a sequence of **`(query_handle, args_tuple)`**. Result order matches **`calls`**. **`workers`** defaults to a sensible value from **`len(calls)`** (capped); **`workers=0`** falls back to that same default. **`snapshot`** defaults to **`snapshot()`** when omitted. There is no **`effects`** parameter on this path—use **`QueryHandle.__call__`** or **`submit`** if you need to collect accumulator output in a dict.
215
+ - **`compute_many(calls, *, workers=None, snapshot=None, effects=None) → list[Any]`** — Run many queries in parallel with a work-stealing scheduler. **`calls`** is a sequence of **`(query_handle, args_tuple)`**. Result order matches **`calls`**. **`workers`** defaults to a sensible value from **`len(calls)`** (capped); **`workers=0`** falls back to that same default. **`snapshot`** defaults to **`snapshot()`** when omitted. When **`effects`** is a dict, accumulator output is collected and appended into **`effects[name]`** deterministically in **call order** (not completion order).
216
216
  - **`shutdown(*, wait: bool = True, cancel_futures: bool = False)`** — Shut down the **default** thread pool created by **`submit`**, if any. Call when discarding the engine if you need threads torn down promptly (for example in tests).
217
217
  - **`traces() → list[TraceEvent]`** — Copy of recent trace events (subject to **`trace_limit`**).
218
218
  - **`clear_traces()`** — Drop all buffered trace events.
@@ -452,6 +452,7 @@ Answer Yes/No:
452
452
  | `dynamic_macro_expansion.py` | Query that **changes downstream dependencies** at runtime |
453
453
  | `snapshot_isolation.py` | Snapshot reads while live inputs change |
454
454
  | `concurrent_background_work.py` | Dedup under concurrency + cancellation after input changes |
455
+ | `compute_many_with_accumulators.py` | `compute_many(..., effects=...)` accumulator collection |
455
456
  | `persistence_and_inspection.py` | Save/load and graph summaries |
456
457
  | `gil_parallel_speedup.py` | Threaded CPU benchmark: GIL vs free-threaded |
457
458
 
@@ -83,6 +83,79 @@ def test_compute_many_with_zero_workers_falls_back_to_default_worker_selection()
83
83
  assert result == [i + 1 for i in range(6)]
84
84
 
85
85
 
86
+ def test_compute_many_can_collect_accumulator_effects() -> None:
87
+ engine = Engine()
88
+ progress = engine.accumulator("progress")
89
+
90
+ @engine.query
91
+ def job(name: str) -> str:
92
+ progress.push(("start", name))
93
+ progress.push(("done", name))
94
+ return name.upper()
95
+
96
+ effects: dict[str, list[object]] = {}
97
+ result = engine.compute_many([(job, ("a",)), (job, ("b",)), (job, ("c",))], workers=3, effects=effects)
98
+ assert result == ["A", "B", "C"]
99
+ assert effects == {
100
+ "progress": [
101
+ ("start", "a"),
102
+ ("done", "a"),
103
+ ("start", "b"),
104
+ ("done", "b"),
105
+ ("start", "c"),
106
+ ("done", "c"),
107
+ ]
108
+ }
109
+
110
+
111
+ def test_compute_many_stream_yields_in_completion_order() -> None:
112
+ engine = Engine()
113
+
114
+ @engine.query
115
+ def delayed(i: int) -> int:
116
+ time.sleep(0.004 * (4 - i))
117
+ return i
118
+
119
+ calls = [(delayed, (i,)) for i in range(4)]
120
+ items = list(engine.compute_many_stream(calls, workers=4))
121
+ # i=3 sleeps least, so it should complete first.
122
+ assert [idx for idx, _value, _effects in items] == [3, 2, 1, 0]
123
+ assert sorted(value for _idx, value, _effects in items) == [0, 1, 2, 3]
124
+
125
+
126
+ def test_compute_many_stream_can_yield_and_collect_accumulator_effects() -> None:
127
+ engine = Engine()
128
+ progress = engine.accumulator("progress")
129
+
130
+ @engine.query
131
+ def job(name: str, seconds: float) -> str:
132
+ progress.push(("start", name))
133
+ time.sleep(seconds)
134
+ progress.push(("done", name))
135
+ return name.upper()
136
+
137
+ calls = [(job, ("a", 0.03)), (job, ("b", 0.01)), (job, ("c", 0.02))]
138
+ effects: dict[str, list[object]] = {}
139
+ items = list(engine.compute_many_stream(calls, workers=3, effects=effects))
140
+
141
+ # Stream yields in completion order (b, c, a).
142
+ assert [value for _idx, value, _call_effects in items] == ["B", "C", "A"]
143
+ # Each yielded item includes just that call's effects.
144
+ by_value = {value: call_effects["progress"] for _idx, value, call_effects in items}
145
+ assert by_value["A"] == [("start", "a"), ("done", "a")]
146
+ assert by_value["B"] == [("start", "b"), ("done", "b")]
147
+ assert by_value["C"] == [("start", "c"), ("done", "c")]
148
+ # Aggregate effects dict is merged in call order, matching compute_many.
149
+ assert effects["progress"] == [
150
+ ("start", "a"),
151
+ ("done", "a"),
152
+ ("start", "b"),
153
+ ("done", "b"),
154
+ ("start", "c"),
155
+ ("done", "c"),
156
+ ]
157
+
158
+
86
159
  def test_work_stealing_run_raises_first_recorded_task_error() -> None:
87
160
  scheduler = WorkStealingExecutor(workers=2)
88
161
 
File without changes