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.
- 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 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 - yasx ¿ - 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. - 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.
- 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.
- 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).
All 19 PRs
| PR | What it changes | Can it change a type or a value? | Verdict |
|---|---|---|---|
| #2603 | 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. | 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 |
| #2651 | 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). | For well-typed module definitions that do not name their own module: no. In two corners, yes. | The argument does not hold |
| #2661 | 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. | No, if two different types never hash the same. | Same results, under a stated assumption |
| #2679 | 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. | 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 |
| #2681 | 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. | No, if two different types never hash the same. | Same results, under a stated assumption |
| #2606 | 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. | 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 |
| #2608 | Statics 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 |
| #2673 | 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. | 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 |
| #2675 | 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. | 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 |
| #2610 | 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. | 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 |
| #2614 | 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. | 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 |
| #2652 | 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. | 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 |
| #2671 | Mapping 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 |
| #2672 | Storing 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 |
| #2490 | 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/. | 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 |
| #2678 | 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). | 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 |
| #2689 | 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). | 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 |
| #2690 | 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). | 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 |
| #2691 | 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). | 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
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:
- Statics.Let: three type checks (plain, recursive, shadowing) and a timing test of 12 nested lets.
- Ctx.TailIndex compares the indexed lookup with a plain scan; TyDi.PrefixPruning.
- The PR reports identical
analyze -Woutput on 72 programs (errors and warnings only).
Tests to add:
- The old let behind a flag, compared per id (type, marks, names read, elaboration) and by evaluation, over the corpus and random nests of lets.
- Cases:
let (f, g) = …where onlygcallsf; an annotation that disagrees (let f : Int = fun x -> x); definitions with holes; a derivation quote naming the binder (which would fail today).
#2651: Statics: a module binding analyzes its definition once, not twice The argument does not hold
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:
- None added; the PR reports the Module, Statics, Elaboration and Evaluator groups passing (913 tests). The evaluator's module tests never reach the lifting path.
Tests to add:
- Evaluation tests for both corners.
- Old against new elaborations over every program with a module.
- A dev-build assertion that the read-back elaboration equals the direct one.
#2661: Types get a Merkle hash; the evaluator asks is_consistent once per pair Same results, under a stated assumption
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:
- TypHash: 10 tests of the hash's properties.
Tests to add:
- A property test over random type pairs (random ids, provenance, parens, recursive types, bad entries): the remembered answer equals the direct one, in both orders.
- A census over every type in the corpus's info maps: same hash implies equal up to ids and provenance.
- The evaluator with the memo on and off, compared.
#2679: An ascription pushed through a value gives the value back when it already has the type Same results: the argument holds
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:
- Test_Ascriptions: a well-typed list of labelled tuples keeps its identity; a list holding a function is still wrapped.
Tests to add:
- Old against new over a corpus: results equal, step counts and stepper traces identical.
- A property test over random typed values.
#2681: A value known to have a type is not walked again for it Same results, under a stated assumption
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:
- Test_Ascriptions: a list's tails are remembered, only for that type; a value without the type is not remembered (mutation-checked on #2635, the PR says).
Tests to add:
- The memo on and off over a corpus and random programs with annotated parameters: results, step counts and cast errors compared.
- A wrongly typed head consed onto a remembered tail.
- A fake hash injected to force a collision, showing it is the only way to a wrong answer.
#2606: Keep the incremental cache in the eval worker Same results, under a stated assumption
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:
- A new group, WorkerServer held cache (4 tests): a slot is kept and left out of the answer; documents and keys are separate; an edited program run against the held cache equals a fresh run; a failure drops the slot.
Tests to add:
- Across programs: for programs A and B from the slides and the tutorial, evaluating B with A's cache equals evaluating B fresh (value, probes, test results).
- The same through the worker's yielding path, with a superseding request mid-run, on a program above the incremental threshold.
- An edit sequence replayed three ways (held cache, the page's cache, fresh), compared at every step.
#2608: Leave the source's whitespace and comments out of elaborations Same results: the argument holds
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:
- A new group, ElabSize: a program full of comments elaborates with no whitespace on any expression or pattern node and still evaluates to 30.
- The whole evaluator suite now runs on stripped elaborations.
Tests to add:
- Over the slides and the tutorial, with and without the change: info maps agree except the elaboration, elaborations agree on every id and lexeme, and results, probes and test results are identical.
#2673: Incremental evaluation recognizes an unchanged expression by its Merkle hash Same results, under a stated assumption
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:
- ValueHash (5 tests), one_walk_lexemes_test and lexeme_edit_test in the incremental suite.
Tests to add:
- A QCheck property over random terms and random edits (new ids, lexeme and literal edits, parens and projectors added or removed, a binder renamed): the hashed check equals the old one, and equal hashes imply equal terms. It would catch the parens case.
- Old against new reuse decisions at every node of the slides and the tutorial, after an edit and after replacing all ids.
- No two structurally different subterms of that corpus share a hash.
#2675: Incremental evaluation reuses expressions around a recursive let Same results: the argument holds
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:
- Two new cases check the result and the reuse plan after an unrelated literal edit.
Tests to add:
- Incremental against fresh after editing an outer variable used inside the recursive definition (
let k = 7 in let go = fun i -> if i == 0 then k else go(i - 1) in go(3): editingkmust not reusego), mutual recursion, function sugar, and an outer binding the recursive name shadows. - Re-enable the incremental-against-fresh property, with a generator that makes recursive lets.
#2610: Parse the load fallback with the segmented parser, projectors kept The argument does not hold
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:
- Parser.Segmented compares 5 files and a prose paragraph with the character-by-character parser, by printed text.
Tests to add:
- The confirmed case (a forced fallback, a top-level
x - ywith the split insidex), andf (y)ande : Tat a split, against the character-by-character parser. - A sweep of split positions: each corpus program with 0 to 99 characters of padding in front, so every top-level space becomes a split point at some offset.
- Fallback-shaped inputs (an unfinished form at the end, concave holes), compared as terms, not only printed text.
- A likely fix, not yet tried: remold the joined zipper once before the final re-grout.
#2614: Load text about 2.5× faster: whole tokens, one re-grout Same results, under a stated assumption
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:
- Parser.LoadPath (6 tests); 386 parse tests; a structural comparison on 72 programs, run once offline.
Tests to add:
- In CI: whole-token runs on against off, ids erased, over the corpus and fallback-shaped inputs (prose, hole-heavy text, unfinished endings,
x+-1,>>=,1.5e-3, Unicode names, strings and comments with spaces). - A lone
\rinside a literal.
#2652: Eval worker in Firefox: start it at load, and do not time out a reply that is queued Same results: the argument holds
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:
- None.
Tests to add:
set_deadlineagainst a fake clock: fires when on time, re-arms when late, clearing cancels a re-armed timer.
#2671: MapPiece keeps unchanged pieces as the same objects Same results, under a stated assumption
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:
- MapPieceIdentity: a no-op update returns the same segment, and the top-level prefix is kept (both fail without the change).
Tests to add:
- A property test: new against old, structurally, for random pure updaters; identity checked at depth.
#2672: Measuring the font no longer scrolls the editor to the caret Same results: the argument holds
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:
- None; checked in a browser.
Tests to add:
- Assert the update returns
scroll_active = false.
#2490: SPIKE: compile the Hazel evaluator with wasm_of_ocaml and benchmark it Changes results: a spike, not for merging
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:
- None that run in CI.
Tests to add:
- Before wasm is used for real: a CI check that results match across the two backends. Keep the util split, if wanted, as a fresh PR without the artifacts or the livelit removal.
#2678: CI fails when an evaluation does more work than its budget Cannot change a type or a value
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:
- Both runs check their values (6085 and 7085), so a stale reuse in the re-run would fail.
Tests to add:
- Edit a literal that is read (fib(10) to fib(11)) and check the value equals a fresh run's: the guard against reusing too much.
- Check the re-run's step count and probe samples equal a fresh run's.
- Check the counted path's value equals
Evaluator.evaluate's.
#2689: Test: statics analyzes each expression within a budget Cannot change a type or a value
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:
- The tests carried from #2603 check results: Test_CtxIndex compares indexed lookups with a plain scan; Test_Statics_Let checks nested lets type-check consistently.
Tests to add:
- Assert each pinned case's type and that it has no errors, since statics already runs there.
- Keep an expected top-level type and error list per corpus program, and compare: where #2603's analyze-once could silently change a type.
#2690: Test: a re-run beside a library with recursive members stays within its step budget Cannot change a type or a value
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:
- The value must be 45158 (300·301/2 + 8).
Tests to add:
- The other direction for #2675: edit inside the library (range(300) to range(301), expect 45458) and inside a recursive member's body, each checked against a fresh run.
#2691: Test: what the eval worker sends and gets back stays within a byte budget Cannot change a type or a value
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:
- The carried Test_WorkerServer (#2606) compares a run with the held cache against a fresh run, on a small program.
Tests to add:
- Decode the request and answer and check they round-trip.
- Compare the answer's value with
Evaluator.evaluateon the same elaboration, and the second answer's value with the first's.
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:
- 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.
- 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. - 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.
- 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).
- 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
- 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).
- 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.
- 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.
- Open the tests PR to dev (the five items above), starting with the incremental-against-fresh property.
- 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
- How each PR was read: its description, its diff and its own commits, at its head (the commit beside each section), with files at that head read where the diff alone was not enough, on 2026-10-19. Four read-only reviews ran side by side: statics (with the evaluator's ascriptions), the evaluator, parsing and the editor, and the test and CI PRs. They built and ran nothing.
- What was run: the two corners that change a result, with Hazel's command-line tool (
format,run) at the PR's head and at its parent: #2651 ate3013b0360and34f9605ddf; #2610 at0aba66119aand its parent. - What was not checked: each PR's own tests were not run here (their CI is green); the other corners named in the sections are from reading.
- The data: in this page's
<script type="application/json" id="report-data">element and in021/data/prs.jsonand021/data/meta.json; the page is made by021/drivers/gen021.py.