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.
- 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.
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
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.
- full rerun, every interaction
- dagpane, the derived set
One interaction: set min_amount = 400
36 interactions: every slider position and every region, each from first render
One session of 37 moves: the slider dragged up through every position in steps of 25, then each region in turn
The same numbers as a table
| Scope | Measure | Full rerun | dagpane | Fewer |
|---|---|---|---|---|
| one interaction | cells computed | 8 | 6 | −25% |
| one interaction | panes sent | 7 | 3 | −57% |
| 36 interactions | cells computed | 288 | 217 | −25% |
| 36 interactions | panes sent | 252 | 117 | −54% |
| one 37-move session | cells computed | 296 | 228 | −23% |
| one 37-move session | panes sent | 259 | 131 | −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
-
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. -
$ 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.
-
$ 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.
-
$ 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.
--jsonturns it into a CI gate that fails when those counts move; this repository's own CI does that. -
$ 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.
-
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
-
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
-
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,
channelsrecomputed to the set it already held, andchannel_countbelow it never ran. ADR-0003 -
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
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.
bruns. a 1→3, so b 2→4-
druns on newband oldcb 4, c 2 — (4−1)×2 = 6, but c is 2 cruns. c 2→6druns 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.
bandc— both height 1, either order b 2→4, c 2→6d— 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, dated | How it decides what re-runs | The 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.
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.
-
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, anddagpane runserves 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.
-
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.
-
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. -
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 runbinds127.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-unknownpasses 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
explainprints it assame 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
dagpanebinary: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.