Hazel reports · ^report(21) · 2026-10-19 · work in progress

Of 19 performance PRs, 16 keep types and values the same; two change a result in a corner, and one is a spike

Cyrus asked whether the performance PRs compromise the correctness of typing or evaluation, and for more analysis and coverage of functional correctness. For each of the 19 PRs against #2609 (the performance tracking issue), this report says what it caches, skips or shares; why the result is the same as without it, or where it is not; what that rests on; and which tests back it and which are missing. For the meeting with Cyrus on 2026-10-21.

Short answers. As of 2026-10-19, each PR read at its head (commit ids below). Two corners were confirmed by running the PR against its parent; the rest is from reading the code.

  1. 16 of the 19 keep every type and value the same. 4 are tests and budgets that cannot change a result; 5 hold outright; 7 hold under an assumption stated with each (below).
  2. 2 change a result in a corner, both confirmed by running. #2610's load fallback, when it splits a long program just after an operand, loads x - y as x ¿ - y: a new hole, and the minus turned unary, saved back on the next edit. #2651 changes what a module under a one-member signature evaluates to (an ill-typed program, and the new value looks like the right one), and drops an error for a module that names itself.
  3. 1 is a spike, not for merging: #2490 (the evaluator compiled to WebAssembly) removes the builtin livelits to build. It is a draft and says so.
  4. The assumptions worth naming: three PRs treat a structural hash as equality (#2661 and #2681 a 60-bit type hash, #2673 a 64-bit term hash), so a collision would silently give a wrong answer; #2673's hash has a structure that makes some collisions easier than chance. #2606 relies on every finished incremental cache being consistent. All rely on terms never being mutated in place.
  5. The biggest gap is in the tests, not the PRs. The property that compares incremental evaluation with evaluation from scratch is switched off on dev (#2457), and no test runs a program with an optimization on and off and compares. A tests PR to dev can add both (below).
Statics 5 PRs #2603 a let analyzed once #2651 module analyzed once #2661 type hash memo #2679 ascription returns the value #2681 typed values remembered Evaluator 4 PRs #2606 cache kept in the worker #2608 no whitespace in elab #2673 reuse by Merkle hash #2675 reuse around a recursive let Parsing, editor, worker 5 PRs #2610 segmented load fallback #2614 load 2.5x faster #2652 no false worker timeout #2671 MapPiece keeps identity #2672 font measure, no scroll Tests, CI, spike 5 PRs #2490 wasm spike (draft) #2678 CI step budgets #2689 statics pass budgets #2690 recursive-library budget #2691 worker byte budgets
Cannot change a type or a value: 4 Same results: the argument holds: 5 Same results, under a stated assumption: 7 Changes results: a spike, not for merging: 1 The argument does not hold: 2
Each PR by area, coloured by its verdict. Statics' column holds the PRs that change statics or the evaluator's ascriptions; the tests column holds test, budget and CI PRs, three of them stacked on runtime PRs. Each box links to its section.

All 19 PRs

PRWhat it changesCan it change a type or a value?Verdict
#2603A let whose definition is a function that neither mentions its own names nor shadows an outer one is analyzed once (the recursion probe's analysis becomes the final one), and a non-function definition skips the probe; context lookups answer the builtins from a hash table; meet is computed once per node; completion skips variables that cannot match the typed token.Types, marks, the names each node reads and elaborations: no, except one corner (below). Inside the definition, the editor's context now shows the binder at the pattern's type; IncrEval, the reuse pass, EvalInfo and the evaluator never read that context.Same results, under a stated assumption
#2651A module binding reads its definition's elaboration back from the info that the expanded let already wrote, instead of analyzing the definition a second time (kept as a fallback).For well-typed module definitions that do not name their own module: no. In two corners, yes.The argument does not hold
#2661Types get a structural hash that ignores ids and the provenance of unknown types, cached per object; the evaluator's consistency check when pushing an ascription through a constructor application is remembered per pair of hashes.No, if two different types never hash the same.Same results, under a stated assumption
#2679Pushing an ascription through a list, cons, tuple, label or constructor payload returns the value itself when every part comes back unchanged, instead of a fresh copy.No: in exactly those cases the old result was a copy of the same term. Only identity and annotations differ.Same results: the argument holds
#2681The evaluator remembers, per value (a term block, or a list's cell), the type hashes at which pushing an ascription returned it unchanged, and skips the walk the next time; a list records every tail.No, if two different types never hash the same.Same results, under a stated assumption
#2606The eval worker keeps the last finished incremental cache for each (document, key), at most 32 slots, and answers without sending the cache back; a request asks for the held one instead of sending its own.Values: no. It changes which earlier cache a run starts from: always the worker's last finished one for that slot, not whatever the page held (an older one after undo, a partial one after a superseded run, none after a slide reset).Same results, under a stated assumption
#2608Statics builds elaborated expressions and patterns without the whitespace and comments (secondary) around each source node, which only made elaborations bigger.No. Only the secondary annotation on elaboration nodes changes.Same results: the argument holds
#2673The reuse check first asks whether the cached and new elaborations hash the same (a structural 64-bit hash of the runtime value, remembered per object); if not, one structural walk compares the terms and their lexemes together, remembering each answer per pair of objects.It is meant not to, but its decision differs from the old check in corners: a hash collision would reuse a different elaboration (a wrong value), and the one walk is stricter than the old check in one case and laxer in another.Same results, under a stated assumption
#2675For a recursive let, statics' record of the names the definition reads (its co_ctx) now leaves out the let's own names, which the fixpoint binds, so an edit elsewhere no longer forces the recursive definition and everything after it to re-run.Values: no; more expressions are reused. It also changes one thing statics shows: in let go = 1 in let go = fun i -> ... go(i - 1) ... in go(3) the outer go is now flagged unused, which is right but not mentioned in the PR. Types do not change.Same results: the argument holds
#2610When the fast parser rejects saved text, the fallback now types it in as top-level pieces of at least 100 characters, each into a fresh zipper, joined and re-grouted once, instead of character by character; projector pins are kept.Yes, on the fallback path (saved programs with an unfinished form or a concave hole, share links, the CLI's tutorial decoding): a piece is parsed without its left context.The argument does not hold
#2614Loading types each run of operand or operator characters in as one insertion and re-grouts once at the end, not after every character; rescanning returns its own argument when nothing changed, tested by identity; a carriage return reads as a newline.Designed not to, and no case found by reading, except the line-ending change, which changes the parse on purpose for text with \r (loading, the paste fallback, splice paste).Same results, under a stated assumption
#2652The eval worker starts with the page; a deadline timer that fires more than 250 ms late waits another 500 ms and checks again instead of declaring a timeout.No: only whether and when the page gives up. What is sent, and which replies are accepted (the latest request id), is unchanged; a spurious "Evaluation timed out" now shows the real result.Same results: the argument holds
#2671Mapping over a segment returns the original object wherever nothing under it changed.No: the output is structurally equal to before; only sharing differs.Same results, under a stated assumption
#2672Storing the measured font no longer asks the editor to scroll the caret into view.No. Only the scroll flag flips; nothing recalculated before or after.Same results: the argument holds
#2490A draft spike, not meant to merge: it benchmarks the evaluator compiled with wasm_of_ocaml. To build, it splits util, swaps the statics memo, and removes the builtin livelits; it also commits 957 build artifacts under _build-wasm/.Yes, as it stands: the builtin livelits (^slider, ^emotion) no longer type or elaborate, and three livelit tests are deleted.Changes results: a spike, not for merging
#2678A test group holds two evaluations to step budgets: a cold run (206,834 steps, budget 227,000) and a re-run after editing a literal nothing reads (378, budget 420, and under 5% of the cold run).No. The trampoline now returns the step count it already kept, and a new entry point, called only by the test, runs the same trampoline.Cannot change a type or a value
#2689hazel analyze --count-passes counts how often statics analyzes each expression; a test pins the known multipliers (a recursive let 3x, nested recursive lets 27x, ...) and budgets every corpus program (at most 5 analyses of one expression, 2.5 on average).Its own commits: no (a counter that is off unless the CLI flag sets it). It is stacked on #2603 and #2651, which do change statics.Cannot change a type or a value
#2690A third step-budget case: a library module with recursive members, and an edit to a knob only the last line reads; the re-run takes 132 steps (budget 145, under 5% of the cold run's 127,394).Its own commit: no (test only). It is stacked on #2678 and #2675; #2675 changes which expressions are reused.Cannot change a type or a value
#2691Measures the encoded size of the eval worker's request and answer for two programs, with budgets about 10% over today (probes.hz: 25,180 and 3,617 bytes; B2T2 leftjoin: 60,628 and 21,258).Its own commit: no (test only). It is stacked on #2606 and #2608, which change runtime behaviour.Cannot change a type or a value

Each PR's argument

For each PR: what it changes, its mechanism, why the result is the same, what that assumes, where it is weakest, and its tests. Three PRs in the tests column (#2689, #2690, #2691) are stacked on others, so their diffs include those PRs' code; their own commits are test-only. #2681's branch also merges #2661 and #2679.

#2603: Statics: a let is analyzed once unless it refers to itself, plus three smaller speedups Same results, under a stated assumption

Read at 70e6677bdb

What it changes. A let whose definition is a function that neither mentions its own names nor shadows an outer one is analyzed once (the recursion probe's analysis becomes the final one), and a non-function definition skips the probe; context lookups answer the builtins from a hash table; meet is computed once per node; completion skips variables that cannot match the typed token.

Mechanism. Reuse when the definition is function-shaped, shadows nothing, and its analysis read none of the pattern's names (CoCtx.has_any); the builtin index is keyed by the physical identity of the builtin list; the shared meet answer is used only when all three old calls were literally the same call.

Why the result is the same. The probe's inputs are exactly those of the old recursive path's first pass, and statics is pure apart from fresh-name counters. With no shadowing, every other name resolves the same way in both contexts, and every way of looking up the binder is recorded in the names the definition reads; so when none of them was read, the final pass would equal the probe. For a non-function definition the old probe only fed a recursion test whose first case already answered false.

What it assumes. That a definition depends on the context only through lookups recorded in the names it reads, and that statics is pure.

Where it is weakest. One corner: a derivation quote (DrvQuote) looks names up but records none, so a quote that names the let's own binder is invisible to the reuse check, and an error mark (VarNoJoin) that the old final pass reported goes missing. Elaboration is unchanged. Very unlikely. Not checked: whether Typ.equal can tell two analyses apart through fresh variable names under Poly.

Tests that back it:

Tests to add:

#2651: Statics: a module binding analyzes its definition once, not twice The argument does not hold

Read at e3013b0360 · since fixed at 29c08fd8fa: the self-naming error is back, and the one-member corner is kept, with a test for each

What it changes. A module binding reads its definition's elaboration back from the info that the expanded let already wrote, instead of analyzing the definition a second time (kept as a fallback).

Mechanism. The elaboration is read at the definition's representative id in the current run's info map.

Why the result is the same. When the let's last analysis had the same context and expected type as the direct call, and recorded exactly the elaboration it returned, and took the non-recursive path, purity gives the same elaboration. A definition that is not a function is never recursive, so that holds in the normal case.

What it assumes. That the let's recorded elaboration equals what a direct analysis returns, and that the definition is not recursive.

Where it is weakest. Singleton-signature lifting: when a module is ascribed a one-member signature and has more members (module M : { let x : Int } = { let x = 1; let y = 2 } in M.x, an error on dev that still runs), the lifting path records a different elaboration from the one it returns, so M at runtime is now the module's own elaboration instead of the label wrapper: evaluation changes (arguably for the better, but not "the same output"). A function definition naming its own module (module F = fun x -> F(x), parser not checked): the read-back elaboration has F unbound with no error mark, where the old second pass reported a free-constructor error. Confirmed by running the first corner with the command-line evaluator at #2651's head (e3013b0360) and its parent (34f9605ddf, dev): M evaluates to (x=({ let x = 1; let y = 2 })) on dev and to (x=1, y=2) with #2651, and M.x to { let x = 1; let y = 2 } on dev and to 1 with #2651. The new results look like the right ones; dev already reports this program as a type error. The second corner, confirmed by running too: for module F = fun x -> F(x) in 1, dev reports a static error at F inside the definition, and #2651 reports none (both evaluate to 1, as F is not called).

Tests that back it:

Tests to add:

#2661: Types get a Merkle hash; the evaluator asks is_consistent once per pair Same results, under a stated assumption

Read at 0875dfcd66

What it changes. Types get a structural hash that ignores ids and the provenance of unknown types, cached per object; the evaluator's consistency check when pushing an ascription through a constructor application is remembered per pair of hashes.

Mechanism. A global table from a pair of 60-bit hashes to a boolean, emptied at 4,096 entries, caching is_consistent(Ctx.empty, unroll(sumt), unroll(t)). Types with expressions inside (proofs), projectors and signatures get no hash and are never cached.

Why the result is the same. The answer depends only on the two types' structure, not on ids or on unknown types' provenance: every unknown case succeeds whatever its provenance, variables in an empty context compare names, and bad sum entries cannot change the result. The hash covers every constructor and field except ids and provenance; binder names and constructor order are included, which only costs missed hits.

What it assumes. That equal 60-bit hashes mean structurally equal types, and that types are immutable.

Where it is weakest. The hash is not cryptographic (a base-31 polynomial and an FNV-like lane, each mod 2^30, and for atoms the second lane is an affine function of the first). A collision would give a wrong consistency answer: a mismatched constructor application pushed through, or a consistent one cast. Rare, no adversary, but silent.

Tests that back it:

Tests to add:

#2679: An ascription pushed through a value gives the value back when it already has the type Same results: the argument holds

Read at c82726a3c2

What it changes. Pushing an ascription through a list, cons, tuple, label or constructor payload returns the value itself when every part comes back unchanged, instead of a fresh copy.

Mechanism. Physical equality on the children; only a recursive push can return a child unchanged.

Why the result is the same. Every place the push returns its input unchanged (an unknown type, a matching atom, a consistent constructor, and the new cases) returns exactly its input, so when every child comes back as itself, the rebuilt node equals the value.

What it assumes. That values are immutable, and that nothing depends on the old copy's stripped annotations.

Where it is weakest. Values now keep their source annotations past an ascription (all their ids, comments, incomplete marks), where the copy had stripped them: the stepper or result printing could show a comment inside a value, and id-keyed machinery sees all ids. Display and metadata only; no bug found.

Tests that back it:

Tests to add:

#2681: A value known to have a type is not walked again for it Same results, under a stated assumption

Read at 8546bde64e · its branch also merges #2661 and #2679

What it changes. The evaluator remembers, per value (a term block, or a list's cell), the type hashes at which pushing an ascription returned it unchanged, and skips the walk the next time; a list records every tail.

Mechanism. A weak map keyed by the value's physical identity, holding type hashes (from #2661). Values are immutable, so nothing needs invalidating; dead keys are dropped.

Why the result is the same. The push decides only from the term's contents and the type (ids are used only to build copies), so two values sharing a term behave the same. A list can come back as itself only when every element did, at an unknown type, or through parens forwarding to one of those, and in each case every suffix list at the same type also comes back unchanged (so recording tails is sound). Values that need wrapping (functions) never come back as themselves, so they are never recorded.

What it assumes. #2661's no-collision assumption; that terms and lists are never mutated in place.

Where it is weakest. A collision would hide a failing push: a list remembered at [Int] would pass unchanged at a type with the same hash, skipping the cast failure. Same probability as #2661.

Tests that back it:

Tests to add:

#2606: Keep the incremental cache in the eval worker Same results, under a stated assumption

Read at 1b5de419b3

What it changes. The eval worker keeps the last finished incremental cache for each (document, key), at most 32 slots, and answers without sending the cache back; a request asks for the held one instead of sending its own.

Mechanism. WorkerServer.Held, keyed by (document, key). A failed run (a step-limit timeout included) drops its slot, a superseded run is never kept, a restarted worker starts empty, a 33rd slot clears the table. The held cache is swapped in once per request, before both the reuse plan and the evaluation run.

Why the result is the same. The reuse check never trusts where its earlier cache came from: for each node it re-checks the elaboration, the provenance of every name the node reads, and the probe targets against the new request. So a cache from an earlier run, or from another program that shares ids, is reused at a node only when the node and all it reads are the same, down to the builtins.

What it assumes. That every finished cache is internally consistent: each entry's value was computed from the values its sources hold. That holds by induction over runs, with one gap shared by all incremental reuse: whether a source counts as reused comes from the reuse plan's prediction, not from what the evaluator did.

Where it is weakest. A bad cache, if one ever exists (the #2457 kind), now survives slide resets and undo until the worker restarts or the page reloads. Some documents share a slot (slides of the same name; three modes send an empty document name), which costs reuse, not correctness. The tests skip the worker's real request path, and their program is below the size where the worker uses an earlier cache at all.

Tests that back it:

Tests to add:

#2608: Leave the source's whitespace and comments out of elaborations Same results: the argument holds

Read at 66f789d916

What it changes. Statics builds elaborated expressions and patterns without the whitespace and comments (secondary) around each source node, which only made elaborations bigger.

Mechanism. Nothing cached: data dropped (IdTagged.unwrap_elab, strip_secondary).

Why the result is the same. Nothing that consumes an elaboration reads secondary: the evaluator only ever empties it; equality ignores annotations except lexemes; printing uses stored whitespace only when preserving parsed source exactly, never for elaborations or results; incremental reuse compares lexemes, not whitespace.

What it assumes. That nothing reads an elaboration's whitespace and comments (none does at this head).

Where it is weakest. A future reader (say, quotation that keeps formatting) would silently get none. The stripping is incomplete (labelled-tuple wrappers, the nodes of types inside an elaboration), which costs size only.

Tests that back it:

Tests to add:

#2673: Incremental evaluation recognizes an unchanged expression by its Merkle hash Same results, under a stated assumption

Read at cea8f0a98f

What it changes. The reuse check first asks whether the cached and new elaborations hash the same (a structural 64-bit hash of the runtime value, remembered per object); if not, one structural walk compares the terms and their lexemes together, remembering each answer per pair of objects.

Mechanism. Util.ValueHash over the whole value (ids, lexemes, whitespace); memos never invalidated, relying on terms never being mutated; values with functions, unforced lazies or cycles get no hash and fall back to the walk. The shortcut is used only with alpha-equivalence off and no environments, so identical subterms mean the same thing.

Why the result is the same. Equal hashes mean an identical structure (barring collisions), and on identical structures the old check was true too. Where the new walk differs: stricter, a parenthesized literal against a parenthesized literal under new ids, or a slider over an Int literal, compares unequal (lost reuse, never wrong); laxer, (e) against e compares equal where the old check refused, and both evaluate to the same value (parens and projectors take one side-effect-free step), though a replayed step count can differ by the number of wrappers.

What it assumes. That the 64-bit hash never collides, and that terms are never mutated in place.

Where it is weakest. A collision silently reuses a wrong value. The hash has a structure that makes collisions easier than chance: each lane is an xor-multiply chain in which flipping bit 31 of a child flips only bit 31 of the state, so two children both differing exactly in bit 31 cancel. Elaborations have few raw int fields and no realistic colliding term was found, but a finalizer per lane (or a 128-bit hash) would remove the structure. The PR claims the same reuse decisions as before; that is not quite so.

Tests that back it:

Tests to add:

#2675: Incremental evaluation reuses expressions around a recursive let Same results: the argument holds

Read at 1e5207494a

What it changes. For a recursive let, statics' record of the names the definition reads (its co_ctx) now leaves out the let's own names, which the fixpoint binds, so an edit elsewhere no longer forces the recursive definition and everything after it to re-run.

Mechanism. The co_ctx decides which names the reuse check must find unchanged; removing a name removes a dependency.

Why the result is the same. A recursive let elaborates to Let(p, FixF(p, def), body), and its definition was analyzed with the pattern's names in scope, so every use of those names in it refers to the recursive binding the fixpoint binds. The free variables of FixF(p, def) are exactly the definition's minus the pattern's names, which is what the new co_ctx computes; a non-recursive let x = x + 1 still reads the outer x.

What it assumes. That the fixpoint binds exactly the names the pattern adds to the context (the fixpoint's own node already assumed this).

Where it is weakest. It widens reuse in exactly the programs (modules of recursive helpers) where any gap in cache consistency would show, while the property that compares incremental with fresh evaluation is switched off on dev (#2457).

Tests that back it:

Tests to add:

#2610: Parse the load fallback with the segmented parser, projectors kept The argument does not hold

Read at 0aba66119a · since fixed at 94ca8a2daa: the joined pieces are remolded once, with three split tests

What it changes. When the fast parser rejects saved text, the fallback now types it in as top-level pieces of at least 100 characters, each into a fresh zipper, joined and re-grouted once, instead of character by character; projector pins are kept.

Mechanism. Parser.to_zipper_segmented splits where the caret is at top level, outside any token, with no missing shards, just after whitespace (Parser.re:81-85). The join is re-grouted (Parser.re:158), never remolded, and the caller does not remold afterwards.

Why the result is the same. The PR checks the printed text against the character-by-character parser on 128 inputs, and keeps projector ids across the split (sound).

What it assumes. That a piece's first token is molded the same way without what precedes it. That is false for tokens whose mold depends on their left neighbour.

Where it is weakest. - (binary or unary) and ( (application or parentheses) are molded by their left neighbour, and an ascription's right side switches to types. If a split lands just after an operand, as when 100 characters end inside x in a top-level x - y, the next piece starts in a concave context: - becomes unary minus and the join gets a hole, so aaa…a - b loads as aaa…a ¿ -b, and f (b) as f ¿ (b). Splits also happen right after a comment's closing #. Rare per program, but silent at load, and written back on the next save. Confirmed by running Hazel's format at #2610's head (0aba66119a) and its parent, on a program the fast parser rejects (a concave hole earlier on) followed by a 100-letter name and - b: the parent loads aaa…a - b, #2610 loads aaa…a ¿ - b. (Both turn c ¿ d into c ¿ ¿ ¿ d; that is older than #2610.)

Tests that back it:

Tests to add:

#2614: Load text about 2.5× faster: whole tokens, one re-grout Same results, under a stated assumption

Read at b4e3914b03

What it changes. Loading types each run of operand or operator characters in as one insertion and re-grouts once at the end, not after every character; rescanning returns its own argument when nothing changed, tested by identity; a carriage return reads as a newline.

Mechanism. to_zipper ~by_run; take_run stops where typing would stop extending a token; one remold_regrout at the end; Siblings.rescan returns its argument unchanged when nothing converted.

Why the result is the same. The identity check is exactly the old structural one: rescanning alters a piece only at a conversion, which it flags. Whole-token runs: the operand and operator character classes are disjoint and no token spans both, and insertion still merges with its left neighbour. One re-grout: remolding skips grout and passes shapes through, so molds cannot depend on grout, and the final pass puts the same 0 or 1 grout at each junction.

What it assumes. That insertion's choices do not depend on grout or on per-prefix side effects (three places in Insert read grout, for ids, a suppressed space and the caret, none for the term).

Where it is weakest. A hole's place relative to a neighbouring space may differ (text and layout, not the term). A lone \r inside a string or comment now splits it into fragments (dev dropped it silently); rare and loud. CRLF text, which loaded with a hole on every line, now loads right. #2610 and #2614 both rewrite the same fallback: merging both needs a choice.

Tests that back it:

Tests to add:

#2652: Eval worker in Firefox: start it at load, and do not time out a reply that is queued Same results: the argument holds

Read at 9de67fdf30

What it changes. The eval worker starts with the page; a deadline timer that fires more than 250 ms late waits another 500 ms and checks again instead of declaring a timeout.

Mechanism. set_deadline (WorkerClient.re:57-74) re-arms a late timer; the timer reference always holds the latest one, so clearing cancels re-armed timers too.

Why the result is the same. A fresh worker starts in the same state whenever it is created; replies are matched by request id, so a stale one is dropped.

What it assumes. That a worker message already queued runs before a 500 ms timer set after it (not guaranteed by the spec; if not, the timer decides as before).

Where it is weakest. The evaluation deadline is no longer a fixed 20 s: in a hidden tab, timers run late and re-arm indefinitely, so a runaway evaluation keeps a core busy until the tab is visible. That delays a timeout message; it never shows a wrong result.

Tests that back it:

Tests to add:

#2671: MapPiece keeps unchanged pieces as the same objects Same results, under a stated assumption

Read at 04e1c6c362

What it changes. Mapping over a segment returns the original object wherever nothing under it changed.

Mechanism. Identity checks (===) on each piece and tile; the updater is applied piece by piece and then recursed into, where the old code applied it to a whole level first: the same calls in a different order.

Why the result is the same. If every piece maps to itself the output is element for element the input, so returning the input is equivalent. All five callers on dev are pure or order-independent.

What it assumes. That updaters are pure, or at least do not depend on call order.

Where it is weakest. A future updater that depends on order would break. It makes identity-keyed caches hit after projector updates; on #2635 that switches on incremental term-building reuse, which is sound only if an item's term depends on nothing outside its own pieces (a question for #2635's review).

Tests that back it:

Tests to add:

#2672: Measuring the font no longer scrolls the editor to the caret Same results: the argument holds

Read at 86bdda9d0c

What it changes. Storing the measured font no longer asks the editor to scroll the caret into view.

Mechanism. SetFontMetrics returns Updated.return_quiet without ~scroll_active=true.

Why the result is the same. The metrics are stored exactly as before, and the update never triggered a recalculation; the flag only decides whether the caret is scrolled into view after drawing.

What it assumes. Nothing.

Where it is weakest. User-visible and deliberate: after a zoom or a web font settling, the caret is no longer brought back into view. The reveal at startup still happens.

Tests that back it:

Tests to add:

#2490: SPIKE: compile the Hazel evaluator with wasm_of_ocaml and benchmark it Changes results: a spike, not for merging

Read at a7b5559cbb · draft spike; conflicts with dev

What it changes. A draft spike, not meant to merge: it benchmarks the evaluator compiled with wasm_of_ocaml. To build, it splits util, swaps the statics memo, and removes the builtin livelits; it also commits 957 build artifacts under _build-wasm/.

Mechanism. Builtins.ctx_entries drops the builtin livelits; Statics.mk's memo empties the whole table at 1,000 entries instead of evicting least-recently-used ones (keys still compared structurally, so a cost change, not a result change).

Why the result is the same. Not a merge candidate. Its benchmark compares the two backends' results by a hash of each workload's printed result (three workloads matched); that is a spot check, not a test.

What it assumes. That it is never merged as it is.

Where it is weakest. If merged: the livelit regression, the deleted tests, 957 build artifacts in history, and a dune 3.17 requirement.

Tests that back it:

Tests to add:

#2678: CI fails when an evaluation does more work than its budget Cannot change a type or a value

Read at 13b2e588ac

What it changes. A test group holds two evaluations to step budgets: a cold run (206,834 steps, budget 227,000) and a re-run after editing a literal nothing reads (378, budget 420, and under 5% of the cold run).

Mechanism. Evaluator.evaluate_counting_work runs the trampoline through run_slice(~step_budget=max_int) and returns the count; Trampoline.Yielding.SliceDone carries the total. The only other matches on SliceDone ignore the new field. No global state.

Why the result is the same. No production path changes what it computes: the new entry point is used by the test alone, and both trampoline drivers interpret Bind, Next and Done the same way.

What it assumes. That step counts repeat exactly across runs (the PR says so; not re-checked here).

Where it is weakest. Budgets have about 10% headroom and a count more than 25% under fails too, so harmless refactors will need budget bumps: noise, not flakiness. Not counted: the reuse pre-pass, statics, the worker's streaming path.

Tests that back it:

Tests to add:

#2689: Test: statics analyzes each expression within a budget Cannot change a type or a value

Read at 0d5d015eff · stacked on #2603 and #2651

What it changes. hazel analyze --count-passes counts how often statics analyzes each expression; a test pins the known multipliers (a recursive let 3x, nested recursive lets 27x, ...) and budgets every corpus program (at most 5 analyses of one expression, 2.5 on average).

Mechanism. A check at the top of uexp_to_info_map reads a ref that is None unless the flag sets it; the counts go in a table keyed by expression id.

Why the result is the same. With the flag off, the only new work is reading one ref per call; nothing statics computes changes.

What it assumes. Merged after #2603 and #2651, as it says: its diff includes their code.

Where it is weakest. A pass count cannot see a wrong type: the new test throws the info map away. With the flag on, Exp.rep_id could raise on an expression without ids (CLI only).

Tests that back it:

Tests to add:

#2690: Test: a re-run beside a library with recursive members stays within its step budget Cannot change a type or a value

Read at d706264f3f · stacked on #2678 and #2675

What it changes. A third step-budget case: a library module with recursive members, and an edit to a knob only the last line reads; the re-run takes 132 steps (budget 145, under 5% of the cold run's 127,394).

Mechanism. Test only.

Why the result is the same. No non-test code of its own.

What it assumes. Merged after #2678 and #2675.

Where it is weakest. 13 steps of headroom; the low-side check fails below 109.

Tests that back it:

Tests to add:

#2691: Test: what the eval worker sends and gets back stays within a byte budget Cannot change a type or a value

Read at 35edf2d1d8 · stacked on #2606 and #2608

What it changes. Measures the encoded size of the eval worker's request and answer for two programs, with budgets about 10% over today (probes.hz: 25,180 and 3,617 bytes; B2T2 leftjoin: 60,628 and 21,258).

Mechanism. Test only: it repeats the worker's resolve, evaluate and keep sequence itself.

Why the result is the same. No non-test code of its own.

What it assumes. Merged after #2606 and #2608.

Where it is weakest. It never looks at the answer's value: a broken evaluation shows only if the answer shrinks by more than 25%. Native and js_of_ocaml Marshal sizes not compared.

Tests that back it:

Tests to add:

What the tests check today, and what to add

What the tests check today. Each PR adds hand-made cases for what it changes, most of them checking values. The step-budget tests (#2678, #2690) also check their programs' values, so a stale reuse there would fail; the pass-count and byte budgets (#2689, #2691) check cost only. dev has 41 hand-made incremental-evaluation tests and property tests, but its property that compares incremental evaluation with evaluation from scratch after an edit is disabled, because of a known bug (#2457: a binding left unsubstituted when evaluation gets stuck after an edited literal). No test runs the corpus of programs with an optimization on and off and compares the results.

What to add, in a tests PR to dev:

  1. Turn the incremental-against-fresh property back on for completed results (stuck results skipped until #2457 is fixed), with a fixed seed, and with a generator that also makes recursive lets and modules of recursive helpers: the programs #2675 and #2606 reuse more of.
  2. A corpus comparison: every program in hazel-programs (the slides, the tutorial), evaluated incrementally after an edit and from scratch, and analyzed with each statics optimization on and off: types, error marks, elaborations (ignoring ids), values, probe samples and test results compared.
  3. Hash checks: for the type hash and the term hash, a property that equal hashes imply equal terms (up to ids and provenance), and a census of every type and subterm in the corpus.
  4. A parser comparison: the load fallback against character-by-character typing, with each corpus program padded by 0 to 99 characters so that every top-level space is a split point at some offset (this finds #2610's case).
  5. Budget tests that check results too: the pass-count and byte budgets already analyze and evaluate their programs; asserting the types and values makes them correctness tests at no cost.

Each PR's own missing tests are in its section above.

What next

  1. Fix #2610 before it merges: remold the joined pieces once before the final re-grout (or split only where the next token cannot change mold), and add the padded split-position sweep. Done at 94ca8a2daa (the remold, and three split cases; the sweep is still to add).
  2. In #2651, say that a module under a one-member signature now evaluates to the module itself (and keep it, with a test); restore the error for a module definition that names itself. Done at 29c08fd8fa, and said in the description.
  3. Strengthen the hashes: a finalizer per lane for #2673's term hash, which removes the bit-31 cancellation, and either a wider type hash or an equality check behind the hit for #2661 and #2681.
  4. Open the tests PR to dev (the five items above), starting with the incremental-against-fresh property.
  5. In #2675's description, mention the unused-variable warning it now gives for a binding a recursive let shadows; in #2673's, that its reuse decisions differ from the old check in two corners.

How this was put together