An interaction recomputes the cells that depend on it, and nothing else.

dagpane serves internal data apps from one static Rust binary. You describe the app in a TOML file: a CSV, a few controls, cells that filter and aggregate, panes that show them. When a viewer moves a control, only the cells downstream recompute, only the panes that changed are sent, and dagpane explain prints exactly that set before you deploy. Its structure is aimed at the team that hosts the dashboards as much as at the person who writes one. No Python, no Node, no build step.

Moving a control replays the engine's rules on the real 600 rows and re-lights the graph. Without JavaScript the page shows the pass from the README.

The Sales explorer app as a dependency graph, lit by the last pass Eleven cells in four height columns. The transcript below the graph lists what the pass did to each. salesdata · 600 rows min_amountinput regioninput filtered all_time_revenue order_count region_totals channels top_orders revenue channel_count
epoch 2 looked at 8 of 11 ran 6 changed 4 reused 1 never looked at 3 panes sent 3 of 7
  • ran, value changed
  • ran, same value
  • reused, inputs had not moved
  • never looked at
  • has a pane

What dagpane explain prints for this interaction

$ dagpane explain examples/sales.toml --set min_amount=400
dagpane: Sales explorer — 11 cells, 7 panes
first render: 8 of 11 cells evaluated

set min_amount = 400
  epoch 2 — looked at 8 of 11 cells
    set      min_amount
    ran      filtered             changed
    ran      order_count          changed
    ran      region_totals        changed
    ran      channels             same value — nothing below it ran
    ran      top_orders           same value — nothing below it ran
    ran      revenue              changed
    reused   channel_count        its inputs had not moved
  3 cell(s) never looked at: sales, region, all_time_revenue
  patch: 3 of 7 panes — revenue, order_count, region_totals

The graph and this transcript are driven by a JavaScript replay of the engine's four rules over examples/sales.toml and its 600 rows. It is not the engine. What was checked, on 2026-09-08: 19 single-interaction passes (10 slider values, 5 regions, 4 pairs) diffed line for line against dagpane explain, the first render, and every pane value at init and after one interaction against the real WebSocket (scripts/check-engine.mjs in the site's source). One known difference: the CLI parses --set min_amount=0 as an integer, which differs by kind from the float default; this page sends floats, as the browser client does.

The seven panes. Only the ones that changed go on the wire.

  • $63010.40Revenue
  • $131255.70All-time revenue
  • 118Orders
  • 3Channels
  • 4 barsRevenue by region
  • 3 rowsChannels present
  • 10 of 118Largest orders

Where dagpane comes in.

A dashboard is four things: the data, the definition of the page, the runtime that turns an interaction into new numbers, and the browser. dagpane replaces the third. The data stays a file, the browser stays a browser, and the definition becomes a manifest instead of a script.

Data Definition Runtime Browser
Conventional
Streamlit, the documented default
a CSV, a table a Python script a script runner reruns the script top to bottom on every interaction unless the author has drawn a fragment boundary by hand; each viewer holds a session thread and its own state rebuilt from the rerun's output
dagpane the same CSV a TOML manifest a graph, built once, shared by every viewer; each interaction is one pass over the cells downstream of it; each viewer is a vector of value slots a patch of the panes that changed

Streamlit's own fundamentals page, fetched 2026-09-08: "any time something must be updated on the screen, Streamlit reruns your entire Python script from top to bottom." Since 1.33 an author can draw fragment boundaries by hand and, since 1.63.0, fire them by name, or with on_change="ignore" skip the rerun for a widget altogether; the boundary is still drawn and maintained by a person. marimo derives its graph, as dagpane does, and is not the comparison below.

The same interactions, counted two ways.

This is the comparison the repository can make truthfully: work per interaction, in cells computed and panes sent, on the bundled 11-cell, 7-pane app. The full-rerun column is what a script with no fragment boundaries and no st.cache_data on these functions does by definition; the dagpane column is what dagpane explain reports, replayed here by the same four rules the binary was diffed against on 19 of these passes; the 36-interaction and 37-move totals are the replay's. Neither column is a measurement of time.

Work on the bundled app: full rerun versus dagpane's derived set
  • full rerun, every interaction
  • dagpane, the derived set

One interaction: set min_amount = 400

cells computed86
panes sent73

36 interactions: every slider position and every region, each from first render

cells computed288217
panes sent252117

One session of 37 moves: the slider dragged up through every position in steps of 25, then each region in turn

cells computed296228
panes sent259131
The same numbers as a table
ScopeMeasureFull rerundagpaneFewer
one interactioncells computed86−25%
one interactionpanes sent73−57%
36 interactionscells computed288217−25%
36 interactionspanes sent252117−54%
one 37-move sessioncells computed296228−23%
one 37-move sessionpanes sent259131−49%

A share of counts, not of time; a reused cell still costs one digest comparison per input.

Read the shape, not the ratio. On this app every interaction touches the one cell that reads both controls, so the derived set is most of the graph and the saving in cells is a quarter. The saving on the wire is half, because two cells recompute to the value they already held, one is reused and one is never looked at: four of seven panes do not move. On a wider app, where most cells sit beside the control rather than below it, the derived set is a smaller share and the counts fall further; dagpane explain prints yours, and --json makes it a CI gate.

Headroom is a structure here, not a number yet.

Per viewer, a Streamlit session is a script-runner thread holding that viewer's state, and marimo's is a sub-thread kernel; both pin a viewer to the process that holds it. A dagpane session is a vector of value slots beside one immutable graph: the app, the graph and every loaded source are built once and shared by every connection, a source is hashed once at build rather than once per viewer, and a test asserts that sharing by pointer identity rather than inferring it from memory.

What has not been measured, stated where it would otherwise be assumed: apps per core, p99 under concurrent viewers, memory per session, and any comparison in seconds against Streamlit or marimo. There is no benchmark in the repository, which is why there is no chart of time on this page. It is the cheapest of the four conditions below to produce, and the first the project owes.

What changes in the workflow.

The job is the same one Streamlit, Dash and marimo do: an analyst has a table, wants a page with a slider and a few charts on it, and the platform team has to host that page for everyone who opens it. Here is that job before and after, in the concrete steps.

Before

You write a script. Either every widget change reruns it, or you draw the rerun boundaries yourself (fragments, callback lists) and keep them in sync with the app by hand; the boundary is wrong the moment the app changes and the annotation does not.

In Streamlit or Dash, nobody can tell you before deploy which parts a control will recompute. You find out in production, from a slow page or a stale number. (marimo derives its graph too; the honest difference there is narrower, and the field section below states it.)

Each app ships with its Python environment, and each viewer is pinned to a sticky session on the process that holds their state. Hundreds of apps is hundreds of those.

After

  1. sales.toml, beside sales.csv

    Describe the app, not the rerun. [[source]] for the CSV, [[input]] for each control, [[cell]] with steps drawn from seven verbs, [[pane]] for what is shown. A step that names an input is an edge; that is the entire wiring.

  2. $ dagpane check sales.toml

    Compile it. An unknown verb, a step that names no input, a pane that names no cell, or a cycle is a build error naming the problem, not something a viewer runs into. A missing column is an error value in one pane, not a crash.

  3. $ dagpane graph sales.toml

    See the shape before anything runs. Every cell with its height and what it reads. The order a pass will evaluate in is fixed here, at build, which is what makes a pass glitch-free.

  4. $ dagpane explain sales.toml --set min_amount=400

    Ask what an interaction will cost, in cells, before deploy. Which cells run, which reuse, which are never looked at, which panes go on the wire. --json turns it into a CI gate that fails when those counts move; this repository's own CI does that.

  5. $ dagpane run sales.toml

    Serve it as one binary. The graph and every loaded source are built once and shared by every connection; a viewer's session is a vector of value slots, not a kernel, a thread or a process. Each interaction is one pass, and only the panes that changed are sent. The same counts print live above the panes.

Still yours to do, stated plainly: anything past the seven verbs is a Rust closure on GraphBuilder::cell; there is no authentication and no session store, so put it behind your own proxy; and dagpane run serves one manifest on one port, so many apps is many processes today.

Invalidation is derived, not declared.

An app author says what each cell reads. Nobody draws a boundary around “the part that should re-run”, because that boundary is a derived fact and deriving it is the runtime’s job. Streamlit’s @st.fragment and Dash’s callback lists are the other answer: the human draws the boundary, and the boundary is wrong whenever the app changes and the annotation does not.

# the whole reactive wiring of one cell
[[cell]]
name = "filtered"
from = "sales"
[[cell.step]]
[cell.step.filter]
column = "amount"
op = "ge"
param = "min_amount"

[[cell]]
name = "region_totals"
from = "filtered"
[[cell.step]]
group_by = { by = ["region"], agg = [
  { agg = "count", as = "orders" },
  { column = "amount", agg = "sum", as = "revenue" },
] }

param = "min_amount" makes filtered depend on the slider, and everything downstream of filtered follows. There is no compiler in the loop and no expression language: seven verbs (filter, select, sort, limit, group_by, scalar, count), and dagpane check names anything outside them rather than ignoring it.

  1. Edges are declared, so the graph is checked once and shared.

    A cycle is a build error naming the loop, not something a user runs into. The graph is built at start-up and shared immutably: a hundred viewers are a hundred vectors of values over one app, and one allocation per source nobody has touched. ADR-0001

  2. Evaluation runs in ascending height, which is what makes it glitch-free.

    Every edge runs from a lower height to a strictly higher one, so a cell’s inputs are final before it runs. A diamond evaluates its join exactly once, and never with one new parent and one old one. ADR-0002

  3. A value that did not change stops the pass.

    Values are compared by a 128-bit content digest taken once when the value is produced, so the comparison costs the same whether the value is a boolean or a table. In the pass above, channels recomputed to the set it already held, and channel_count below it never ran. ADR-0003

  4. Errors are values.

    A failing cell holds its error, cells below it hold one naming the cell that actually failed, and the pass finishes. One broken column takes out one number, not the page and not the control that will fix it. ADR-0004

A diamond. The cell a feeds b and c, and both feed d. Because d is one height above b and c, it runs once, after both of them, and never on a mixture of one new parent and one old one. a · h0 b = a + 1 · h1 c = a × 2 · h1 d · h2, runs once h0 → h1 → h2 never h2 before h1
This is the diamond in the test suite, not an illustration of one. The build-time Kahn ordering is the schedule; heights are printed by dagpane graph before any cell has run.

Why the order is the argument, and not an optimisation

Give the diamond real arithmetic and the claim becomes checkable. Let b = a + 1 and c = a × 2. Then every consistent observation of this app satisfies (b − 1) × 2 == c — that is what it means for b and c to be looking at the same a. Set a and there is exactly one right answer for d. There are several ways to arrive at a wrong one.

Walk out from the change

Mark a dirty, follow the edges, evaluate as you go.

  1. b runs. a 1→3, so b 2→4
  2. d runs on new b and old c b 4, c 2 — (4−1)×2 = 6, but c is 2
  3. c runs. c 2→6
  4. d runs again, now correct

Two evaluations of d, and the first saw a state the app was never in.

Ascending height

Evaluate every cell at height n before any at n+1.

  1. b and c — both height 1, either order b 2→4, c 2→6
  2. d — height 2, so both parents are final (4−1)×2 = 6 = c

One evaluation of d, and no intermediate value ever existed.

The wasted evaluation is the smaller of the two problems. A cell that does anything beyond returning a value — appends to a log, increments a counter, writes a patch a socket is about to flush — has already done it by the time the correct run happens. Re-running until nothing changes does not fix this. It converges on a DAG, so the final values are right, which is exactly what makes it seductive: every assertion about final state passes. The intermediate states are still real, d still runs at least twice, the first run still saw the mixture, and the iteration count now depends on the graph’s shape rather than on the size of what actually changed.

Height is the longest path from any source, not the shortest. Kahn’s algorithm computes it in the same pass that checks for cycles, and it is final when a node is popped because every predecessor was visited first. The shortest path would not do: a cell that reads both a raw input and something derived from that same input would sit at the same height as its own dependency, and could run before it.

Two tests pin this, and one of them would not be enough. a_diamond_join_runs_once_per_pass asserts the count, but a count alone cannot catch a scheduler that runs d exactly once on a mixed state. a_diamond_join_never_sees_a_mixed_state asserts the values d was actually handed.

The same shape, in the app on this page

filtered reads three cells: the source table and both controls. Move two of them in one interaction and it still evaluates once, because everything it reads sits at height 0 and it sits at height 1. The heights come from dagpane graph, which answers before a single cell has run:

$ dagpane graph examples/sales.toml
 0  data   sales
 0  input  min_amount
 0  input  region
 1  cell   filtered           ← sales, min_amount, region
 1  cell   all_time_revenue   ← sales
 2  cell   order_count        ← filtered
 2  cell   region_totals      ← filtered
 2  cell   channels           ← filtered
 2  cell   top_orders         ← filtered
 3  cell   revenue            ← region_totals
 3  cell   channel_count      ← channels

# both controls moved, in one interaction
$ dagpane explain examples/sales.toml --set min_amount=400 --set region=south
  epoch 2 — looked at 9 of 11 cells
    set      min_amount
    set      region
    ran      filtered             changed
    ran      order_count          changed
    ran      region_totals        changed
    ran      channels             same value — nothing below it ran
    ran      top_orders           changed
    ran      revenue              changed
    reused   channel_count        its inputs had not moved
  2 cell(s) never looked at: sales, all_time_revenue
  patch: 4 of 7 panes — revenue, order_count, region_totals, top_orders

Read the filtered line: two of its three inputs moved and it ran once. Then read the last three. channels recomputed to the set it already held, so channel_count below it was never evaluated at all — height order decides what may run, and the digest of ADR-0003 decides what actually does. Two cells were never looked at, and three of seven panes stayed off the wire.

This is not a claim that reactivity is new.

Shiny shipped a real reactive graph in 2012. marimo derives a dependency graph from Python source and has for years. The digest short-circuit is salsa’s backdating at cell granularity. What dagpane claims is one sentence: the edges are declared once; the set of cells that recompute is derived from them and from value equality; and the runtime prints that set before you deploy. Here is who that is a claim against, and who it is not.

Project, datedHow it decides what re-runsThe honest line
Streamlit 1.63.0
2026-09-01
Named fragments you fire from callbacks with st.rerun("filters"). A hand-drawn callback graph, which is to say Streamlit converged on Dash. A claim. Its docs: a fragment “can’t detect a change in input values”. You draw the boundary and you name what it invalidates.
Dash 4.3.0
2026-06-18
An explicit Output/Input list per callback. The invalidation set is written by hand. A claim. The complaint against Dash is verbosity, not correctness, and a callback loop is found at run time rather than at build.
marimo 0.24.0
2026-08-17
Static analysis of each cell’s references and definitions. Derived, like dagpane, and it reads the source where dagpane makes you write the edge. Not a claim. The only honest line is marimo’s own: it does not track mutation or attribute assignment. dagpane has no such hole because it has no such analysis, and pays with one recomputation per over-declared edge.
Shiny for Python 1.6.0
2026, month not in the ledger
Dependencies captured at read time. The original fine-grained reactive graph, and the prior art dagpane credits first. 1.6.0 ships OpenTelemetry for inspecting the graph in production. Not a claim. The difference is only that dagpane explain answers before a server exists.

Two more names belong here before a reader finds them. Perspective already ships the same engine on the server, in a Web Worker, or over a WebSocket, decided at deploy time; dagpane has not built that and does not claim it. Mosaic gets most of the perceived “only recompute what changed” benefit at the query layer over DuckDB, peer-reviewed, with no language-level graph at all.

And the lane itself is thin: four Rust data-app frameworks published in the last twelve months have 599 downloads across 90 days between them, and one of them is the full re-execution design dagpane argues against, already built, with 96. The argument this makes against building dagpane at all is the first thing in the project’s own positioning document, unsoftened.

Every version, date and download count above was verified on 2026-09-08 against the projects’ own changelogs and registries. COMPETITORS.md in the repository carries the full ledger.

2,400 passes, each checked both ways.

Every other test asserts a count, and a count asserted on a graph the author drew is a test of the author’s expectations: a scheduler that skips too much passes all of them and is silently wrong. That is the one failure this project must not have, a stale number on a page that looks like it is working. So the oracle draws graphs nobody wrote.

2,400 points: 200 random dependency graphs across, 12 random interactions down. One point is marked: the pass on which the oracle found the error-digest bug.
200 pseudo-random DAGs from a seeded in-tree generator, so a failing seed is reproducible for the life of the project × 12 random interactions each = 2,400 passes, and after every one: correctness, every cell equals a fresh session computed from scratch; economy, every cell the pass touched is inside the structural closure of what changed

It has already earned its place.

An error’s digest originally covered its message and not its attribution. Two upstream cells failing with the same words digested alike, so a cell below them served its cached error and kept naming the one that was no longer the problem. No hand-written test found that. The oracle found it on a random graph, and crates/core/tests/reactive.rs now pins it.

The rest of the suite is 161 tests, cargo fmt and clippy -D warnings clean. The engine crate has one dependency (serde), no I/O, no async, no clock and forbid(unsafe_code). And there are zero numbers in the repository measured in seconds: the counts on this page are counts.

Four conditions. Zero of four fully met.

The project’s positioning document names the four things that would have to be true for this module to matter, all of them, and grades v0.1.0 against each. The grades are printed here because a page that hid them would be selling something.

  1. Not met

    The buyer is the platform team, not the analyst.

    The pain is hosting hundreds of internal apps at a sticky-session-pinned container each. The structure points the right way: one Arc<App> behind every connection, asserted by pointer identity in a test rather than inferred from memory. But no apps-per-core, p99 or memory figure has been measured, and dagpane run serves one manifest on one port. Until a number exists this is a description of a data structure.

    What changes it: a measured apps-per-core number against a Streamlit or marimo baseline under identical load. The cheapest of the four to produce.

  2. Partly

    Nobody writes Rust.

    The bundled app is complete TOML with no Rust in it. The ceiling is exact and small: seven verbs over five widget kinds, no SQL, no expression language, on purpose, because an edge inferred wrongly from SQL text is a wrong app. The eighth verb an author needs is a Rust closure.

    What changes it: an authoring surface past seven verbs that still produces edges statically.

  3. Not met

    The differentiator is sub-node invalidation.

    Not started. dagpane invalidates at whole-value granularity: change one cell of a table and every cell reading that table recomputes. What exists is the digest short-circuit that would make column-granular invalidation worth building, and the oracle that would catch it getting it wrong. Foundations, not the condition.

    What changes it: “changed one column of a 200-column frame, recomputed 2 of 40 cells”, printed by dagpane explain.

  4. Partly

    Perspective and Mosaic are cited as ancestors on page one.

    Met in the competitor ledger, and on this page. The README does not yet name them on its first screen, which is the page one the condition means. A reader who finds Perspective before this project mentions it has already decided what kind of project this is.

    What changes it: the README's first screen naming them.

What this is not, stated here rather than discovered later.

  • No performance claim. There is no benchmark in the repository and no number in it measured in seconds.
  • No authentication. dagpane run binds 127.0.0.1; binding anything else prints a warning saying what it means. A session is a connection: closing the tab discards it.
  • Nothing runs in a browser. cargo check -p dagpane-core --target wasm32-unknown-unknown passes and CI runs it. That is the entire WASM claim.
  • The table is small on purpose. Vec<Option<T>> per column. Not Arrow, not Polars, not DuckDB, and it does not pretend to be.
  • The dirty closure is structural. A pass visits every cell downstream of what changed, including the ones that turn out to reuse. Visiting is a digest comparison per input; it is cheap, and it is not zero, and the trace reports it separately.
  • An over-declared edge costs a recomputation. A cell that reads an input only on a branch it did not take still declares it and still re-runs. The digest stops the damage at that cell’s boundary, and explain prints it as same value.

Run the pass yourself.

There is a published container image as of today. There is still no crate on crates.io and no binary attached to a release. From source the workspace builds with stable Rust 1.85 or later — dagpane-core alone holds to 1.82 — and every number on this page comes out of the commands below.

# the published image: one static binary on `scratch`, and the app you mount into it
$ docker run --rm -v "$PWD:/app:ro" mancube/dagpane:0.1.1 check /app/your-app.toml

# or from source — the whole thing is one cargo workspace
$ git clone https://github.com/lucheeseng827/dagpane && cd dagpane
$ cargo install --path crates/cli

# questions an app can answer before it runs, because the edges are written down
$ dagpane check examples/sales.toml
$ dagpane graph examples/sales.toml

# the question this project exists to answer, in a terminal
$ dagpane explain examples/sales.toml --set min_amount=400

# the same counts, live, above the panes — repainted panes flash their border
$ dagpane run examples/sales.toml
dagpane: Sales explorer — 11 cells, 7 panes
dagpane: http://127.0.0.1:8787

github.com/lucheeseng827/dagpane went public on 2026-09-17, so that link and the ADR links on this page resolve. The image is mancube/dagpane:0.1.1, linux/amd64 only: the build pins an x86_64 musl target, and an arm64 manifest carrying an x86_64 binary would advertise a container that cannot start. There is no 0.1.0 image — that release's build failed on a static-linking check that could not work on Alpine, and 0.1.1 is the fix.

crates/core
the engine. Pure: no I/O, no async, no clock, no unsafe. One dependency. Embeddable without any of the rest.
crates/app
widgets, panes, the patch, the manifest compiler, an in-tree CSV reader. No sockets.
crates/serve
axum, one session per connection, and the whole front end as one embedded HTML file.
crates/cli
the dagpane binary: check, graph, explain, run.

The dependency direction is cli → serve → app → core, and CI greps for it. Apache-2.0; NOTICE names the prior art this engine owes, which is most of it.