Skip to content

Merge the Lam IR into Lambda - #8608

Open
cristianoc wants to merge 46 commits into
masterfrom
lam-convert-cleanup-2
Open

Merge the Lam IR into Lambda#8608
cristianoc wants to merge 46 commits into
masterfrom
lam-convert-cleanup-2

Conversation

@cristianoc

Copy link
Copy Markdown
Collaborator

Continuation of #8604. That PR made Lam_convert.convert structural; this one finishes the job and removes the Lam layer, leaving a single Lambda IR.

The merge

With conversion already a one-to-one rebuild, Lam.t and Lambda.lambda were the same type written twice — same 22 expression constructors in the same order, same 115 primitives, every payload type already a manifest alias. So Lam.t becomes Lambda.t and conversion becomes the identity, then disappears.

Retired with it: Lam, Lam_convert, Lam_primitive, Lam_constant, Lam_compat, Lam_tag_info, Lam_free_variables, Lam_subst, Lam_iter, Lam_print, Lam_pass_apply_arity, Lam_eta_conversion, the Switch functor, and the primitives Pisout, Poffsetint, Poffsetref, %function_arity, %succint, %predint along with the curried-application machinery.

What replaces it: one private Lambda.t whose constructors normalize as they build, two shallow traversals (shallow_map_sharing for rewriting, shallow_exists for querying), one printer, and one scheduled pass for the rewrite that turned out not to be normalization.

Where the line between constructor and pass falls

The interface now states a rule the work established empirically: a constructor may replace a node with an equivalent one, but may not move code between branches.

Porting Lam.if_'s folds to Lambda broke the runtime build — Fatal error: exception Not_found from compile_staticraise. Bisected per arm, the culprit was rewriting if a then raise e else c into (if a then raise e else ()); c. That is code motion, and matching inspects the shapes it built after building them, so rewriting during construction leaves static raises without their catch. Every other fold was safe at production, and fires almost never there — measured at zero firings for prim.

As a scheduled pass it is fine. It has to run last: instrumenting the firing site shows the opportunities come from conversion (18) and from four different passes — exits, remove_alias, lets_dce, deep_flatten (5) — so a pass placed after all of them catches every case without knowing which produced it. It is worth keeping: dropping the rewrite changes 20 files, adding an indentation level to bodies as large as Stdlib_List.getOrThrow. The pass allocates nothing when it has nothing to do — 742 of 760 modules come back physically unchanged having allocated zero minor words.

Bug fixes

Three, each with a regression test:

  • A recursive module with an empty signature discarded its right-hand side entirely: module rec M: {} = { let () = Console.log("effect") } emitted nothing for M.
  • Int.Ref.increment(mkRef()) evaluated mkRef() twice.
  • A polymorphic variant whose numeric name exceeds the int32 range crashed the compiler with Failure("Int32.of_string") and no location.

Output

Generated JavaScript is unchanged, with one deliberate exception: four snapshots change the operand order of two-value range tests (x !== 2 && x !== 1x !== 1 && x !== 2). E.is_out had two arms for that case which disagreed on order, so which you got depended on whether the offset landed in an addition or a subtraction. They are now consistent.

Every commit was verified against the full corpus — runtime, Belt and the 620 modules in tests/tests — plus ounit, mocha, build tests, syntax, gentype and analysis. The playground compiler builds and yarn workspace playground test passes.

Notes for review

  • The polymorphic-variant fix (compiler/ml/typecore.ml) is the one commit unrelated to the merge. It is a standalone crash fix and can be split into its own PR if preferred; it is also this branch's only substantial edit outside the Lambda area.
  • On ordering against Normalize string literal representation across the compiler #8606: that PR modifies seven files this one deletes, but its changes there total roughly +57/−31 — it is propagating a changed Const_string payload through a representation that was duplicated across Lam_constant, Lam_primitive, Lam_print and the converters. Landing this first collapses those edits into one place. Details in a comment on Normalize string literal representation across the compiler #8606.

cristianoc and others added 30 commits September 1, 2026 10:15
`%identity`, `%component_identity`, `%ignore`, and unary `+` erase at
translation: they have no IR form. They were encoded as `Peliminated`, a
constructor present in both `Lambda.primitive` and `Lam_primitive.t` that
neither IR can hold - `Lambda.mk_prim` expanded it before it could reach an
`Lprim`, and every Lam consumer answered it with `assert false`.

Give the primitive table an element type that says what a `%name` means:

    type builtin = Primitive of primitive | Eliminated of eliminated

`builtin` is a lookup type in `translcore`, not an IR type, so it needs no Lam
counterpart. Both primitive types lose `Peliminated`, along with its four
`assert false` sites, the unreachable `printlambda` case, and the `lam_convert`
pass-through for a case that could not occur.

`Unified_ops` had the same problem in its own table, where unary `+` was
`Peliminated Identity` in a field of operations. Its cells now carry a
lowering, `Lower of Lambda.primitive | Pass_through`, so `Ignore` is not
representable there and the module depends on `Lambda.primitive` only. The
`option` alone answers the type checker's question of whether an operand type
is supported.

`make_key`, `subst_lambda`, and matching's `make_prim` rebuilt an `Lprim`
through `mk_prim`, so a substitution nominally carried the power to change term
shape; they construct `Lprim` directly now.

Generated JavaScript is unchanged for the runtime, Belt, and tests/tests.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`%null` and `%undefined` were zero-argument primitives whose only job was to
become constants in conversion, so a value spelled `Nullable.null` was not a
constant at Lambda level. They are now `Constant` entries in the builtin table,
and `Pnull` / `Pundefined` are gone from `Lambda.primitive`.

`Const_js_null` and `Const_js_undefined` join `Lambda.structured_constant`.
Unit becomes `Const_js_undefined {is_unit = true}` rather than a constant
constructor named `"()"`, and a constructor with an optional shape produces the
undefined constant directly, so `Pt_shape_none` is gone. Conversion of all
four forms is now the identity.

Constant constructors, polymorphic variants and the module alias are built
through `Lambda.const_constructor` / `const_polyvar` / `const_module_alias`,
and `pointer_info` is `private` so nothing outside `lambda.ml` can build one.
That is what keeps the unit constructor from escaping as a pointer: the unit
check lives in `const_constructor`, and the type system now enforces that every
producer goes through it. Redefining `()` is already rejected for source and
PPX-generated declarations by `Bs_ast_invariant`.

The polymorphic-comparison warning check reads the constant forms instead of
the primitives, deliberately excluding `is_unit = true`: unit shares the
undefined constant but is not a `%null` / `%undefined` literal, and comparing
it never suppressed the warning. `tests/build_tests/super_errors` covers both
directions.

Generated JavaScript changes in two option-representation tests, as intended:
`Some(Nullable.null)` and friends are constants now, so they fold at compile
time and let calls with constant arguments inline. Runtime and Belt output is
unchanged.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
A polymorphic variant with a payload is `{NAME, VAL}` at runtime. The name was
stored twice: `translcore` put `Btype.hash_variant l` in the block and the name
in `Blk_poly_var`, and conversion then threw the hash away and rebuilt the name
from the descriptor. The hash is meaningless for this target - it is OCaml's
runtime discriminator, kept when the encoding changed to an object - and the
descriptor's copy was read by nothing except the printer and that rebuild.

`translcore` now emits the final name, decided once by
`Lambda.const_polyvar_name`: numeric-looking names are numbers at runtime,
everything else is a string. `Blk_poly_var` becomes a payload-free shape
marker like `Blk_tuple`, so both conversions lose their special case and the
`assert false` guarding a two-element shape they could not express. Every
code-generation site keeps reading the name where it always did, from the
block, so the JavaScript layer is unchanged apart from the constructor arity.

`Btype.hash_variant` is no longer emitted anywhere; its one remaining use is a
deterministic sort key in `matching`.

Generated JavaScript is unchanged for the runtime, Belt, and tests/tests.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
A polymorphic variant whose numeric name exceeds the int32 range crashed the
compiler with `Failure("Int32.of_string")` when it carried a payload or
appeared in a pattern:

    let x = #99999999999("a")
    switch x { | #99999999999 => 1 | _ => 2 }

The range check lived in the frontend AST pass and matched only
`Pexp_variant (s, None)`, so those two positions reached
`hash_number_as_i32_exn` unvalidated, with no location and no message.

Move it to `Typecore`, next to the integer literal decoding whose overflow
error it mirrors, and call it from both label positions. That follows what the
compiler already does for numeric literals: the parser keeps the text and never
judges ranges, and the hard error is raised where the value is decoded. It also
means the check covers a position once rather than once per syntactic form, and
lets `Lambda.const_polyvar_name` and the constant converter keep decoding with
the `_exn` function, now with a guarantee behind it.

A bare `type t = [#99999999999]` still compiles: nothing decodes a row field
name, so it cannot crash, and reporting it would belong in `Typetexp`.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Conversion of constants was a fan-out: one `Const_pointer of pointer_info`
became five Lam constants, option blocks became `Const_some`, and the boolean
names differed. All of it is decided at Lambda production now, so
`Lam_constant.t` is a manifest re-export of `Lambda.structured_constant` and
`lam_constant_convert.ml` is deleted - `Lconst` passes straight through.

Three moves:

Options. `Const_some` and the `Psome` / `Psome_not_nest` primitives move to
Lambda, and `translcore` emits them instead of a block that conversion had to
reinterpret. `Blk_some` / `Blk_some_not_nested` leave `tag_info`, which also
retires a dead `Const_block (Blk_some, _)` case in `lam_pass_remove_alias` and
an `assert false` in `js_exp_make`.

Pointers. `Const_constructor`, `Const_polyvar`, `Const_assertfalse` and
`Const_module_alias` become explicit constants, so `Const_pointer` and
`pointer_info` are gone. The two decisions conversion used to make happen once,
in `const_constructor` (unit first, then integer-tagged constructors as
ordinary integers so folding sees their runtime representation) and in
`const_polyvar` (numeric-looking names as integers). `const_polyvar_name`, the
name field of a variant carrying a payload, is defined in terms of the latter,
so the choice exists in exactly one place. Lam's `Const_pointer of string` is
renamed to `Const_polyvar` to match.

Booleans. Lambda's `Const_false` / `Const_true` take the Lam names.

Generated JavaScript is unchanged for the runtime, Belt, and tests/tests; each
of the three moves was verified output-neutral on its own.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
A call whose arity was unknown used to be emitted as `Primitive_curry._N(f, …)`,
which discovered the real arity at runtime and applied, re-applied, or returned
a closure collecting more arguments. Uncurried-by-default plus structural arity
removed the need: every call site now knows how many arguments the callee takes.

The state that drove it is unreachable. `ap_status` is only ever set to
`App_uncurry` (conversion) or `App_infer_full` (alpha conversion), so `App_na` -
the only source of `arity = NA` outside the applier it gated - is never
constructed. With it gone, `Full` is the only value `Js_call_info.arity` can
hold, so the field goes too, and with it `Curry_gen`, the curried print branch,
and the `curry_id` import injection.

`%curry_apply1`..`8` were declared only by `Primitive_curry.res` and used only
by that module's own fast paths, so the primitive and its table entries go with
the runtime module. `Pcurry_apply` already discarded its arity during
conversion, making it `Pjs_apply` with a vestigial integer.

Generated JavaScript is unchanged for the runtime, Belt, tests/tests and
gentype, apart from the deleted module itself. `packages/artifacts.json` is
regenerated.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`Pfn_arity` emitted `f.length`. Its only declaration site in the tree was
`Primitive_curry.res`, which used it to discover a callee's real arity at
runtime; with the curry machinery gone nothing names `%function_arity` any
more, and the arity a call site needs is now known statically.

`E.function_length` had no other caller and goes with it.

Note this is user-visible: an `external` declared as `"%function_arity"` was
accepted before and is now rejected as an unknown builtin primitive.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
The pass renames nothing - there is no alpha conversion anywhere in it. What it
does is normalize applications against the callee's arity: saturate an
under-supplied call by eta expansion, split an over-supplied one, and record on
the rest that the arity is known. The name is OCaml lineage, where a pass in
this position did rename bound variables.

`alpha_conversion` becomes `normalize`, and the `-debug-ir` dump labels follow.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`ap_info` carried `ap_status`, a cache of "is the callee's arity known and does
this call saturate it". Conversion stamped `App_uncurry` on every application
and `Lam_pass_apply_arity` later rewrote the saturated ones to `App_infer_full`,
so the field was optimizer state living in the IR, populated by one pass and
read by one consumer: the emitter, deciding whether a call may be printed as
`Call_ml` and so allow the printer to eta reduce a wrapper around it.

The fact belongs to the callee, not to each call site. `Lam_compile_context.t`
already carries `meta`, so the emitter asks `Lam_arity_analysis.get_arity`
directly, and the field, the `apply_status` type and both stamping sites go.

Conversion no longer attaches optimizer state to the applications it builds.

Generated JavaScript is unchanged for the runtime, Belt, tests/tests and
gentype: the query returns what the cache held, including across the
`simplify_lets` that runs between the old stamping point and emission.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
With the arity fact queried where it is used, the pass had nothing left to do
but re-group applications that do not match the callee's arity: eta expand an
under-supplied call, split an over-supplied one.

Neither happens. Instrumenting the four branches over the runtime, Belt and
tests/tests - 14346 applications - gives 12510 exact, 1836 arity unknown, and
zero under- or over-supplied. Deliberate attempts to provoke them (partial
application `f(1, ...)`, an over-applied `mk()(1, 2)`, a partially applied
argument passed to a higher order function, a partially applied external) all
stay exact: uncurried ReScript saturates applications at Lambda production,
and `translcore` already eta expands partial application before conversion.

`Lam_eta_conversion` goes with it - `transform_under_supply` was its only
export and this pass its only caller.

Note the branches are removed rather than proven unreachable: `get_arity` is
conservative, so a mismatched application could in principle still be built by
inlining. Emitting such a call as written is correct for this target, where a
JavaScript call with a mismatched argument count is well defined.

Generated JavaScript is unchanged for the runtime, Belt, tests/tests and
gentype.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`Lam_pass_collect.collect_info` writes identifier information into
`meta.ident_tbl`, which `simplify_alias`, `get_arity`, `Lam_stats_export` and
`Lam_coercion` later look up. Two of its five calls sit immediately before a
`simplify_exits` that is immediately followed by another `collect_info`.

`Lam_pass_exits.simplify_exits : Lam.t -> Lam.t` takes no `meta`, so it cannot
consult the table, and nothing else runs in between. Neither call can therefore
be observed before the following one rewrites what it wrote from the same term.
The only entries they contributed that the following call does not are those for
identifiers `simplify_exits` had just deleted - unreachable from the term, and
never removed, since nothing ever removes from this table.

Three traversals of the term instead of five, and fewer stale rows in a table
that is only ever added to.

Generated JavaScript is unchanged for the runtime, Belt, tests/tests and
gentype.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`Hash_ident.add` conses onto the bucket, so an existing binding stays
underneath: `find_opt` reads the newest and sees the update, but the old row is
never reclaimed, since nothing ever removes from `meta.ident_tbl`.

Both sites are updates, not new bindings. `annotate` re-records a function on
every collect round with a refined arity and a fresh lambda, and
`alias_ident_or_global` re-records an alias's kind. Their keys already exist by
the second round, so each round leaked a row per function and per alias.

`replace` is observationally identical - every consumer of the table is a
`find_opt` lookup, and `find_all` is used nowhere in `compiler/core`, so the
shadowing was never read. Duplicate rows for one function in a probe drop from
7 to 3; the 3 remaining are the parameter bindings, which keep using `add`
because their keys are freshly stamped and genuinely new.

The stale commented-out alternative above `annotate` is replaced by a note
saying why it is an update.

Generated JavaScript is unchanged for the runtime, Belt, tests/tests and
gentype.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Two rewrites conversion was doing to primitives.

`Pisout` tested a range against an argument that `matching` had first shifted
with `Poffsetint`, and conversion folded the shift back into `Pisout of int` -
the form Lam has always had. The offset is known where the test is built: the
`Switch` functor calls `make_isout` with the bound it is shifting by, so it can
pass it instead. That also collapses the `l = 0` special case, since an offset
of zero is no offset, and leaves `make_offset` with no callers, so it goes from
the `Switch` argument signature too.

`Pisnullable` and `Pnullable_to_opt` are renamed to their Lam spellings,
`Pis_null_undefined` and `Pnull_undefined_to_opt`.

All three conversion cases are now the identity.

Generated JavaScript is unchanged for the runtime, Belt, tests/tests and
gentype.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Conversion rewrote `Pinit_mod` and `Pupdate_mod` to unit when the module's
shape had no fields. For `Pupdate_mod` that discarded the primitive's
arguments, and one of them is the module's right hand side, so

    module rec M: {} = { let () = log("side effect") }

emitted nothing at all for M.

The elision itself is right - a shape with no fields has nothing to initialize
and nothing to patch - so make it at the producer, where the right hand side is
still in hand: `eval_rec_bindings_aux` binds unit instead of calling
`Pinit_mod`, and sequences the right hand side itself instead of calling
`Pupdate_mod`. Conversion of both primitives is now the identity.

Output for such a module gains its effects and keeps the elision: no
`Primitive_module` call is emitted.

The test is snapshot based, as the suite is - `rec_module_test` now has an
empty-signature module beside one with a field, and both `record` calls have to
appear in the checked-in output. Verified to fail before the fix.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`Lam_primitive.Pmakeblock` carried a `mutable_flag` beside its tag info, but
conversion was the only place in the Lam layer that ever built one, and it set
the flag to `Lambda.mutable_flag_of_tag_info info` - the shape decides it.

Drop the field and derive it where it is read, through
`Lam_primitive.is_immutable_block`. Conversion of `Pmakeblock` is now the
identity.

The cost is at the nine sites that discriminated on the flag: they become
guards rather than patterns, and one or-pattern in `lam_pass_deep_flatten` has
to split, since a `when` cannot appear inside one.

Generated JavaScript is unchanged for the runtime, Belt, tests/tests and
gentype.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
BUG FIX - needs a changelog entry when the branch gets one.

`%incr` / `%decr` lowered to `E.assign v (E.offset v n)` where `v` is the
field access on the argument, so the argument expression was emitted twice:

    Int.Ref.increment(mkRef())
    =>  mkRef().contents = mkRef().contents + 1 | 0;   // called twice

The primitive was `Poffsetref`, surviving into both IRs. It only had to be a
primitive so far as the caller's optimizer needed a form it could recognise:
`Lam_pass_eliminate_ref` unboxes a local reference when every use is a field
read or a field write, and a call through a library function would defeat that,
since the reference would appear in argument position and have to be assumed to
escape. Primitives are expanded at the application site, in the caller's own
module, so expanding to the field write directly gives the analysis exactly
what it already reads - and lets the expansion bind the argument.

`Offset_ref` joins `Lambda.builtin`, and `mk_builtin` expands it to
`r := r.contents + delta`, binding the reference unless it is already a
variable:

    let ref = mkRef(); ref.contents = ref.contents + 1 | 0;

`test_incr_ref` pins the expression case.

Local references still unbox, escaping ones still compile to the same field
write, and a first-class use still eta expands. One other test changes:
`gpr_1762_test` no longer inlines a function whose body is an increment,
because `Lam_analysis.size` counted `Poffsetref` as a single node while it
stood for a read-modify-write. The inlining decision is now taken on the code
that is actually there.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
With `%incr` and `%decr` expanded at the construction site, nothing builds a
`Poffsetref` any more. Its lowering, its conversion case and the arm of
`Lam_pass_eliminate_ref` that rewrote it all go; the `Psetfield` arm above that
one already covers the expanded form, and the read arm already rewrites the
field access on an eliminated reference.

Generated JavaScript is unchanged for the runtime, Belt, tests/tests and
gentype.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
With `Poffsetref` gone, the arm of `Lam_pass_eliminate_ref` that rewrote it was
the only thing producing a `Poffsetint` in Lam, and `%succint` / `%predint`
were the only things producing one in Lambda. Both go, with their lowering,
their conversion case, the `compile_assign` peephole that recognised them (the
general path emits the same `x = x + 1 | 0`) and the `for … to finish - 1`
alternative, which keeps its `Psubint` form.

Note this is user-visible in the same narrow way `%function_arity` was: an
`external` declared as `"%succint"` or `"%predint"` was accepted before and is
now rejected as an unknown builtin. Their only declaration site in the tree was
`test_per`, which pins the OCaml-compatible surface; the two cases are removed
there rather than reimplemented, since they existed to exercise the primitives.

Generated JavaScript is unchanged for the runtime, Belt, tests/tests and
gentype.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Lprim (Pgetglobal id, [], loc)` encoded a reference to another compilation
unit as an application of a nullary operation. A module reference computes
nothing: it is a name the module system resolves, closer to `Lvar` than to any
primitive - which is how Lam has always represented it, as the `Lglobal_module`
leaf that later passes match against to recognise cross module field access.

Give Lambda the same leaf and delete `Pgetglobal`. Every traversal that had to
learn the case treats it as one, beside `Lvar` or `Lconst`.

`transl_normal_path` is the single place that resolves a path, so the choice
between the three forms is made there: a predefined exception is its own name
at runtime and becomes that string constant, a global becomes the module
reference, anything else stays a variable. Conversion was deriving the first of
those itself, and no longer has to.

Conversion of the reference is now structural apart from recording the module
in `may_depends`.

Generated JavaScript is unchanged for the runtime, Belt, tests/tests and
gentype.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`convert` returned the set of modules this unit refers to alongside the
translated term, accumulating it as a side effect while translating. The set is
a property of the term as written - a reference the optimizer later deletes
still has to be imported when the module it names is impure (#3852) - so it can
be read off the Lambda term directly.

`Lam_convert.required_modules` does that, in twelve lines, because
`Lambda.iter` already visits every subterm. `convert` becomes
`Lambda.lambda -> Lam.t`: a function of its input with nothing accumulated on
the side, and every case of `convert_aux` a structural rebuild.

It sits beside `convert` rather than in `Lambda` because `Lam_module_ident`
lives in `compiler/core` and `Lambda` in `compiler/ml`, so the traversal has to
be on the core side either way.

Generated JavaScript is unchanged for the runtime, Belt, tests/tests and
gentype.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Two constructors stood between conversion and a mechanical rebuild.

`Pjs_apply` had no producer left: it was reached only from `Pcurry_apply`,
which went with the curried application machinery. Delete it.

`Pis_null` and `Pis_undefined` existed only in Lam, produced by
`Lam_pass_remove_alias` when it can prove which half of a nullable test is
needed. Give them to Lambda as well. They are not built by translation, and
the comment beside them says so - unlike the constructors removed earlier in
this series, they are built by a pass over the term rather than by nothing at
all, which is the status `Poffsetint` had through `eliminate_ref`.

All 116 primitive cases and all 22 expression cases in `Lam_convert` are now a
1-1 rebuild.

Generated JavaScript is unchanged for the runtime, Belt, tests/tests and
gentype.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`value_kind` had a single inhabitant, `Pgenval`, passed at all 31 construction
sites and ignored at every pattern. It is a remnant of the native backend's
unboxing information, which this target has no use for.

`Llet` now has the same shape in both layers.

Generated JavaScript is unchanged for the runtime, Belt, tests/tests and
gentype.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Two fields carried by one layer and not the other.

`Lam.lfunction.arity` was always `List.length params`: conversion computed it
that way and every pass threaded it through unchanged. Its one real reader,
`Lam_arity_analysis`, derives it instead.

`Lswitch` and `Lstringswitch` carried a `Location.t` in Lambda that conversion
discarded, so nothing downstream ever saw it. Dropping it changes one file's
output for the better: `matching` shares identical match actions by comparing
Lambda terms structurally, and two actions differing only in a switch location
compared unequal, so they were emitted twice. `mario_game` loses 16 lines,
where two constructor cases now fall through to one body.

`lfunction`, `Llet`, `Lswitch` and `Lstringswitch` now have the same shape in
both layers.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`Lprim` carried a tuple in Lambda and a record in Lam, with the same three
components. Make Lambda's a record with Lam's field names and order, so the
constructor differs only in that Lam's is private.

The 61 call sites change mechanically, patterns and constructions alike. The
line count is inflated by the formatter: a record literal wraps where a tuple
fitted on one line.

`compiler/ml/dune` gains `-30`, since `loc` now appears in both `lfunction` and
`prim_info`. Lam has the same collision and is silent because
`compiler/core/dune` already disables that warning; this makes the two agree.
It does disable duplicate-definitions for all of `compiler/ml`, which is the
price of the field names matching.

Generated JavaScript is unchanged for the runtime, Belt, tests/tests and
gentype.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Lam carries an application's location and inline attribute in an `ap_info`
record; Lambda had them flat. Give Lambda the same record and make Lam's a
manifest alias of it, so the two are the same type rather than the same shape.

Conversion stops taking the record apart and rebuilding it.

Every constructor of the two expression types now has the same payload. What
remains between them is `private` on `t`, `apply` and `prim_info`.

Generated JavaScript is unchanged for the runtime, Belt, tests/tests and
gentype.

Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
The generic switch compiler guards a jump table with a range test, because
on a machine target a table plus a range check beats a table carrying a
default. A JS switch has a native default clause, so when the table already
covers the whole guarded range the guard is pure overhead.

Lam.if_ recognized that shape and merged the guard back in as the switch's
failaction. Do it in matching's S_arg.make_if instead, where the switcher
builds the two branches, and drop the arm and its complete_range helper from
Lam.if_.

This removes a subtree-traversing peephole from a shared smart constructor:
once Lambda and Lam are one type, if_ is what translcore and matching build
their conditionals with, and it should not be pattern-matching on shapes a
specific producer happens to emit.

Generated JavaScript is unchanged across the runtime, Belt and the 620 test
modules. Instrumenting the Lam arm before deleting it showed zero firings
over the same corpus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Pisout was a derived predicate: "arg + offset is outside [0, range]", with
both bounds already compile-time constants at the only place that built it.
It never became an unsigned comparison in the output either - E.is_out
expanded every case into ordinary ===, ||, < and >.

Pass the range across the switch functor instead of a lowered test. The Arg
signature loses make_isout / make_isin and gains make_if_out / make_if_in,
which take the bounds as ints along with both branches, so the producer
decides how to test a range. switch.ml's four helpers become two one-line
delegations, and matching emits the comparisons directly. The failaction
merge now reads the bounds from its arguments rather than recovering them by
pattern-matching an Lprim.

Removes Pisout from Lambda.primitive, Lam_primitive.t, lam_convert,
lam_compile_primitive, lam_analysis, both printers and Lam.has_boolean_type,
along with E.is_out and its interface entry.

Constant range tests used to be folded by E.int_comp, which reinterprets its
left operand as unsigned - an undocumented coupling that is what made
is_out's fallback correct despite dropping the lower-bound test. They now
fold at the Lam level through the signed Lam_compat.cmp_int32, by the
ordinary Pintcomp and Psequor rules.

Four snapshots change, one line each: E.is_out had two arms for a two-value
range that disagreed on operand order, so a test came out as [x !== 2 &&
x !== 1] or [x !== 1 && x !== 2] depending on whether the offset landed in an
addition or a subtraction. All of them are now ascending. The rest of the
runtime, Belt and the 620 test modules are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Switch.Make had a single instantiation. Upstream OCaml has two - matching.ml
for Lambda and cmmgen.ml for Cmm - and the functor exists so one copy of
Baudinet's algorithm serves both. ReScript dropped native codegen at the fork,
so the abstraction has had one client ever since.

switch.ml now opens Lambda and defines the operations it needs directly;
matching.ml loses S_arg and calls Switch.zyva / Switch.test_sequence. The
trivial builders were pure indirection and are inlined: make_const, make_prim,
make_if and the six comparison primitives. Only emit_if_out, emit_if_in and
emit_switch keep names.

Three dead things the functor was hiding:

- S.bind was in the signature and implemented, but the algorithm never called
  it.
- zyva's Location.t only fed Arg.make_switch, whose implementation ignored it.
  Removing it also retires the loc of call_switcher and of
  call_switcher_variant_constant, which was dead in both matching's and
  polyvar_pattern_match's implementations. call_switcher_variant_constr keeps
  its loc - that one reaches a Pfield.
- Switch.Not_simple was declared in the .ml and the .mli and never raised or
  caught. Lambda.Not_simple is a different exception and is still used.

'a t_ctx is now monomorphic. 'a inter stays polymorphic: it is instantiated at
both lambda and t_ctx -> lambda, which is now documented rather than
rediscovered.

make_exit, as_simple_exit and make_catch_delayed move to lambda.ml, the shared
dependency, since matching still uses them outside the switcher.

The Bernstein/Spuler attribution stays; the line costing an interval test as
one addition plus one unsigned test and branch described the backend the
algorithm was written for, and now says what this one emits.

Generated JavaScript is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
The two declarations had the same 115 constructors with the same payloads -
Lam_compat.comparison, Lam_tag_info.t and Lambda.import_source are all
already aliases of the Lambda types - and differed only in declaration order.
Keeping them apart meant every new primitive had to be added twice and stay
in sync by hand.

Lam_primitive now re-exports the type manifestly, as Lam_constant already
does for constants, and keeps its operations. Lam_convert's lam_prim was 116
lines mapping each constructor to itself; it is gone, and Lprim conversion is
a direct Lam.prim call. lam_convert.ml goes from 230 lines to 95.

Generated JavaScript is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Lam.t is private so that every term the optimizer builds goes through the
normalizing constructors. That guarantee cannot survive sharing the type with
a public Lambda: once the two are equal, Lambda's constructors build values of
Lam.t directly. Privacy has to hold on both sides or on neither.

So Lambda gains the same discipline first. lambda, prim_info and lambda_apply
are private - matching Lam's t, prim_info and apply - and the interface
documents a constructor per variant, with signatures mirroring Lam's so the
later alias is a rename.

238 construction sites move over, across translcore, matching, translmod,
transl_recmodule, lambda_scc, switch, translattribute and
polyvar_pattern_match.

The constructors are plain wrappers for now. The folds stay in Lam until the
two types become one, so there is never a second copy of the fold logic in
flight; when Lam.t becomes a private re-export, lam.ml loses the ability to
construct and its folds move up into these constructors.

Generated JavaScript is unchanged, which is expected rather than lucky: at
this stage the constructors build exactly what the call sites built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
cristianoc and others added 16 commits September 1, 2026 20:53
[map_sharing] returns the original list when every element maps to a
physically equal value, so a traversal that rewrites nothing allocates
nothing. Same for [map_snd_sharing] and the option map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Lambda's constructors were plain wrappers; Lam's normalize as they build.
Since the two types are converging, Lambda gets the same treatment, and the
interface documents which six normalize and what each does - so the rule is
readable rather than folklore.

The folds need three operation sets that lived on the Lam side, and
compiler/core cannot be a dependency of compiler/ml, so they move down and
are delegated from their old homes: cmp_int32 / cmp_float from Lam_compat,
eq_approx from Lam_constant (as const_eq_approx), and eq_primitive_approx
from Lam_primitive.

One fold does not come along. [if a then raise e else c] becoming
[(if a then raise e else ()); c] is code motion, not normalization: matching
inspects the terms it has built after the fact, and rewriting them as they
are constructed leaves static raises without their catch - the runtime build
fails outright with Not_found from compile_staticraise. It stays in Lam.if_
for now and moves to a scheduled pass next. The interface states the rule
this taught us: a constructor may replace a node with an equivalent one, but
may not move code between branches.

Generated JavaScript is unchanged. That is expected rather than lucky: at
production the operands are still in source shape, so the folds almost never
fire there - measured at zero firings for prim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
[if a then raise e else c] reads better as [(if a then raise e else ()); c]:
the continuation stops being nested inside a branch, which is the guard
clause idiom in the emitted JavaScript. It is worth keeping - dropping it
changes 20 files, adding an indentation level to bodies as large as
Stdlib_List.getOrThrow.

But it is code motion, not normalization, and it cannot run from a
constructor: matching inspects the terms it has built after the fact, so
rewriting them during construction leaves static raises without their catch.

As a scheduled traversal it is fine. It has to run last: instrumenting the
firing site shows the opportunities come from conversion (18) and from four
different passes - exits, remove_alias, lets_dce and deep_flatten (5) - so a
pass placed after all of them catches every case without having to know which
one produced it. Applying it only at the end of translation recovers 18 of
the 20 files; the two it misses need inlining first, where invalid_arg and
assert(false) become raises.

Lam gains shallow_map_sharing, which maps a node's immediate children and
rebuilds through the smart constructors, so a rewritten child is still
normalized while an unchanged node is returned as-is. The pass is then only
its own logic, and the traversal is available to other passes.

The pass allocates nothing when it has nothing to do: over the corpus, 742 of
760 modules come back physically identical having allocated zero minor words;
the 18 that are rewritten allocate 63 words each, the spine down to each
rewritten node.

Generated JavaScript is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
The two types had the same constructors in the same order with every payload
already aliased, so Lam.t becomes a plain alias. Not a re-export: the passes
never open Lam, they reach the constructors by type-directed disambiguation
from the (lam : Lam.t) annotation, and that resolves them from Lambda just as
well - warnings 40 and 41 are already off in this tree.

lam.ml goes from 653 lines to 69: the type block is aliases and everything
else delegates. shallow_map_sharing moves to Lambda, since with the type
shared and private there, lam.ml can no longer construct - which is the
invariant we wanted.

Lam_convert.convert was the identity, so it goes and lam_compile_main feeds
the Lambda term straight into the pipeline. required_modules stays.

Three files reached constructors through the module path rather than by
disambiguation - Lam.Lvar and friends - and now say Lambda.

Generated JavaScript is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Lam_tag_info, Lam_constant, Lam_primitive and Lam_compat had become
re-exports standing between the core and types it already shared with
Lambda: a manifest alias, a delegation or two, and one or two real
functions each.

The real functions move down - const_is_allocating, is_immutable_block,
str_of_field_info - and the four modules go. Every reference names Lambda
now, including the constructors the shells re-exported: Lam_compat.Strict,
Lam_compat.Fld_module, Lam_primitive.Pfield and the rest.

Seven files deleted, 44 touched.

Generated JavaScript is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
The two were the same computation - one written through iter and a get
callback, the other hand-recursed - differing only in the set type.

Lambda's wins, because Ident_set is load-bearing beyond free variables:
matching uses it for pattern variable sets and unions those with
free_variables results, so moving the other way would have cascaded through
matching. Converting the five core call sites was the cheap direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
subst_lambda was 49 lines of hand-written shallow rebuild, and the only
place left that constructed a term without going through the constructors -
it could, being inside the module that owns the type. shallow_map_sharing
already does that walk, so substitution is the Lvar case and a recursion:
eight lines, normalizing as it rebuilds, and returning a subterm that
contains no substituted variable physically unchanged.

Lam_subst was the same function over Map_ident instead of Ident.tbl, kept
apart only because it rebuilt through the smart constructors while
subst_lambda did not. With that difference gone it is redundant, and its one
caller in lam_pass_exits switches map type.

Generated JavaScript is unchanged, at both steps. Normalizing during
substitution turns out to be a no-op for the same reason it is at
production: translmod substitutes module identifiers, so the folds have
nothing to act on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Lam_iter.inner_exists and Lambda.iter were two hand-written walks over the
same twenty-two constructors, in different modules. Lambda gains
shallow_exists - the short-circuiting query - and iter becomes three lines on
top of it. iter_opt existed only to serve iter and goes with it.

Lambda now has two shallow traversals rather than four: shallow_map_sharing
for rewriting, shallow_exists for querying.

Visit order shifts for a couple of node kinds, which nothing observes: iter's
only consumers accumulate into a set and into a hash set.

Generated JavaScript is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Lam was 69 lines of delegation: its type was an alias of Lambda.lambda and
every function forwarded. Its users now name Lambda directly.

Lam.t becomes Lambda.lambda, the type aliases become the Lambda ones -
lambda_switch, lambda_apply, lfunction, prim_info - and the two values Lam
spelled differently, unit and false_, become lambda_unit and lambda_false.

63 files touched. Generated JavaScript is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Every module this tree retired named its principal type t - Lam.t,
Lam_primitive.t, Lam_constant.t - as do the ext modules. Lambda.lambda was
the odd one out, and only because the file arrived from upstream OCaml with
that spelling. Deleting Lam had just propagated it to three hundred sites.

Comments keep saying lambda, and the compound names are untouched:
lambda_switch, lambda_apply, lambda_unit, subst_lambda, name_lambda. Only the
type is renamed. printlambda has a value called lambda as well as the type,
so inside modules that open Lambda only type positions were rewritten.

The core README loses its Lam framing, and its "changing a representation"
section states the invariant that actually holds now: the type is private,
six constructors normalize as they build, and a constructor may replace a
node but not move code between branches. The CLAUDE.md bullet warning that
Lam and Lambda are distinct IRs with confusable constructors is gone, being
no longer true.

Generated JavaScript is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Seven were left, all prose rather than code: the -debug-ir and -check-lam
help text, the doc comment on check_lam, the -debug-ir dump messages, two
comments pointing at a module that no longer exists, and my own note in
lambda.ml saying the raise-guard rewrite "stays a Lam-side rewrite until it
can be expressed as a pass" - which it now is, so it points at
Lam_pass_guard_raises.

The lam_ filenames stay. They read as "the Lambda backend", which is what
they are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
lam_print and printlambda printed the same twenty-two constructors and
hundred-odd primitives, differing on twenty-eight of them, and both were
live: -debug-ir and lam_group went through one, -drawlambda and matching
through the other, and the playground rendered the same term twice.

printlambda is the base - it carries more information almost everywhere:
field debug info (field:var/0 rather than field 0), the block tag (makeblock
ext, makeblock module/exports rather than makeblock 0), the import path, unit
distinguished from undefined, and readable names where lam_print had bracket
noise ([null->opt], [?null], +*).

From lam_print: the mutable block distinction, and the serialize and
lambda_to_string entry points that -debug-ir and the playground need.

Better than either, the comparisons. printlambda printed Pobjcomp, Pboolcomp,
Pintcomp, Pstringcomp and Pjscomp all as bare ==, so five primitives were
indistinguishable in a dump; lam_print tagged two of the six operators on
Pintcomp and left the rest. One helper now tags every comparison with its
operand kind - ==[int], ==[string], >[int] - which also retires the bigint
comma suffix, which mirrored float's dot but was unguessable.

Const_constructor and Const_polyvar also both printed as `name. The polyvar
keeps the backtick, matching source syntax; the constructor prints its name.

The debug_ir build test asserts dump filenames and numbering rather than
content, so the new rendering does not disturb it. Generated JavaScript is
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
lam_convert was down to one function with one caller, and its name had not
described it since conversion became the identity. The function moves next to
its use in lam_compile_main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
There were four: Set_ident in compiler/ext, used throughout compiler/core,
and Set.Make (Ident) written out again in lambda.ml, mtype.ml and - under the
name Id_set - parmatch.ml.

They all go through Set_ident now. The orderings were already equivalent,
which had to be checked rather than assumed: matching lists the elements of
an intersection to name ambiguous or-pattern variables, and parmatch does the
same for its ambiguity warnings, so a different order would have changed both
generated code and warning text.

Set_ident takes the set first where the stdlib takes it last, so all 56 call
sites needed flipping. The argument types differ, so the compiler checked
every one.

Generated JavaScript is unchanged, and the syntax and analysis suites - which
cover the warning text - pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Three bug fixes and five internal entries, linking PR 8607.

Also corrects the constructor comment in lambda.mli, which said six
constructors normalize where there are seven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
The predicted number was taken between prediction and creation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.09385% with 323 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.97%. Comparing base (59372ec) to head (a0c916b).

Files with missing lines Patch % Lines
compiler/ml/lambda.ml 67.10% 177 Missing ⚠️
compiler/ml/printlambda.ml 27.45% 37 Missing ⚠️
compiler/ml/switch.ml 86.16% 35 Missing ⚠️
compiler/ml/matching.ml 80.24% 16 Missing ⚠️
compiler/ml/translcore.ml 91.97% 13 Missing ⚠️
compiler/core/lam_bounded_vars.ml 57.14% 9 Missing ⚠️
compiler/core/lam_pass_lets_dce.ml 80.55% 7 Missing ⚠️
compiler/core/lam_analysis.ml 44.44% 5 Missing ⚠️
compiler/ml/parmatch.ml 70.58% 5 Missing ⚠️
compiler/ml/mtype.ml 0.00% 4 Missing ⚠️
... and 8 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #8608      +/-   ##
==========================================
+ Coverage   76.44%   76.97%   +0.52%     
==========================================
  Files         478      467      -11     
  Lines       63163    62452     -711     
==========================================
- Hits        48286    48070     -216     
+ Misses      14877    14382     -495     
Files with missing lines Coverage Δ
compiler/bsc/rescript_compiler_main.ml 71.49% <ø> (ø)
compiler/core/ir_diagnostics.ml 92.59% <100.00%> (ø)
compiler/core/js_block_runtime.ml 100.00% <ø> (+16.66%) ⬆️
compiler/core/js_call_info.ml 100.00% <100.00%> (ø)
compiler/core/js_cmj_format.ml 92.59% <ø> (ø)
compiler/core/js_of_lam_block.ml 100.00% <100.00%> (ø)
compiler/core/js_of_lam_variant.ml 44.73% <100.00%> (ø)
compiler/core/lam_arity_analysis.ml 85.96% <100.00%> (+0.25%) ⬆️
compiler/core/lam_beta_reduce.ml 83.33% <100.00%> (+0.72%) ⬆️
compiler/core/lam_beta_reduce_util.ml 97.29% <100.00%> (ø)
... and 50 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant