incr

Salsa-inspired incremental recomputation library with automatic dependency tracking, backdating, and durability-based verification skipping

incremental
salsa
memoization
reactive
dependency-tracking
backdating
durability
moon add dowdiness/incr@0.15.0
Download zip
Author
Version
0.15.0
License
Apache-2.0
Last updated
14 days ago
Downloads
25K

Dependencies

README

#incr

An incremental computation library for MoonBit.

When one input changes, incr recomputes only the values that actually depend on it — and skips everything else. You write ordinary straight-line code; the runtime figures out what is affected.

The mental picture is a spreadsheet: some cells hold values you type in, other cells hold formulas. Change one input cell and only the formulas that read it (directly or indirectly) update. incr gives your MoonBit program the same behavior for any computation — editor state, build pipelines, reactive app models, language tooling — without you writing any change-tracking code.

You can try exactly this picture live: the typed spreadsheet demo is built on incr. Edit one cell and its trace panels show which formulas recomputed, which didn't, and why.

#Quick Start

Two kinds of cells cover most programs:

  • Input[T] — a value you set directly (a spreadsheet cell you type into)
  • Derived[T] — a value computed from other cells (a formula cell); it caches its result and notices which cells it read, automatically

let rt = Runtime()

// Create inputs
let x = rt.input(10, label="x")
let y = rt.input(20, label="y")

// Combine inputs and chain further derived stages
let sum = x.derived2(y, (a, b) => a + b, label="sum")
let doubled = sum.map(v => v * 2, label="doubled")

// Outside the graph, read with `read_or_abort()` or `read()`.
// (`.get()` is only legal inside another derived computation —
// use `Derived(rt, () => ...)` when a stage reads several cells.)
inspect(sum.read_or_abort(), content="30")
inspect(doubled.read_or_abort(), content="60")

// Update an input — downstream derived values recompute on the next read
x.set(5)
inspect(doubled.read_or_abort(), content="50")

There is no subscription or dependency declaration anywhere in that code: sum knows it depends on x and y because it read them.

When a group of cells or long-lived reads shares a lifetime, construct cells through a Scope (scope.input(...), scope.derived(...)) and register watches with scope.add_watch(...) so one scope.dispose() tears the group down. See Getting Started.

Note on the example above: It is nocheck because the snippet omits imports and a test wrapper. The same construction is checked in docs/target_api_examples.mbt.md and exercised end-to-end by tests/quickstart_test.mbt — if you edit the example, update those in lockstep.

#Installation

Add incr to the import list of your moon.pkg.json:

{ "import": ["dowdiness/incr"] }

The core library packages use only moonbitlang/core; optional repository demos may declare extra dependencies such as Rabbita.

#How It Works, in Plain Words

  1. Writes are cheap. x.set(5) records "something changed" (a counter ticks up) and returns. Nothing recomputes yet.
  2. Reads check before they work. When you later read doubled, the runtime walks back through what doubled read last time and asks: did any of it actually change? Untouched branches are skipped without running any of your code.
  3. "Same answer" stops the ripple. If a recomputed value comes out equal to its previous value, everything downstream of it is also skipped. (The docs call this backdating — the value's "last changed" timestamp is kept in the past.)
  4. Dependencies can change between runs. If a formula reads different cells this time (say, an if took the other branch), the recorded dependencies are simply replaced. Nothing is declared up front.

Guarantees you can rely on:

  • Failed computations and cycle errors never corrupt the cache: the last successful result and its recorded dependencies stay authoritative.
  • Reads that can fail return Result (read() / get()); a dependency cycle or a disposed cell comes back as an Err you can handle, not a crash. The _or_abort variants abort instead, for when failure is a bug.
  • Caching is in-memory and per-process. Nothing persists across runs.

#Beyond Input and Derived

Most programs only need Input and Derived. The rest of the toolbox, from most to least commonly needed:

TypeWhat it is for
DerivedMap[K, V]One cached derived value per key (e.g. type_of(function_id)), created lazily on first read
InputField[T]One input cell per field of a struct, so changing one field doesn't disturb readers of the others
EagerDerived[T] / EffectValues/side effects that update immediately when inputs change (push style) — for UI-facing state
ReachableDerived[T]A lazy derived value that must stay alive across garbage collection while something downstream watches it
Accumulator[T]A side channel for log-like data (diagnostics, traces) emitted during computation
Relation / MapRelationDatalog-style facts and rules, computed to a fixed point

Also available on any runtime: batching (rt.batch groups several set calls into one atomic change, with rollback on error) and durability (mark rarely-changing inputs like configuration so their whole subgraph can skip checks).

#Which type should I use?

NeedUse
Default cached computation, especially if it may not be read after every input writeDerived
One memoized value per semantic key, created lazilyDerivedMap
Field-level invalidation inside a larger objectInputField
UI-facing value that should stay eagerly current after input writesEagerDerived
Side effect that should run eagerly when dependencies changeEffect
Lazy derived value that must stay alive through downstream push subscribers or long-lived watchesReachableDerived
Relational/fixpoint computationRelation / MapRelation

When unsure, start with Derived. Move to EagerDerived only when the consumer really benefits from push-first maintenance.

#Learn More

Full documentation index: docs/README.md.

#Background and Theory

This section is for readers who know the incremental-computation literature; skip it freely.

incr is inspired by Salsa (the demand-driven incremental recomputation model behind rust-analyzer) and Build Systems à la Carte (the separation between task meaning, store/trace data, scheduler, and rebuilder strategy; see the build-oriented boundary design and internal evaluation boundaries).

In that vocabulary, the default pull engine is a suspending scheduler plus a revision-based verifying-trace rebuilder:

  • A Derived compute closure is the task; the runtime records the dependencies read by the last successful compute.
  • Input::set(...) bumps a revision; it does not eagerly recompute the pull graph.
  • A later read verifies the recorded trace on demand: no dependency changed → the closure does not run (green path); a dependency changed → the closure reruns and records a new trace (red path); an equal result on the red path preserves changed_at (backdating, the paper's early cutoff).

The push engine (EagerDerived / Effect) uses a different contract: compute at construction, then recompute during Input::set(...) / batch commit propagation in topological-level order. ReachableDerived is hybrid only in reachability/GC behavior; its recomputation is the same lazy revision check as Derived. Cross-session/content-addressed caching is not automatic — see the constructive traces feasibility note.

Naming note: as of v0.13.0 the compatibility names (Reactive, TrackedCell, FunctionalRelation, Database, Readable, Trackable, and the older Memo-family / Signal names) have been removed; use the target names (EagerDerived, InputField, MapRelation, RuntimeContext, Freshness, InputFieldOwner, Derived, Input) instead. Migrating older code? See the CHANGELOG. The naming direction is recorded in ADR 2026-05-21.

#Development

moon check # Type-check the workspace moon build # Build the workspace moon test # Run all workspace tests moon bench # Run benchmarks (always pass --release for representative numbers)

Contributor and coding-agent guidance lives in AGENTS.md.

#Supported targets

Builds and tests pass on the WASM-GC backend (the default for moon test). Other MoonBit backends are not currently exercised in CI; treat them as unverified.

#License

Apache-2.0

#
AcceptStatus

Status of a single committed-revision transition through the accept gate. Mirrors the spec's state-machine Status column.

#
AcceptedDerived

A success-gated derived value. Construct with AcceptedDerived::AcceptedDerived (owns its candidate compute), AcceptedDerived::from_candidate (wraps an existing candidate Derived), or Scope::accepted_derived — all V : Eq. For candidate values that are not Eq but carry a Revision, use the BackdateEq tier (AcceptedDerived::accepted_memo / Scope::accepted_memo), which gates acceptance by revision identity.

#
AcceptedSnapshot

A coherent view of one committed revision: the current candidate result, the retained accepted value, and the transition status. current carries the domain Result[V, E] (the read-error channel lives on the accessor return type, not here).

#
Accumulator

A side-channel collector for values pushed by memo compute closures.

Typed buffers (per_memo, prev_push_sets) live on the handle; runtime holds only type-erased closures in SlotMeta. See the design doc docs/superpowers/specs/2026-04-19-accumulator-api-design.md.

#
AccumulatorId

A unique identifier for an Accumulator in the runtime.

Monotonically allocated by the runtime; never reused across dispose/new. Stale references to disposed accumulators resolve to "slot disposed" at verify time rather than aliasing to a recycled slot.

Each AccumulatorId is scoped to its originating runtime via runtime_id.

#
BackdateEq

A backdate-aware equality check for memo outputs.

The default implementation compares changed_at revisions: two values are considered "backdate equal" (i.e., the memo should not advance its changed_at) when both carry the same revision stamp. Override to provide custom logic.

Supertrait

Requires HasChangedAt — the type must expose a changed_at revision.

#
CellId

A unique identifier for a cell (signal or memo) in the runtime.

Cell IDs are monotonically increasing integers allocated by the runtime. They serve as direct array indices for O(1) cell lookup.

Each CellId is scoped to its originating runtime via runtime_id. This prevents cells from different runtimes being incorrectly queried against each other.

#
CellInfo

Structured metadata about a cell in the dependency graph.

This structure provides a uniform view of both Input and Derived cells. For inputs, the dependencies array will be empty.

Fields

  • label: Optional human-readable name for debugging and introspection
  • id: The unique identifier for this cell
  • changed_at: When this cell's value last actually changed
  • verified_at: When this cell was last confirmed up-to-date
  • durability: How often this cell is expected to change
  • dependencies: Cell IDs this cell depends on (empty for inputs)

#
CycleError

Error type for cycle detection during memo computation.

Returned by get_result() methods when a memo transitively depends on itself. Use cell(), path(), and format_path() to inspect or render the cycle. Labels are snapshotted at construction time so format_path is pure-value — rendering needs no runtime handle and reflects the cell labels at the moment the cycle was detected, even if those cells are later renamed or disposed.

Example

match memo.get_result() {
Ok(value) => println("Got: " + value.to_string())
Err(err) => {
println("Cycle at cell " + err.cell().to_string())
println(err.format_path())
}
}

#
Derived

Target-name lazy derived cell facade. Owns its fields directly — no wrapper indirection.

#
DerivedAbortedEvent

Event emitted when a derived recompute raises a catchable error.

#
DerivedCompletedEvent

Event emitted when a derived recompute completes successfully.

#
DerivedEnteringEvent

Event emitted when a derived recompute starts.

#
DerivedEvent

Derived recompute lifecycle event delivered by Runtime::on_derived_event.

#
DerivedMap

Keyed derived map facade. Lazily caches per-key Derived entries on first access; scope-owned maps clear entries on dispose and participate in Scope::collect() maintenance.

#
Durability

Classifies how often an input is expected to change.

Durability enables a powerful optimization: when only low-durability inputs change, memos that depend solely on high-durability inputs skip verification entirely.

Levels

  • Low: Frequently changing values (user input, source text)
  • Medium: Moderately stable values
  • High: Rarely changing values (configuration, schemas)

Inherited Durability

Memos inherit the minimum durability of their dependencies. A memo depending on both Low and High durability signals has Low durability.

#
EagerDerived

User-facing push-mode eager derived cell.

Recomputed eagerly when any upstream cell changes. Value is cached; consecutive reads without upstream changes do not trigger recomputation.

#
Effect

User-facing push-mode effect.

Runs the provided function immediately on creation to establish dependencies, then re-runs whenever any dependency changes.

#
Expr

Lazy formula expression over target facade handles. Materializes to a single Derived[T] via Expr::derived; operator chains allocate no incremental cells.

#
GcRole

Categorizes a cell's role in garbage collection.

  • Source: Input cells (signals, relations) — no upstream deps, never collected
  • Interior: Derived cells (memos, reactives) — has deps, collectible when unobserved
  • Root: Terminal cells (effects) — keeps upstream alive, never collected

#
HasChangedAt

A type that carries its own changed_at revision stamp.

Implement this on value types that embed a Revision tracking when their content last changed. Used in combination with BackdateEq to enable O(1) backdate decisions based on revision comparison rather than O(N) structural equality.

#
Input

An input cell with an externally-set value.

Inputs are the leaves of the dependency graph — values you control directly. The T: Eq constraint enables same-value optimization: setting an input to its current value is a no-op (no revision bump, no downstream recomputation).

Example

let rt = Runtime()
let count = Input(rt, 0)
count.set(5)
inspect(count.get(), content="5")

#
InputField

Target-name field-level input cell — a thin wrapper around Input[T].

InputField provides the same functionality as Input but is intended for use cases where you want a more structured, named input cell (e.g. fields of a tracked struct). All operations delegate directly to the inner Input.

Example

let rt = Runtime()
let field = InputField(rt, 0, label="counter")
field.set(5)
inspect(field.get(), content="5")

#
InputFieldOwner

Contract for types that expose their runtime-tracked cell ids — typically structs that own InputField fields, but any cell handle qualifies.

The ordering of CellIds returned by cell_ids() must be stable across calls (i.e., always return fields in the same order), and the ids must belong to the runtime of any scope they are registered with.

#
InputView

A read-only view of an input cell.

Obtain an InputView with input[:] or input.as_view(). The view reads the same cell as its source, but does not expose write, callback, or disposal authority.

#
InternId

A stable integer key returned by InternTable::intern.

Same value always maps to the same InternId within a table. IDs are monotonically increasing and can be used as array indices or MemoMap keys.

#
InternTable

A grow-only interning table that assigns stable InternId keys to values.

Interning guarantees that equal values always receive the same InternId, enabling cheap identity comparison (id1 == id2) instead of deep structural equality. Used by the bidirectional type-checker as MemoMap keys for cross-revision cache stability.

#
ListenerId

A handle identifying a single runtime-global listener registration.

Returned by the additive listener APIs (Runtime::add_on_change_listener, Runtime::add_derived_event_listener) and passed back to their remove_* counterparts to detach a specific listener.

An id pairs the originating RuntimeId with an allocation number drawn from a single per-runtime monotonic counter shared across both listener registries. The counter makes ids unique across the two registries of one runtime; the RuntimeId makes them unique across runtimes (the bare counter alone would collide — the first listener of two runtimes would both be number 0). Together they guarantee that passing an id to the wrong registry, or to a different runtime, is a no-op rather than an accidental removal of an unrelated hook.

Like RuntimeId, a ListenerId is an introspection/debug identity — compare it for equality or use it as a map key; it is not a stable application key across program runs and carries no ordering or arithmetic meaning.

#
MapRelation

A Datalog functional relation: a key-value map with delta tracking for fixpoint evaluation.

Unlike Relation[T] (a HashSet), MapRelation[K, V] uses @hashmap.HashMap[K, V] and supports replacing values for existing keys.

Three layers:
  • current — materialized post-drain (readable via get()/iter())
  • delta — frontier for current fixpoint iteration
  • staged_delta — staging buffer during fixpoint

  • insert() adds to frontier delta outside fixpoint, staged delta during fixpoint
  • get() reads current (the materialized post-drain map)
  • iter() iterates current; records a dependency for pull verification
  • delta_iter() iterates delta; used by rule bodies

#
ReachableDerived

Reachable lazy derived facade. Same revision-based verification as Derived, and additionally participates in push reachability and Watch/GC lifetimes for downstream eager subscribers.

#
ReadError

Error type for the public read channel.

A read asks for a stored graph snapshot, so the only failures it can report are mechanism failures — intrinsic to the read itself and unrecoverable by any domain reader:

  • Cycle — the cell transitively depends on itself.
  • Disposed — the cell being read has been disposed.

Domain fallibility (a parse error, a validation failure) is not a read error: it belongs in the value as Result[V, E] (see Derived::fallible), where it is cached, change-detected, and replayed like any other value. A retained compute raise Failure is a defect, not a domain error, and still aborts.

Cross-runtime misuse and strict-context misuse are programmer defects and abort rather than surfacing here.

See docs/design/specs/2026-05-28-honest-read-error-ownership.md.

#
Relation

A Datalog relation: a set of tuples with delta tracking for fixpoint evaluation.

Relation[T] holds typed current and delta sets via Ref. The Runtime sees only type-erased closures on RelationData.

  • insert() adds to frontier delta outside fixpoint, and to staged delta during fixpoint
  • contains() checks current (the materialized post-drain set)
  • iter() iterates current; records a dependency for pull verification
  • delta_iter() iterates delta; used by rule bodies

#
Revision

A monotonically increasing revision counter representing the system's logical clock.

Revisions are bumped each time an input signal changes. Every cell tracks two revision timestamps:

  • changed_at: When this cell's value last actually changed
  • verified_at: When this cell was last confirmed up-to-date

A cell is stale when verified_at < current_revision. A cell has changed (relative to an observer) when changed_at > observer.verified_at.

#
RuleId

#
Runtime

Central coordinator for the incremental computation framework.

The Runtime manages all bookkeeping for dependency tracking, revision counting, and batch operations. Every Input and Derived is associated with exactly one Runtime.

Pointer chase tradeoff

All sub-struct field accesses (e.g. self.core.revision.current_revision) require one extra pointer dereference compared to the old flat layout. MoonBit structs are heap-allocated, so RuntimeCore is a separate heap object. In hot loops (verify dep-walk, push propagation), the sub-struct pointers are cache-hot since they are dereferenced on every iteration.

Example

let rt = Runtime()

let x = Input(rt, 10)

let doubled = Memo(rt, () => x.get() * 2)

#
RuntimeId

A unique identifier for a Runtime.

Runtime ids are monotonically allocated when a Runtime is created and scope every CellId / AccumulatorId to its originating runtime.

A RuntimeId is an introspection / debug identity: compare it for equality ("are these two runtimes the same?"), use it as a map key, or display it. It is not a stable application key across program runs, and carries no ordering or arithmetic meaning — that is why it is a nominal wrapper rather than a bare Int.

#
Scope

Hierarchical cell ownership with bulk disposal.

Scope owns cells and child scopes. Disposing a scope disposes all owned cells and children recursively. This enables UI component lifecycle patterns: create cells during mount, dispose the scope on unmount.

Disposal Order

  1. Children (bottom-up, recursively)
  2. Dispose hooks (watches and Scope::on_dispose cleanups)
  3. Owned cells

Example

let scope = Scope::new(rt) let local = scope.input(42) let derived = scope.derived(fn() { local.get() * 2 }) // Component unmounts — one cleanup call scope.dispose()

#
Watch

A typed persistent read root for values read outside the graph.

A Watch keeps its target reachable across Runtime::gc() and preserves read errors as values.

#
Freshness

pub(open) trait Freshness {
fn is_fresh(Self) -> Bool
}

Freshness trait for readable incremental nodes.

Use it for generic code that only needs to ask whether a handle is current with its runtime's latest revision.
impl Freshness for Derived[T]
impl Freshness for Input[T]
impl Freshness for InputField[T]

#
RuntimeContext

pub(open) trait RuntimeContext {
fn runtime(Self) ->
Runtime

}

A provider of the runtime that owns a piece of incremental state.

This is the core trait that user-defined database/context types implement to connect to the incr runtime. It enables helpers like create_input, create_derived, batch, create_accumulator, and create_scope.

This trait corresponds to Salsa's #[salsa::db] trait, but without macro magic — users implement it explicitly.

Example

struct MyDb {
rt : @incr.Runtime
}

impl @incr.RuntimeContext for MyDb with fn runtime(self) {
self.rt
}

#
DURABILITY_COUNT

let DURABILITY_COUNT : Int

The number of durability levels (3: Low, Medium, High).

Must equal Durability::High.index() + 1. Adding a new Durability variant requires updating both Durability::index() and this constant; failing to do so will silently under-size the durability_last_changed array in Runtime, causing incorrect fast-path revision checks.

#
MAX_CYCLE_DISPLAY_STEPS

let MAX_CYCLE_DISPLAY_STEPS : Int

Maximum number of path entries rendered by CycleError::format_path, and the number of labels snapshotted at error-construction time.

Cycles longer than this are truncated in the formatted output with "→ ...", but CycleError::path() still returns the full untruncated sequence.

#
add_input_fields

Registers all cells from an InputFieldOwner with a scope for bulk disposal.

When the scope is disposed, all registered cells are disposed. This is the recommended way to manage the lifetimes of cells owned by a struct.

Example

let scope = Scope::new(rt)
let owner = MyTracked::new(rt)
add_input_fields(scope, owner)
scope.dispose() // disposes all owned cells

#
batch

fn[Ctx : RuntimeContext] batch(ctx : Ctx, f : () -> Unit raise?) -> Unit raise?

Executes a batch of input updates using the context runtime.

All input updates inside the closure are deferred until the batch ends, resulting in a single revision bump. If the closure raises, pending writes are rolled back and the error is re-raised.

Parameters

  • ctx: Any type implementing RuntimeContext
  • f: The closure containing the input updates

#
batch_result

fn[Ctx : RuntimeContext] batch_result(ctx : Ctx, f : () -> Unit raise) -> Result[Unit, Error]

Runs a batch and returns raised errors as Result.

#
create_accumulator

fn[Ctx : RuntimeContext, T : Eq] create_accumulator(ctx : Ctx, label? : String) ->
Accumulator
[T]

Convenience mirror of create_input / create_derived.

#
create_derived

fn[Ctx : RuntimeContext, T : Eq] create_derived(ctx : Ctx, f : () -> T raise Failure, label? : String) ->
Derived
[T]

Creates a target-name lazy derived facade using the context runtime.

#
create_derived_map

fn[Ctx : RuntimeContext, K : Hash + Eq, V] create_derived_map(ctx : Ctx, f : (K) -> V raise Failure, label? : String) ->
DerivedMap
[K, V]

Creates a target-name keyed derived facade using the context runtime.

#
create_eager_derived

fn[Ctx : RuntimeContext, T : Eq] create_eager_derived(ctx : Ctx, compute : () -> T) ->
EagerDerived
[T]

Creates a target-name eager derived facade using the context runtime.

#
create_input

fn[Ctx : RuntimeContext, T] create_input(ctx : Ctx, value : T, durability? :
Durability
, label? : String) ->
Input
[T]

Creates a target-name input facade using the context runtime.

#
create_input_field

fn[Ctx : RuntimeContext, T] create_input_field(ctx : Ctx, value : T, durability? :
Durability
, label? : String) ->
InputField
[T]

Creates a target-name input-field facade using the context runtime.

#
create_reachable_derived

fn[Ctx : RuntimeContext, T : Eq] create_reachable_derived(ctx : Ctx, f : () -> T raise Failure, label? : String) ->
ReachableDerived
[T]

Creates a target-name reachable lazy derived facade using the context runtime.

#
create_scope

fn[Ctx : RuntimeContext] create_scope(ctx : Ctx) ->
Scope

Creates a new root scope using the context runtime.

Cells created via the scope's constructors are automatically disposed when the scope is disposed. Use scope.child() for nested scopes.

Parameters

  • ctx: Any type implementing RuntimeContext

Returns

A new root scope associated with the context runtime

Source Files