Skip to main content

Minimal demand-driven query framework for incremental computation.

Project description

Cascade Query

PyPI version Python versions Distribution format

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 (free-threaded Python)

The library targets free-threaded CPython 3.14 so CPU-bound parallel work can scale without fighting the GIL. The package itself is pure Python; what matters is the interpreter you run with.

Windows

  1. 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.
  2. Install from PyPI:

py -3.14t -m pip install -U query-cascade
  1. 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_entries bounds memoized query nodes (LRU-style eviction when over capacity). trace_limit caps the number of TraceEvent records retained.

Properties and methods:

  • revision (read-only int) — Monotonic counter bumped on each successful input publish; also carried on Snapshot.
  • input(fn) → InputHandle — Register fn as 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 — Register fn as a memoized query. The engine records which inputs and other queries run during each evaluation. Re-entrancy that forms a cycle raises CycleError.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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).
  • traces() → list[TraceEvent] — Copy of recent trace events (subject to trace_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), edges as (parent_key, dep_key) pairs for recorded dependencies.
  • prune(roots) — Remove memoized query nodes not reachable from roots, following dependency edges backward. Each root is a QueryKey: ("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 from path. 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 for importlib resolution—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 to snapshot).
  • handle.set(*args, value) — Publish a new value for key args. Variants: handle.set(value=x) when the input has no key parameters, or handle.set(key_arg, …, value=y) using the keyword. The last positional argument may also be the value when value is omitted (see tests for the exact set shapes). Returns the new engine.revision after the write.
  • handle.id (property, str) — Stable string id (module:qualname) used in persistence and prune keys.

Query handles

Returned by engine.query:

  • handle(*args, *, snapshot=None, effects=None) → Any — Run or reuse the memoized query. When effects is a dict, accumulator push calls append to effects[accumulator_name] for this evaluation (including replayed effects on cache hits).
  • handle.id (property, str) — Use in prune([("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 in effects dicts.
  • push(item) — Record item for the current query evaluation. Must be called from inside a running query; otherwise raises RuntimeError. 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 using snapshot= 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 reading Future.result() from submit after inputs changed.
  • CancellationError — Base type for QueryCancelled; 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 / load are point-in-time snapshots, not a multi-process transactional store with WAL semantics.
  • Trust load like code: Snapshots use versioned JSON; types are resolved via importlib. Only load files from sources you trust (similar caution to pickle or “data that names types”).
  • Side effects: Only effects sent through Accumulator are 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:

  1. Can you express the logic as inputs → derived queries (A → B → C)?
  2. Are derived values mostly pure functions of tracked inputs (side effects routed through accumulators)?
  3. Do you ask the same queries repeatedly?
  4. Do inputs usually change in small increments rather than full replacement every time?
  5. Is a full recompute noticeably expensive?
  6. Do you need precise invalidation (only affected downstream nodes)?
  7. Do concurrent callers often request the same keys?
  8. Do some readers need a stable snapshot while writes continue?
  9. Can background work become waste when new writes land?
  10. 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.tla
  • docs/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.json
  • artifacts/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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

query_cascade-0.1.32.tar.gz (50.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

query_cascade-0.1.32-py3-none-any.whl (25.2 kB view details)

Uploaded Python 3

File details

Details for the file query_cascade-0.1.32.tar.gz.

File metadata

  • Download URL: query_cascade-0.1.32.tar.gz
  • Upload date:
  • Size: 50.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for query_cascade-0.1.32.tar.gz
Algorithm Hash digest
SHA256 9dd5ab984a893b7dca0afee2d87d05ac20c4387724113efe2e5ff62bbc40f1db
MD5 0d49cb3032f4dc43cb6c1c893c93d18e
BLAKE2b-256 6f0a125504b0ea6d17aee39b61383354445fa195f88b6319e93b2ca036c59054

See more details on using hashes here.

Provenance

The following attestation bundles were made for query_cascade-0.1.32.tar.gz:

Publisher: workflow.yml on hmatt1/cascade-query

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file query_cascade-0.1.32-py3-none-any.whl.

File metadata

  • Download URL: query_cascade-0.1.32-py3-none-any.whl
  • Upload date:
  • Size: 25.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for query_cascade-0.1.32-py3-none-any.whl
Algorithm Hash digest
SHA256 e58e7c45e73a6f6ffd8f309a210e7ec0c1b3dcc12279ee9982f49afd12bddb32
MD5 438a1be6d8ce786f45010b99cd302677
BLAKE2b-256 f57e81a37b7c4691618b1363078890c0d641e04eb3d8533f9c2d5f683ff86ef3

See more details on using hashes here.

Provenance

The following attestation bundles were made for query_cascade-0.1.32-py3-none-any.whl:

Publisher: workflow.yml on hmatt1/cascade-query

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page