Minimal demand-driven query framework for incremental computation.
Project description
Cascade Query
PyPI: query-cascade
Cascade Query is a small Python library for incremental, on-demand computation. You write normal functions; the engine figures out what depends on what, caches results, and recomputes only what changed.
It fits the same mental space as a build system or an IDE’s analysis pipeline: lots of derived values, inputs that change in small steps, and a graph of steps you do not want to rerun from scratch every time.
What you get
| Capability | What it means in practice |
|---|---|
| Lazy evaluation | Nothing runs until something asks for a result. |
| Dependency tracking | The engine records which queries read which inputs and other queries. |
| Targeted invalidation | After a change, only downstream work that is still needed gets redone. |
| Deduplication | Identical in-flight requests share one computation. |
| Snapshots | Readers can pin a consistent view while inputs keep moving. |
| Background work | Submit work in the background; stale work can be cancelled safely. |
| Replayable side effects | Effects recorded through accumulators replay on cache hits so diagnostics stay consistent. |
| Save / load | Persist graph and cache state (SQLite-backed API below). |
| Inspection | Inspect the graph and trace events for debugging. |
Quickstart
from cascade import Engine
engine = Engine()
calls = {"lines": 0, "mentions": 0}
@engine.input
def doc() -> str:
return ""
@engine.input
def needle() -> str:
return "TODO"
@engine.query
def lines() -> tuple[str, ...]:
calls["lines"] += 1
return tuple(line.rstrip() for line in doc().splitlines() if line.strip())
@engine.query
def mentions() -> int:
calls["mentions"] += 1
n = needle().upper()
return sum(1 for line in lines() if n in line.upper())
@engine.query
def report() -> str:
return f"{len(lines())} lines, {mentions()} matching {needle()}"
needle.set("TODO")
doc.set("# Notes\n\nShip the feature.\n\nTODO: add tests")
assert report() == "3 lines, 1 matching TODO"
assert calls == {"lines": 1, "mentions": 1}
# Only `needle` changed — `lines()` stays cached; `mentions()` reruns.
needle.set("SHIP")
assert report() == "3 lines, 1 matching SHIP"
assert calls == {"lines": 1, "mentions": 2}
# `doc` changed — `lines()` runs again, then downstream queries refresh.
doc.set("# Notes\n\nShip the feature.\n\nNothing to find")
assert report() == "3 lines, 1 matching SHIP"
assert calls == {"lines": 2, "mentions": 3}
# Same revision as before — nothing is recomputed.
doc.set("# Notes\n\nShip the feature.\n\nNothing to find")
assert report() == "3 lines, 1 matching SHIP"
assert calls == {"lines": 2, "mentions": 3}
Install
The package metadata allows CPython 3.12 and newer (requires-python >= 3.12). The library is pure Python, so pip installs the same wheel everywhere. For correctness and ordinary use, any supported interpreter is fine.
Parallel CPU work: best results still come from free-threaded CPython 3.14 with the GIL disabled at runtime (see below). On 3.12/3.13 with the standard GIL, threaded speedups for CPU-heavy graphs may be limited even though the API behaves the same.
Standard Python (3.12+)
python -m pip install -U query-cascade
Use the same command with your python/py launcher for 3.12 or any newer version you rely on.
Install (free-threaded Python 3.14, recommended for parallel CPU work)
The library is optimized for free-threaded CPython 3.14 so CPU-bound parallel work can scale without fighting the GIL. What matters for throughput is the interpreter you run with, not how the wheel was built.
Windows
-
Install free-threaded Python 3.14 from python.org/downloads/windows
- Look for the build that includes free-threaded (
python3.14t/py -3.14t). - In the full installer, enable the free-threaded binaries option if needed.
- Look for the build that includes free-threaded (
-
Install from PyPI:
py -3.14t -m pip install -U query-cascade
- Confirm import and GIL-off mode (example):
py -3.14t -X gil=0 -c "import cascade, sys, sysconfig; print('cascade import ok from', cascade.__file__); print('Py_GIL_DISABLED=', sysconfig.get_config_var('Py_GIL_DISABLED')); print('GIL enabled?', sys._is_gil_enabled())"
You want to see Py_GIL_DISABLED= 1 and GIL enabled? False when exercising the free-threaded + no-GIL path.
Editable install (from this repo)
python3.14t -m pip install -e ".[dev]"
API reference
Public symbols (all importable from cascade):
| Name | Role |
|---|---|
Engine |
Graph engine: registration, evaluation, persistence, tracing. |
Accumulator |
Named channel for side effects recorded during queries and replayed on cache hits. |
Snapshot |
Immutable handle pinning a global revision for consistent reads. |
TraceEvent |
One record from the in-memory trace log. |
CycleError |
Raised when a query cycle is detected during evaluation. |
QueryCancelled |
Raised when background work is invalidated by newer input revisions (subclass of CancellationError). |
CancellationError |
Base class for cancellation-style failures. |
from cascade import (
Accumulator,
CancellationError,
CycleError,
Engine,
QueryCancelled,
Snapshot,
TraceEvent,
)
Example
from cascade import Engine
engine = Engine()
warnings = engine.accumulator("warnings")
@engine.input
def source(file_id: str) -> str:
return ""
@engine.query
def parse(file_id: str) -> tuple[str, ...]:
return tuple(line.strip() for line in source(file_id).splitlines() if line.strip())
@engine.query
def symbols(file_id: str) -> tuple[str, ...]:
return tuple(row.split("=")[0].strip() for row in parse(file_id))
Engine
Constructor:
Engine(*, max_entries: int = 10_000, trace_limit: int = 50_000)—max_entriesbounds memoized query nodes (LRU-style eviction when over capacity).trace_limitcaps the number ofTraceEventrecords retained.
Properties and methods:
revision(read-onlyint) — Monotonic counter bumped on each successful input publish; also carried onSnapshot.input(fn) → InputHandle— Registerfnas a mutable root. The decorated callable’s parameters are the key (see Input handles); its return value is only used as the initial value before the first.set. Dependencies from queries are tracked automatically when the body calls the handle.query(fn) → QueryHandle— Registerfnas a memoized query. The engine records which inputs and other queries run during each evaluation. Re-entrancy that forms a cycle raisesCycleError.accumulator(name: str) → Accumulator— Return a namedAccumulator(see below). Names are keys in the optionaleffectsmap passed to synchronousQueryHandlecalls andsubmit.snapshot() → Snapshot— CaptureSnapshot(revision=engine.revision)for use assnapshot=on reads. Reads through that snapshot see input and memo state as of that revision; livesetcalls do not disturb snapshot reads.submit(query, *args, *, snapshot=None, effects=None, executor=None) → concurrent.futures.Future— Runquery(*args)onexecutor, or on a lazily created per-engineThreadPoolExecutorwhenexecutorisNone. The engine capturescancel_epochat schedule time; if inputs move on before the task runs, the future may complete withQueryCancelled.snapshotdefaults to a freshsnapshot()at submit time when omitted.compute_many(calls, *, workers=None, snapshot=None) → list[Any]— Run many queries in parallel with a work-stealing scheduler.callsis a sequence of(query_handle, args_tuple). Result order matchescalls.workersdefaults to a sensible value fromlen(calls)(capped);workers=0falls back to that same default.snapshotdefaults tosnapshot()when omitted. There is noeffectsparameter on this path—useQueryHandle.__call__orsubmitif you need to collect accumulator output in a dict.shutdown(*, wait: bool = True, cancel_futures: bool = False)— Shut down the default thread pool created bysubmit, if any. Call when discarding the engine if you need threads torn down promptly (for example in tests).traces() → list[TraceEvent]— Copy of recent trace events (subject totrace_limit).clear_traces()— Drop all buffered trace events.inspect_graph() → dict[str, Any]— Under the store lock, summarize memoized queries:revision,memo_count,input_count,nodes(string keys for memo entries),edgesas(parent_key, dep_key)pairs for recorded dependencies.prune(roots)— Remove memoized query nodes not reachable fromroots, following dependency edges backward. Each root is aQueryKey:("query", query_handle.id, args_tuple)(the same shape the engine uses internally). Unknown roots are ignored safely.save(path: str)— Persist graph state (inputs, memos, dependents, trace buffer metadata, revision counters) into a SQLite file via a versioned JSON payload.load(path: str)— Restore frompath. If the table is empty or missing payload data, returns without error. Clears in-flight futures. Only load snapshots from trusted sources (payloads name types forimportlibresolution—see Limitations).
Input handles
Returned by engine.input. Treat as the callable you defined:
handle(*args, *, snapshot=None) → Any— Read the current value for that input key (optionally pinned tosnapshot).handle.set(*args, value)— Publish a new value for keyargs. Variants:handle.set(value=x)when the input has no key parameters, orhandle.set(key_arg, …, value=y)using the keyword. The last positional argument may also be the value whenvalueis omitted (see tests for the exactsetshapes). Returns the newengine.revisionafter the write.handle.id(property,str) — Stable string id (module:qualname) used in persistence andprunekeys.
Query handles
Returned by engine.query:
handle(*args, *, snapshot=None, effects=None) → Any— Run or reuse the memoized query. Wheneffectsis adict, accumulatorpushcalls append toeffects[accumulator_name]for this evaluation (including replayed effects on cache hits).handle.id(property,str) — Use inprune([("query", handle.id, args_tuple), …]).handle.raw(property) — The original decorated callable (for advanced use).
Accumulator
name(attribute,str) — Channel name used as the key ineffectsdicts.push(item)— Recorditemfor the current query evaluation. Must be called from inside a running query; otherwise raisesRuntimeError. On a memo hit, stored effects are replayed so diagnostics stay stable without re-executing the query body.
Snapshot
Frozen dataclass:
revision: int— Global revision pinned for reads usingsnapshot=on inputs and queries.
TraceEvent
Frozen dataclass (see traces()):
event: str,key: str,revision: int,detail: str,timestamp: float.
Exceptions
CycleError— Dynamic dependency graph formed a cycle; no fixed-point solver is applied.QueryCancelled— Scheduled or running work was superseded; typical when readingFuture.result()fromsubmitafter inputs changed.CancellationError— Base type forQueryCancelled; you can catch it if you treat all cancellation the same.
Persistence and inspection (quick usage)
engine.save("state.db")
engine.load("state.db")
print(engine.inspect_graph())
for event in engine.traces():
print(event.event, event.key, event.detail)
Guarantees (the short version)
- Incremental updates: Stale paths recompute; work that is still valid is reused.
- Selective invalidation: When a child signals “unchanged,” parents can stay valid without redoing everything above (the engine’s red/green style early bailout).
- One compute, many waiters: Concurrent identical requests share a single in-flight run.
- Cycles: Recursive query cycles raise
CycleError(there is no fixed-point solver in core). - Stale background work: Obsolete background queries raise
QueryCancelled.
Limitations (read before you bet the farm)
- Interpreter: Best results for parallel CPU work come from free-threaded 3.14 with the GIL disabled at runtime. Other interpreters work for correctness, but threaded speedups may disappoint. Building or publishing the package from a non-free-threaded Python does not block users on free-threaded Python—there is no native extension ABI tied to GIL mode.
- Persistence:
save/loadare point-in-time snapshots, not a multi-process transactional store with WAL semantics. - Trust
loadlike code: Snapshots use versioned JSON; types are resolved viaimportlib. Only load files from sources you trust (similar caution to pickle or “data that names types”). - Side effects: Only effects sent through
Accumulatorare replayed. Prints, network I/O, or writes inside query bodies are not magically replayed. - Cycles: Dynamic cycles are detected and rejected; cyclic dataflow is not solved to a fixed point inside this library.
Deliberately out of scope (for a small core)
- Nominal interning (
@interned), tracked structs (@tracked) - Fixed-point solvers for cyclic graphs
- Distributed or shared cache protocols
You can add these on top of the same query model if you need them.
Is this a good fit? (checklist)
Strong fits look like graphs of mostly pure steps over inputs that change a little at a time, with expensive recomputation if you always recompute everything.
Answer Yes/No:
- Can you express the logic as inputs → derived queries (
A → B → C)? - Are derived values mostly pure functions of tracked inputs (side effects routed through accumulators)?
- Do you ask the same queries repeatedly?
- Do inputs usually change in small increments rather than full replacement every time?
- Is a full recompute noticeably expensive?
- Do you need precise invalidation (only affected downstream nodes)?
- Do concurrent callers often request the same keys?
- Do some readers need a stable snapshot while writes continue?
- Can background work become waste when new writes land?
- Would graph/tracing help you debug cache hits and invalidation?
Rough read: 9–10 Yes → excellent · 7–8 → strong · 5–6 → try a spike · 0–4 → probably not this abstraction.
Problems that usually score high
- Incremental IDE analysis (parse, index, diagnostics, code actions)
- Monorepo “what tests ran” / impact planners
- Incremental static analysis or security scanning
- Compilers / DSL pipelines with live diagnostics and warning replay
- Policy-as-code over IaC (re-eval only what changed)
- Feature flags / entitlements from layered config
- Schema evolution impact
- Derived metrics / analytics compilers
- Build or asset graphs
- Rules engines with heavy duplicate concurrent requests
Examples (in examples/)
| Script | What it shows |
|---|---|
compiler_pipeline.py |
source → parse → symbols → typecheck, warnings accumulator, cache-hit narration |
dynamic_macro_expansion.py |
Query that changes downstream dependencies at runtime |
snapshot_isolation.py |
Snapshot reads while live inputs change |
concurrent_background_work.py |
Dedup under concurrency + cancellation after input changes |
persistence_and_inspection.py |
Save/load and graph summaries |
gil_parallel_speedup.py |
Threaded CPU benchmark: GIL vs free-threaded |
Run one:
python3.14t examples/compiler_pipeline.py
Run all (Unix-style shell):
for example in examples/*.py; do
echo "Running $example"
python3.14t "$example"
done
Examples print narration as they run so you can follow each behavior.
Compare GIL vs free-threaded (same machine)
Install both 3.14 and 3.14t if you want apples-to-apples. On Ubuntu (deadsnakes):
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt update
sudo apt install -y python3.14 python3.14-venv python3.14t python3.14t-venv
python3.14 -m pip install -e .
python3.14t -m pip install -e .
Quick check on free-threaded build:
python3.14t -c "import sys, sysconfig; print('Py_GIL_DISABLED=', sysconfig.get_config_var('Py_GIL_DISABLED')); print('GIL enabled?', sys._is_gil_enabled())"
Same interpreter, toggle GIL at runtime:
PYTHON_GIL=1 python3.14t examples/gil_parallel_speedup.py --workers 8 --tasks 96 --rounds 300000 --repeats 5
PYTHON_GIL=0 python3.14t examples/gil_parallel_speedup.py --workers 8 --tasks 96 --rounds 300000 --repeats 5
Or compare python3.14 vs PYTHON_GIL=0 python3.14t on the same script.
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.
Design stance
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.
Development
Tests (match main CI)
export PYTHON_GIL=0 # Windows: set PYTHON_GIL=0
python3.14t -m pip install -e ".[dev]"
python3.14t -c "import sys, sysconfig; print('Py_GIL_DISABLED=', sysconfig.get_config_var('Py_GIL_DISABLED')); print('GIL enabled?', sys._is_gil_enabled())"
python3.14t -m pytest -q \
--ignore=tests/test_performance.py \
--cov=src/cascade \
--cov-branch \
--cov-report=term-missing \
--cov-fail-under=95
Branch coverage check (CI uses an equivalent step on coverage.json):
python3.14t - <<'PY'
import json
with open("coverage.json", encoding="utf-8") as fh:
b = json.load(fh)["totals"]["percent_branches_covered"]
print(f"branch coverage: {b:.2f}%")
assert b >= 90.0
PY
Stateful fuzz:
PYTHON_GIL=0 python3.14t -m pytest -q tests/test_stateful_engine_invariants.py
Mutation testing:
PATH="$HOME/.local/bin:$PATH" PYTHON_GIL=0 mutmut run
PATH="$HOME/.local/bin:$PATH" PYTHON_GIL=0 mutmut results
Use the mutmut CLI (mutmut run), not python -m mutmut run. Bounded local loop:
PYTHON_GIL=0 MUTMUT_MAX_CHILDREN=2 ./scripts/mutation_fast.sh
Focused mutants:
PYTHON_GIL=0 MUTMUT_MAX_CHILDREN=2 ./scripts/mutation_fast.sh "<mutant-name>" "<mutant-name>"
See docs/mutation_triage.md for survivor triage.
Formal model (TLA+)
Specs live under docs/formal/:
docs/formal/cascade_core.tladocs/formal/cascade_core.cfg
Run TLC (example):
java -cp tla2tools.jar tlc2.TLC docs/formal/cascade_core.tla -config docs/formal/cascade_core.cfg
Checked properties include snapshot consistency, active-dependency validity (red/green alignment), and cancellation epoch monotonicity.
Performance suite
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.
python -m benchmarks.performance_suite --report-dir artifacts/performance --assert-thresholds
Outputs:
artifacts/performance/performance-report.jsonartifacts/performance/performance-report.md
CI runs the same suite and uploads performance-report.
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:
- Re-run the test, or set
CASCADE_QUERY_PARALLEL_PERF_RETRIES(e.g.3). - To skip while iterating:
CASCADE_QUERY_SKIP_PARALLEL_PERF=1(CI does not set this).
Nightly: .github/workflows/nightly-performance.yml runs a longer sweep (e.g. 8 runs) and publishes nightly-performance-report.
Scale and stress tests
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.
Internal invariants are concentrated in tests/test_internal_invariants.py (via engine._internals) to limit coupling while keeping safety checks.
Default CI-like run (no perf file, no slow):
PYTHON_GIL=0 python3.14t -m pytest -q --ignore=tests/test_performance.py
Slow only:
pytest -q -m slow
Everything including slow:
pytest -q -m "slow or not slow"
CI overview
- Workflow:
.github/workflows/ci.yml(pushes and PRs). - Ruff before tests.
- Separate package build (
python -m build) to catch packaging issues early.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file query_cascade-0.1.33.tar.gz.
File metadata
- Download URL: query_cascade-0.1.33.tar.gz
- Upload date:
- Size: 51.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b3171a06f838e330f126fe43f09c8c41e0ad97fdfbe8826247780348baee35e8
|
|
| MD5 |
3e66395eb97bd5dad7aff041ab1bf44f
|
|
| BLAKE2b-256 |
15373bc21eea27232a38981a26aae98d1fac3fea58e5863b117e495f83fca034
|
Provenance
The following attestation bundles were made for query_cascade-0.1.33.tar.gz:
Publisher:
workflow.yml on hmatt1/cascade-query
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
query_cascade-0.1.33.tar.gz -
Subject digest:
b3171a06f838e330f126fe43f09c8c41e0ad97fdfbe8826247780348baee35e8 - Sigstore transparency entry: 1268689023
- Sigstore integration time:
-
Permalink:
hmatt1/cascade-query@64c5cc5ba230de0b8ca7accb2456b555c19e243d -
Branch / Tag:
refs/heads/main - Owner: https://github.com/hmatt1
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
workflow.yml@64c5cc5ba230de0b8ca7accb2456b555c19e243d -
Trigger Event:
push
-
Statement type:
File details
Details for the file query_cascade-0.1.33-py3-none-any.whl.
File metadata
- Download URL: query_cascade-0.1.33-py3-none-any.whl
- Upload date:
- Size: 25.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4fe69ec96cd25e4c4e35ff7c44b4cf7b2ec5911a946c2d70adfde01a17954ce7
|
|
| MD5 |
dc3a4f9395d9b86d41f9fed6f551b52c
|
|
| BLAKE2b-256 |
a4a4cd29891ac562d1975c4e46bf622404375b4b2f6981fa37d325ed0b6dd31f
|
Provenance
The following attestation bundles were made for query_cascade-0.1.33-py3-none-any.whl:
Publisher:
workflow.yml on hmatt1/cascade-query
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
query_cascade-0.1.33-py3-none-any.whl -
Subject digest:
4fe69ec96cd25e4c4e35ff7c44b4cf7b2ec5911a946c2d70adfde01a17954ce7 - Sigstore transparency entry: 1268689098
- Sigstore integration time:
-
Permalink:
hmatt1/cascade-query@64c5cc5ba230de0b8ca7accb2456b555c19e243d -
Branch / Tag:
refs/heads/main - Owner: https://github.com/hmatt1
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
workflow.yml@64c5cc5ba230de0b8ca7accb2456b555c19e243d -
Trigger Event:
push
-
Statement type: