query-cascade 0.2.4__tar.gz → 0.3.2__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 (44) hide show
  1. query_cascade-0.3.2/PKG-INFO +381 -0
  2. query_cascade-0.3.2/README.md +363 -0
  3. {query_cascade-0.2.4 → query_cascade-0.3.2}/pyproject.toml +7 -1
  4. {query_cascade-0.2.4 → query_cascade-0.3.2}/src/cascade/__init__.py +2 -0
  5. query_cascade-0.3.2/src/cascade/_canonical.py +185 -0
  6. query_cascade-0.3.2/src/cascade/_disk_cache.py +186 -0
  7. {query_cascade-0.2.4 → query_cascade-0.3.2}/src/cascade/_errors.py +4 -0
  8. query_cascade-0.3.2/src/cascade/_evaluator.py +616 -0
  9. {query_cascade-0.2.4 → query_cascade-0.3.2}/src/cascade/_store.py +12 -1
  10. {query_cascade-0.2.4 → query_cascade-0.3.2}/src/cascade/engine.py +36 -5
  11. query_cascade-0.3.2/src/query_cascade.egg-info/PKG-INFO +381 -0
  12. {query_cascade-0.2.4 → query_cascade-0.3.2}/src/query_cascade.egg-info/SOURCES.txt +5 -0
  13. {query_cascade-0.2.4 → query_cascade-0.3.2}/src/query_cascade.egg-info/requires.txt +6 -0
  14. query_cascade-0.3.2/tests/test_disk_cache.py +845 -0
  15. query_cascade-0.3.2/tests/test_engine_private.py +100 -0
  16. query_cascade-0.3.2/tests/test_engine_private2.py +36 -0
  17. query_cascade-0.2.4/PKG-INFO +0 -148
  18. query_cascade-0.2.4/README.md +0 -135
  19. query_cascade-0.2.4/src/cascade/_evaluator.py +0 -305
  20. query_cascade-0.2.4/src/query_cascade.egg-info/PKG-INFO +0 -148
  21. {query_cascade-0.2.4 → query_cascade-0.3.2}/setup.cfg +0 -0
  22. {query_cascade-0.2.4 → query_cascade-0.3.2}/src/cascade/_graph_export.py +0 -0
  23. {query_cascade-0.2.4 → query_cascade-0.3.2}/src/cascade/_persistence.py +0 -0
  24. {query_cascade-0.2.4 → query_cascade-0.3.2}/src/cascade/_runtime.py +0 -0
  25. {query_cascade-0.2.4 → query_cascade-0.3.2}/src/cascade/_scheduler.py +0 -0
  26. {query_cascade-0.2.4 → query_cascade-0.3.2}/src/cascade/_serde.py +0 -0
  27. {query_cascade-0.2.4 → query_cascade-0.3.2}/src/cascade/_state.py +0 -0
  28. {query_cascade-0.2.4 → query_cascade-0.3.2}/src/cascade/_synthetic_graph.py +0 -0
  29. {query_cascade-0.2.4 → query_cascade-0.3.2}/src/query_cascade.egg-info/dependency_links.txt +0 -0
  30. {query_cascade-0.2.4 → query_cascade-0.3.2}/src/query_cascade.egg-info/top_level.txt +0 -0
  31. {query_cascade-0.2.4 → query_cascade-0.3.2}/tests/test_dependency_parallelism.py +0 -0
  32. {query_cascade-0.2.4 → query_cascade-0.3.2}/tests/test_engine.py +0 -0
  33. {query_cascade-0.2.4 → query_cascade-0.3.2}/tests/test_engine_compute_many.py +0 -0
  34. {query_cascade-0.2.4 → query_cascade-0.3.2}/tests/test_engine_concurrency.py +0 -0
  35. {query_cascade-0.2.4 → query_cascade-0.3.2}/tests/test_engine_persistence_and_tracing.py +0 -0
  36. {query_cascade-0.2.4 → query_cascade-0.3.2}/tests/test_evaluator_and_synthetic_coverage.py +0 -0
  37. {query_cascade-0.2.4 → query_cascade-0.3.2}/tests/test_examples.py +0 -0
  38. {query_cascade-0.2.4 → query_cascade-0.3.2}/tests/test_internal_invariants.py +0 -0
  39. {query_cascade-0.2.4 → query_cascade-0.3.2}/tests/test_performance.py +0 -0
  40. {query_cascade-0.2.4 → query_cascade-0.3.2}/tests/test_repro_touch_memo_keyerror.py +0 -0
  41. {query_cascade-0.2.4 → query_cascade-0.3.2}/tests/test_scale_behavior.py +0 -0
  42. {query_cascade-0.2.4 → query_cascade-0.3.2}/tests/test_serde.py +0 -0
  43. {query_cascade-0.2.4 → query_cascade-0.3.2}/tests/test_stateful_engine_invariants.py +0 -0
  44. {query_cascade-0.2.4 → query_cascade-0.3.2}/tests/test_subgraph_stats_export.py +0 -0
@@ -0,0 +1,381 @@
1
+ Metadata-Version: 2.4
2
+ Name: query-cascade
3
+ Version: 0.3.2
4
+ Summary: Minimal demand-driven query framework for incremental computation.
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Provides-Extra: disk
8
+ Requires-Dist: lmdb; extra == "disk"
9
+ Requires-Dist: msgpack; extra == "disk"
10
+ Provides-Extra: dev
11
+ Requires-Dist: hypothesis; extra == "dev"
12
+ Requires-Dist: lmdb; extra == "dev"
13
+ Requires-Dist: msgpack; extra == "dev"
14
+ Requires-Dist: mutmut; extra == "dev"
15
+ Requires-Dist: pytest; extra == "dev"
16
+ Requires-Dist: pytest-cov; extra == "dev"
17
+ Requires-Dist: ruff; extra == "dev"
18
+
19
+ # Cascade Query
20
+
21
+ Cascade Query is a Python library for incremental dependency tracking. It caches function results and re-executes them only when their specific inputs or upstream dependencies change.
22
+
23
+ [![PyPI version](https://img.shields.io/pypi/v/query-cascade?color=3775A9&label=PyPI)](https://pypi.org/project/query-cascade/)
24
+ [![Python versions](https://img.shields.io/pypi/pyversions/query-cascade?color=5A9)](https://pypi.org/project/query-cascade/)
25
+
26
+ ---
27
+
28
+ ## Core Principles
29
+
30
+ 1. **Automatic Caching:** Results are stored. If dependencies are unchanged, the function body does not execute.
31
+ 2. **Dependency Tracking:** Cascade records every `@engine.input` or `@engine.query` accessed during execution.
32
+ 3. **Targeted Updates:** When an input changes, Cascade identifies and invalidates only the affected downstream functions.
33
+ 4. **Early Bail-out:** If a function's output remains identical after its dependencies change, re-computation stops for that branch.
34
+
35
+ ---
36
+
37
+ ## Quickstart
38
+
39
+ ```python
40
+ import time
41
+ from cascade import Engine
42
+
43
+ engine = Engine()
44
+
45
+ @engine.input
46
+ def user_id():
47
+ return "user_1"
48
+
49
+ @engine.query
50
+ def fetch_data():
51
+ time.sleep(2)
52
+ return {"id": user_id(), "data": "value"}
53
+
54
+ @engine.query
55
+ def get_result():
56
+ data = fetch_data()
57
+ return f"Result for {data['id']}"
58
+
59
+ # First run: Executes for 2 seconds.
60
+ print(get_result())
61
+
62
+ # Second run: Returns immediately from cache.
63
+ print(get_result())
64
+
65
+ # Update input:
66
+ user_id.set("user_2")
67
+
68
+ # Third run: Executes for 2 seconds to refresh.
69
+ print(get_result())
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Engine API
75
+
76
+ ### Core Methods
77
+ * **`Engine(max_entries=10000, stats=False, cache_dir=None, cache_map_size=2**30)`**: Initializes the engine. `max_entries` sets the limit for the Least Recently Used (LRU) cache. Passing `cache_dir` enables persistent disk caching (see below).
78
+ * **`@engine.input`**: Decorator for mutable data roots.
79
+ * `input.set(value)`: Updates the value and increments the global revision.
80
+ * `input.set(*args, value=value)`: Updates a keyed input.
81
+ * **`@engine.query`**: Decorator for cached computations.
82
+ * **`engine.snapshot()`**: Returns a `Snapshot` object pinning the current global revision. Use `query(snapshot=s)` to read data as it existed at that revision.
83
+ * **`engine.save(path)` / `engine.load(path)`**: Persists all inputs and cached results to a SQLite database.
84
+ * **`engine.clear_disk_cache()`**: Deletes every entry in the persistent disk cache. Raises if the engine was created without `cache_dir`.
85
+
86
+ ### Parallel & Background Execution
87
+ * **`engine.compute_many(calls, workers=None)`**: Executes a list of queries in parallel using a thread pool.
88
+ * **`engine.submit(query, *args, executor=None)`**: Schedules a query for background execution. Returns a `concurrent.futures.Future`.
89
+ * **`QueryCancelled`**: Exception raised if a background query's dependencies change before it completes.
90
+
91
+ ### Graph Utilities
92
+ * **`engine.inspect_graph()`**: Returns a dictionary of all nodes and edges in the dependency graph.
93
+ * **`engine.subgraph(roots, direction="deps")`**: Filters the graph to the dependency chain of the specified root nodes.
94
+ * **`engine.prune(roots, vacuum_disk=False)`**: Removes cached query results from the in-memory LRU cache that are not reachable from the specified roots. Set `vacuum_disk=True` to also do a deep vacuum of the persistent LMDB disk cache, deleting all orphaned blobs and metadata.
95
+
96
+ ---
97
+
98
+ ## Persistent Disk Caching
99
+
100
+ Passing `cache_dir` to the `Engine` turns on zero-config persistence. Cascade provisions an embedded LMDB store in that directory, serializes query results with a deterministic msgpack encoding, and fingerprints every input value by hashing its serialized bytes with blake2b. Nothing else changes: queries and inputs are written exactly as before.
101
+
102
+ ```python
103
+ from cascade import Engine
104
+
105
+ engine = Engine(max_entries=10_000, cache_dir=".cascade_cache")
106
+
107
+ @engine.input
108
+ def package_source_text(pkg: str) -> str:
109
+ with open(pkg, "r") as f:
110
+ return f.read()
111
+
112
+ @engine.query
113
+ def parsed_package_ast(pkg: str):
114
+ return parse(package_source_text(pkg))
115
+ ```
116
+
117
+ The first run executes normally and writes each result to disk. A later run in a new process starts with an empty in-memory cache, finds the entry on disk, and verifies it top-down: leaf inputs are re-executed and re-hashed (for the input above, that means re-reading the file), and the current hashes are compared against the fingerprints saved with the entry. If everything matches, the stored value is deserialized and returned without running any query body. If a file changed, its hash mismatches, and exactly the queries downstream of that file recompute. Early bail-out works across sessions too, since dependency fingerprints are content hashes: a whitespace-only edit that leaves an intermediate result unchanged will not recompute anything past it.
118
+
119
+ Accumulator effects are stored with each entry and replayed on disk hits, so a warning emitted in run 1 still appears in run 2 even when the query is served from disk.
120
+
121
+ `lmdb` and `msgpack` are required once `cache_dir` is set; there is no fallback, and the engine raises `PersistentCacheError` with install instructions if either is missing:
122
+
123
+ ```bash
124
+ pip install query-cascade[disk]
125
+ ```
126
+
127
+ A few things to know:
128
+
129
+ * Values and arguments must be serializable: primitives, bytes, `list`/`tuple`/`set`/`frozenset`/`dict`, `@dataclass` instances, and `typing.NamedTuple` instances. A query that returns anything else raises at compute time when persistence is on. A query called with an unserializable argument still computes and memoizes in memory, it just skips the disk.
130
+ * Cache addresses are derived from the function id (`module:qualname`) and the hashed arguments, so renaming or moving a function starts it from a cold cache. Editing a function body does not invalidate its entries; bump the cache with `engine.clear_disk_cache()` or delete the directory when query logic changes.
131
+ * The store supports concurrent access from multiple processes through LMDB's own locking. Within one process, engines sharing a `cache_dir` share one LMDB environment; the first opener's `cache_map_size` wins.
132
+ * The default `cache_map_size` is 1 GiB. LMDB allocates this lazily, so the file only grows as entries are written. If the cache fills up, `PersistentCacheError` explains the options.
133
+ * The on-disk data is a cache: clearing it is always safe and only costs recomputation. Cascade wipes it automatically when its own storage format version changes.
134
+
135
+ ---
136
+
137
+ ## Advanced Features
138
+
139
+ ### Side-Effect Accumulators
140
+ Queries must be pure functions. Use `Accumulator` to record side-effects (like logs or warnings) that must be replayed when a result is served from the cache.
141
+
142
+ ```python
143
+ warnings = engine.accumulator("warnings")
144
+
145
+ @engine.query
146
+ def validate_data():
147
+ data = fetch_data()
148
+ if not data:
149
+ warnings.push("No data found")
150
+ return data
151
+
152
+ # On cache hit, 'warnings' are re-populated into the effects dictionary.
153
+ effects = {}
154
+ validate_data(effects=effects)
155
+ print(effects["warnings"])
156
+ ```
157
+
158
+ ### Performance Metrics
159
+ Set `stats=True` in the `Engine` constructor to track execution timing.
160
+ * **`engine.stats_summary()`**: Returns wall-clock time spent in function bodies and cache eviction counts.
161
+ * **`engine.reset_stats()`**: Clears accumulated timing data.
162
+
163
+ ---
164
+
165
+ ## Visualization
166
+ Cascade provides renderers for the dependency graph.
167
+
168
+ ```python
169
+ from cascade import export_dot, export_mermaid
170
+
171
+ graph = engine.inspect_graph()
172
+ # Generate Graphviz DOT format
173
+ dot_text = export_dot(graph)
174
+ # Generate Mermaid flowchart format
175
+ mermaid_text = export_mermaid(graph)
176
+ ```
177
+
178
+ ---
179
+
180
+ ## Limitations
181
+
182
+ 1. **Cycle Detection:** Cascade detects and rejects recursive function calls (cycles) with a `CycleError`.
183
+ 2. **Thread Safety:** While Cascade supports parallel query execution, the `Engine` object itself should be modified (`.set()`, `@engine.query`) from a single thread or with external synchronization.
184
+ 3. **Persistence Security:** `engine.load()` and the persistent disk cache resolve `@dataclass` and `NamedTuple` types via `importlib`. Only load databases or open cache directories from trusted sources.
185
+ 4. **Python Version:** Optimization for parallel CPU-bound work requires **CPython 3.14+ free-threaded** builds with `PYTHON_GIL=0`.
186
+
187
+ ---
188
+
189
+ ## Installation
190
+
191
+ ```bash
192
+ pip install query-cascade
193
+ ```
194
+
195
+ ## Examples (in `examples/`)
196
+
197
+ | Script | What it shows |
198
+ |--------|----------------|
199
+ | `compiler_pipeline.py` | `source → parse → symbols → typecheck`, warnings accumulator, cache-hit narration |
200
+ | `dynamic_macro_expansion.py` | Query that **changes downstream dependencies** at runtime |
201
+ | `snapshot_isolation.py` | Snapshot reads while live inputs change |
202
+ | `concurrent_background_work.py` | Dedup under concurrency + cancellation after input changes |
203
+ | `compute_many_with_accumulators.py` | `compute_many(..., effects=...)` accumulator collection |
204
+ | `persistence_and_inspection.py` | Save/load and graph summaries |
205
+ | `gil_parallel_speedup.py` | Threaded CPU benchmark: GIL vs free-threaded |
206
+
207
+ Run one:
208
+
209
+ ```bash
210
+ python3.14t examples/compiler_pipeline.py
211
+ ```
212
+
213
+ Run all (Unix-style shell):
214
+
215
+ ```bash
216
+ for example in examples/*.py; do
217
+ echo "Running $example"
218
+ python3.14t "$example"
219
+ done
220
+ ```
221
+
222
+ Examples print narration as they run so you can follow each behavior.
223
+
224
+ ### Compare GIL vs free-threaded (same machine)
225
+
226
+ Install both **3.14** and **3.14t** if you want apples-to-apples. On Ubuntu (deadsnakes):
227
+
228
+ ```bash
229
+ sudo add-apt-repository ppa:deadsnakes/ppa
230
+ sudo apt update
231
+ sudo apt install -y python3.14 python3.14-venv python3.14t python3.14t-venv
232
+ python3.14 -m pip install -e .
233
+ python3.14t -m pip install -e .
234
+ ```
235
+
236
+ Quick check on free-threaded build:
237
+
238
+ ```bash
239
+ python3.14t -c "import sys, sysconfig; print('Py_GIL_DISABLED=', sysconfig.get_config_var('Py_GIL_DISABLED')); print('GIL enabled?', sys._is_gil_enabled())"
240
+ ```
241
+
242
+ Same interpreter, toggle GIL at runtime:
243
+
244
+ ```bash
245
+ PYTHON_GIL=1 python3.14t examples/gil_parallel_speedup.py --workers 8 --tasks 96 --rounds 300000 --repeats 5
246
+ PYTHON_GIL=0 python3.14t examples/gil_parallel_speedup.py --workers 8 --tasks 96 --rounds 300000 --repeats 5
247
+ ```
248
+
249
+ Or compare `python3.14` vs `PYTHON_GIL=0 python3.14t` on the same script.
250
+
251
+ Compare **`median parallel seconds`** (lower is better) and **`threaded speedup in this runtime`** (higher is better). Keep args identical, reduce background load, and use `--repeats` (e.g. `5`) to smooth noise. On multi-core machines, **free-threaded + GIL off** usually wins clearly for this CPU-bound demo.
252
+
253
+ ---
254
+
255
+ ## Design stance
256
+
257
+ The core is intentionally minimal: **pull-based** evaluation, dependency capture, red/green style bailout, dedup, snapshots, cancellation, accumulator replay, tracing, and persistence. That set is enough for many real pipelines without baking in advanced internals (e.g. fixed-point cycle solving or custom AST red/green structures). CPU-bound parallelism is expected to matter when you use **free-threaded CPython with the GIL disabled**.
258
+
259
+ ---
260
+
261
+ ## Development
262
+
263
+ ### Tests (match main CI)
264
+
265
+ ```bash
266
+ export PYTHON_GIL=0 # Windows: set PYTHON_GIL=0
267
+ python3.14t -m pip install -e ".[dev]"
268
+ python3.14t -c "import sys, sysconfig; print('Py_GIL_DISABLED=', sysconfig.get_config_var('Py_GIL_DISABLED')); print('GIL enabled?', sys._is_gil_enabled())"
269
+ python3.14t -m pytest -q \
270
+ --ignore=tests/test_performance.py \
271
+ --cov=src/cascade \
272
+ --cov-branch \
273
+ --cov-report=term-missing \
274
+ --cov-fail-under=95
275
+ ```
276
+
277
+ Branch coverage check (CI uses an equivalent step on `coverage.json`):
278
+
279
+ ```bash
280
+ python3.14t - <<'PY'
281
+ import json
282
+ with open("coverage.json", encoding="utf-8") as fh:
283
+ b = json.load(fh)["totals"]["percent_branches_covered"]
284
+ print(f"branch coverage: {b:.2f}%")
285
+ assert b >= 90.0
286
+ PY
287
+ ```
288
+
289
+ Stateful fuzz:
290
+
291
+ ```bash
292
+ PYTHON_GIL=0 python3.14t -m pytest -q tests/test_stateful_engine_invariants.py
293
+ ```
294
+
295
+ Mutation testing:
296
+
297
+ ```bash
298
+ PATH="$HOME/.local/bin:$PATH" PYTHON_GIL=0 mutmut run
299
+ PATH="$HOME/.local/bin:$PATH" PYTHON_GIL=0 mutmut results
300
+ ```
301
+
302
+ Use the `mutmut` CLI (`mutmut run`), not `python -m mutmut run`. Bounded local loop:
303
+
304
+ ```bash
305
+ PYTHON_GIL=0 MUTMUT_MAX_CHILDREN=2 ./scripts/mutation_fast.sh
306
+ ```
307
+
308
+ Focused mutants:
309
+
310
+ ```bash
311
+ PYTHON_GIL=0 MUTMUT_MAX_CHILDREN=2 ./scripts/mutation_fast.sh "<mutant-name>" "<mutant-name>"
312
+ ```
313
+
314
+ See `docs/mutation_triage.md` for survivor triage.
315
+
316
+ ### Formal model (TLA+)
317
+
318
+ Specs live under `docs/formal/`:
319
+
320
+ - `docs/formal/cascade_core.tla`
321
+ - `docs/formal/cascade_core.cfg`
322
+
323
+ Run TLC (example):
324
+
325
+ ```bash
326
+ java -cp tla2tools.jar tlc2.TLC docs/formal/cascade_core.tla -config docs/formal/cascade_core.cfg
327
+ ```
328
+
329
+ Checked properties include snapshot consistency, active-dependency validity (red/green alignment), and cancellation epoch monotonicity.
330
+
331
+ ### Performance suite
332
+
333
+ Heavy behavior clusters around cache hits vs full recompute, concurrent dedup, `compute_many` throughput on free-threaded workloads, large-graph mutation vs rebuild, mark-green cost vs depth, and prune scaling.
334
+
335
+ ```bash
336
+ python -m benchmarks.performance_suite --report-dir artifacts/performance --assert-thresholds
337
+ ```
338
+
339
+ Outputs:
340
+
341
+ - `artifacts/performance/performance-report.json`
342
+ - `artifacts/performance/performance-report.md`
343
+
344
+ CI runs the same suite and uploads **`performance-report`**.
345
+
346
+ The **`compute-many-parallel-speedup`** scenario (and `tests/test_performance.py::test_compute_many_parallel_speedup_scenario`) is sensitive to CPU scheduling. On a busy laptop or small VM, thresholds may flap without a real regression. Mitigations:
347
+
348
+ - Re-run the test, or set **`CASCADE_QUERY_PARALLEL_PERF_RETRIES`** (e.g. `3`).
349
+ - To skip while iterating: **`CASCADE_QUERY_SKIP_PARALLEL_PERF=1`** (CI does not set this).
350
+
351
+ **Nightly:** `.github/workflows/nightly-performance.yml` runs a longer sweep (e.g. 8 runs) and publishes **`nightly-performance-report`**.
352
+
353
+ ### Scale and stress tests
354
+
355
+ `tests/test_scale_behavior.py` covers large-graph invalidation, dynamic dependency churn, prune stress, persistence at scale, eviction under churn, and mixed concurrency (`submit` + `compute_many` + writes). The heaviest cases are marked **`@pytest.mark.slow`**; default `pytest` skips them via `pyproject.toml`.
356
+
357
+ Internal invariants are concentrated in `tests/test_internal_invariants.py` (via `engine._internals`) to limit coupling while keeping safety checks.
358
+
359
+ Default CI-like run (no perf file, no slow):
360
+
361
+ ```bash
362
+ PYTHON_GIL=0 python3.14t -m pytest -q --ignore=tests/test_performance.py
363
+ ```
364
+
365
+ Slow only:
366
+
367
+ ```bash
368
+ pytest -q -m slow
369
+ ```
370
+
371
+ Everything including slow:
372
+
373
+ ```bash
374
+ pytest -q -m "slow or not slow"
375
+ ```
376
+
377
+ ### CI overview
378
+
379
+ - Workflow: `.github/workflows/ci.yml` (pushes and PRs).
380
+ - **Ruff** before tests.
381
+ - Separate **package build** (`python -m build`) to catch packaging issues early.