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.
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 bodyis checked aslet M = def in body. The let runs a recursion probe overdefand 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).Analyses of a module literal's definition per binding. 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_termat the definition's id instead of computing it again, the kind of read-back statics already does elsewhere (statics).The let writes; the ModuleExp case reads back. 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 … insits inside another binding's definition; amoduleitem inside braces is a plain let, and only #2603 changes it (the cost).Analyses of the innermost definition, by depth. 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).M.x before and after, for the extra-member module. 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, sonames_itselfand its fallback are gone and a module's definition is analyzed once. Its tests include his twofixprograms: the first runs to 6 with no errors and is analyzed once; the second reportsmunbound at the innerm. 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 second commit's guard. #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=trueto 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: atfa3eef62ce, and atb5a3e1d90a, which only renames a test (the review).Three-way merges, run read-only on 2026-10-21. 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 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).
- The editor. The program is a segment: a list of pieces (tiles, grout, whitespace, projectors), where each tile has an id and child segments (
Base.re:3-28). The zipper is the caret's place in that tree (ZipperBase.re:13-20). A key press becomes anAction, wrapped asPerform(CodeEditable.re:343), andPerform.goedits the zipper, consulting the previous statics as it does (Editor.re:146-184). - MakeTerm. Turns tiles into a term,
Exp.t, and gives each term node the ids of the tiles that make it up (MakeTerm.re:290-306). For statics it first fills in the pieces missing from the visible segment, its canonical completion (MakeTerm.re:1997-2013). - Statics. One recursive walk over the whole term, from an empty map (
Statics.re:4350-4381), that types and elaborates at once. It returns the info map, one record per expression id, and the elaborated program. It runs on the main thread, 225 ms after typing stops, or at once after a projector's commit (CodeWithStatics.re:93-141). - Evaluation. The elaborated program goes to the eval worker by
postMessage, with a slice of the info map (EvalInfo) and the previous run's cache (EvalResult.re:250-293). The worker evaluates in slices; for a program of at least 2,500 EvalInfo entries it also reuses earlier results, keyed by expression id, and below that it evaluates from scratch (WorkerServer.re:300-304). - Results back. The worker's messages come back as actions: values, probe samples and test results. The page updates its model, recalculates without running statics again, and redraws, error marks included.
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.
| Representation | Defined at (dev) | What it holds | Made by | Read by |
|---|---|---|---|---|
| Segment, piece, tile | Base.re:3-28 | the editor's source: tiles with ids and child segments, grout, whitespace, projectors | Perform.go edits | MakeTerm (both parses), the layout, printing |
| Zipper | ZipperBase.re:13-20 | the caret's place in the segment: selection, relatives, caret, probes | Perform.go Editor.re:173-174 | CachedStatics (probe ids, the parse), the syntax cache, Indicated.ci_of Indicated.re:228-236 |
| Editor model | Editor.re:13-32 | root sort, the zipper, and the syntax cache (layout, projector shapes) | Editor.Update.update and calculate | the code views |
| Exp.t | TermBase.re:254-258; exp_t is Annotated.t(exp_term, 'a) Grammar.re:79, with the constructors of exp_term Grammar.re:27 | one 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-166 | MakeTerm MakeTerm.re:290-306; statics' elaboration | statics, the evaluator, projectors |
| Info map | StaticsBase.re:4-11 | Id.Map from id to Info; the last write wins | add in the walk Statics.re:204-269 | the eight readers of Figure 1 |
| Info.exp | Info.re:35-53 | one expression's statics: context, expected type, type, marks, co-context, elaboration | add; the lift Statics.re:356-357; the FixF patch Statics.re:2216-2228 | the readers above; elab_term also the ModuleExp case, with #2651 Statics.re:2775-2780PR |
| Ctx | Ctx.re:37-57 | variables, constructors, type aliases and livelits in scope, newest first | binders: patterns, TyAlias, the module alias Statics.re:2124-2151 | variable lookup Statics.re:691-705, completion TyDiCtx.re:192-201 |
| CoCtx | CoCtx.re:30-53 | each free variable's uses, with the type each use expects | variables; union and mk at binders | unused-variable warnings, requires_fixf Statics.re:2172-2181, probe targets StaticsBase.re:46-53 |
| CachedStatics | CachedStatics.re:4-13 | term, elaborated program, info map, error and warning ids, probe targets | CachedStatics.init CachedStatics.re:188-226 | the editor, EvalResult, Perform.go |
| EvalInfo | EvalInfo.re:18-29 | for each expression id: its elaboration, co-context and probe targets | of_info_map EvalInfo.re:56-74 | the worker's reuse check IncrEval.re:313-353 |
| Worker request and messages | WorkerServer.re:7-89 | {expr, eval_info_map, prev}; Ack, ReusePlan, Stream, Result | EvalResult.calculate EvalResult.re:270-293 | the worker; the page through EvalRequest.re:6-41 |
| Dynamics | Dynamics.re:50-55 | probe samples, test results and theorems, by id | the worker's evaluator state | probe 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):
- The key becomes an action,
Perform(Insert(c))(CodeEditable.re:343), andEditor.Update.updaterunsPerform.goon the zipper with the statics of the previous refresh (Editor.re:146-184). - 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). CachedStatics.initparses the zipper for statics, runsStatics.mkover 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).Editor.Update.calculaterefreshes the completion buffer from the record at the caret, the syntax cache and the probes (Editor.re:186-230).EvalResult.calculateprojects 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).- The worker answers with messages, Figure 4; each comes back as an action, which updates the result and recalculates without running statics again.
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).
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).
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
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
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:
- Records the name's cursor info, an
InfoMPat(Statics.re:2728-2735). - Turns the name pattern into an ordinary pattern, desugaring a signature annotation to a labeled product (
ModuleHelpers.re:51-60). - Builds
Let(pat, def, body)and gives it the ModuleExp's own id (Statics.re:2736-2741). - Analyzes that let (
Statics.re:2742): the probe and real pass overdef, the body, and the pattern, whose record overwrites the name'sInfoMPat. - Changes the class in the let's record to ModuleExp (
Statics.re:2743-2756), which step 7 overwrites anyway. - Analyzes
defagain, directly, against the signature or nothing, to getdef_elab_direct(Statics.re:2757-2762). This pass's records overwrite the let's for every node insidedef. - Builds the binding's elaboration with
moduleexp_elaband adds the ModuleExp's record (Statics.re:2763-2772).
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.
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).
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.
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
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
| Definition | dev | #2651 | #2603 alone | both | Where the passes are |
|---|---|---|---|---|---|
a module literal, module M = { … } in body | 3 | 2 | 2 | 1 | probe 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) | 5 | 4 | 2 | 1 | probe, two dropped and a final pass Statics.re:2090-2122, direct |
a function that names it, module F = fun x -> F(x) in 1 | 5 | 5 | 4 | 4 | as 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.
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 checks | What the reading found | Where |
|---|---|---|
| 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 signature | The 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 view | Unchanged 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 PR | The 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.
| Program | dev 962a7cb009 | #2651 29c08fd8fa | The path it takes |
|---|---|---|---|
module M : { let x : Int } = 5 in M.x | 5, no static errors | 5 .x (stuck), no static errors | the 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 errors | the one-member lift, with an extra member (the corner) |
module F = fun x -> F(x) in 1 | runs to 1; 1 static error, "Constructor is not defined" at the inner F (1:21) | the same | the guard's fallback (the guard) |
module F = fun x -> x + 1 in F(2) | 3, no static errors | 3, no static errors | the read-back after the let's recursive path, untested in the PR (question 5) |
module M : { let x : Int } = { let x = 42 } in M.x | 42, no static errors | 42, no static errors | a 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 1 | runs to 1; "Constructor is not defined" at the inner F (1:34) | the same | the fallback's signature arm, Codecov's one missed line (the checks) |
The one corner where the result changes
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 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
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.
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):
- After a synthesis pass, every expression analyzed against a type that weak-head-normalizes to a one-field labeled product goes through
autolabel_singleton_tuple, except when its own type is a one-field product with that same label, or unknown.Statics.re:2778-2791 - It analyzes a fresh copy against the field type and returns the surface tuple
(l = e)around the raw source. It writes the lifted info and then the bare info under the same ids (rewrap keeps the ids), so the info left at the definition's id is the bare analysis: what it records and what it returns differ.Statics.re:310-358·Statics.re:312-321·Statics.re:329-334·Statics.re:356-357·IdTagged.re:158-164 mpat_to_patdesugars{ let x : Int }to the one-field product(x = Int): the direct pass'sdef_ana, and the type the let analyzes def against.ModuleHelpers.re:51-60·Typ.re:615-646·Statics.re:2079- Dev's direct pass analyzes def against that product, so the term it hands
moduleexp_elabis(x = 5), or(x = raw module)for the extra-member case.Statics.re:2757-2764 - #2651 takes
elab_termfrom the InfoExp atrep_id(def): on this path the bare inner analysis, 5, or the module's own elaboration without the label.Statics.re:2775-2780PR moduleexp_elabstrips the binder's signature, so nothing wraps or seals the definition again at run time.ModuleHelpers.re:447-456·ModuleHelpers.re:364-371- When Dot meets an Int (a value of the wrong form: not a tuple, and not a list, which Dot maps over), unboxing gives IndetMatch and the step is Indet, so
M.xstays stuck.Transition.re:1024-1087·Unboxing.re:184-217 - The lift is meant for plain lets:
let x : (l=String) = "a" in xelaborates to(l="a").Test_Elaboration.re:386-399 - Dev's statics tests call an extra member a limitation that 'should pass with full modules': it is an error only because modules desugar to products.
Test_Statics_Modules.re:373-387 - The new test asserts
M.x = 1; its comment calls the program an error in statics that still runs as the module, and its name says it 'runs as itself'. Since thenfa3eef62cehas reworded the comment (M.x is the member, 1, as a labeled tuple's projection gives), andb5a3e1d90arenamed the test 'Projecting a declared member of a module wider than its signature', as Alexander suggested.Test_Evaluator_Modules.re:86-96PR ·Test_Evaluator_Modules.re:86-88fa3eef62ce ·Test_Evaluator_Modules.re:90-93b5a3e1d90a - The PR description says that on dev
M.xevaluates to the module's unelaborated source and with #2651Mto(x=1, y=2), kept since the new result looks like the right one. It does not mention a non-module definition. #2651's description (read 2026-10-21) - From part 1 on,
mpat_to_patkeeps the raw signature (nodesugar_sig), andweak_head_normalizehas no Sig case, so a signature-ascribed module is never lifted; part 3 also rejects a product annotation on a module.ModuleHelpers.re:47-56part 1 ·ModuleHelpers.re:47-56part 3 ·Typ.re:675-738part 3 ·Test_Statics_Modules.re:1657-1662part 3 - In part 1,
module M : { let x : Int } = { let x = 1; let y = 2 } in Mevaluates to{ let x = 1 }.Test_Evaluator_Modules.re:196-202part 1 - Your docs make
module M : S = ea coercion site, where extra members are sealed away and the run-time value keeps only the signature's members.modules.md:82-90part 9
Options.
- 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, wheneverdef_anais a one-field labeled product. #2651 then changes neither result the review found. Replace its evaluator test withmodule M : { let x : Int } = 5 in M.xgiving 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 - 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 infa3eef62ceandb5a3e1d90a), and the PR description, to say only what holds on both:M.xis 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 - Fix the cause in a separate PR: make what
autolabel_singleton_tuplerecords at the definition's ids agree with what it returns. #2651 would then need no special case, but the extra-member module'sM.xwould 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.
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):
- On dev, after checking the expanded let, the ModuleExp case analyzes the definition again, directly, to get
def_elab_direct.Statics.re:2757-2762 - #2651 reads
def_elab_directfrom the InfoExp the let recorded atExp.rep_id(def). It falls back to the direct analysis, as on dev (no~coercibleexists there), when the let's elaboration isLet(_, FixF(_), _)or no InfoExp was recorded.Statics.re:2770-2789PR - Part 2 changed the direct analysis to
go(~ana=def_ana, ~coercible=true, def, m); the same line is still there in part 3 and at the top of the stack in part 9.Statics.re:2833-2834part 2 ·Statics.re:2853-2854part 3 ·Statics.re:2973-2974part 9 - In part 2 the let passes
~coercible=truein all five analyses of the definition (the recursion probe, the non-recursive pass, and the recursive path's three), under the comment that every analysis of it is a coercion site.Statics.re:2152-2155part 2 ·Statics.re:2164-2165part 2 ·Statics.re:2177-2178part 2 ·Statics.re:2190-2191part 2 ·Statics.re:2209-2210part 2 coercibleswitches the expected-type check from a meet toTyp.coercion, which seals a wider module.StaticsBase.re:265-271part 2- #2494's description says that
Asc,Let(all analyses of the definition),ModuleExpandApset~coercibleon their operand. #2494's description (read 2026-10-21) - From part 1 on,
mpat_to_patkeeps the raw signature (later asPat.of_mpat), and the pattern Asc case reports that annotation as its type, so the let's expected type for def is the annotation the direct pass takes asdef_ana.ModuleHelpers.re:47-56part 1 ·ModuleHelpers.re:47-56part 3 ·Pat.re:298-306part 9 ·Statics.re:3710-3725part 1 ·Statics.re:3747-3761part 3 ·Statics.re:2848-2852part 3 - The let wraps def in a fixpoint only when it took the recursive path and def or the binder's annotation mentions the bound name (uses in the body are filtered out); otherwise a function-shaped def is read back from the recursive pass, where the name is in scope but unused.
Statics.re:2233-2242part 2 ·CoCtx.re:43-53 - When the expected type is a one-member labeled tuple, the analysis records the inner analysis at the user's ids but returns the user's term wrapped as a one-field tuple, so what is recorded at def's id and what
goreturns can differ.Statics.re:310-359·Statics.re:2776-2791 - On dev a module signature desugars to a labeled tuple, so a one-member signature takes that path; #2651's second commit names this as the one result it changes, and a new test checks it.
ModuleHelpers.re:51-60·Typ.re:615-646·Test_Evaluator_Modules.re:86-96PR - On the Modules II branches a signature is a Sig type, which
weak_head_normalizereturns unchanged, so a signature ascription does not take the one-member-tuple path.Typ.re:629-690part 2 - Part 2's
moduleexp_elabkeeps the binder's signature ascription, which is what seals at run time, and still swapsdef_elab_directin for the let's definition.ModuleHelpers.re:617-627part 2 - A three-way merge of 29c08fd8fa with each part (merge base 34f9605ddf; every part contains dev 962a7cb009) gives exactly one conflict, in
Statics.reon that line, for each of parts 2 to 9, and none for part 1; it also merges cleanly with dev and with #2603's head 4aa493c05a.git merge-tree(run 2026-10-21; parts 1, 2 and 9 and dev re-run for this report) - Separately, #2603 conflicts with every part, 1 to 9 (TyDiCtx.re, Ctx.re and StaticsBase.re, plus Statics.re from part 2 on). That belongs to #2603's review, not #2651's.
git merge-treeof 4aa493c05a with each part (run 2026-10-21) - Alexander reviewed #2651 on GitHub from 18:24Z on 2026-10-21, and none of his comments raises a conflict with Modules II. Matthew's top-level 🤖 comment of 18:42Z gives this question's resolution,
~coercible=trueon the fallback, as a statement rather than a question. GitHub's reviews, review comments, comments and check runs for #2651 (read 2026-10-21, 19:01Z) · Matthew's comment
Options.
- Merge #2651 into dev now. When your stack next rebases, keep the read-back and add
~coercible=trueto the fallback'sgocall in parts 2 to 9. Matthew can send you the resolved hunk. recommended - Merge parts 1 to 3, or the whole stack, first. Matthew then rebases #2651 onto it with
~coercible=truein the fallback and reruns the Statics.Modules and Evaluator.Modules groups there. - 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.
- Hold #2651 until you have decided whether ModuleExp keeps
def_elab_directin 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?
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):
is_recursivelooks only at the shape of the code: one bound variable and one function of arrow-like type count as recursive, whether or not the name is used.Statics.re:2024-2039·Statics.re:2076- On the recursive path the let analyzes def in
def_ctx, where the bound name is in scope at def's synthesized type, and records those infos.Statics.re:2090-2122 - A fixpoint is used only when def (or the pattern's types) uses the bound name; the body's uses are dropped by
CoCtx.mk. The let then removes the name from the co_ctx of def's info, so that info cannot show the self-reference.Statics.re:2172-2181·Statics.re:2200-2206·Statics.re:2216-2228·CoCtx.re:43-53 - The let's own co_ctx still contains the name when def uses it, because it unions the unpatched
def.co_ctx: a check onexpanded_info.co_ctxwould also work as a signal, but only without #2675 (incremental evaluation reuses expressions around a recursive let), still open, which removes the name from the let's co_ctx under a fixpoint.Statics.re:2231-2240·Statics.re:22442675 head - Dev's direct pass analyzed def in the outer context after the let and overwrote def's infos. There
Fis not a variable, so it elaborates as a constructor with a FreeConstructor mark: the error the fallback still reports.Statics.re:2757-2762·Statics.re:1598-1606·ConstructorStaticsHelpers.re:61-64 - #2651's signal is the shape of the let's elaboration; the direct pass also runs when def has no recorded InfoExp. Its new test's comment says 'modules are not recursive'.
Statics.re:2770-2774PR ·Statics.re:2775-2789PR ·Test_Statics_Modules.re:406-413PR - ModuleExp's elaboration replaces the let's def with the direct (dev) or read-back (#2651) elaboration, so a FixF the let built is dropped either way.
ModuleHelpers.re:448-456·Statics.re:2764-2769PR ·Statics.re:2790-2791PR - Completion offers the context's variables, at the Info under the caret, whose type is consistent with the expected type.
TyDiCtx.re:28-39·TyDiCtx.re:192-201 - A
module F = defitem inside a module body is lowered to a plain Let with no fallback, on dev and on every Modules II part; the item's elaboration keeps the let's FixF.ModuleHelpers.re:300-304·ModuleHelpers.re:442-445·ModuleHelpers.re:265-271part 3 ·ModuleHelpers.re:268-274part 9 ·ModuleHelpers.re:642-651part 9 - #2603 keeps the probe's analysis for a function that does not mention its binders and whose binders shadow nothing, with the binder in scope at its synthesized type, and calls that an accepted editor-visible difference. On dev the binder was already in scope there.
Statics.re:2079-20892603 head ·Statics.re:2108-21192603 head ·Statics.re:2091-2102·Statics.re:2121 - The only module bound to a function anywhere in the tree is #2651's self-naming test, and part 9 has none.
git grepover the whole tree at 29c08fd8fa and b02ce6dab5 (run 2026-10-21) - #2493 lists 'No mutually recursive modules' among its limitations and says nothing about a module binding that names itself. #2493's description (read 2026-10-21)
Options.
- 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 (formoduleitems only, notletitems). 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, andnames_itselfand 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 - 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.
- Use the module's own name in
expanded_info.co_ctxas 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. - 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. - 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?
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):
moduleexp_elabkeeps the let's pattern (with its signature ascription stripped) and body, and putsdef_elab_directin place of the let's definition.ModuleHelpers.re:447-456- ModuleExp analyzes the definition a second time, in the outer context, against the binder's annotation, and swaps the result in.
Statics.re:2757-2764 - Both of the let's paths align and name the definition's elaboration; the recursive path, when the binding refers to itself, also wraps it in a fixpoint.
Statics.re:2172-2230 - On dev a module binder's signature annotation is desugared to a labeled product.
ModuleHelpers.re:51-60·Typ.re:615-657 - Against a one-field labeled product, alignment wraps any elaboration that is not already a tuple, such as a module's let-chain, into
(x = e).LabeledTupleHelpers.re:49-75·LabeledTupleHelpers.re:108-110 - Dev's test that depends on the swap:
module M : { let x : Int } = { let x = 42 } in M.xevaluates to 42.Test_Evaluator_Modules.re:123-129 add_namechanges onlyFunandTypFun.Exp.re:544-551- A let is treated as recursive only when its definition is a function or a tuple of functions;
get_num_of_functionsreturns None for a Module literal, on dev and on your stack.Statics.re:2024-2039·Statics.re:2076·Exp.re:407-460·Statics.re:2241-2256part 9 ·Statics.re:2284part 9 ·Exp.re:456part 9 - #2651 keeps the direct pass for a definition that names its own module: there the let wraps a fixpoint and analyzes the name in scope, while the direct pass reports it unbound; its test is
module F = fun x -> F(x) in 1.Statics.re:2764-2789PR ·Test_Statics_Modules.re:409-413PR - On your stack a module literal elaborates to a Module value.
Statics.re:2765-2815part 3 ·Statics.re:2925part 9 weak_head_normalizeleaves a Sig as a Sig, through its fall-through case;LabeledTupleHelpers.reis unchanged from dev through part 9, and its alignment returns a term unchanged when the expected type is not a product; a product annotation on a module is rejected from part 3.Typ.re:675-739part 3 ·Typ.re:737part 3 ·Test_Statics_Modules.re:1657-1662part 3 ·LabeledTupleHelpers.re:77part 9 ·LabeledTupleHelpers.re:104part 9- Your docs say a ModuleExp is checked as
Let(pat, def, body)and 'elaborates to that Let'.modules.md:496-497part 9 - The code on your stack still analyzes the definition a second time (with the same
~coercible=truethe let uses) and swaps it in, keeping the binder's signature for sealing.Statics.re:2968-2976part 9 ·Statics.re:2287-2288part 9 ·ModuleHelpers.re:671-681part 9 - Before 'delete elaborator' (
cc620bbbdc, committed 2026-04-17), the separate Elaborator elaborated ModuleExp's definition from the info map, with no second analysis;def_elab_directfirst appears in that commit.Elaborator.re:518-524a81e3188fd ·git log -S def_elab_direct - The info at def's id is not always what the let's analysis returned: when a definition is auto-labelled into a one-field tuple, the analysis returns
(x = the unelaborated term)but records the elaboration from before the label, which is why #2651's new one-member-signature test gives 1.Statics.re:310-359·Statics.re:357·Test_Evaluator_Modules.re:86-96PR
Options.
- 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 inmoduleexp_elaband #2651's read-back, and #2651 is then a step needed only on dev. recommended - 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. - 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?
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):
- The self-naming test is an
inconsistent_typecheckcase.Test_Statics_Modules.re:406-413PR inconsistent_typecheckonly checks that the list of static errors is not empty; that list is every info in the statics map that carries marks.Test_Statics_Prelude.re:142-151PR ·Test_Statics_Prelude.re:225-242PR- The error the fallback reports is the FreeConstructor mark at the use of F: Ap analyzes a constructor head against an arrow,
syn_marks_ctrfinds no constructor F, and the Constructor case finds no variable F in the outer context.Statics.re:1561-1565·Statics.re:1598-1606·Statics.re:1705-1718·ConstructorStaticsHelpers.re:46-67 - Under #2651 the self-naming case reaches the direct analysis only because the let wrapped the definition in a FixF, which
names_itselfdetects.Statics.re:2172-2181PR ·Statics.re:2202-2206PR ·Statics.re:2229PR ·Statics.re:2770-2789PR - A single variable bound to a function with an arrow type takes the let's recursive branch, which analyzes the definition last with the name in scope; with no self-reference no FixF is built, so the read-back is used.
Statics.re:2024-2039PR ·Statics.re:2070-2076PR ·Statics.re:2090-2123PR ·Statics.re:2172-2187PR ·Statics.re:2775-2780PR - On dev the direct analysis passes the signature, a one-field labeled product, as the expected type, and the singleton-label lift returns the tuple
(x=5).Statics.re:2757-2762·Statics.re:2778-2789·Typ.re:615-646 - The lift records the tuple's info at the definition's ids, then overwrites them with the unlabeled inner info, and returns the tuple; so the PR's read-back gets 5, not
(x=5).Statics.re:310-358 - Projecting a label from a non-tuple value such as 5 is indeterminate, so
M.xis stuck.Transition.re:1024-1086·Unboxing.re:185-217 - The PR's own evaluator test covers the same read-back for a module value with an extra member, not for a bare value.
Test_Evaluator_Modules.re:86-96PR - The only module bound to a function anywhere in the tests is the self-naming one, and no test binds a module to a non-module value.
git grepover test/ and docs/ at 29c08fd8fa (run 2026-10-21) - Codecov reports 87.5% patch coverage with one line missing: the fallback's signature-ascribed arm, which was hit on the base.
Statics.re:2784PR ·Statics.re:2759· codecov's comment on #2651 (read 2026-10-21)
Options.
- (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) addmodule M : { let x : Int } = 5 in M.xwith 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 - Only tighten the self-naming test now, and add the rest with Modules II.
- 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.
- 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 amodule 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). - 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 amodule … = fix …can reach #2651's guard, andnames_itselfcould 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 themodulekeyword (b0735de6d6to131f508825are by disconcision, the account Andrew opened #2469 (Modular editors) from), so it can be answered there, by Andrew or Cyrus. - What should the
modulekeyword add overlet? Alexander had favoured plainletfor 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
- 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); atb5a3e1d90a, which only renames a test, they passed too (run 39657741479, Full tests at 19:26Z). Merge into dev now, and add~coercible=trueto the fallback when Alexander's stack next rebases (parts 2 to 9). - Name
module M : { let x : Int } = 5 in M.xin #2651's description as a known change, and pin it on Modules II part 1.5on dev, a stuck5 .xwith #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. - 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). - Finish the comment fix:
fa3eef62cealready took out "add records exactly the elab_term it returns", which holds foraddbut 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. - 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 headb5a3e1d90a(a0a781effe,e3dc50cc0bandea23dd4930), 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_itselfand 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
fixprograms 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 innerm(1:52), exactly one mark,Free("m"), and stops at the freem(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 afix, where depth n is n + 1 modules: wall times of the command-line tool'sanalyze, 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 thefixchain 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 itsfixchain 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 withSys.time, on the same chains; their names give the depth, so "twelve nested" is thirteen modules: thefixchain 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 withnames_itselfmade to skip afix(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
moduleitem 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 inStatics.re) and with #2603 (one) have resolutions recorded ine3dc50cc0b's message, which parse but have not been built.
- What it does: a module binding is a non-recursive let, by an argument to the let;
- 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
modulekeyword should add overlet. 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). - Port the guard (
29c08fd8fa) to Matthew's livelits branches #2635 and #2597, and redo #2689's merge, which tooke3013b0360. - 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
- What was read: dev at
962a7cb009and #2651 at29c08fd8fa(first commite3013b0360, merge base34f9605ddf), read on 2026-10-21 after fetching both; 29c08fd8fa was the PR's head untilfa3eef62ceandb5a3e1d90a, pushed during the review, which change only comments and one test's name. For Modules II, the heads of parts 1, 2, 3 and 9 (c4bae23739,be90bf4b21,111775a60c,b02ce6dab5); #2603 at4aa493c05a; #2469 atb5eaa33031. Every file:line in this page links to GitHub at the full commit it names, and each range was checked to exist there. - Six readers of the code, one subsystem each: the edit-to-statics pipeline, the statics core, modules, elaboration to evaluation, the PR itself, and its performance context. They read only. Their notes are in
024/data/research-notes.json. - Six programs, run: afterwards the command-line tool was built at dev 962a7cb009 and at #2651's 29c08fd8fa, and each program in the runs was run and analyzed with it (
./hazel runand./hazel analyze). The first was found by reading the code, while checking question 1, and the run confirmed it. Nothing was timed for this report. - The questions were drafted from two lenses, what a reviewer of #2651 needs and what only the Modules II owner can say, and each was checked for its premise (does the code say what it assumes, line by line at the named commits) and its need (does anything on record already answer it: the PR's conversation, the Modules II descriptions, the docs, the tests). Their checks are in
024/data/questions.json. None was found already answered when they were drafted; Alexander then answered four in the review and in conversation, questions 2 and 4 as recorded on this report's interim page, published during the review, and the fifth was settled on #2651. - Alexander's answers are from his review with Matthew on 2026-10-21 and from conversation that day, relayed by Matthew and paraphrased here; from then on he answered on #2651, and those answers are paraphrased with a link to each comment. The PR's comments, reviews and checks were read from GitHub at 19:34Z that day, and #2651's again at 20:34Z, with nothing new but #2717's cross-reference.
- The follow-up, #2717: what it does, its tests and their results, and its timings are from its description and from the notes of the run that built it, at
ea23dd4930. Its new test files were read; its code was not read for this report, and its tests were not run again here. - Finished on 2026-10-21 at 14:41 MDT, once the follow-up #2717 was open. Later changes come as dated updates.
- A fact-check re-derived each line number at the commit its link names; dev had moved since some notes were written. The figures were rendered to images with ffmpeg's SVG reader, in the light and dark colours, and looked at; the page was not opened in a browser.
- Not confirmed: the editor's context inside a function-valued module and the tuple-literal case were traced in the code, not run. The six programs in the runs are confirmed by running them at dev 962a7cb009 and at 29c08fd8fa, the bare-value program's stuck
5 .xamong them (also in Matthew's comment on #2651). The analysis counts were counted from the code, not measured. The timings quoted are #2651's own and #2609's finding 7 (two runs each), and #2717's: the command-line tool's times on nested chains, two runs per depth, and its tests' own time bounds, timed withSys.timeat its head and in throwaway builds; this report timed nothing. The two corners^report(21)found, the extra-member module and the self-naming definition, were confirmed there by running the command-line tool ate3013b0360and at dev's34f9605ddf. - Credit: #2651 and #2603 are Matthew Hammer's, made with Claude. Dev's module system and the
modulekeyword are Andrew Blinn's; Modules II is Alexander Bandukwala's; the statics without a separate elaborator, Mode or Self is Matt Keenan's; DefStatics is Andrew Blinn's, in #2469. - The page was first made by
024/drivers/gen024.py(figures infigs.py, questions inquestions.py); since then it has been edited directly as the review went on, so rerunning the generator would undo those edits. - The numbers, for programs too: every table in this report, and what its numbers measure, is also in this page as JSON, in its
<script type="application/json" id="report-data">element, so a reader or an agent that fetches the page alone has the dataset. The same, with any raw files that survive, is in this report'sdata/folder, next to the page:meta.json,questions.json,tables.jsonandresearch-notes.json.