Source: context/adr/009-incremental-evaluation.md

On this page

ADR 009 — Incremental evaluation: an in-house DBSP core, plugged in behind a trait

Status: accepted. · Ticket: improvise-felo.4 (the record of this ADR; implementation goes to its own epic) · Relates to: ADR 001 (the basis a write claims), ADR 002 (surrogates on the wire), ADR 003, ADR 005, ADR 006, ADR 007, ADR 008

#Problem

Every change recomputes every formula cell (Model::recompute_formulas, called by rebuild_layout after each batch), and raw aggregates are re-folded on every read. That does not scale to a live source (ADR 003) streaming changes into a large table, nor to ADR 008's virtualized grid, which needs occupancy, totals and widths maintained without building the grid. A change should cost in proportion to what it touches.

#Decision

Formulas are evaluated incrementally by an in-house implementation of the DBSP core. A cell write becomes a delta, aggregates are maintained, and only the formula cells whose inputs moved are re-evaluated by the existing evaluator. The incremental results must equal today's eager results exactly.

Principle: everything is as incremental as possible, so the work done per model mutation is minimised. Where this ADR does work that grows with more than the mutation, it says so and names the incremental path that normally applies.

#1. The zset crate

A problem-agnostic workspace crate with no dependencies beyond std (so it is wasm32-clean and serde-free). It provides:

Operators are self-contained. An operator's state and behaviour depend only on its inputs and its own state, never on a shared in-process global or on another operator's internals. That keeps the graph distributable: in principle the model could run across a cluster of machines. Nothing more is designed for that here.

There are no nested streams, no recursion operator and no timestamps beyond one clock. The formula language is the query language. What a request asks of the model beyond its axes and window (filters, aggregation, derived values) is written in formula text over ids (ADR 007). It goes through the same evaluator and circuit as a document formula, so there is no second query language.

#2. The plug

improvise-core gains a trait, Derived, for the two things the evaluator reads: the formula result at a key, and the raw fold of the cells that match a partial key. The incremental backend implements it in a seam crate, improvise-incremental, which depends on zset and improvise-core. improvise-core never depends on zset.

A live source (ADR 003) that reports its changes feeds cell deltas like DataStore. A source that answers aggregate queries but reports no changes implements the derived-value operator directly, by query pushdown, with no change feed.

The circuit decides which formula cells to re-evaluate. The one existing evaluator computes each value, so formulas are never compiled into operators.

Eager is temporary. Today's code moves behind the trait as Eager, unchanged. It exists only to show discrepancies between the new evaluator and the old one: a differential property test applies one random edit sequence to both backends and compares every drawn cell, total and occupancy count. Eventually it is deleted, and nothing depends on it surviving. Long-term tests compare against exact references, such as ExactSum against rational sums, not against Eager.

#3. The cell delta

DataStore::set and DataStore::remove emit a ZSet<(CellKey, CellValue)>:

Values compare by normal float rules, except that every NaN is one canonical value: -0.0 equals +0.0, and an equal-value overwrite consolidates to nothing. NaN payloads are not preserved, so nothing can be smuggled through them. Today's record tier compares bits, so overwriting 0.0 with -0.0 stops counting as a change. Multisets order values by f64::total_cmp after mapping every NaN to the canonical one and -0.0 to +0.0. CellKey gains Ord, which is its canonical order: the natural order of ids (ADR 007 amendment 1, clause 4), the order eager folds in. The circuit steps once per model effect.

The record tier and the dirty flag read this delta instead of scanning the store. The dirty flag fires on a non-empty (key, value) delta, or when a fingerprint of the formula set moves, because formula edits move no cell.

Under ADR 007 and its amendment, CellKey pairs are (CategoryId, ItemHandle). So a rename is a display write and emits no delta. The formula-set fingerprint must use the id form too, or a rename reads as a formula change.

#4. Aggregates

A group is the cell key minus the None categories. One aggregation circuit serves every shape key with the same None list, which removes the per-session model clone.

aggregatestate per groupper delta element
Countcount of values, count of numbersO(1)
Sum, Avgan exact accumulator (clause 8), and the countO(1)
Min, Max, text unanimitya multiset of valuesO(log d)

A group is read with eager's rules, including NaN and signed zero. LAST/FIRST and semi-additive measures (improvise-felo.7, felo.8) reuse the multiset shape, keyed by the ordering category's order. Those tickets choose the key. It must keep a reorder or an insert incremental: only the entries under the moved items change, and nothing is rebuilt.

#5. Formulas

Stems are distinct over the delta, keyed by "cell key minus the target and None categories". A formula counts at a stem where its top-level WHERE matches or raw data exists at the target.

Inputs. A formula cell is re-evaluated when one of its inputs moved:

The affected pairs are evaluated in the formula graph's topological order.

An aggregate over an expression (SUM(Revenue * Price)) is a pipeline:

  1. A per-record stage retracts and reinserts each touched record. It reads the record's previous cells as the store minus the delta, so it keeps no copy of the values. The store is the integrate operator's trace over the cell delta, read as an input of the stage, so the stage stays self-contained.
  2. A map produces (context, order key, expr). The order key is (context rank, record key in CellKey order), which is eager's concatenation order.
  3. The group aggregate, with an error channel that reads the first Err in order.

No cross product is built. A formula whose expression reads other records is evaluated over the occupied contexts only, and Eager uses the same enumeration. The answer is unchanged, because a context with no records contributes nothing. One exception: an inner bare ref that names no item errors at every context, so for it the ref is checked before enumerating. This replaces Expansion::members.

#6. Circular references

DBSP's least fixed point is not improvise's circularity: X = X + 1 has none, and the answer must be Error("circular"). The formula graph's strongly connected components are maintained incrementally as formulas change. Acyclic formulas are evaluated once each, in topological order. A cyclic component is maintained incrementally too: a change re-iterates only the cells it reaches. A component that does not converge within the existing bound of sixteen passes (MAX_EVAL_DEPTH) propagates Error("circular"), as today. An optimisation that speeds convergence is allowed only where it is exact: it gives the same result as the plain iteration, and a test compares the two.

#7. Operator state follows the request's shape key

A projection request's shape key selects operator state, shared by every request with the same key, from any client. The key is:

The key uses the canonical parsed id form of the request's query text, so two spellings of one query share their operator state. Everything else in a request is applied when output is read: hidden and collapsed items on axis categories, item order, the page selection and prune. Occupancy and column aggregates are keyed by axis items, so a collapse, hide, show, reorder or added item changes no operator. The visible axis is a remap.

Column totals and hidden rows. Totals are kept over all rows, because the shape key is shared and a hide is one client's. Whether hidden items travel in a request is ADR 008's open question. If they do not, a total is read as it is kept. If they do, a total must leave the hidden rows out, so a read subtracts the request's H hidden cells as a temporary overlay:

That is O(H log d) per read for every aggregate, with no view-store state in the model store. Aggregate-over-expression totals index their entries by row tuple for the same subtraction.

#8. Exact sums

An f64 sum maintained by +v and −v drifts. Measured by crates/improvise-core/examples/retraction_drift.rs, at 18,000 cells after a million cycles, it is 1.7e-9 from eager, past FLOAT_EQ_EPSILON, so = could answer differently on the two paths. So every Sum and Avg uses ExactSum, in both backends:

The sign of a zero sum follows IEEE rules for the same addends: it is -0.0 only if every addend is -0.0, and an exact cancellation gives +0.0. It lives in ADR 005's numtower crate. User-visible change: displayed sums change in their last bits, because they become correctly rounded.

#9. Delivery

The model store (the document model and the DBSP graph) and the view store (the cursor, the window and unsaved view definitions) are conceptual boxes, not a network boundary. Where a piece of state physically lives (a client, the host or a worker) is an implementation detail. The model store's decisions depend only on the model. A server-side twin of a client's state, such as its filter, watermark or window, may be held at the serving end of an edge where that makes the graph genuinely simpler. It is then part of the edge, not of the model store, and it is disposable: losing it never gives wrong data. A client that keeps its own watermark recovers with a correction from it. A client that relied on the twin for its watermark recovers with a full refetch.

Every edge may cross a network. An edge, meaning the stream from one operator to the next, may be in-process or remote. No edge assumes a function call or shared memory. An operator that sits at a wire boundary is just one implementation of an edge, an edge adapter. So every edge has the same semantics:

Aggregation stays on the model side (ADR 008), so the operators that aggregate run on the model side. Which other edges cross a wire is a deployment choice. The engine's invalidation signal, which ADR 006's replay trigger relies on, is a correction trigger below, followed from a displayed cell's key back to the model.

A request is a shape key plus a window, given as an absolute cross-product top-left and dimensions. The window is view-store state, and a request costs O(visible).

Pruning support. Pruning is window-relative: a row or column empty within the displayed values is hidden. This replaces today's whole-grid prune_empty. The model answers one restricted successor query: "the next k rows after position p with a value in a column of S", and the same for columns. A per-column sorted index of occupied rows answers it in O(|S| log n + k|S|), holding O(occupied cells). Whether pruning runs on the client and which parameters travel are ADR 008's questions, and this design supports either answer. If the client prunes, it prunes its window, fills forward with that query, and, when scrolling up or left, finds a new top-left by galloping backwards in doubling jumps (about 14 to 28 probes at density 10⁻⁴).

The change notice. Each command commits on its own (ADR 006), and after each committed command the host emits one notice, addressed to no client. A frame whose later command fails keeps its earlier commands, and their notices, published. A notice carries:

A client receives only what it needs. Filtering is part of the DBSP graph, not a separate mechanism. A client either pulls just the state it needs, or pushes a filter to the host's side of its connection: a filter operator on its shape's query, written in the query language, through which its notices pass. Whether the filter also carries the client's window is an optimisation, since the model store's decisions depend only on the model. A pushed filter is held by the open connection, or by a twin. Otherwise the client sends it with each request. While a connection's filter is open, it counts as a request naming its shape, so its shape is not evicted.

A notice whose key lists exceed a fixed size carries "correct" instead. That is a fallback, and it costs every reader a correction.

What a client does. It intersects the moved keys with its window. A fold row has no maintained key, so it maps member keys through its own remap first. It then fetches what moved. It asks for a correction when it sees a publication gap, a new document_generation or host instance, its shape in the eviction list, a notice it cannot read, or a dropped connection or failed request. A slow client may merge pending notices: the union of keys, the covered range of publications, and axis changes kept in order. Because the wire is lossless, outputs keep the circuit's value identity end to end.

What this replaces. Today's per-session mirrors and pushed diffs (Sessions, ClientSession, grid_updates, Projection, Dirty). The model store keeps no view-store state, only the shared notice log. GridExtent is derived by the client, and in records mode the record count is fetched. Projection also published the ADR 001 basis a write claims. Whether project returns it instead, and what replaces the layout tier, are ADR 008's questions.

Edge adapters. An edge that crosses a wire has one adapter at each end. Toward the circuit, the client's adapter is a sink for results and notices and a source of requests. Toward the client it is the mirror image. The client's end keeps the watermark. In process, an edge is a function call.

#10. Column width hints

Every column incrementally maintains a width hint, which optimises grid sizing and reduces jitter. The hint is an integrated aggregate: a histogram from display width to count, so a retraction is O(log d). Where in the graph the hint is computed is a scheduling choice, made as an optimisation later.

#11. Rollback and memory

#Alternatives rejected

#Consequences

Good:

Bad:

Order (each step is a ticket in the implementation epic, behind the differential test):

  1. zset;
  2. the Derived trait with Eager;
  3. the cell delta, the record tier and the dirty flag;
  4. order-free aggregates and occupancy, measured at ADR 008's fixture;
  5. formulas and the aggregate-over-expression pipeline;
  6. Sum, Avg and totals through ExactSum;
  7. live sources;
  8. delivery.

Step 8's required tests:

#Open questions

None.