Hazel reports · ^report(17) · 2026-10-15
Fourteen performance PRs to dev, and a plan to merge them
Since late September, the profiling behind this series has produced fourteen pull requests to dev: statics, loading text, the evaluator, the worker, the editor, and CI. Each is small, measured and tested, and none has been reviewed. This report is for whoever reviews them (Cyrus, Alexander, Andrew, Matt K): for each PR, what it buys and where that was measured, the honest reasons not to merge it yet, and what to look at; then the order to merge them in, which ones depend on or conflict with which, and what each needs first.
Short answers. The PRs as of 2026-10-15 evening, against dev at e12145c69b. Every number below was measured in its PR (or in the report named), each against dev or the livelits branch as it says.
- All fourteen are green and merge cleanly onto today's dev, one at a time. Every one passes CI (five checks pass and one is skipped, on each; dev requires none to merge,
^report(6)) and GitHub calls each mergeable. None has a review or a comment from a person. A fifteenth, #2608, is open too, and was missing from the list this report began from (below). - What they buy, on dev: statics of nested functions from exponential to linear (5.3 s to 13 ms at eight levels; real programs 20 to 30% faster, #2603); loading text 2.5× faster and the parse test groups 267 s to 142 s (#2614, with #2610 for the fallback, 182 s to 63 s); re-evaluation after an edit 2.7 s to 0.41 s in a 400-definition program (#2673); and typed code that no longer copies or re-walks its values (#2661, #2679, #2681). On the livelits branches the same changes are larger: a Polygons click 15 s to 2 s, a Kids' Choice release 1.6 s to 0.8 s (Figure 1).
- The reasons to wait are few and specific. Three pairs conflict textually (#2610 with #2614, #2661 with #2679, #2673 with #2681), and three PRs conflict with Andrew's #2469 (#2603, #2606, #2614). #2681 contains #2661 and #2679. #2603 and #2673 are the two to review most carefully: the largest statics change, and a new general hashing mechanism. #2490 is a spike, not a merge candidate (Figure 2).
- The merge plan: four waves, small and independent first, the evaluator together, and the two that need coordination last. Merged in that order, 9 of the fourteen go in with no conflict; 5 need a hand resolution, two of them only the test list. With those resolutions all fifteen together build and pass the full test suite, 4,756 tests (the plan).
^report(5) for the worker's answer size; the runs and conditions are in the PRs. Not every PR is here: #2652 and #2672 fix wrong behaviour, #2651 was measured on one slide, #2675 has no timing of its own, and #2678 adds a check.What depends on what
test/haz3ltest.re, the test list, where each PR adds its tests' line; they resolve by keeping both lines.Each PR
| PR | What it does, and why it matters | Reasons to wait | What a reviewer should look at |
|---|---|---|---|
| #2672 the editor · +8 −1, 1 file | Measuring the font no longer scrolls the editor to the caret. A correctness fix with a responsiveness cost: a font re-measure after the reader scrolled jumped the editor back, and a click aimed at what was there landed on something else. On the livelits branch the first click on a slide's picture was lost 7 or 8 times in 8; with this, 8 of 8 land (^report(1)). | None found. One line. | That startup still reveals the caret once (scroll_to_caret starts true). |
| #2652 the worker and the page · +69 −39, 2 files | The eval worker starts at load, and a late timer no longer kills a worker whose answer is already queued. In Firefox a busy main thread could keep the worker from loading, and a late deadline then killed it: the slide showed “Evaluation timed out” and drew nothing. With this those slides draw (#2609's finding 8). | Firefox-only behaviour, so it rests on the tests in the PR and on manual checks; the 250 ms lateness threshold is a judgement call. | WorkerClient.set_deadline's lateness rule, and creating the worker before IndexedDB loads. |
| #2651 statics · +18 −5, 1 file | A module binding's definition is analyzed once, not twice. Statics analyzed module M = def's definition a second time only to read back an elaboration it had already recorded. On one slide, 1.8 s of its 4.2 s of statics (^report(1), on the livelits branch); hazel analyze -W output is the same on all 73 programs, but for warnings' random ids. | None found. 18 lines; it touches Statics.re, as #2603 and #2675 do, but merges cleanly with both. | The fallback to the direct analysis when no info was recorded. |
| #2610 loading text · +117 −3, 4 files | Text the fast parser rejects is parsed by the segmented parser, which keeps projectors. The fallback inserted text one character at a time, re-molding the whole sibling run each time: quadratic. Over 128 inputs (every program and tutorial section) the fallback takes 63 s instead of 182 s; 8 KB of prose, 1.3 s instead of 64 s. | It conflicts with #2614 in MarkerParse.re (both change how the fallback is called): merge one, then rebase the other. | to_segment_with_manuals keeping each piece's projectors. |
| #2614 loading text · +292 −46, 9 files | The recovering parser inserts whole tokens and re-grouts once, instead of per character. Parsing the 72 programs takes 87 s instead of about 220 s, and the parse-heavy test groups 142 s instead of 267 s: CI time for everyone, and load time for slides and share links. | It conflicts with #2610 (above) and with #2469 in Segment.re and Zipper.re. Eight commits, each checked on its own. | Siblings.rescan returning its argument unchanged, which the identity checks downstream now rely on. |
| #2603 statics · +563 −67, 10 files | A let is analyzed once unless it refers to itself, plus three smaller statics speedups. Every let-bound function took the recursive path, and passes multiplied with nesting, up to 4depth: eight nested functions took 5.3 s of statics, now 13 ms. On real programs statics is 20 to 30% faster. Output is byte-identical on all 72 programs. | The largest statics change here (563 lines, four fixes in their own commits), and it conflicts with #2469 in Builtins.re. It deserves the closest review. | When a function-shaped definition skips the recursive probe: it must not mention its own binders, and they must shadow nothing in scope. |
| #2675 statics · +90 −1, 2 files | Statics reports a recursive let's free variables correctly, so the evaluator reuses what is around it. A module with a recursive member, or a function with a recursive helper, was never reused by incremental evaluation, nor was anything reading it: every edit re-ran them. The PR has no timing of its own; on the livelits branch the same fix lets the woods slide reuse its code after a figure move (^report(5), ^report(6)). | It changes what statics reports (a let's co_ctx), which reuse trusts: a mistake there would reuse a stale value. Only when a fixpoint binds the pattern. | The requires_fixf case taking CoCtx.mk(ctx, p_ana.ctx, def.co_ctx), as fun_id already does. |
| #2671 the editor · +173 −6, 3 files | MapPiece returns the same object for any piece with nothing changed beneath it. A projector update copied the whole program, and looked to anything keyed on identity like a new program. On dev, 3.1 ms to 1.9 ms per update in a 2,000-let program; on the livelits branch, where items are re-parsed by identity, a Kids' Choice slider release went from 1.5–1.7 s to 0.7–0.9 s. | On dev itself the gain is small: the copy only. Its value is for what keys on identity, as #2469 and the livelits branches do. | That every of_* returns its argument when its children came back physically equal. |
| #2661 the evaluator · +245 −1, 4 files | Types get a Merkle hash, and the evaluator asks is_consistent once per pair of types. Ascribing a constructor application compared two copies of the same sum type in full, every time. On dev's benchmark 75 ms to 60 ms; on the livelits branch, where a view is a tree of Html constructors, a click on the Polygons editor went from 15–17 s to 2 s (profiled), and the ascription step from 88% of the click to under 6%. | It conflicts with #2679 in Ascriptions.re; #2681 carries the resolution (merge commit a032bd5c6a). The hash lives in a JS WeakMap: a 60-bit key, and types holding an expression get no hash. | TypHash's key and what it refuses to hash. |
| #2679 the evaluator · +103 −47, 3 files | An ascription pushed through a value gives the value back when nothing in it changed. A well-typed list crossing a typed parameter was copied whole on every call. A list of 2,000 tuples passed 30 times: 382 ms to 268 ms; ordinary code unchanged. On the livelits branch the Scenes picture's view is 20 to 25% faster, and its values keep their ids, so identity memos hit. | It conflicts with #2661 (see #2681). The copy was invisible to step counts, so the gain shows only in time. | Each case building a node only when a part came back different. |
| #2681 the evaluator · +508 −49, 7 files | A value already known to have a type is not walked again for it. A recursive function with an annotated list parameter walked its whole argument at every call: quadratic. At 400 elements 205 ms to 33 ms; a parameter annotated at the let is unchanged. | It contains #2661 and #2679 (merged into its branch): review only its last commit, 8546bde64e, and merge it after both. It conflicts with #2673 in Util.re. | The memo's key, and that it costs nothing measurable when there is nothing to save. |
| #2673 the evaluator · +519 −7, 8 files | Incremental evaluation recognizes an unchanged expression by its Merkle hash, and remembers each comparison. The reuse check compared expressions structurally, and statics makes fresh objects every run, so every check walked its whole subterm: O(n²) along a program's lets. Re-evaluating after an edit in a 400-definition program: 2.7 s to 0.41 s. On the livelits branch, about 410 to 145 ms per release (^report(5)). | It adds Util.ValueHash, which hashes any immutable value by its runtime structure: the most general new mechanism here, and the one to read carefully for soundness (functions and unforced lazies are refused). It conflicts with #2681 in Util.re. | What ValueHash refuses to hash, and ValueHash.Pairs' memory. |
| #2606 the worker and the page · +300 −9, 6 files | The incremental cache stays in the eval worker instead of crossing to the page and back on every run. An answer carried its whole evaluator state, almost all cache, and the page sent it straight back: answers 86 to 98% smaller (^report(1)); on the livelits branch, an answer of 332 KB became 0.9 KB (^report(5)). | It conflicts with #2469 in four files (EvalResult.re, WorkerServer.re, ScratchMode.re and its test), which change the same request and answer: merging both needs a plan with Andrew. | What happens after a failed run or a restarted worker: both keep nothing, so the next run is a full one. |
| #2678 CI · +216 −8, 7 files | CI fails when an evaluation takes more steps than its budget; the two broken benchmark workflows stop running on comments. Nothing in CI catches a performance regression today. Times can't be held to a budget on shared runners; step counts can (^report(6)). Two budgets, in the quick suite, under a second. | A budget can fail someone's legitimate change: the budgets are about 10% over today's counts. Merge it after the evaluator PRs, so its counts are theirs. Turning off /perf and /bench-compare on comments is the maintainers' call. | evaluate_counting_work counting a reused result as one step. |
A fifteenth: #2608, “Leave the source's whitespace and comments out of elaborations” (+83 −3, 4 files; the program half of the worker's request). Every elaborated node kept its source's whitespace and comment runs, which nothing reads: a request's program and its info map are 12 to 49% smaller without them (on five programs, marshaled bytes; probes.hz 48.0 to 24.5 KB), statics time unchanged. It is independent of #2606, which shrinks the other half. Green and unreviewed, like the others, it merges cleanly onto dev and with #2469, and conflicts only with #2610 in the test list. It belongs in wave 1. Reasons to wait: none found; a reviewer should check that nothing reads an elaboration's secondary (the PR says only ExpToSegment's exact mode does, on parsed source).
Where they act
^report(5), on the livelits branch). One change, in eight steps. The dev PRs act at these steps: the edit (#2671, and #2672's scroll), statics (#2603, #2651, #2675), the boundary (#2606, and #2652's start-up), the worker's evaluation with reuse (#2673, #2675), and the evaluator wherever it runs, in the worker or the page (#2661, #2679, #2681). #2610 and #2614 act before any of this, when a program's text is loaded.A merge plan
- Wave 1. Small and independent: #2672, #2652, #2651, #2610. Each is small, touches nothing the others do, and merges cleanly onto dev; any order. #2608 (below the table) belongs here too.
- Wave 2. Loading and statics: #2614, #2603. #2614 after #2610, resolving
MarkerParse.re. #2603 is independent of both but is the biggest statics change: give it its own review. - Wave 3. The evaluator: #2675, #2671, #2661, #2679, #2681, #2673. #2681 contains #2661 and #2679 with their conflict resolved: merge it in their place (GitHub then shows both as merged), or merge them first, #2679 with the same resolution. #2673 last, resolving
Util.rewith #2681. - Wave 4. Coordinated: #2606, #2678. #2606 when #2469's plan is known (they change the same request and answer). #2678 last, so its budgets count the evaluator as merged.
Checked by merging the PRs' branches into dev at e12145c69b in this order, without building: 9 merge with no conflict, and these need a hand resolution:
- #2614:
src/haz3lcore/zipper/MarkerParse.re - #2661:
test/haz3ltest.re - #2679:
src/language/dynamics/transition/Ascriptions.re - #2673:
src/util/Util.re - #2678:
test/haz3ltest.re
MarkerParse.re: the same line, the load fallback's call to the parser. #2610 calls the segmented parser (to_zipper_segmented); #2614 calls the old one in its new whole-token mode (to_zipper(~by_run=true)). Take #2610's, and measure whether passing ~by_run through to the pieces gains more; #2614's speedup still applies everywhere else to_zipper runs. Ascriptions.re: #2661 and #2679 each add a helper in the same place; #2681's merge commit a032bd5c6a keeps both. Merging #2681 in their place avoids this step entirely: it merges cleanly onto dev, and brings both. Util.re: #2673 and #2681 each add a module; keep both. The test list: keep both lines.
With #2469. #2603, #2606 and #2614 conflict with Andrew's modular editors: in Builtins.re (#2603), in the worker's request and answer and the code around them (#2606: EvalResult.re, WorkerServer.re, ScratchMode.re and its test), and in Segment.re and Zipper.re (#2614). Whichever lands second rebases; #2606 most of all needs agreeing with Andrew, as both change what crosses to the worker. The other eleven merge cleanly with #2469.
Built and tested together. All fifteen (#2608 included), merged into dev at e12145c69b in this order with the resolutions above and #2681 in place of #2661 and #2679, build with no errors and no formatting changes, and pass the full test suite: 4,756 tests in 411 s, in node, on 2026-10-15 (a local branch, not pushed).
What next
- Ask for reviews, a wave at a time: wave 1 can go to anyone; #2603 and #2673 to someone who knows statics and incremental evaluation.
- Agree with Andrew the order of #2606 and #2469.
- Measure dev with all fifteen against dev today, parsing and analyzing hazel-programs and running the test suite, for one before-and-after number (the merged branch is ready).
How this was put together
- Together: the fifteen branches merged into dev in the plan's order on a local branch (the conflicts resolved as the plan says), built with the dev profile, and the full suite run under node.
- The PRs: their titles, descriptions, checks, reviews and comments, read from GitHub on 2026-10-15 at 21:40 MDT (
gh pr view --json). The numbers are the PRs' own, from their descriptions, which say how each was measured and on what; a few come from earlier reports, named where they are used. - Conflicts: each branch's files against its merge base with dev; each pair of branches merged with
git merge-tree --write-tree; and all fourteen merged into dev in the plan's order, a conflicted step continued with both sides kept, to find the next conflict. The trial merges themselves were not built; the merged branch below was. - The data: in this page's
<script type="application/json" id="report-data">element and in017/data/(each PR's JSON as GitHub gave it, and the file and conflict analysis); this page's generator is in017/drivers/. - All fourteen PRs are Matthew Hammer's, written with Claude; several began as fixes on the livelits branches, and #2681's base merges are noted in it.