Hazel reports · ^report(36) · the editor and undo · 2026-11-02 · work in progress · after ^report(14)
What an Undo Costs
How do the times of an edit, an undo and a redo grow with the size of the program? Where does that time go? And what would it take for each to cost O(log n) in the program's size, rather than O(n)? Matthew's question (2026-11-02), for the meeting with Cyrus on 2026-11-04. Measured on #2635 (livelits × modular editors), in Node, on a program of n definitions at five sizes.
Short answers. A program of n top-level definitions, an edit in the middle one, then undos and redos, each through the app's own update loop; medians of 12 steps. The method is at the end.
Every step grows in proportion to the program. At 6,400 definitions, after the fixes below, an edit takes 106 ms, an undo 292 and a redo 184, and the statics refresh after each about 78 more. From 800 to 6,400 definitions, doubling the program multiplies each step's time by 2.1 to 2.2: a slope of 1.10 to 1.16 on a log-log plot, a little steeper than linear (Figure 1).
After the fixes, log-log. All of it, before and after, in Figure 1. The incremental caches work; the passes around them do not scale. Whatever n, a step parses 1 or 2 definitions again, re-measures 1 or 2 chunks of layout, and compares 2 definitions' heads in statics. What grows with n is the passes over every definition around that work: rebuilding the top-level term (38% of the time at 3,200 definitions), statics' walk down the chain of definitions (20%), the outline (15%, on undo and redo only), and layout's walk (6%) (Figure 3). The undo history itself costs almost nothing: a snapshot is pushed and popped at the head of a list, and an older, compacted snapshot restores as fast as the newest, whole one.
One profile of the 12 steps at 3,200 definitions, after the fixes; Figure 3 has the one before. An undo costs 2.7 to 3.0 edits, a redo 1.6 to 2.0. A restored snapshot takes a second frame, which rebuilds the outline from scratch: its memo of recent programs misses on the restored one. That is about 26 ms of each undo and redo at 3,200 definitions: most of the 42 ms a redo costs beyond an edit, but only a third of an undo's 77. The rest of an undo's extra is not explained yet (Figure 4).
At 3,200 definitions, after the fixes: each step, then its statics. Two cheap fixes, now on #2635, took 31–46% off at the two largest sizes. The auto-probe rebuilt the whole top level's skeleton on every frame, because its memo was keyed on a segment that every edit replaces (394552d5d0). And completion suggestions were computed after every typed digit, though none can start with one (bfa36a6ede). Both were whole-program passes, and both are gone. Each step is still linear (the fixes).
Before (grey) and after the fixes, at 6,400 definitions. O(log n) needs the top level as a balanced tree, not a chain. The top level is a chain of nested lets, n deep: changing a definition in the middle rebuilds every let around it, however good the caches. With the definitions in a balanced tree whose nodes keep summaries (the names a subtree binds, its rows of layout, its part of the skeleton), each pass touches only the log n nodes on the changed definition's path. And since the previous version shares every other node, a snapshot can keep its derived data, so an undo becomes a swap of roots (Figure 5).
A change rebuilds only its path. Figure 5 sets it beside today's chain.
The question#
An edit in Hazel's editor is meant to cost time in proportion to what it changes. Matthew's question on 2026-11-02 was whether that is true of undo. It seemed to cost O(n) in the size of the program. Could an edit and an undo each cost O(log n)? The question is for the meeting with Cyrus on 2026-11-04. A related question, how much structure sharing is lost as values go to the evaluation worker and back, is not measured here.
^report(14) measured what the undo history keeps, in a browser on the Full Body slide: whole versions of the editor's state, about 92 MB an edit on #2597, until Andrew Blinn's compaction of snapshots (6010128b7d) was ported there. #2635 had it already and keeps about 8 MB an edit; keeping the newest undo and redo entries whole (032fe3aca6) followed. This report measures what an undo costs in time, and how that grows with the program.
How an undo works today#
The undo history (src/web/app/History.re, at bfa36a6ede) is two lists of page models, the undo stack and the redo stack, capped at 250 entries. An edit pushes the model from before it onto the undo stack. Pushing compacts the entry below the new head: it drops the derived caches (syntax, statics, evaluation results) and keeps the zippers, the editor's own representation of the text and the caret. The newest entry stays whole. An undo pops the head and restores it:
- Syntax is rebuilt against the live caches.
Page.carry_overhands the restored model the live editor's parse and layout caches, which hit for every piece the step did not change. So 1 or 2 definitions are parsed again, whatever the size of the program. - A second frame realizes the view. The restore schedules
RealizeView, which rebuilds the outline of the program: the tree of its definitions that the sidebar and the agent read. - Statics wait for the typing debounce, as after a keystroke, and re-analyze incrementally: the definitions are walked in order and their heads compared with the previous version's, and only those that changed are analyzed again.
History.re, Page.re, MakeTerm.re, DefStatics.re and Workspace.re at bfa36a6ede.How it grows#
The probe builds a program of n definitions, let v0 = 0 in let v1 = v0 + 1 in … vn-1. It moves the caret into the middle definition and types four digits there, then undoes four times and redoes four times. Each step goes through the app's own update loop, with each frame told whether it was an edit, as the app's main loop tells it. Then comes the statics refresh that the typing debounce would run. Evaluation is off. Sizes 400 to 6,400, three runs of four steps of each kind for each build.
| definitions | an edit, before | after | an undo, before | after | a redo, before | after | statics, after | building the program |
|---|---|---|---|---|---|---|---|---|
| 400 | 14 | 10.5 (7–14) | 23 | 18.5 (14–39) | 16 | 13 (8–23) | 5 | 173 |
| 800 | 16.5 | 10 (9–23) | 38 | 30 (22–36) | 30 | 16.5 (13–25) | 7 | 251 |
| 1,600 | 34 | 22 (20–40) | 80 | 61 (49–77) | 63 | 41 (34–70) | 17 | 685 |
| 3,200 | 72 | 43 (40–83) | 176 | 120 (104–205) | 130 | 85 (62–148) | 32 | 1,007 |
| 6,400 | 163 | 106 (85–204) | 422 | 292 (244–337) | 340 | 184 (166–469) | 78 | 2,637 |
Milliseconds per step, medians of 12 (3 runs × 4 steps); the after column's range in brackets. "Statics, after" is the median refresh after each of the 36 steps at that size. "Building the program" is the first run's initial build of the editor, for scale.
Two things in the spread are worth knowing. The first edit of each run takes about twice as long as the other three. The last redo takes longer than the other redos: about 40 ms more at 3,200 definitions, and up to 300 more at 6,400. Neither is explained yet; medians of 12 are robust to both. Runs of the same two builds this morning (800 to 3,200 definitions) were 20 to 70% slower at every size, for both builds alike. That is the laptop's spread between sessions, larger than the fixes' effect at the small sizes, so compare within a session: this morning's gap between the builds was 25 to 45% at the larger sizes, much as here.
Where the time goes#
| part of the time | where | before | after |
|---|---|---|---|
| rebuilding the top-level term | MakeTerm.go_incr (src/haz3lcore/lang/MakeTerm.re:3382) | 527 ms · 29% | 523 ms · 38% |
| statics' walk down the chain | DefStatics.calc (src/haz3lcore/derived/DefStatics.re:1934) | 289 ms · 16% | 283 ms · 20% |
| the outline, rebuilt (undo and redo only) | Outline.of_source (src/haz3lcore/tiles/Outline.re:290) | 210 ms · 12% | 211 ms · 15% |
| the auto-probe's program root | AutoProbePerform.program_root_id (src/haz3lcore/AutoProbePerform.re:188) | 244 ms · 13% | under 78 ms |
| completion suggestions | TyDi.suggest (src/haz3lcore/TyDi/TyDi.re:57) | 178 ms · 10% | under 78 ms |
| the rest | garbage collection, the probe's own frames, and everything below the list | 375 ms · 21% | 370 ms · 27% |
| total | 1,823 ms | 1,387 ms |
Inside those parts, after the fixes:
| function | share |
|---|---|
MakeTerm.record_top_frame / sim (MakeTerm.re:2622, 2635) | 239 ms · 17% |
ItemSpans.memo / top (ItemSpans.re:793, 822): the top level's spans | 141 ms · 10% |
MakeTerm.parse_level (MakeTerm.re:2891) | 92 ms · 7% |
DefStatics.step_chain (DefStatics.re:788) | 154 ms · 11% |
DefStatics.calc_chain (DefStatics.re:1725) | 128 ms · 9% |
Outline.build (Outline.re:241) | 195 ms · 14% |
Measured.of_segment (Measured.re:1568): layout's walk | 78 ms · 6% |
Id.compare (src/util/Id.re:123), from several of the above | 134 ms · 10% |
Every remaining part is a pass over the whole top level, around work that is already incremental:
- The top-level term (
MakeTerm.go_incr). The syntax cache re-parses only the changed definition, but the top level's term is then rebuilt from all of them: a simulation of the top-level frame over every definition (record_top_frame), and every definition's span (ItemSpans). - Statics' chain (
DefStatics.step_chain,calc_chain). Definitions are analyzed one by one, each in the context the ones before it make. Only 2 heads are compared structurally, and every other definition is found physically the same in constant time, but the walk itself visits all n. - The outline (
Outline.build), rebuilt on every undo and redo (below). - Layout's walk (
Measured.of_segment). One or two chunks are measured again, and the walk to place them visits the rest.
Two cheap fixes#
Both were passes over the whole program that nothing needed. Both are on #2635 since 2026-11-02, cherry-picked as 394552d5d0 and bfa36a6ede (first made on a probe branch as 459b029f68 and d06473240f).
The auto-probe's program root (394552d5d0)#
Auto-probes find the program's root expression on every frame (AutoProbePerform.program_root_id). They did it from a skeleton of the whole top level, through a memo keyed on the segment's identity, which every edit replaces, so it missed on every frame after an edit: 13% of the profile before. The parser already keeps that skeleton (MakeTerm.Incr's c_skel, from #2469), so the fix reads it when it belongs to this segment:
let skel =
switch (syntax.t_cache.c_skel) {
| Some((Sort.Exp, parsed, skel)) when parsed === seg => skel
| _ => Segment.skel(seg)
};
switch (Segment.root_id(skel, seg)) {
This one is #2635's and #2469's only: dev has no c_skel.
No completions after a numeral (bfa36a6ede)#
After each keystroke, the editor fills its completion buffer with the best suggestion for the token left of the caret (TyDi.set_buffer). To find it, it scans the context for names and completes the whole segment to find the delimiters the syntax expects. That is a pass over the program, 10% of the profile before. No suggestion starts with a digit (names, keywords, operators and missing delimiters don't), so after a numeral none is computed now:
let* tok_to_left = token_to_left(z);
let* _ =
switch (tok_to_left.[0]) {
| '0' .. '9' => None
| _ => Some()
| exception (Invalid_argument(_)) => Some()
};
A limit to keep in mind: this fix helps exactly the probe's edits, which type digits. Typing a name still computes suggestions over the program. Dev has the same code, so the fix would suit dev as well.
Why an undo costs more than an edit#
An undo or a redo is two frames: the restore, then the RealizeView it schedules. The second builds the program's outline (Program.outline → Outline.of_source). The outline is memoized on the last four programs, keyed by the segment's identity and then by Segment.same_program, and the restored program matches none of them. So the outline is built from scratch, about 26 ms at 3,200 definitions. In this probe an edit never builds it, because nothing draws the sidebar. In the app, the sidebar's view may ask for it after edits too; that is not measured here.
That accounts for most of a redo's extra over an edit (42 ms), but only a third of an undo's (77 ms). An undo parses 2 definitions again where an edit parses 1, but that is constant work. Why an undo costs about one and a half redos is the next thing to profile, with the undo steps alone.
Toward O(log n)#
Every pass left is a walk over the top level's definitions. The incremental work inside each pass is already constant, so making each step O(log n) means making the walks O(log n). That starts with how the top level is represented.
| pass | today | over a balanced tree of definitions |
|---|---|---|
| the top-level term | O(n): a chain of nested lets, rebuilt from every definition | O(log n): each node keeps its subtree's term pieces and skeleton; an edit rebuilds its path. The evaluator and statics would read the top level as a sequence of items, as DefStatics already does. |
| statics' chain | O(n) walk, O(1) analysis | O(log n) to find the changed definition, plus the definitions that use a name whose type changed: each node keeps the names its subtree binds and uses, so the walk can skip subtrees that cannot be affected. |
| layout | O(n) walk to place a chunk | O(log n): each node keeps its subtree's rows and widths, so a definition's position is a sum along its path, as in a rope. |
| the outline | O(n), rebuilt on every undo and redo | O(1) on undo: kept with the snapshot, or found again by the restored program's identity. O(log n) on edit: one entry per node, rebuilt along the path. |
| the undo history | O(1) to push and pop | The same. A snapshot of a persistent tree is its root, so it could keep its derived data at the cost of one path per edit rather than a whole version, which is why snapshots are compacted today (^report(14)). An undo would then swap roots and redraw what differs. |
There are two ways to make an undo cheap, and they compose. Undo as a swap of roots keeps each version's derived data in a shared tree, and needs that tree. Undo as an inverse edit replays the edit backwards through the same incremental path a keystroke takes, and needs every action to have an inverse. Either way the walks above have to become O(log n) first, since an edit pays them too. The cheapest walk to isolate and try first is statics' chain: DefStatics already treats the top level as a list of items with summaries.
What this does not measure#
- Evaluation. The probe turns it off. In the app, an undo also re-evaluates the program in the worker, which ships the program to it and the results back.
- Drawing. The probe builds no view and no DOM, so the time to redraw the code after an edit or an undo is not here.
- The browser. These are Node timings of the test build. Hosted builds in a browser differ in speed, not in how the work grows.
- Real programs. A chain of short lets is the plainest shape that grows. Slides like Full Body have fewer, larger definitions, modules and livelits; the same walks apply to them, but with smaller n and larger per-definition costs.
What next#
- Profile undo steps alone, to explain why an undo costs about one and a half redos.
- Let a restored program reuse its outline: keep the outline with the snapshot, or find it by the restored program's identity. Then measure an undo again.
- Time an edit and an undo in the browser on Full Body, with evaluation and drawing, to see what share of a real undo these passes are.
- Try the top level as a balanced tree of definitions for statics' chain first, and measure it with this probe: does an edit's statics refresh stop growing with n?
- Then the top-level term and layout over the same tree.
- Offer the completions fix (no suggestions after a numeral) to dev as a PR of its own: dev has the same code.
How this was put together#
- The builds. Before: #2635 at 2cab658ef1 (dev's merge day of 2026-10-29 merged in) plus the probe, 1d98b3aed7 and 167247e61d. After: the same plus the two fixes, 459b029f68 and d06473240f. On #2635 the fixes are 394552d5d0 and bfa36a6ede, with the same code. The probe commits are on a local branch; the probe's source is in
036/drivers/. - The probe (
Test_UndoScaling.re, one of Hazel's test modules): for each size, build the program, move the caret into the middle definition, then 4 edits (typing1), 4 undos and 4 redos. Each step runs throughHistory.Update.updateandcalculate, then the frames it schedules, then a statics refresh (RefreshStatics, which the typing debounce runs in the app). Each frame'scalculateis told whether its update was an edit, asMain.retells it, anddynamics=false. For each step it counts the top-level items parsed in full (MakeTerm.Incr.full_analyzed), the layout chunks measured (Measured.Incr.built) and the definitions' heads compared structurally (DefStatics.head_compares). - Two checks the test runner installs were turned off for the probe, because each made the numbers mean something else. One compares the incremental statics with a whole-program analysis after every calculation (
DefStatics.after_calc). It made the first numbers look quadratic, then cheap past 3,000 entries, where it stops. The other re-molds the whole program after every edit (Zipper.normalize_check). A helper of the undo tests also told every frame it was an edit; the probe tells each frame what the app's main loop would. - Timing: Node 22.23 on a laptop (Intel Core i7-1370P, Linux), the test build compiled by js_of_ocaml in its release profile, run with
nice. Times come fromSys.time, read to the millisecond. There were 3 runs per build at 400, 800, 1,600, 3,200 and 6,400 definitions, with every after run before every before run (not alternated), on 2026-11-02 near 10:30 MST. Node runs with a 64 MB stack under a 256 MB system stack, because at 6,400 definitions the default one overflows: the same probe crashed in four of six runs with an 8 MB stack. - Profiles:
node --cpu-profat a 200 µs interval, one run of each build at 3,200 definitions (earlier the same day, same builds). Read by036/drivers/prof.py, which maps the test bundle's functions to their source through js_of_ocaml's source markers and sums inclusive time per function, under the probe'ssample. Node's profiler keeps about 255 frames of a stack, so deep recursion can hide callers; the parts in Figure 3 are separate branches belowsample, so none is counted twice. Raw profiles are summarized indata/profile_3200.json. - The data, twice: in this page (
script#report-data) and as files in036/data/, every run's raw output (runs/), the medians and samples (steps.json, bydrivers/medians.py) and the profiles' parts. - The numbers, for programs too: every table in this report, and what its numbers measure, is also in this page as JSON, in its
<script type="application/json" id="report-data">element, so a reader or an agent that fetches the page alone has the dataset. The same, with any raw files that survive, is in036/data/.