Hazel reports · ^report(24) · architecture · 2026-10-21

Hazel's Architecture Around #2651

How is Hazel built around #2651 (Statics: a module binding analyzes its definition once, not twice), and what does the PR change in that architecture? Matthew Hammer and Alexander Bandukwala, who owns Modules II, reviewed #2651 together on 2026-10-21, aiming to merge it that day. Matthew had tested its behaviour, and asked how Hazel is built around it: the subsystems an edit passes through, the representations they share, the protocols between them, and the PR drawn in those terms. Read, and six module programs run with the command-line tool (the runs), at dev 962a7cb009 and at 29c08fd8fa, the PR's head until fa3eef62ce and b5a3e1d90a, pushed during the review, which change only comments and one test's name.

Short answers. From reading the code at those two commits, and from running six module programs with the command-line tool built at each (the runs); nothing was timed for this report (the measurements quoted are the PRs' own: #2651's, and those of its follow-up #2717 (Statics: a module binding is a non-recursive let)). Alexander's answers are his, given on 2026-10-21: in the review and in conversation, relayed by Matthew, and since then in comments on #2651, linked where they are cited. As of 13:34 MDT every question for Alexander is answered, and none holds up the merge.

  1. On dev a module binding analyzes its definition three times; #2651 makes it two, and with #2603 (a let is analyzed once unless it refers to itself) one. module M = def in body is checked as let M = def in body. The let runs a recursion probe over def and then its real pass; the ModuleExp case then ran a third, direct pass only to get an elaboration the real pass had already recorded (the PR).

    Bars: analyses of a module binding's definition. dev 3, with #2651 2, with #2651 and #2603 1. analyses of def, per module binding dev 3 #2651 2 #2651 + #2603 1
    Analyses of a module literal's definition per binding.
  2. The architectural change is small and specific: one case of the statics walk now takes another's result from the info map. Statics keeps one record per expression id, the last write winning, and every case writes its node's record, elaboration included. #2651 reads elab_term at the definition's id instead of computing it again, the kind of read-back statics already does elsewhere (statics).

    Three boxes: the expanded let writes an Info at the definition's id into the info map; the ModuleExp case reads its elaboration back. expanded let writes Info info map id → Info ModuleExp reads elab one Info per id; the last write wins
    The let writes; the ModuleExp case reads back.
  3. Nesting multiplies: the innermost definition was analyzed 3depth times on dev; it is 2depth with #2651, and once with #2603 as well. This holds where a module … in sits inside another binding's definition; a module item inside braces is a plain let, and only #2603 changes it (the cost).

    Grouped bars by nesting depth 1, 2, 3: dev 3, 9, 27 analyses of the innermost definition; with #2651 2, 4, 8; with #2651 and #2603 1, 1, 1. dev #2651 #2651 + #2603 3 2 1 depth 1 9 4 1 depth 2 27 8 1 depth 3
    Analyses of the innermost definition, by depth.
  4. Two results change, both through the one-member signature lift, and Alexander accepted both. module M : { let x : Int } = { let x = 1; let y = 2 } in M.x, a type error that still runs, gave the module's raw source on dev and gives 1 with #2651: dev's result was a bug in the auto-labelling of one-member tuples. The same corner with a plain value, = 5, gives 5 on dev and gets stuck with #2651 (the runs). Alexander accepted that on the PR (his comment): a bare value under a module signature should be stuck and a static error, which Modules II part 1 already gives, so dev's missing error is temporary and #2651 needs no special case; the test for it goes on part 1 (the corner).

    What M.x gives for a module with an extra member under a one-member signature: on dev the module's raw source, with #2651 the value 1. M.x on dev { let x = 1; let y = 2 } M.x, #2651 1 a fix (Alexander)
    M.x before and after, for the extra-member module.
  5. A definition that names its own module is still an error, through a guard on the let's fixpoint; Alexander suggested a cleaner signal, now a draft follow-up. When the let wrapped the definition in a fixpoint, #2651 analyzes it directly, as dev did, and reports the name as unbound. Alexander's suggestion: tell the let analysis not to try the recursive case for a module binding; the guard, the fallback and the recursion probe then all go away. He has since found that the guard also matches a fixpoint the user wrote, module m = fix f -> …, which then loses only the speed-up (his comment). His signal is now the draft follow-up #2717 (Statics: a module binding is a non-recursive let), on #2651's head: a module binding is a non-recursive let, by an argument to the let, so names_itself and its fallback are gone and a module's definition is analyzed once. Its tests include his two fix programs: the first runs to 6 with no errors and is analyzed once; the second reports m unbound at the inner m. Nested module bindings no longer multiply the cost: a chain of ten nested function modules took 31.04 and 29.59 s to analyze with #2651, and 1.43 and 1.45 s with #2717, two runs each, where the command-line tool's startup alone takes 1.27 to 1.42 s (the guard, What next).

    The self-naming guard: if the let's elaboration is Let(_, FixF, _), the definition is analyzed again and the error is kept; otherwise its elaboration is read back with no pass. Let(_, FixF, _)? read back, no pass pass again: error no yes
    The second commit's guard.
  6. #2651 merges cleanly into today's dev and with #2603; against Modules II it conflicts on one line, in parts 2 to 9. The resolution keeps the read-back and adds ~coercible=true to the fallback. Alexander answered that #2651 does not conflict with Modules II in substance, so the order does not hold up the merge (question 2). CI is green at its head: at fa3eef62ce, and at b5a3e1d90a, which only renames a test (the review).

    Merge check chips: dev clean, #2603 clean, Modules II part 1 clean, parts 2 to 9 one conflicting line. dev: clean #2603: clean Modules II part 1: clean parts 2 to 9: one line conflicts
    Three-way merges, run read-only on 2026-10-21.
  7. All five questions for Alexander are answered, one of them for now, and none holds up the merge; three design questions go to Cyrus. Four were answered in today's review and in conversation. The fifth, which tests to pin, was settled on #2651, where Alexander now gives his feedback: the bare-value test goes on Modules II part 1, and the draft follow-up #2717 (Statics: a module binding is a non-recursive let) pins the self-naming test to its mark (questions).

    The five questions for Alexander: all answered, one of them for now, and none holds up the merge; and three design questions for Cyrus. 1 one-member lift: answered 2 merge order: answered 3 recursive modules: answered for now 4 def_elab_direct: answered 5 tests to pin: answered for Cyrus: 3 design questions
    The questions and where they stand.

The subsystems, and what flows between them

Hazel is a structure editor: the program is always a tree of tiles, and every edit leaves a program that can be typed and run. An edit passes through five subsystems in a fixed order, all on the page's main thread except evaluation (Figure 1).

Five subsystems left to right: editor, MakeTerm, statics, evaluation, results; results go back as actions. Below statics, the info map, and the eight things that read it. Editor tiles with ids zipper = the caret Perform.go edits it MakeTerm tiles to Exp.t term id = tile id holes filled first Statics walks the program info map: id → Info elaborates too Evaluation in the eval worker runs the program reuse keyed by id Results values, samples, tests, as actions; then a redraw segment Exp.t message Result the worker's answers come back as actions: update, calculate, redraw info map one Info per id the last write wins add writes cursor inspector cls, ty, ana, marks, message error and warning marks marks, via error_ids completion ctx and ana at the caret the next edit Perform.go reads the old map projectors and livelits the Info at their id; its ctx probes ctx, co_ctx, ancestors EvalInfo, for the worker elab_term, co_ctx per id ModuleExp case (#2651) elab_term at def's id dev: one walk of the whole program per refresh. Modular editors (#2469, not on dev): one per top-level definition (DefStatics).
Figure 1. The five subsystems an edit passes through, on dev at 962a7cb009, and the eight places that read the info map, with the fields each reads. The ModuleExp case is a reader only with #2651.

Every reader looks a record up by id. So whichever analysis writes a node's record last decides what the cursor inspector, the error marks, completion, projectors, probes and the worker's reuse check all see (StaticsBase.re:10-11: add_info is a plain Id.Map.add). That is what gives #2651 its architectural side: it changes which analysis writes last inside a module's definition (the PR).

On dev statics is one walk of the whole program per refresh. Andrew Blinn's #2469 (Modular editors), not yet on dev, adds DefStatics: statics per top-level definition, with the context passed from one to the next, so that an edit re-analyzes the edited definition and those that read a name it changed (DefStatics.re:3-102469 head). Inside one definition the walk is the same, so #2651 applies under both.

The representations they share

The subsystems pass five things along: the segment, the term, the info map, the elaborated program and EvalInfo. What ties them together is the id. MakeTerm gives a term node its tile's id, statics files the node's record under that id, EvalInfo keeps the node's elaboration under it, and the worker keys its probe samples and cached results by it (Figure 2). A term made inside statics can carry an id it borrows: the ModuleExp case builds its let with the ModuleExp's own id (IdTagged.re:175-183, used at Statics.re:2737-2741), so that let's record lands where the binding's would.

One id across five representations: the tile, the term node, the Info, the EvalInfo entry, and the worker's samples and cache entries all use the same id. tile in the segment id: a1 term node Exp.t, from MakeTerm ids: [a1, …] Info statics' record at a1 in the map EvalInfo entry elab_term, co_ctx at a1, sent along in the worker probe samples and cache entries at a1 MakeTerm Statics.mk of_info_map postMessage the same id, a1, names one piece of code in every representation; consumers meet at it
Figure 2. One id, a1, across five representations: the tile, the term node, its Info, its EvalInfo entry, and what the worker records for it. Each consumer meets the others at the id.
RepresentationDefined at (dev)What it holdsMade byRead by
Segment, piece, tileBase.re:3-28the editor's source: tiles with ids and child segments, grout, whitespace, projectorsPerform.go editsMakeTerm (both parses), the layout, printing
ZipperZipperBase.re:13-20the caret's place in the segment: selection, relatives, caret, probesPerform.go Editor.re:173-174CachedStatics (probe ids, the parse), the syntax cache, Indicated.ci_of Indicated.re:228-236
Editor modelEditor.re:13-32root sort, the zipper, and the syntax cache (layout, projector shapes)Editor.Update.update and calculatethe code views
Exp.tTermBase.re:254-258; exp_t is Annotated.t(exp_term, 'a) Grammar.re:79, with the constructors of exp_term Grammar.re:27one tree for user terms and elaborated terms (DHExp includes Exp, DHExp.re:11); each node carries its ids, as IdTagged.t IdTagged.re:114, and its first id is its rep id IdTagged.re:165-166MakeTerm MakeTerm.re:290-306; statics' elaborationstatics, the evaluator, projectors
Info mapStaticsBase.re:4-11Id.Map from id to Info; the last write winsadd in the walk Statics.re:204-269the eight readers of Figure 1
Info.expInfo.re:35-53one expression's statics: context, expected type, type, marks, co-context, elaborationadd; the lift Statics.re:356-357; the FixF patch Statics.re:2216-2228the readers above; elab_term also the ModuleExp case, with #2651 Statics.re:2775-2780PR
CtxCtx.re:37-57variables, constructors, type aliases and livelits in scope, newest firstbinders: patterns, TyAlias, the module alias Statics.re:2124-2151variable lookup Statics.re:691-705, completion TyDiCtx.re:192-201
CoCtxCoCtx.re:30-53each free variable's uses, with the type each use expectsvariables; union and mk at bindersunused-variable warnings, requires_fixf Statics.re:2172-2181, probe targets StaticsBase.re:46-53
CachedStaticsCachedStatics.re:4-13term, elaborated program, info map, error and warning ids, probe targetsCachedStatics.init CachedStatics.re:188-226the editor, EvalResult, Perform.go
EvalInfoEvalInfo.re:18-29for each expression id: its elaboration, co-context and probe targetsof_info_map EvalInfo.re:56-74the worker's reuse check IncrEval.re:313-353
Worker request and messagesWorkerServer.re:7-89{expr, eval_info_map, prev}; Ack, ReusePlan, Stream, ResultEvalResult.calculate EvalResult.re:270-293the worker; the page through EvalRequest.re:6-41
DynamicsDynamics.re:50-55probe samples, test results and theorems, by idthe worker's evaluator stateprobe views, the test panel, the syntax cache

Each row's links point to dev at 962a7cb009, except the read-back, which is at #2651's head (marked PR). User terms and elaborated terms share one type, so an elaboration is an ordinary Exp.t whose ids mostly match the source.

An edit's round trip, and the worker's messages

Figure 3 follows one key press. Every action goes through an update phase and then a calculate phase (Main.re:73-110):

  1. The key becomes an action, Perform(Insert(c)) (CodeEditable.re:343), and Editor.Update.update runs Perform.go on the zipper with the statics of the previous refresh (Editor.re:146-184).
  2. The calculate phase asks the debounce whether to run statics. While typing it says Defer and starts a 225 ms timer; when the timer fires it dispatches a refresh that says Force (CodeWithStatics.re:104-140). Statics runs on Force, on an edit that is not deferred, or when the set of probes changed (CodeWithStatics.re:200-204).
  3. CachedStatics.init parses the zipper for statics, runs Statics.mk over the whole term, and derives the error ids, warning ids and probe targets (CachedStatics.re:188-226, CachedStatics.re:99-135). The time it takes shows in the debug sidebar's Statics timer (CodeWithStatics.re:188).
  4. Editor.Update.calculate refreshes the completion buffer from the record at the caret, the syntax cache and the probes (Editor.re:186-230).
  5. EvalResult.calculate projects the info map to EvalInfo every time it runs (EvalResult.re:270-275). It queues a request for the worker only when the elaborated program (compared ignoring ids), the settings or the probe targets changed (EvalResult.re:251-261, EvalResult.re:279-293).
  6. The worker answers with messages, Figure 4; each comes back as an action, which updates the result and recalculates without running statics again.
Sequence diagram of one edit: the key becomes an action, the editor edits the zipper with the old statics, the calculate phase debounces and then reruns MakeTerm and statics, the syntax cache and probes are refreshed, EvalResult sends the elaborated program and EvalInfo to the eval worker, and the worker's Ack, ReusePlan, Stream and Result messages come back as actions. main thread worker thread key event CodeEditable Editor.update Perform.go Main.apply calculate phase CachedStatics MakeTerm, Statics EvalResult EvalRequest eval worker Evaluator Perform(Insert(c)) edits with the OLD statics Updated.t {is_edit} debounce 225 ms: Defer while typing timer: Force CachedStatics.init(zipper) MakeTerm: complete, parse Statics.mk: walk from an empty map info map, elaborated completion, CachedSyntax, probes EvalResult.calculate EvalInfo.of_info_map, every time then a request, if anything changed Evaluate (Marshal) Ack ReusePlan, Stream Result UpdateResult: update, calculate, redraw
Figure 3. One edit's round trip on dev, as a sequence of calls between the parts of the main thread and the eval worker. Notes on a lifeline are work done there. Statics runs once per refresh, after the debounce, not on every key while typing.

The worker's protocol is in WorkerServer.re:7-89. A request carries, for each editor, the elaborated program, the EvalInfo slice and the previous run's incremental cache, Marshal-encoded. The worker posts an Ack at once; then a ReusePlan, its prediction of what it can reuse, for every batch it plans (WorkerServer.re:495-506, WorkerServer.re:431-439); then a Stream message after each slice of 5,000 steps (WorkerServer.re:271); and last a Result. Below 2,500 EvalInfo entries the prediction is empty and the worker evaluates from scratch, without the previous run's cache (WorkerServer.re:300-304, WorkerServer.re:315-319, WorkerServer.re:366). The page restarts a worker that has not acked in 5 s (15 s from a cold start), up to three times, and gives up on a request after 20 s (WorkerClient.re:5-11). Its callbacks become result actions (EvalRequest.re:6-41).

Sequence diagram of the eval worker's protocol: the page posts Evaluate; the worker answers Ack at once, then ReusePlan, then Stream after each slice of 5000 steps, then Result. Timeouts and retries on the page's side. WorkerClient the page WorkerServer the eval worker Evaluate {request_id, batch of (key, {expr, eval_info_map, prev})} Marshal-encoded string on_request Ack {request_id}: posted at once clears the 5 s ack timer ReusePlan: predicted reuse (empty below 2500 entries) UpdateStreamingEval slices of 5000 steps, setTimeout(0) between Stream {key, outbox}: entries done so far MergeStreamingEval Stream … (one per slice that finished entries) Result {response: (value, EvaluatorState) per key, eval_time} UpdateResult a newer request abandons the old one's slices no Ack in 5 s: restart, retry (×3) 20 s in all: restart, report Timeout
Figure 4. The eval worker's messages on dev, as a sequence diagram: the page's actions for each message on the left, the worker's scheduling on the right, and the page's timeouts at the bottom.

Reuse is where statics and the worker meet, for a program of at least 2,500 EvalInfo entries; a smaller one is evaluated from scratch, with no reuse check (WorkerServer.re:300-304, WorkerServer.re:366). At each expression, the worker's reuse check compares the elaboration EvalInfo recorded at that id with the one the cached result was computed from, then the provenance of the free variables in the record's co-context, then the probe targets (IncrEval.re:313-353). So what statics writes at an id matters to the worker as much as to the editor: the same last write is what both see.

Inside statics

The walk

Statics is one function, uexp_to_info_map, called on every expression (Statics.re:191-201). It takes the context (what is in scope), the expected type, the term and the map, and returns the term's record, its elaboration and the new map. Each case analyzes the children it needs, passing context and expected type down and getting records and elaborations back, and ends by calling add (Statics.re:204-269). add works out the error marks, the inspector's message and the fixed type, builds the record, writes it at every id of the term, and returns the same elaboration it wrote (Statics.re:268). So the walk has two outputs at once: the map, and the root's elaboration, which Statics.mk returns for the evaluator (Statics.re:4350-4381).

The statics walk: uexp_to_info_map takes a context, an expected type and a term; each case analyzes its children (context and expected type go down, Info, elaboration and map come up) and ends in add, which writes an Info at the term's ids and returns the same elaboration. Two outputs: the info map, and the root's elaboration for the evaluator. uexp_to_info_map(~ctx, ~ana, term, m) returns (Info.exp, elab, m) Let(p, def, body) go(def) go(body) ctx, ana Info, elab, m solid: what goes down (context, expected type) dashed: what comes up (Info, elaboration, map) add (lines 204-269) marks and message ty = fixed_typ(ana, syn) writes Info at its ids every case ends in info map Id.Map, the last write wins id(Let) → Info id(def) → Info id(body) → Info … add_info returns the same elab (268) the root's returned elaboration: Statics.mk → CachedStatics.elaborated → the evaluator its readers (Figure 1)
Figure 5. The statics walk on dev, for a let. What goes down (context, expected type) is solid; what comes up (record, elaboration, map) is dashed. Every case ends in add, which writes the record and returns the elaboration it wrote.

Two more facts about the map matter for #2651. Some passes throw their map away: probes such as the let's recursion probe (Statics.re:2070) analyze a whole subterm and keep only its type. And statics already reads its own map back in the middle of the walk: the let's fixpoint patch (Statics.re:2216-2228), the ModuleExp case's class override (Statics.re:2743-2756), and a module's type, built from the records of its exported patterns (ModuleHelpers.re:413-439). #2651 adds one more read of the same kind.

One record: mode and self, context in, co-context out

An Info.exp record field by field, colour-coded into five groups: the context in (ctx, ancestors), the mode (ana), the self (elab_syn_ty, marks, message, warnings, ty), the co-context out (co_ctx, probe_targets), and the terms (user_term, elab_term, which #2651 reads back). Info.exp one per expression id (Info.re:35-53) user_term the term as written elab_term elaboration; #2651 reads it ancestors ids of the enclosing terms ctx the variables in scope ana the type expected of it elab_syn_ty the type it gives marks its static errors co_ctx free variables it uses probe_targets probes at or under it cls its form, for display message the inspector's text warnings e.g. an unused variable ty its type after hole fixing label_inference labeled tuples (and 3 more) context in ctx and ancestors come from the parent: what is in scope here, and which terms enclose this one. mode: the expected type ana. Unknown(SynSwitch) means synthesize; anything else means check against it. Mode is no longer a type. self: what the term gives elab_syn_ty, marks, message, ty: what it synthesizes, its errors, and the meet with ana (fixed_typ). co-context out co_ctx lists each free variable's uses, passed up to its binder (unused names, probes, reuse checks). the terms user_term as written; elab_term, its elaboration, which add both records and returns (line 268).
Figure 6. The fields of Info.exp at dev, grouped by colour: context in, mode, self, co-context out, and the terms. The elaboration, elab_term, is the field #2651 reads back.

Hazel's statics has long been described in terms of a mode (analyze against a type, or synthesize one) and a self (what a term gives, errors included), and both used to be types of their own; Matt Keenan removed them (d22a778d64, Mode, 2025; ae3f3edde3, Self, 2026). Today the mode is just the expected type, ana: Unknown(SynSwitch) means synthesize (StaticsBase.re:263). The self is the synthesized type, elab_syn_ty, with the marks and message add computes, and ty is their meet with the expected type (StaticsBase.re:268-277).

The context goes down: a list of entries, newest first (Ctx.re:37-57). A variable looks itself up there and reports itself unbound if it is missing (Statics.re:691-705). The co-context goes up: each free variable with the ids of its uses (CoCtx.re:30-53). A binder takes its own names out (CoCtx.re:43-53), which is how unused variables are found, how a let decides it needs a fixpoint, and how a probe knows which bindings to capture (StaticsBase.re:46-53).

Elaboration inside statics

There is no separate elaborator. Matt Keenan's cc620bbbdc ("delete elaborator", committed 2026-04-17) folded it into statics: each case builds its node's elaboration from its children's, and add records it. One detail of that port is the subject of #2651. The old elaborator elaborated a module binding's definition by reading the info map, with no second analysis (Elaborator.re:518-524a81e3188fd); the port turned that into a second call to the walk, def_elab_direct (Statics.re:2757-2762). #2651 returns to reading the map.

How a let is analyzed

Flow of the Let case on dev: a pattern probe and a recursion probe whose maps are dropped, then the structural is_recursive test; not recursive: one real pass in the outer context; recursive: two dropped passes and a final pass with the name in scope; then the body, the pattern again, and the elaboration, with a FixF when the definition uses the name. Counts: 2 analyses of a non-function definition, 4 of a function; with #2603, 1, 1 and 3. pattern probe: p's type; map dropped (2052) recursion probe: go(def) in p's context (2070); map dropped is_recursive? one variable, one function, an arrow type: the shape only (2024-2039) real pass in the outer context, against p's type (2079): kept def_base, in p's context (2091): dropped def_base2, name in scope (2103): dropped final pass, name in scope (2121): kept no yes body (2152), then p again with the body's co_ctx (2154) requires_fixf? recursive, and def or p's type uses the name (2172-2181) Let(p, def′, body) def′ aligned and named (2184-2187) Let(p, FixF(p, def′), body) co_ctx patched at def's id (2216-2228) no yes analyses of def, dev not a function: 2 (probe, real) a function: 4 (probe, 2 dropped, final) with #2603 (open) not a function: 1, and no probe a function that neither uses nor shadows its name: 1, the probe kept otherwise: 3 (the probe is def_base) key dashed: the map is dropped solid: writes the info map
Figure 7. The Let case at dev, pass by pass: dashed passes throw their map away; solid ones write the info map. Right: the analyses of the definition this costs, on dev and with #2603.

A let analyzes its definition more than once (Statics.re:2023-2248). It first types its pattern, then runs a recursion probe: a full analysis of the definition in the pattern's context, map thrown away (Statics.re:2070). Whether the let is recursive is decided by shape alone: one bound variable and one function of arrow-like type (Statics.re:2024-2039, Statics.re:2076). If not, one real pass analyzes the definition in the outer context (Statics.re:2079). If so, two more passes are thrown away and a final one runs with the name in scope (Statics.re:2090-2122). The body follows, then the pattern again with the body's co-context, for unused-variable warnings (Statics.re:2152-2155). Last, the let builds its elaboration: the definition aligned to the pattern's labels and given the function's name, or, when the definition uses its own name, wrapped in a fixpoint, FixF (Statics.re:2172-2230).

So on dev every let analyzes a definition that is not a function twice, and a function four times, and these multiply with nesting. #2603 (a let is analyzed once unless it refers to itself), open, skips the probe for a definition that is not a function, and keeps the probe as the final analysis for a function that neither uses nor shadows its own name (Statics.re:2070-20892603 head, Statics.re:2113-21622603 head).

Modules: a module binding is a let

On dev a module is "a syntactic gloss over labeled tuples" (modules.md:5). A module literal { … } is lowered to a chain of lets and type aliases ending in a labeled tuple of its exported names (ModuleHelpers.re:286-337, ModuleHelpers.re:162-171) and analyzed as that (Statics.re:2693-2724). A signature is a type that desugar_sig turns into a labeled product, dropping its type items (Typ.re:615-657). Andrew Blinn built this module system and the module keyword in February 2026 (b0735de6d6 to 131f508825).

module M = def in body, step by step

module M = def in body is a ModuleExp, and it has no typing rule of its own. Its case on dev (Statics.re:2725-2772) does seven things, Figure 8:

  1. Records the name's cursor info, an InfoMPat (Statics.re:2728-2735).
  2. Turns the name pattern into an ordinary pattern, desugaring a signature annotation to a labeled product (ModuleHelpers.re:51-60).
  3. Builds Let(pat, def, body) and gives it the ModuleExp's own id (Statics.re:2736-2741).
  4. Analyzes that let (Statics.re:2742): the probe and real pass over def, the body, and the pattern, whose record overwrites the name's InfoMPat.
  5. Changes the class in the let's record to ModuleExp (Statics.re:2743-2756), which step 7 overwrites anyway.
  6. Analyzes def again, directly, against the signature or nothing, to get def_elab_direct (Statics.re:2757-2762). This pass's records overwrite the let's for every node inside def.
  7. Builds the binding's elaboration with moduleexp_elab and adds the ModuleExp's record (Statics.re:2763-2772).
A table of the ModuleExp case's seven steps on dev and which ids each step writes in the info map: the name's InfoMPat, the let's Infos for the definition, the body, the pattern and the binding, the cls override, the direct pass that writes the definition's Infos again, and the final add. M, the name ModuleExp's id inside def inside body step (line numbers in Statics.re at dev) 1 MPat: the name's cursor info (2728-2735) InfoMPat · · · 2 mpat_to_pat: M : S, with S desugared to a labeled product (ModuleHelpers.re:51-60) · · · · 3 expanded = Let(pat, def, body), given the ModuleExp's own id (2736-2741) · · · · 4 go(expanded): the let case (2742), probe and real pass over def, then body and pattern InfoPat overwrites the let's Info real pass: Infos A Infos 5 cls override (2743-2756), which step 7 writes over anyway · cls changed, rewritten · · 6 dev: go(def) again, directly, against S or syn (2757-2762) · · direct pass: Infos B win · 7 moduleexp_elab, then add (2763-2772) · final Info · · with #2651, step 6 is a lookup at def's id: Infos A stay, and their elab_term is what moduleexp_elab gets
Figure 8. The ModuleExp case's steps at dev, and which ids each writes in the info map. Step 6 is the one #2651 replaces with a lookup.

What moduleexp_elab builds, and what it drops

moduleexp_elab takes the let's elaboration and swaps its definition for def_elab_direct, and strips the signature from the pattern (ModuleHelpers.re:447-456, ModuleHelpers.re:364-371). That drops everything the let put around the definition: the label alignment, the function's name (Exp.re:544-551) and a fixpoint. The alignment is the one that matters on dev. Against a one-member signature, alignment would wrap a module's let-chain as (x = whole module) (LabeledTupleHelpers.re:49-75), and dev's test module M : { let x : Int } = { let x = 42 } in M.x gives 42 only because of the swap (Test_Evaluator_Modules.re:123-129). Stripping the signature means a module is not sealed at run time on dev: its value keeps every member.

Left, the let's elaboration: a Let whose pattern carries the signature ascription and whose definition is aligned and named, or wrapped in a FixF. Right, what moduleexp_elab builds: the same Let with the ascription stripped and the direct elaboration of the definition in place. Below, the four things that are dropped. the let's elaboration expanded_elab (Statics.re:2182-2230) pattern: M : S (the ascription) def: align(def_elab) + name, or FixF(p, def) body_elab what moduleexp_elab builds ModuleHelpers.re:447-456 pattern: M (the ascription stripped) def: def_elab_direct, unwrapped body_elab, the same swap dropped from the let's elaboration: the ascription: no sealing at run time label alignment the function's name a FixF, when def names M why it matters on dev: under a one-member signature the let would wrap the module as (x = whole module); dev's test module M : { let x : Int } = { let x = 42 } in M.x gives 42 only because of the swap (Test_Evaluator_Modules.re:123-129)
Figure 9. Left, the let's elaboration of a module binding at dev; right, what moduleexp_elab makes of it. The four things it drops are below.

How members enter the context

The name enters the body's context twice (Figure 10). As a value, the pattern binds a variable whose type is the module's labeled product (Statics.re:3164-3193). As a type, the let adds an alias under the module's name for its type members (Statics.re:2124-2151). A capitalized M parses as a constructor, and the Constructor case turns it into the variable when a variable is its newest binding (Statics.re:1561-1597). M.x is then a labeled-tuple projection (Statics.re:1278-1420; at run time Transition.re:1024-1058), and M.T a type projection through the alias (Typ.re:481-500).

How a module's members reach the code after it: the module body is lowered to a type alias and let chain ending in a labeled tuple; the binding puts a variable M of the tuple's type and a type alias M for its type members in the context; M.x is a labeled-tuple projection, and M.T a type projection through the alias. module M = { type T = Int; let x : T = 1 } in M.x lowered (ModuleHelpers 286-337) type T = Int in let x : T = 1 in (x = x) lower the body's context and value value: VarEntry M : (x = Int) type: TVarEntry M = (T = Int), an alias at run time: M = (x = 1), a labeled tuple M.x M parses as a constructor; the newest binding is a variable: Var M Dot: find label x in the product type (Statics 1278-1420) run time: project field x (Transition 1024-1058) M.T ProdProjection(Var M, T) weak_head_normalize: lookup_alias M, then project T (Typ.re:481-500) a capitalized name parses as a constructor; Statics.re:1561-1597 turns it into the variable when a variable is its newest binding
Figure 10. A module's members at dev: the body is lowered to a type alias and let chain ending in a labeled tuple; the binding adds a variable and a type alias to the context; member access is projection.

Modules II

Alexander Bandukwala's Modules II stack replaces "modules are labeled tuples" with modules of their own: #2493 (part 1: first-class signature types and module values), #2494 (part 2: sealing at explicit coercion sites) and #2495 (part 3: abstract type members, sealing, path types), then parts 4 to 9, #2498, #2500, #2501, #2503, #2508 and #2510, the last of them the standard library as modules. A signature stays a signature type, a module literal elaborates to a module value, and a binder's signature seals the value at run time. Two things on his stack touch #2651's code. Parts 2 to 9 keep the direct pass, now with ~coercible=true (Statics.re:2833-2834part 2, Statics.re:2973-2974part 9), and their moduleexp_elab keeps the signature, because it is what seals (ModuleHelpers.re:617-627part 2). His docs already say a module binding "elaborates to that Let" (modules.md:496-497part 9). Questions 2 and 4 ask what that means for #2651.

The pull request, drawn

#2651 is four commits by Matthew Hammer, +54 and −5 lines in three files: e3013b0360 reads the definition's elaboration back, and 29c08fd8fa brings back the error for a definition that names its own module and adds two tests. Two more, pushed during the review at Matthew's word in answer to Alexander's comments (on the code comments, on the test's name), change only comments and a test's name: fa3eef62ce rewrites the comments, one per binding, and b5a3e1d90a renames the evaluator test. This report reads the code at 29c08fd8fa; at fa3eef62ce the same code sits at names_itself (Statics.re:2762-2766fa3eef62ce), the read-back (Statics.re:2773-2778fa3eef62ce), the fallback (Statics.re:2779-2786fa3eef62ce) and the call to moduleexp_elab (Statics.re:2788-2789fa3eef62ce). It is part of #2609 (Performance Challenges (Fall 2026)), finding 7, statics re-analyzing whole subterms, the same kind of problem as #2603.

The idea in one sentence: the let already recorded the definition's elaboration at the definition's id, before wrapping it, so the ModuleExp case can look it up instead of analyzing the whole definition again (Figure 11). The lookup is safe because the map is passed along explicitly and started empty for this run (Statics.re:4354-4362), and because passes that throw their map away never reach it: the record found is the one the let's real pass wrote in this very call.

Before and after flow of the ModuleExp case. Shared: the binding is expanded to a let, whose real pass records an Info at the definition's id with elaboration E. Before (dev): the definition is analyzed again directly and its Infos overwrite the let's. After (#2651): if the let's elaboration has a FixF, or no Info was recorded, the direct pass runs as before; otherwise E is read back with no pass. Below, a timeline of writes at the definition's ids. module M = def in body expanded = Let(M, def, body), with the ModuleExp's id (2736-2742) the let case: recursion probe (map dropped), then the real pass: an Info at def's id, whose elab_term is E before: dev at 962a7cb009 after: #2651 at 29c08fd8fa go(def) again, directly (2757-2762) the whole definition, every node again, against S or syn: its Infos overwrite the let's def_elab_direct = E′ (equal to E but for one corner) moduleexp_elab(E′), add (2763-2772) names_itself: is the let's elaboration Let(_, FixF(_), _)? (2770-2774) no: InfoExp at rep_id(def): E, no pass (2775-2780) yes, or no Info: go(def) directly, as on dev (2781-2788) moduleexp_elab(E), add (2790-2799) writes at def's ids, in order dev probe: map dropped, no write real pass: writes A direct pass: writes B, which wins #2651 probe: map dropped, no write real pass: writes A lookup: reads A's elab_term
Figure 11. The ModuleExp case before (dev at 962a7cb009) and after (#2651 at 29c08fd8fa; the two later commits leave its code unchanged). Both share the let's analysis; before, a direct pass overwrites the definition's records; after, they are read back, and the direct pass runs only as the fallback. Below, the writes at the definition's ids in order.

The code, before and after

Before, at dev: Statics.re:2757-2762

(* Statics.re:2757-2762 at dev *)
let def_ana =
  switch (pat.term) {
  | Asc(_, typ) => typ
  | _ => syn
  };
let (_, def_elab_direct, m) = go(~ana=def_ana, def, m);

After, at #2651: Statics.re:2770-2789PR

(* Statics.re:2770-2789 at 29c08fd8fa, comments cut *)
let names_itself =
  switch (expanded_elab.term) {
  | Let(_, {term: FixF(_), _}, _) => true
  | _ => false
  };
let (def_elab_direct, m) =
  switch (Id.Map.find_opt(Exp.rep_id(def), m)) {
  | Some(Info.InfoExp({elab_term, _})) when !names_itself =>
    (elab_term, m)
  | _ =>
    let def_ana = ... ;                (* as on dev *)
    let (_, elab, m) = go(~ana=def_ana, def, m);
    (elab, m);
  };

Both feed the same call, ModuleHelpers.moduleexp_elab(~def_elab_direct, expanded_elab) (Statics.re:2763-2764; Statics.re:2790-2791PR). At 29c08fd8fa the PR's comment said the read-back works because "add records exactly the elab_term it returns" (Statics.re:2757-2763PR). That is true of add (Statics.re:268) but not of one path of the walk, the one-member-signature lift (below). Alexander pointed this out on the PR (his comment), and fa3eef62ce corrected the comment: it now says that under singleton-label lifting (autolabel_singleton_tuple) the info holds the inner elaboration, not the lifted term (Statics.re:2767-2772fa3eef62ce). He also asked for one comment per binding, saying what its value is rather than the PR's history, and the same for the tests' comments; fa3eef62ce does both (Matthew's reply).

The second commit: a definition that names its own module

How module F = fun x -> F(x) in 1 goes: the let takes the recursive path with F in scope, so it records no error, and wraps the definition in a FixF; the first commit read that Info back and lost the error; the second sees the FixF and analyzes the definition directly in the outer context, where F is a free constructor, an error. Alexander's suggested follow-up: tell the let not to try the recursive case. module F = fun x -> F(x) in 1 the let (2023-2248) one variable, one function: the recursive path, F in scope; no error at F F is used in def requires_fixf: the elab is Let(F, FixF(F, def), 1); F is dropped from def's co_ctx Alexander's suggestion tell the let not to try the recursive case for a module binding: no FixF to check e3013b0360, the first commit reads the let's Info back: no error at F (lost) 29c08fd8fa: names_itself sees the FixF: go(def) directly, in the outer context, where F is a free constructor: error the error is a FreeConstructor mark (ConstructorStaticsHelpers.re:61-64) on the F inside the definition, as on dev
Figure 12. How module F = fun x -> F(x) in 1 is handled: the let analyzes it recursively and records no error; the first commit read that back and lost dev's error; the second sees the let's fixpoint and runs the direct pass, which reports F, as dev did. Right: Alexander's suggested follow-up, now the draft #2717.

The first commit lost an error. module F = fun x -> F(x) in 1 is one variable bound to one function, so the let takes its recursive path and analyzes the definition with F in scope: no error. On dev the direct pass then analyzed it again in the outer context, where F is not a variable, and marked it a free constructor (Statics.re:1598-1606, ConstructorStaticsHelpers.re:61-64), overwriting the let's records. With the read-back, nothing overwrote them, so the error was gone; the correctness review in ^report(21) found it by running both commits.

29c08fd8fa adds names_itself: if the let's elaboration is Let(_, FixF(_), _), the let saw the definition use its own name, and the direct pass runs as before (Statics.re:2764-2774PR). The fixpoint is the signal because the let removes the name from the co-context of the definition's record (Statics.re:2216-2228), so the record itself cannot show the self-reference. A test pins the error (Test_Statics_Modules.re:406-413PR).

Run with the command-line tool, the program reports "Constructor is not defined" at the inner F (1:21) and runs to 1, at dev and at 29c08fd8fa alike; so does the same definition under a signature, module F : Int -> Int = fun x -> F(x) in 1 (at 1:34), which takes the fallback's other arm (the runs).

Found in review: the guard also matches a fixpoint the user wrote. Alexander found on #2651 (his comment) that module m = fix f -> fun x -> … f(x - 1) in m(3) elaborates to Let(_, FixF(…), _) too, because fix itself elaborates to a FixF. So the read-back is skipped and the definition is analyzed a third time, after the let's recursion probe and its own pass. On Modules II part 1 with #2651 merged, a temporary print in that match fired for every module … = fix … he tried (four programs), and not for a fix inside a module body. The results match dev, and only the speed-up is lost, but the check does not test what names_itself claims to. Telling the two apart by shape is hard: skipping whenever the definition is a fix would also skip module m = fix g -> fun x -> … m(x - 1), which, as he read it, gets its unbound-m error from the fallback. (Building the follow-up found that on #2651 the let's own analysis already reports that m as unbound: a FixF has no function count, since Exp.get_num_of_functions returns None for it, so the let is not recursive there, and the fallback then reports the same mark again, at the same id. With names_itself made to skip a fix, the error is still reported; what the skip keeps is the recursion probe, two analyses per module instead of one.) So, he concludes, the signal belongs in the let analysis itself: an argument that makes a module binding non-recursive (his suggestion in question 3), with no shape check and no fallback, and fix still the way a module binds a recursive function. It can be a follow-up, to keep #2651 small. It also depends on the answer to the question he asked on the PR (his comment; For Cyrus): if binding a module to a function becomes a static error, neither a self-naming function nor a module … = fix … can reach this code, and names_itself could go. Matthew agreed (his reply) and chose a follow-up: the draft #2717 (Statics: a module binding is a non-recursive let), opened on 2026-10-21 at 20:31Z against dev, on #2651's head b5a3e1d90a. It does not wait on that question: with it a module's definition is analyzed once whatever the definition is, apart from the one exception below.

The follow-up, #2717. In Statics.re, uexp_to_info_map takes a new argument, ~check_rec, true by default. It applies only to the term it is called on, so a let inside the definition or in the body is still recursive as usual. With it false, the let runs no recursion probe and has no recursive branch: it analyzes the definition once, in the outer context, where the bound name is not in scope, so the let itself reports a self-reference, and it never wraps the definition in a FixF. The ModuleExp case passes it false to the let it expands to, and the singleton-label pre-pass passes it along. That removes names_itself and its fallback; the definition's elaboration is still read back from the let's record, as on #2651. Counted from the let rule, #2651 analyzes a module's definition four times when it is a function that does not name the module, five when it does, three for a fix and two for anything else; #2717 analyzes it once. The one exception is not new: a module binding checked against a one-label product type such as (a = Int) costs three analyses, through the pre-pass. One statics result changes, for a definition that uses the name it shadows: in module F = fun (x : Int) -> x > 0 in module F = fun x -> F(x) in F(1) + 1 the inner F is now the outer module, so F(1) is a Bool and + 1 is an error, which agrees with the evaluator (both builds run it to true + 1). A module item inside braces is still lowered to a recursive let, as on dev. Its tests, and what it was measured to cost, are in What next.

What it costs, by shape and by nesting

Analyses of a module binding's definition for three kinds of definition and four builds, drawn as unit blocks. A module literal: dev 3, #2651 2, #2603 2, both 1. A function that does not name the module: 5, 4, 2, 1. A function that names it: 5, 5, 4, 4. dev #2651 #2603 alone #2651 + #2603 module M = { … } in body a module literal: never recursive 3 2 2 1 module F = fun x -> x + 1 in F(2) a function that does not name F 5 4 2 1 module F = fun x -> F(x) in 1 a function that names F 5 5 4 4 each block is one full analysis of the definition; the let's recursion probe counts
Figure 13. Full analyses of a module binding's definition at dev, with #2651, with #2603 alone, and with both, for three kinds of definition, counting the let's recursion probe. Counted from the code, not measured.
Definitiondev#2651#2603 alonebothWhere the passes are
a module literal, module M = { … } in body3221probe Statics.re:2070, real Statics.re:2079, direct Statics.re:2762
a function that does not name the module, module F = fun x -> x + 1 in F(2)5421probe, two dropped and a final pass Statics.re:2090-2122, direct
a function that names it, module F = fun x -> F(x) in 15544as above; #2651 falls back Statics.re:2781-2788PR; #2603 reuses the probe as the first dropped pass Statics.re:2135-21392603 head

So on today's dev #2651 takes a module literal's definition from three analyses to two; the third is the let's recursion probe, which #2603 removes. The PR's title, "once, not twice", counts only the ModuleExp case's own extra pass; it is exact for the whole binding once both PRs are in. Inside the definition, every member is itself a let and pays the let's own count again.

Left, module bindings nested in each other's definitions, each level multiplying the analyses of what is inside it. Right, bars on a log scale: analyses of the innermost definition at depth 1 to 4: dev 3, 9, 27, 81; #2651 2, 4, 8, 16; #2603 alone 2, 4, 8, 16; both 1, 1, 1, 1. module A = ( … ) in … module B = ( … ) in … module C = ( … ) in … the innermost definition per level: ×3 on dev, ×2 with #2651 or #2603 alone, ×1 with both analyses of the innermost definition (log scale) dev #2651 #2603 alone both 1 3 9 27 81 3 2 2 1 depth 1 9 4 4 1 depth 2 27 8 8 1 depth 3 81 16 16 1 depth 4
Figure 14. Analyses of the innermost definition when module bindings sit inside each other's definitions, on a log scale: each level multiplies by 3 on dev, 2 with #2651 or with #2603 alone, and 1 with both. Counted from the code.

Nesting multiplies, but only one kind of nesting goes through this case. A module … in inside another module binding's definition pays the binding's factor at every level: 3depth analyses of the innermost definition on dev, 2depth with #2651, one with #2603 as well. A module Inner = { … } item inside braces is lowered to a plain let (ModuleHelpers.re:300-304), so it pays only the let's factor, 2 per level on dev, which #2603 removes and #2651 does not touch. Dev's only module program, hazel-programs/docs/reference/modules.hz, nests by items: module Outer = { module Inner = { … } } in (modules.hz:64-69).

Measured, by the PR (not in this report). On dev the effect is within the noise: on an 800-function module made up for timing, statics was about 0.13 s of 6 s, and whole-command times overlapped (6.06 and 6.74 s before, 5.26 and 6.05 s after, two runs each). On the livelits branch #2635 (livelits × modular editors), where a module holds the livelit definitions, a profile of the Shirt and Pants slide put 1.79 s of its 4.2 s of statics in the second pass, and the command line's statics went from 6.7 s to 4.3 s, against 4.1 s for the same slide with the module flattened into top-level lets (two runs each, within 0.3 s; #2609, finding 7). That branch also carries #2603's change (as b9e7561323) and per-definition statics, so these are not dev's numbers. ^report(17) and ^report(1) list these numbers too.

What a reviewer checks

Matthew has tested #2651's behaviour; what a review adds is the argument for why the result is the same, and where it is not. The table gives each check, what reading the code found, and where. The earlier reviews of this PR are ^report(17) (merge plan: wave 1, small and independent) and ^report(21) (a draft, not yet published; correctness, which found and confirmed by running both corners the second commit settles).

What a reviewer checksWhat the reading foundWhere
Is the read-back the same as the direct pass?Yes, in the let's non-recursive branch, with one exception. There the let's real pass and dev's direct pass get the same context (the ModuleExp's), the same expected type (p_syn.ty is the signature already desugared by mpat_to_pat, met with syn; desugar_sig ignores its context and changes nothing the second time), the same term and the same flags; only the ancestors list differs. And the elaboration recorded is the one returned on every path that ends in add. The exception is the lift (next row).Statics.re:2079, Statics.re:2757-2762, ModuleHelpers.re:51-60, Typ.re:615-657, StaticsBase.re:268-277, Statics.re:268
Where does it not hold?Under a one-member signature the definition is lifted: autolabel_singleton_tuple records the bare analysis but returns (x = raw source). The extra-member module now gives M.x = 1 (dev: its raw source; confirmed by running in ^report(21)); Alexander confirmed 1 is right. A plain value under that signature gives 5 on dev and gets stuck with #2651 (the runs); Alexander accepts that too, since Modules II part 1 makes it stuck and a static error (his comment; below).Statics.re:310-358, Statics.re:2778-2791, Test_Evaluator_Modules.re:86-96PR
A tuple literal under a product signatureThe let rearranges a tuple definition to the pattern's labels before analyzing it, so the read-back is the rearranged tuple's elaboration where dev's direct pass analyzed the original. The tuple case rearranges its own elaboration the same way, so the result should match; a test would settle it.Statics.re:2054-2069, Statics.re:1117-1132
What does the editor see inside the definition now?The let's records, not the direct pass's. Two differences, neither in the elaboration: the ancestors list has the ModuleExp's id twice, as the body's already did (auto-probes drop adjacent repeats); and inside a function-valued module that does not name itself, the recorded context contains the module's own name, so completion can offer F inside F, where using it is an error. Dev never showed F there. Alexander's flag (question 3) would remove it.Statics.re:270, Statics.re:2737-2741, AutoProbePerform.re:81-93, Statics.re:2090-2122, TyDiCtx.re:28-39
Can the fallback be reached?The self-naming arm, yes: that is the second commit's case. The 'no record found' arm looks unreachable: every analysis ends in add at the term's ids, and the map starts empty each run. It is defensive. Codecov's one missed patch line is the fallback's signature arm, Asc(_, typ) => typ, reached by a module with a signature whose definition names the module, or is a fix the user wrote; module F : Int -> Int = fun x -> F(x) in 1 reaches it when run, and reports its error as dev does (the runs). The self-naming arm is also reached by a definition the user wrote with fix (Alexander's finding, next row).Statics.re:2781-2788PR, Statics.re:2784PR, Statics.re:4354-4362
Is the self-naming signal robust?It reads the shape of the let's elaboration. If the let ever wraps its fixpoint differently, the check turns false silently and the error goes away again; only the one test would notice. A flag that tells the let not to try the recursive case (Alexander's suggestion, question 3) would not depend on the shape at all. Found since, by Alexander: the check is also not exact. It matches a fixpoint the user wrote, module m = fix f -> …, which then takes the old second pass: dev's results, without the speed-up (the guard). He and Matthew agree the signal belongs in the let analysis, and the draft follow-up #2717 (Statics: a module binding is a non-recursive let) puts it there: the module's let is told it is not recursive, so there is no shape to read and no names_itself (the guard, What next).Statics.re:2770-2774PR, Statics.re:2188-2230, Alexander's comment
The worker's viewUnchanged in the common case, and more consistent: the record at the definition's id and the elaboration spliced into the program are now the same value. The guard matters here too: without it, a self-naming definition's record would keep the let's patched co-context, which lacks the module's name, beside an elaboration that uses it, so the reuse check would not see changes to that name.IncrEval.re:326-341, Statics.re:2207-2228
How strong are the tests?The statics test passes on any static error anywhere in the program, so it does not pin where the error is or what kind. Untested: a function-valued module that does not name itself (module F = fun x -> x + 1 in F(2), 3), the bare value under a one-member signature, and the fallback's signature arm; all three were run with the command-line tool instead (the runs). The bare value's test goes on Modules II part 1. The draft follow-up #2717 (Statics: a module binding is a non-recursive let) pins the self-naming test, and its form under a signature, to one FreeConstructor mark at the inner F, and adds module F = fun x -> x + 1 in F(2), 3, as an evaluator test (question 5). Evaluator tests run without EvalInfo or a previous run, so they never exercise reuse.Test_Statics_Modules.re:406-413PR, Test_Statics_Prelude.re:225-242PR, Test_Evaluator_Prelude.re:24-27PR, Test_Evaluator_Prelude.re:126-140PR
Is it ready to merge?Into dev, yes: 19 commits behind, and merges cleanly; dev's only change under src/language since the base is Ascriptions.re (#2679 (an ascription pushed through a value gives the value back)). It merges cleanly with #2603's head too. Against Modules II, one line conflicts in parts 2 to 9, which Alexander called no conflict in substance (question 2). On GitHub, Alexander (7h3kk1d) reviewed it from 18:24Z: the extra-member result matches how labeled tuples work, and his sealing PR would make y private (comment); he asked @disconcision whether defining a module to be a function should be an error (comment); and a 🤖 comment found that the stacked comments read as the PR's history and that "add records exactly the elab_term it returns" does not hold under autolabel_singleton_tuple (comment). At 18:42Z Matthew replied with the bare-value program run with the command-line tool (comment), with "Done in fa3eef62ce" (comment), and with a top-level 🤖 comment on the merge order and the tests to add (comment). From 18:48Z Alexander chose to accept the bare value's stuck result, option 2: stuck and a static error is right, as Modules II part 1 already gives, so dev needs no special case (comment); found that names_itself also matches a fixpoint the user wrote, module m = fix f -> …, which then takes the old second pass, with dev's results (comment; the guard); and suggested a name for the evaluator test that says what makes the case special and what it asserts (comment). At 19:00Z, at Matthew's word, b5a3e1d90a applied the name (reply), and Matthew replied that the bare-value test goes on Modules II part 1 (reply) and that a prototype of the let-analysis argument was in progress (reply). At 19:08Z Alexander explained the description's 6 → 4 warnings (next row). At 20:31Z the prototype was opened as the draft follow-up #2717 (Statics: a module binding is a non-recursive let), which cross-references #2651. CI: Fast checks and Full tests passed at fa3eef62ce (run 39655086864, 18:59Z); at b5a3e1d90a they passed too, Full tests at 19:26Z (run 39657741479); codecov's comment, updated at 19:27Z, now compares against b5a3e1d and still finds the one missed patch line.git merge-tree; GitHub's reviews, comments and checks (read 2026-10-21, 19:34Z; read again at 20:34Z: nothing new on #2651 but #2717's cross-reference)
Is the output the same? (the description's 6 → 4 warnings)The PR's description reports hazel analyze -W on all 73 hazel-programs byte for byte the same before and after, except Measured.WARNING: id … not found lines: 6 before and 4 after on docs/reference/modules.hz. Alexander settled which two went, and why (his comment). His three runs each at the base 34f9605ddf and at fa3eef62ce gave 6 and 4 every time, with the rest of the output byte for byte the same. The ids with no source position are minted by autolabel_singleton_tuple, which analyzes fresh terms and records infos with marks at their ids. module M : {let x : Int} = { let x = 1; let y = 2 } goes through the lift: at the base it went through twice, in the expanded let and in the direct pass, and left two copies of its errors in the map; now it leaves one. analyze sorts and dedups every info with marks, so the duplicates showed only as warnings. So the info map loses two orphaned duplicate errors: a small improvement, not a change in output.#2651's description (read 2026-10-21), modules.hz:165base, Alexander's comment
Pre-existing, not this PRThe class override at the ModuleExp's id is overwritten by the final add; docs/modules.md still describes the deleted Elaborator; and the lift returns raw source, so any term needing elaboration under a one-member labeled product reaches the evaluator unelaborated (dev's extra-member result is one case). And the lift records error infos at fresh ids with no source position, which is what those warnings were: a dev issue of its own, Alexander notes, not for this PR (his comment).Statics.re:2743-2756, Statics.re:2765, modules.md:261-264, Statics.re:329-334

Six programs, run with the command-line tool

To check the corners by running them, not only by reading, the command-line tool was built at dev 962a7cb009 and at #2651's 29c08fd8fa, and each program below was run and analyzed with it (./hazel run and ./hazel analyze). The PR's later commits, fa3eef62ce and b5a3e1d90a, change only comments and a test's name. The first row was found by reading the code, while checking question 1, and this run confirms it (Matthew's comment on #2651 gave the same). Only the first two rows differ between the commits, both through the one-member lift (below), and Alexander accepted both results.

Programdev 962a7cb009#2651 29c08fd8faThe path it takes
module M : { let x : Int } = 5 in M.x5, no static errors5 .x (stuck), no static errorsthe one-member lift, with a bare value (the corner)
module M : { let x : Int } = { let x = 1; let y = 2 } in M.x{ let x = 1; let y = 2 } (the raw source), 3 static errors ("Expecting type Int but got inconsistent type x=Int, y=Int")1, the same 3 errorsthe one-member lift, with an extra member (the corner)
module F = fun x -> F(x) in 1runs to 1; 1 static error, "Constructor is not defined" at the inner F (1:21)the samethe guard's fallback (the guard)
module F = fun x -> x + 1 in F(2)3, no static errors3, no static errorsthe read-back after the let's recursive path, untested in the PR (question 5)
module M : { let x : Int } = { let x = 42 } in M.x42, no static errors42, no static errorsa module literal under a one-member signature, which on dev needs the swap of elaborations (question 4)
module F : Int -> Int = fun x -> F(x) in 1runs to 1; "Constructor is not defined" at the inner F (1:34)the samethe fallback's signature arm, Codecov's one missed line (the checks)

The one corner where the result changes

The one-member signature corner: the module with an extra member is checked against the one-field product the signature desugars to; its own type does not match, so autolabel_singleton_tuple runs. It records the bare analysis at the definition's ids but returns the labeled tuple around the raw source. Dev's direct pass used what it returns, so M.x was the raw module; #2651 reads what it records, so M.x is 1. Alexander: dev's result is a bug. module M : { let x : Int } = { let x = 1; let y = 2 } in M.x the definition is checked against (x : Int): the signature desugared to a one-field product its own type (x : Int, y : Int) is not (x : _), so autolabel_singleton_tuple runs (Statics.re:310-358) what it RETURNS (line 358) (x = raw source), not elaborated: the module as written what it RECORDS at def's ids (357) the bare analysis against Int, whose elab_term is the module's own elaboration dev: the direct pass gets what it returns M = (x = { let x = 1; let y = 2 }) M.x = the module's raw source #2651: the read-back gets what it records M = (x = 1, y = 2) M.x = 1, pinned by the PR's new test dev's result is a bug, from the auto-labelling of one-member tuples; the program should give 1 or be stuck, so #2651's 1 is a fix Alexander, 2026-10-21
Figure 15. The one-member signature corner, traced at dev and #2651. The lift records one thing and returns another; dev used what it returns, #2651 uses what it records. Alexander's answer of 2026-10-21 is at the bottom.

A one-member signature desugars to a one-field labeled product. When a definition's own type is something else, the walk lifts it into that product: autolabel_singleton_tuple analyzes a fresh copy against the field's type, records that bare analysis at the definition's ids, and returns (x = …) around the raw, unelaborated source (Statics.re:310-358; it runs from Statics.re:2778-2791). Dev's direct pass handed what it returns to moduleexp_elab; #2651 reads what it records. For the extra-member module that turns M.x from the module's raw source into 1, which ^report(21) confirmed by running both commits. Alexander's answer: dev's result is a bug from the auto-labelling of one-member tuples, and 1 (or stuck) is right; the PR's test pins the fix.

The same corner with a plain value: module M : { let x : Int } = 5 in M.x. On dev the direct pass returns (x = 5), so M.x is 5 with no static error; with #2651 M is bound to 5 and M.x is stuck, run with the command-line tool (Matthew's comment on #2651). module M : { let x : Int } = 5 in M.x the direct pass returns (x = 5): M = (x = 5), M.x = 5, and no static error the read-back gets the bare 5: M = 5, and M.x is stuck: a Dot on an Int (run with the CLI) dev #2651 Alexander accepts the stuck result (option 2): on Modules II part 1 this program is stuck and a static error; Matthew put its test there
Figure 16. The same corner with a plain value instead of a module: 5 on dev, stuck with #2651. Run with the command-line tool at dev 962a7cb009 and at 29c08fd8fa, with no static errors either way (Matthew's comment on #2651).

The same path with a plain value is the one result found where dev gives a value and #2651 does not: module M : { let x : Int } = 5 in M.x has no static error on dev and gives 5; with #2651, M is bound to 5 and M.x projects a label out of an Int, which is indeterminate (Unboxing.re:184-217, Transition.re:1024-1087). Written with let instead of module, the same program still gives 5, because the let keeps the lift (Statics.re:2182-2187). Run with the command-line tool at dev and at 29c08fd8fa, it gives 5 on dev and 5 .x, stuck, with #2651, and no static errors either way (the runs; Matthew's comment on #2651). Alexander accepted the stuck result (his comment, option 2 of question 1): a bare value under a module signature should be stuck and a static error. Modules II part 1 already does both, with or without #2651: signatures are Typ.Sig there, so the lift never runs, and the program reports "Expecting type { let x:Int } but got inconsistent type Int" and evaluates to (5:({ let x:Int })).x. On dev the missing error is temporary, so #2651 needs no special case. Matthew decided that the test for this program goes on part 1, not in #2651 (his reply). Whether binding a module to a non-module, a function for instance, should be a static error is a question for Cyrus (below). It does not hold up the merge; what is left is to name it in the PR's description (What next).

Where #2651 sits

Where #2651 sits: under #2609's finding 7; beside #2603, with which it merges cleanly and brings a module binding to one analysis; #2675, #2661 and #2681 touch other parts of statics; #2689 pins the counts; Modules II keeps the direct pass and conflicts on one line in parts 2 to 9; the livelits branches carry the read-back without the self-naming guard. #2609, finding 7: statics re-analyzes whole subterms #2651 drops the direct pass: one analysis fewer #2603: a let analyzed once clean; together: 1 analysis #2675: reuse around a recursive let touches the let's co_ctx only #2661, #2681: type hash, typed values independent #2679: merged on 10-19 the only dev change since the base #2689 (draft): pass budgets has #2651 at e3013b0360 Modules II, #2493 to #2510 one-line conflict, parts 2 to 9 #2635, #2597: livelits branches read-back without the guard DefStatics, #2469: per definition the walk inside one is the same
Figure 17. #2651 among the other work: the finding it answers, the statics PRs beside it, the test that pins its count, Alexander's Modules II stack, Matthew's livelits branches, and Modular editors' DefStatics.

Of the open #2609 PRs, #2651 is the smallest that touches statics, and it touches only the ModuleExp case. #2603 (a let analyzed once) merges cleanly with it, and together they bring a module binding to one analysis; #2689 (statics analyzes each expression within a budget), a draft, pins that count, but merged #2651's first commit, e3013b0360, without the guard. #2675 (reuse around a recursive let) changes the let's co-context under a fixpoint, which names_itself does not read (question 3's co-context option does). #2661 (types get a hash) and #2681 (a value known to have a type is not walked again for it) touch other parts of statics and are independent. The livelits branches #2635 and #2597 (user-defined livelits) carry the read-back without the guard. Modules II keeps the direct pass with ~coercible=true, hence the one-line conflict.

Questions for Alexander

Alexander, this section stands on its own. #2651 (Statics: a module binding analyzes its definition once, not twice) stops the ModuleExp case in statics from analyzing a module's definition a second time: module M = def in body is checked as let M = def in body, and the case now reads the definition's elaboration back from the record that let wrote, keeping the second analysis only as a fallback (the picture is The pull request, drawn). You and Matthew reviewed it on 2026-10-21, and your answers are below, paraphrased: from that review and in conversation (relayed by Matthew), and since then from your comments on #2651, each linked. All five are answered, question 3 for now, and none holds up the merge.

From here on, please answer on #2651 itself, as comments there, rather than here. Where they stand: 1 (the one-member signature lift): answered, its bare-value half on the PR (option 2); 2 (the merge order against Modules II): answered; 3 (whether module bindings are recursive): answered for now, for Cyrus, with your let-analysis argument now the draft follow-up #2717 (Statics: a module binding is a non-recursive let); 4 (whether Modules II still needs def_elab_direct): answered; 5 (which module behaviours to pin as tests): answered on the PR. None holds up the merge.

1. The one-member signature lift

Alexander, on dev should module M : { let x : Int } = 5 in M.x keep evaluating to 5? If so, #2651 must not read back the bare analysis whenever statics lifted the definition into a one-member signature: either it skips the read-back there, or autolabel_singleton_tuple first records what it returns. Either way it gives up the new result its evaluator test pins for module M : { let x : Int } = { let x = 1; let y = 2 } in M.x.

answered
Answered, Alexander, 2026-10-21: dev's result for the module with an extra member (its unelaborated source) is a bug, which comes from the auto-labelling of one-member tuples; that program should evaluate to 1, or be stuck. So #2651's 1 is a correct result: the behaviour change fixes a dev bug, and the PR's test pins the fix.
Answered further, Alexander, on #2651, 2026-10-21. On the evaluator test (his comment): fine; the result matches how labeled tuples work. One could argue for making incompatible signatures stuck, but much of Hazel does not work that way, and his signature-sealing PR would make y private. On the bare value (his comment, answering Matthew's): option 2, accept it. A bare value under a module signature should be stuck and a static error; Modules II part 1 already gives both, with or without #2651, so on dev the missing error is temporary and #2651 needs no special case. Matthew's decision: the test for this program goes on part 1, not in #2651 (his reply).

What the answers leave (our reading). The first settles the extra-member half, so the first option below, drafted before the answer, would undo a fix and is withdrawn. The bare-value half is a different program: dev gives 5 with no static error, and #2651 leaves M.x stuck (the runs; Figure 16). The second answer settles it as option 2 below. What is left is to name it in the PR's description (What next); whether binding a module to a non-module, a function for instance, should be a static error is Cyrus's question (below). Nothing here holds up the merge.

Context.

On dev a one-member signature desugars to a one-field labeled product. A definition whose own synthesized type has a different shape (and is not unknown) is lifted into it by autolabel_singleton_tuple. That function records the bare definition's analysis (against the field type) at the definition's ids, but returns (x = def) wrapped around the raw, unelaborated source. Dev's second, direct pass used that returned tuple, so module M : { let x : Int } = 5 in M.x gives 5 with no static error (run with the CLI, Matthew's comment on #2651).

The extra-member module is a static error on dev, the kind dev's tests call a limitation, and M.x evaluated to its raw source. #2651 reads back the recorded bare analysis instead: the extra-member module now gives M.x = 1, which the PR keeps and pins with a test first named 'runs as itself' and, since b5a3e1d90a, 'Projecting a declared member of a module wider than its signature'. For the bare value, though, M becomes 5 and M.x gets stuck, because projecting a label out of an Int is indeterminate (run with the CLI, Matthew's comment on #2651).

From part 1 (#2493 (first-class signature types)) on, a signature stays a Sig type and is never lifted this way, and your stack seals the extra-member module to { let x = 1 }. So this question is about dev only, until your stack lands.

Evidence (links to the code at the commit named on each):

Options.

  1. Yes, keep dev's results. Skip the read-back when the lift ran: narrowly, when the info recorded at def's id was analyzed against the field type rather than def_ana; more simply and a little dearer, whenever def_ana is a one-field labeled product. #2651 then changes neither result the review found. Replace its evaluator test with module M : { let x : Int } = 5 in M.x giving 5, and take 'runs as itself' out of the test and the description. The stuck result has since been run with the command-line tool (Matthew's comment on #2651). recommended before the answer withdrawn: it would undo the fix Alexander confirmed
  2. No: dev need not support a module bound to a non-module value, since Modules II makes it a type error, so #2651 can merge as it is. Keep only the assertion M.x = 1, which holds on dev with #2651 and on your stack. Reword the test's name and comment (done in fa3eef62ce and b5a3e1d90a), and the PR description, to say only what holds on both: M.x is the member, not the module's raw source; dev still reports a type error there (the module's type against the field type Int) and does not seal; Modules II seals. chosen by Alexander on #2651; the bare-value test goes on Modules II part 1
  3. Fix the cause in a separate PR: make what autolabel_singleton_tuple records at the definition's ids agree with what it returns. #2651 would then need no special case, but the extra-member module's M.x would become the whole elaborated module, not 1.

2. The merge order against Modules II

Alexander, can #2651 merge into dev ahead of your Modules II stack, with the one-line conflict in parts 2 to 9 resolved by keeping its read-back and adding ~coercible=true to its fallback? That resolution is right only if, on your branches, the let's recorded analysis of the definition is the same analysis as the direct pass it replaces.

answered
Answered, Alexander, 2026-10-21, in conversation, relayed by Matthew: he sees no conflict between #2651 and Modules II. He had not yet looked at #2603.

What the answer leaves (our reading). Checked with git merge-tree: #2651 is clean against part 1, and against each of parts 2 to 9 there is one conflicting hunk in Statics.re, because part 2 changed the direct pass to go(~ana=def_ana, ~coercible=true, def, m) and #2651 moves that call into its fallback. On part 2 the let also analyzes the definition with ~coercible=true (Statics.re:2155-2210part 2), so the read-back reads the same analysis, and the resolution is to add ~coercible=true to the fallback's go: one line to resolve, nothing in substance. Matthew's top-level 🤖 comment on #2651 states this resolution (18:42Z), for Alexander's stack to apply when it next rebases. It does not hold up the merge.

Context.

On dev, module M = def in body is checked as let M = def in body, and ModuleExp then analyzes def a second time, directly, only to get its elaboration. #2651 reads that elaboration back from the info the let recorded at def's id and keeps the direct pass only as a fallback. Your #2494 (part 2, sealing at explicit coercion sites) changed that same direct-pass line to pass ~coercible=true, so #2651 conflicts on exactly that line with each of parts 2 to 9, while it merges cleanly into dev, part 1 and #2603 (a let is analyzed once unless it refers to itself).

As we read your branches, both passes check def against the same type with the same flag: from part 2 every analysis of a let's definition is a coercion site, and from part 1 the binder keeps its raw signature. Two other differences look harmless there. The let's recursive path has the module's name in scope, which matters only when def uses it, and then the let builds a fixpoint and #2651 falls back. And the read-back is what was recorded at def's id, not what the analysis returned; the two differ only when the expected type is a one-member labeled tuple, which is what a one-member signature desugars to on dev, but not on your branches, where a signature stays a signature type. You would know whether sealing or abstract type members make the two passes differ anywhere else.

Evidence (links to the code at the commit named on each):

Options.

  1. Merge #2651 into dev now. When your stack next rebases, keep the read-back and add ~coercible=true to the fallback's go call in parts 2 to 9. Matthew can send you the resolved hunk. recommended
  2. Merge parts 1 to 3, or the whole stack, first. Matthew then rebases #2651 onto it with ~coercible=true in the fallback and reruns the Statics.Modules and Evaluator.Modules groups there.
  3. Merge #2651 now. If ModuleExp on your stack can use the let's elaboration as is (see question 4), remove the direct pass there altogether, so the conflict resolves by deleting both sides.
  4. Hold #2651 until you have decided whether ModuleExp keeps def_elab_direct in Modules II.

3. Whether module bindings are recursive

Alexander, are module bindings meant never to be recursive, items inside { … } included, and if so, would you rather the module expansion tell the let that it is not recursive than have ModuleExp spot self-reference from the fixpoint in the let's elaboration, as #2651 now does?

answered for nowfor Cyrus
Answered for now, Alexander, 2026-10-21: whether modules should be recursive is not settled. For now it is fine for them not to be recursive by default, since recursion has a number of known correctness problems; whether they should be is a question for Cyrus. On the signal: Modules II has none for this, and he did not write the original module keyword; as things stand a module binding is a let that is not recursive. His suggestion is an argument on the let analysis saying whether to check the recursive case, so that a module binding is never analyzed recursively.
Answered further, Alexander, 2026-10-21. On the module keyword (in conversation, relayed by Matthew; his own agent's reading, which matches ours): it makes the binding non-recursive and allows a capitalized name, and is otherwise a let; #2651 keeps both. On #2651 he asked whether defining a module to be a function should be an error, addressing @disconcision on the self-naming test (his comment; For Cyrus). And on the self-naming signal (his comment): names_itself also matches a fixpoint the user wrote, module m = fix f -> …, which then keeps dev's results but loses the speed-up, and the two cannot easily be told apart by shape; so the signal belongs in the let analysis, as an argument that makes a module binding non-recursive, which can be a follow-up to keep #2651 small, and names_itself could go altogether if binding a module to a function becomes a static error (the guard). Matthew agreed (his reply), and the argument is now the draft follow-up #2717 (Statics: a module binding is a non-recursive let), opened that day at 20:31Z.

What the answers settle (our reading). #2651's statics test, which pins the expression form as not recursive, matches the current intent; a module item inside a module body, which is lowered to a plain let and so is recursive, stays as it is until Cyrus decides. His suggestion is the flag of the first option: with it the let reports a self-reference as unbound by itself, names_itself and its fallback are not needed, and a module binding skips the let's recursion probe as well. It went into a follow-up, the draft #2717 (Statics: a module binding is a non-recursive let), for the module … in form; an item in braces is still a recursive let there, and tests pin that split as it is. Its tests include both of Alexander's fix programs (the guard, What next).

Context.

A module binding module F = def in body is checked as let F = def in body. The let treats a definition that is one function of arrow-like type as possibly recursive: it analyzes def with F in scope, and wraps it in a fixpoint (FixF) only if def uses F. On dev, module F = fun x -> F(x) in 1 is an error only because ModuleExp then analyzed def a second time in the outer scope, where F is a free constructor; that pass's infos, with no F in scope, were also what the editor saw inside def.

#2651 keeps the let's infos and reruns the old pass only when the let's elaboration is Let(_, FixF, _) (or def has no recorded info), so that error stays; but inside a function-valued module that does not name itself, the context at the caret now contains F, so completion offers F wherever its type fits, and picking it brings the error back. #2603 accepts a related editor-visible difference for lets, but there the binder was already in scope inside its own function on dev, and only its type there changes. Meanwhile a module F = … item inside { … } is lowered to a plain let with no such pass, so by our reading it is recursive with no error, on dev and all through the Modules II stack, and no test or doc binds a module to a function.

Evidence (links to the code at the commit named on each):

Options.

  1. Never recursive, at either level, as in OCaml without module rec. Merge #2651 with the FixF check now, then follow up with a 'not recursive' flag on the let, set by ModuleExp and by the item lowering (for module items only, not let items). With it the let skips the recursion probe and passes and analyzes def once, in the outer scope, so the error and the editor's context both come out right, and names_itself and the fallback go away. recommended; matches Alexander's suggestion, agreed on #2651; the expression form is now the draft follow-up #2717 (Statics: a module binding is a non-recursive let), and the item form waits for Cyrus
  2. Keep the FixF shape check as the long-term signal, and add a comment where the let builds its FixF saying that ModuleExp depends on that shape.
  3. Use the module's own name in expanded_info.co_ctx as the signal instead. It also fires when def uses an outer binding of the same name, which then only costs the old extra pass. It works only until #2675 merges, which removes the name from the let's co_ctx under a fixpoint.
  4. Recursive like let, at both levels: #2651's fallback and its new statics test go away, and ModuleExp's elaboration must take the let's FixF (dev's direct pass and #2651's read-back both drop it), so the module can call itself at run time.
  5. Keep dev's split for now (not recursive as an expression, recursive as an item) and decide in a later Modules II part. the state for now, until Cyrus decides

4. Whether Modules II still needs def_elab_direct

Alexander, on your stack does ModuleExp still need its separate analysis and elaboration of the definition (def_elab_direct, which moduleexp_elab swaps in for the let's), or could a module binding use the let's elaboration as is, as your docs/modules.md already says it does?

answered
Answered, Alexander, 2026-10-21, in conversation, relayed by Matthew, to the question as first asked (is the definition's elaboration before the let wraps it exactly what moduleexp_elab is meant to take?): yes, as he understands it, and it comes from the old elaborator. Reading the definition back from the stored info is closer to the original design than dev's second pass, because the old elaborator also read the definition from the statics map.

What the answer settles (our reading). The read-back meets the contract moduleexp_elab was built for, so the PR moves toward the original design, not away from it. Whether his stack can drop def_elab_direct altogether, the first option below, is a follow-up there once module binders are not recursive; it does not hold up the merge. Run with the command-line tool, the module literal that needs the swap on dev, module M : { let x : Int } = { let x = 42 } in M.x, gives 42 at dev and with #2651 alike (the runs).

Context.

moduleexp_elab builds the binding's run-time term from the let's elaboration, with def_elab_direct (from a second analysis of the definition in the outer context) in place of the let's definition. That drops what the let wraps around a definition: label alignment, the function's name, and, for a recursive function that refers to itself, a fixpoint. On dev, alignment is why the swap matters for a module literal: a module elaborates to a let-chain and a one-member signature desugars to a one-field product, so the let would wrap the module as (x = whole module), and module M : { let x : Int } = { let x = 42 } in M.x gives 42 only because of the swap.

On your stack a signature stays a Sig type, which alignment leaves alone; a module literal elaborates to a Module value; the name touches only functions; and a module literal is never treated as recursive. So for a module literal the let's elaboration already is the direct one. What is left is a definition that is a function naming its own module (module F = fun x -> F(x)), where the let adds a fixpoint and, analyzing it recursively, does not report F as unbound; #2651 handles that by falling back to the direct pass, and a non-recursive module binder would remove it. The direct pass is also not part of the modules design: it arrived in April 2026 with 'delete elaborator' (cc620bbbdc), and before that ModuleExp elaborated its definition from the info map with no second analysis, which is what #2651 goes back to.

Evidence (links to the code at the commit named on each):

Options.

  1. Nothing is left for a module literal, and for a function definition only the fixpoint and the unbound-name report (plus the harmless name). Once module binders are not recursive, a follow-up on your stack removes def_elab_direct, the swap in moduleexp_elab and #2651's read-back, and #2651 is then a step needed only on dev. recommended
  2. It still guarantees something else on Modules II (please say what). #2651's read-back is then the long-term shape, and its contract should be written down next to moduleexp_elab: the info at def's id holds the definition's own elaboration, which is what the analysis returned, except when the definition was auto-labelled into a one-field tuple, where it holds the elaboration from before the label.
  3. Not decided yet: merge #2651 either way and revisit when the stack lands.

5. Which module behaviours to pin as tests

Alexander, which of these module behaviours do you want pinned as tests in #2651: the self-naming error at its exact place, a function-valued module that does not name itself, and a module bound to a bare value under a one-member signature?

answered
Answered on #2651, 2026-10-21. The third, the bare value: Alexander offered to pin it as stuck here, with a note that the static error comes with Modules II part 1, or to leave it out of #2651 and add it on part 1, where it can assert both the stuck term and the error (his comment); Matthew chose part 1 (his reply). The first, the self-naming error: Matthew answered that the let-analysis prototype pins the test to its mark and that its tests would include Alexander's two fix programs (Matthew's reply). The prototype is now the draft follow-up #2717 (Statics: a module binding is a non-recursive let), and it does both: the self-naming test and its form under a signature each pin one FreeConstructor mark at the inner F; the first fix program runs to 6 with no errors, and the second has one mark, Free("m"), at the inner m. It also has the second, module F = fun x -> x + 1 in F(2), as an evaluator test (3).

What the answer leaves (our reading). The second needs nobody: module F = fun x -> x + 1 in F(2) gives 3 under any reading, and gives 3 at dev and with #2651 when run (the runs), and #2717 adds it as an evaluator test (What next). The first also follows from the recursion answer: for now the expression form is not recursive. None of the three holds up the merge.

Context.

#2651 adds one statics test, module F = fun x -> F(x) in 1, written with inconsistent_typecheck. That helper passes whenever the program has any static error at all, so the test would still pass if the error moved or changed kind; today the error is a FreeConstructor mark ('Constructor is not defined') on the F inside the definition, because a capitalized name parses as a constructor and the fallback analysis runs without F in scope.

Three paths the PR changes have no test. The first is the read-back after the let's recursive branch, which a module bound to a function takes even when it does not name itself, such as module F = fun x -> x + 1 in F(2) (expected 3). The second is a module bound to a non-module value under a one-member signature, module M : { let x : Int } = 5 in M.x, which gives 5 on dev but a stuck 5 .x under #2651 (run with the CLI, Matthew's comment on #2651) unless the PR adds a guard. The third is the fallback's signature-ascribed arm, the one patch line Codecov marks as missed. These behaviours are yours to define, so we would rather pin the ones you consider part of the modules contract.

Evidence (links to the code at the commit named on each):

Options.

  1. (1) Pin the self-naming error to the FreeConstructor mark on F's use inside the definition; (2) add module F = fun x -> x + 1 in F(2), expected 3, which covers the read-back after the recursive branch; (3) add module M : { let x : Int } = 5 in M.x with whatever result the one-member-signature question settles. recommended; (1) and (2) are in the draft follow-up #2717 (Statics: a module binding is a non-recursive let), (3) on Modules II part 1 instead
  2. Only tighten the self-naming test now, and add the rest with Modules II.
  3. Leave the tests as they are.

For Cyrus

Three design questions came out of the review that are Cyrus's to decide; Alexander raised the second and third, and has asked a form of the second on #2651 itself. None holds up #2651.

  1. Should module bindings be recursive? Today module F = … as an expression is not (a self-reference is an error, and #2651 keeps it one), while a module F = … item inside { … } is lowered to a plain let and, by our reading, is. Alexander: for now it is fine for modules not to be recursive by default, since recursion has a number of known correctness problems (question 3).
  2. Should binding a module to something that is not a module be a static error? module F = fun x -> 1 in F(3) is accepted today. With a signature the case is settled: module M : { let x : Int } = 5 in M.x, accepted on dev, is already a static error, and stuck, on Modules II part 1, which Alexander accepted as the intended behaviour, dev's missing error being temporary (question 1). If a module bound to a function becomes an error too, neither a self-naming function nor a module … = fix … can reach #2651's guard, and names_itself could go (the guard). Alexander asked a form of this on #2651 on 2026-10-21 at 18:25Z, in a review comment on its self-naming test (Test_Statics_Modules.re:411): "should this be an error defining a module to be a function?" (his comment). He addressed it to @disconcision, Andrew Blinn, who wrote the module keyword (b0735de6d6 to 131f508825 are by disconcision, the account Andrew opened #2469 (Modular editors) from), so it can be answered there, by Andrew or Cyrus.
  3. What should the module keyword add over let? Alexander had favoured plain let for module definitions; Cyrus wanted the keyword to do more. Today a module binding is checked as a let that is not recursive (Modules). By Alexander's reading (his agent's, in conversation, relayed by Matthew), which matches ours, the keyword makes the binding non-recursive and allows a capitalized name, and is otherwise a let; #2651 keeps both.

What next

  1. Merge #2651: CI is green at its head. Every question for Alexander is answered, and none holds up the merge; on question 2 he sees no conflict with Modules II. CI passed at fa3eef62ce (run 39655086864, Fast checks and Full tests, 18:59Z); at b5a3e1d90a, which only renames a test, they passed too (run 39657741479, Full tests at 19:26Z). Merge into dev now, and add ~coercible=true to the fallback when Alexander's stack next rebases (parts 2 to 9).
  2. Name module M : { let x : Int } = 5 in M.x in #2651's description as a known change, and pin it on Modules II part 1. 5 on dev, a stuck 5 .x with #2651, no static errors either way (the runs). Alexander accepted the stuck result (his comment): stuck and a static error is right, and part 1 already gives both. Matthew decided its test goes on part 1, not in #2651 (his reply), where it can assert both the stuck term and the error.
  3. Tighten the tests: #2717 has them. The follow-up (below) adds module F = fun x -> x + 1 in F(2), expecting 3 (as it gives when run), and the self-naming module under a signature, and pins both self-naming tests to the FreeConstructor mark on F's use. On #2651 alone they would cover the read-back after the let's recursive path and the fallback's other arm (it reports its error when run).
  4. Finish the comment fix: fa3eef62ce already took out "add records exactly the elab_term it returns", which holds for add but not for the one-member lift, and the new comment names the lift (Statics.re:2767-2772fa3eef62ce). It still presents the no-info arm as a real case ("or when no info was recorded") rather than calling it defensive.
  5. Review the let-analysis argument, now the draft follow-up #2717 (Statics: a module binding is a non-recursive let). Alexander's suggestion, agreed on #2651 (his comment; Matthew's reply), and opened as a draft on 2026-10-21 at 20:31Z, against dev. It depends on #2651: its branch, module-let-nonrec, is three commits on #2651's head b5a3e1d90a (a0a781effe, e3dc50cc0b and ea23dd4930), so until #2651 merges its diff shows #2651's commits too. It does not wait on Cyrus: if binding a module to a function becomes an error, a module's definition is still analyzed once.
    • What it does: a module binding is a non-recursive let, by an argument to the let; names_itself and its fallback are gone, and a module's definition is analyzed once, where #2651 analyzes it two to five times by its shape (the guard).
    • Tests, passing locally at ea23dd4930: Statics.Modules 86, which is 75 on #2651 plus 11 new, the self-naming test now pinned to its one mark; Evaluator.Modules 24, 18 plus 6 new; Statics.Fixpoint (1), Evaluator.Fixpoints (10), Statics.Let (2), Evaluator.Let (5), Elaboration tests (39) and Evaluator.Incremental (41). The full suite was not run locally; CI was running at its head when this page was finished (run 39671006602).
    • Alexander's two fix programs are tests. module m = fix f -> fun n -> if n == 0 then 1 else n * f(n - 1) in m(3) runs to 6 with no errors, and its definition is analyzed once. module m = fix g -> fun n -> if n == 0 then 0 else m(n - 1) in m(3) reports "Variable m is not bound" at the inner m (1:52), exactly one mark, Free("m"), and stops at the free m (m(2):Int). Both statics tests pass on #2651 too, which already gives both answers (the guard); what tells the two builds apart is the cost.
    • The cost, measured on the tests' chains of nested modules, module F0 = fun x : Int -> module F1 = … in F1(x) in F0(1) and the same with each definition a fix, where depth n is n + 1 modules: wall times of the command-line tool's analyze, startup included, two runs each; startup alone is 1.27 to 1.42 s on both builds. At depth 9 the function chain took 31.04 and 29.59 s on #2651 and 1.43 and 1.45 s with #2717; at depth 10 the fix chain took 16.58 and 15.71 s, and 1.31 and 1.46 s. From depth 7 on, above startup, #2651's function chain grows about 3.8 to 3.9 times per level, which fits four analyses per module, and its fix chain 2.6 to 3.3 times, which fits three. With #2717 both chains take 1.25 to 1.55 s at every depth measured, 4 to 12: within 0.15 s of startup's range, and not growing with depth. Two runs per point is not a benchmark, but the differences on #2651 are far above that spread. The tests also bound the cost, timed with Sys.time, on the same chains; their names give the depth, so "twelve nested" is thirteen modules: the fix chain at depth 12 must check in under 0.5 s, and took 0.0 s with #2717, 197.2 s with #2651's statics, and 1.7 s with names_itself made to skip a fix (the probe alone, which still fails the bound); the function chain at depth 9 must check in under 5 s, and took 0.0 s with #2717 and 44.4 s with the module's let made recursive again; the function chain at depth 7, at one-label types, must check in under 10 s, and took 0.7 to 0.8 s with #2717 and 50.1 s without the flag carried through the pre-pass.
    • Left open: whether a module item inside braces should be non-recursive too. It is still a recursive let, and tests pin that split as it is, for Alexander, Matthew and Cyrus to decide. Its merges with Modules II part 2 (#2494, two conflicts in Statics.re) and with #2603 (one) have resolutions recorded in e3dc50cc0b's message, which parse but have not been built.
  6. Take the three design questions to Cyrus (For Cyrus): recursive modules (and the item form inside braces), modules bound to non-modules as a static error (asked on #2651 too, Alexander's comment), and what the module keyword should add over let. Alongside them: Alexander says there are a number of other issues with modules, unrelated to this PR's finding, and expects to clean some of them up on the Modules II stack after its first PR, part 1 (#2493), merges (in conversation, relayed by Matthew).
  7. Port the guard (29c08fd8fa) to Matthew's livelits branches #2635 and #2597, and redo #2689's merge, which took e3013b0360.
  8. File dev issues for the lift (Statics.re:329-334): it returns raw source, so any term that needs elaboration, analyzed against a one-member labeled product it does not match, reaches the evaluator unelaborated; and it records error infos at fresh ids with no source position, a dev issue of its own, Alexander notes, not for #2651 (his comment; the checks).

How this was put together