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.

  1. 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).

    10 ms 100 ms 400 6,400 definitions edit 106 undo 292 redo 184
    After the fixes, log-log. All of it, before and after, in Figure 1.
  2. 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.

    3,200 definitions: where a step's time goes 38% the top-level term 20% statics' chain 15% the outline 27% the rest
    One profile of the 12 steps at 3,200 definitions, after the fixes; Figure 3 has the one before.
  3. 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).

    an edit 43 + 32 ms a redo 85 + 32 ms an undo 120 + 32 ms the outline, about 26 msstatics after it
    At 3,200 definitions, after the fixes: each step, then its statics.
  4. 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).

    an edit, 6,400 definitions 163 ms 106 ms, 35% less an undo, 6,400 definitions 422 ms 292 ms, 31% less a redo, 6,400 definitions 340 ms 184 ms, 46% less
    Before (grey) and after the fixes, at 6,400 definitions.
  5. 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).

    16 definitions: an edit to one rebuilds its path, 4 nodes + 1
    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:

An edit the keystroke O(1) the zipper edited at the caret parse O(1) the changed definition parsed again: 1 the top-level term O(n) rebuilt from every definition layout O(n) 1 chunk measured; the walk over the rest statics, debounced O(n) 2 heads compared; the chain walked An undo pop a snapshot O(1) the stack's head; it holds zippers, no caches parse O(1) against the live caches: 2 definitions again the top-level term O(n) rebuilt from every definition layout O(n) 1 or 2 chunks; the walk over the rest statics, debounced O(n) 2 heads compared; the chain walked frame 2: the outline O(n) RealizeView: rebuilt from scratch (a miss) Green, O(1): touches only the changed definition, whatever the size of the program. Red, O(n): a pass over every definition.The statics refresh runs after the typing debounce, for an edit and an undo alike; it is timed separately below.
Figure 2. What an edit and an undo run, as the probe below counted and profiled them. The work on the changed definition is constant; each pass over every definition is linear. Read from 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.

an edit 10 ms 30 ms 100 ms 300 ms 400 800 1,600 3,200 6,400 definitions in the program if it grew as log n before the fixes, 400 definitions: 14 ms 14 before the fixes, 800 definitions: 16.5 ms 16.5 before the fixes, 1,600 definitions: 34 ms 34 before the fixes, 3,200 definitions: 72 ms 72 before the fixes, 6,400 definitions: 163 ms 163 after the fixes, 400 definitions: 10.5 ms 10.5 after the fixes, 800 definitions: 10 ms 10 after the fixes, 1,600 definitions: 22 ms 22 after the fixes, 3,200 definitions: 43 ms 43 after the fixes, 6,400 definitions: 106 ms 106 an undo 10 ms 30 ms 100 ms 300 ms 400 800 1,600 3,200 6,400 definitions in the program if it grew as log n before the fixes, 400 definitions: 23 ms 23 before the fixes, 800 definitions: 38 ms 38 before the fixes, 1,600 definitions: 80 ms 80 before the fixes, 3,200 definitions: 176 ms 176 before the fixes, 6,400 definitions: 422 ms 422 after the fixes, 400 definitions: 18.5 ms 18.5 after the fixes, 800 definitions: 30 ms 30 after the fixes, 1,600 definitions: 61 ms 61 after the fixes, 3,200 definitions: 120 ms 120 after the fixes, 6,400 definitions: 292 ms 292 a redo 10 ms 30 ms 100 ms 300 ms 400 800 1,600 3,200 6,400 definitions in the program if it grew as log n before the fixes, 400 definitions: 16 ms 16 before the fixes, 800 definitions: 30 ms 30 before the fixes, 1,600 definitions: 63 ms 63 before the fixes, 3,200 definitions: 130 ms 130 before the fixes, 6,400 definitions: 340 ms 340 after the fixes, 400 definitions: 13 ms 13 after the fixes, 800 definitions: 16.5 ms 16.5 after the fixes, 1,600 definitions: 41 ms 41 after the fixes, 3,200 definitions: 85 ms 85 after the fixes, 6,400 definitions: 184 ms 184 before the fixes (2cab658ef1)after (bfa36a6ede)
Figure 1. The time of one step against the number of definitions, both on log scales, so a straight line of slope 1 is linear growth. The statics refresh after each step is not included (it is in the table). Grey dashed: before the fixes; coloured: after. The green dotted line is not a measurement: it is how a step would grow if it cost O(log n), drawn from the first point. Medians of 12 steps; the after column's ranges are in the table.
definitionsan edit, beforeafteran undo, beforeaftera redo, beforeafterstatics, afterbuilding the program
4001410.5 (7–14)2318.5 (14–39)1613 (8–23)5173
80016.510 (9–23)3830 (22–36)3016.5 (13–25)7251
1,6003422 (20–40)8061 (49–77)6341 (34–70)17685
3,2007243 (40–83)176120 (104–205)13085 (62–148)321,007
6,400163106 (85–204)422292 (244–337)340184 (166–469)782,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#

before the fixes · 1,823 ms profiled, 12 steps and their statics the top-level term: 527 ms, 29% the top-level term527 ms · 29% statics' chain: 289 ms, 16% statics' chain289 ms · 16% the outline: 210 ms, 12% the outline210 ms · 12% auto-probe root: 244 ms, 13% auto-probe root244 ms · 13% completions: 178 ms, 10% completions178 ms · 10% the rest: 375 ms, 21% the rest375 ms · 21% after · 1,387 ms profiled, 12 steps and their statics the top-level term: 523 ms, 38% the top-level term523 ms · 38% statics' chain: 283 ms, 20% statics' chain283 ms · 20% the outline: 211 ms, 15% the outline211 ms · 15% the rest: 370 ms, 27% the rest370 ms · 27% the top-level term statics' chain the outline auto-probe root completions the rest
Figure 3. One CPU profile of the probe's 12 steps and their statics refreshes at 3,200 definitions, for each build, split into parts of the call tree that do not overlap. Profiled times run long, so read the shares. The auto-probe's root and completions, 23% of the time before the fixes, are gone after; the rest is the same work.
part of the timewherebeforeafter
rebuilding the top-level termMakeTerm.go_incr (src/haz3lcore/lang/MakeTerm.re:3382)527 ms · 29%523 ms · 38%
statics' walk down the chainDefStatics.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 rootAutoProbePerform.program_root_id (src/haz3lcore/AutoProbePerform.re:188)244 ms · 13%under 78 ms
completion suggestionsTyDi.suggest (src/haz3lcore/TyDi/TyDi.re:57)178 ms · 10%under 78 ms
the restgarbage collection, the probe's own frames, and everything below the list375 ms · 21%370 ms · 27%
total1,823 ms1,387 ms

Inside those parts, after the fixes:

functionshare
MakeTerm.record_top_frame / sim (MakeTerm.re:2622, 2635)239 ms · 17%
ItemSpans.memo / top (ItemSpans.re:793, 822): the top level's spans141 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 walk78 ms · 6%
Id.compare (src/util/Id.re:123), from several of the above134 ms · 10%

Every remaining part is a pass over the whole top level, around work that is already incremental:

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 edit 43 + 32 ms a redo 85 + 32 ms an undo 120 + 32 ms the outline, about 26 msstatics after it
Figure 4. An edit, a redo and an undo at 3,200 definitions after the fixes, each followed by its statics refresh (grey). The dashed box inside the redo and the undo is the outline's rebuild, from the profile: the profile's outline time over the 8 steps that build it.

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.

Today: a chain of nested lets let v0 let v1 let v2 let v3 let v4 let v5 let v6 let v7 let v8 let v9 let v10 let v11 let v12 let v13 let v14 let v15 Changing v9 rebuilds every let around it: 10 of 16, n/2 on average. A balanced tree of definitions v0 v1 v2 v3 v4 v5 v6 v7 v8 v9 v10 v11 v12 v13 v14 v15 the old root (dashed) keeps the old pathand shares every other node Changing v9 rebuilds its path: log2 n nodes, here 4, each with its summary.
Figure 5. Left: the top level as it is, a chain of nested lets, where each definition's term contains every definition after it. A persistent update to definition 9 rebuilds the lets around it, 10 of 16 here, n/2 on average. Right: the same definitions as the leaves of a balanced tree. An update rebuilds only the path to the changed leaf, log2 n nodes, and the previous version keeps its own path while sharing everything else.
passtodayover a balanced tree of definitions
the top-level termO(n): a chain of nested lets, rebuilt from every definitionO(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' chainO(n) walk, O(1) analysisO(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.
layoutO(n) walk to place a chunkO(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 outlineO(n), rebuilt on every undo and redoO(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 historyO(1) to push and popThe 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#

What next#

  1. Profile undo steps alone, to explain why an undo costs about one and a half redos.
  2. 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.
  3. 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.
  4. 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?
  5. Then the top-level term and layout over the same tree.
  6. 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#