Skip to content

Fix constant folding of overlapping unboxed variant matches - #8631

Merged
cknitt merged 6 commits into
masterfrom
codex/fix-unboxed-variant-match-folding
Sep 6, 2026
Merged

Fix constant folding of overlapping unboxed variant matches#8631
cknitt merged 6 commits into
masterfrom
codex/fix-unboxed-variant-match-folding

Conversation

@cknitt

@cknitt cknitt commented Sep 6, 2026

Copy link
Copy Markdown
Member

Unboxed variant payloads can share their runtime representation with literal constructors. Previously, folding f(Color("primary")) could select the Color branch even though runtime matching selected Primary.

Preserve runtime dispatch when the scrutinee is a constant untagged block. Ordinary boxed variants retain constructor-based folding.

Adds Lambda, syntax, and runtime regression coverage for overlapping strings and numbers, default branches, and non-overlapping values.

Validation: full make test and syntax suite pass, including formatting checks.

Fixes #6950.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-06T11:21:12.660087Z 3509fdb Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

cknitt added a commit that referenced this pull request Sep 6, 2026
Signed-off-by: Christoph Knittel <ck@cca.io>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1b0d0ba91e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CHANGELOG.md Outdated
Comment thread CHANGELOG.md Outdated
@codecov

codecov Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.72566% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.58%. Comparing base (7e17118) to head (3509fdb).

Files with missing lines Patch % Lines
compiler/ml/lambda.ml 68.42% 24 Missing ⚠️
tests/ounit_tests/ounit_lambda_constant_tests.ml 97.64% 2 Missing ⚠️
compiler/core/js_stmt_make.ml 0.00% 1 Missing ⚠️
compiler/ml/ast_untagged_variants.ml 92.85% 1 Missing ⚠️
compiler/ml/printlambda.ml 0.00% 1 Missing ⚠️
compiler/ml/variant_layout.ml 93.33% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #8631      +/-   ##
==========================================
+ Coverage   77.55%   77.58%   +0.02%     
==========================================
  Files         475      475              
  Lines       63904    64051     +147     
==========================================
+ Hits        49562    49693     +131     
- Misses      14342    14358      +16     
Files with missing lines Coverage Δ
compiler/core/js_dump.ml 90.81% <100.00%> (-0.03%) ⬇️
compiler/core/js_exp_make.ml 65.45% <100.00%> (+0.41%) ⬆️
compiler/core/lam_compile.ml 89.77% <100.00%> (ø)
compiler/ml/datarepr.ml 91.91% <100.00%> (ø)
compiler/ml/parmatch.ml 76.10% <100.00%> (ø)
compiler/ml/translcore.ml 86.36% <100.00%> (+0.06%) ⬆️
compiler/ml/typecore_record_rest.ml 89.18% <100.00%> (ø)
compiler/ml/typeopt.ml 79.24% <100.00%> (+0.39%) ⬆️
compiler/ml/variant_runtime.ml 95.65% <100.00%> (ø)
tests/ounit_tests/ounit_string_literal_tests.ml 90.90% <ø> (ø)
... and 6 more

... and 2 files 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.

@pkg-pr-new

pkg-pr-new Bot commented Sep 6, 2026

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript-lang/rescript@8631

@rescript/belt

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/belt@8631

@rescript/darwin-arm64

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/darwin-arm64@8631

@rescript/darwin-x64

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/darwin-x64@8631

@rescript/linux-arm64

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/linux-arm64@8631

@rescript/linux-x64

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/linux-x64@8631

@rescript/runtime

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/runtime@8631

@rescript/win32-x64

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/win32-x64@8631

commit: 3509fdb

@cknitt
cknitt requested a review from cristianoc September 6, 2026 05:46
@cknitt
cknitt enabled auto-merge (squash) September 6, 2026 05:46
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

@cristianoc cristianoc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for digging this one out — the diagnosis is right and the fix is sound. I spent some time on it and have data that I think argues for a different shape, plus some findings about the bug that go beyond #6950. Sharing it all rather than just an opinion.

Everything below is on branch repr-untagged-canonical-constants so you can read the actual diff and run the tests — the interesting file is tests/tests/src/unboxed_variant_fold_test.res and its checked-in .mjs, where the left of each comparison is a folded literal and the right is a real call.

The bug is pre-existing, not from the recent lambda.ml work

git log -L on the fold points at four commits from the last few days, which makes it look recent. It isn't. The same fold was in Lam.switch at v11.1.3:

| Lconst (Const_block (i, _, _)) ->
    (try Ext_list.assoc_by_int lam_switch.sw_blocks i lam_switch.sw_failaction
     with _ -> Lswitch(lam, lam_switch))

Keyed by integer tag then, by block_runtime now (108aa8f06), but both select the branch from the source constructor and never consult the untagged runtime representation. The recent commits moved and re-keyed it; they didn't introduce it. #6950's playground repro on v11.1.3 confirms this independently.

It's six bugs, not one

I built a differential harness: compute each value twice, once folded and once through an @inline(never) boundary so the generated dispatch computes it, then compare at runtime. 27 cases. On master:

MISMATCH 1:  folded=primary   runtime=not Color   <- #6950
MISMATCH 2:  folded=secondary runtime=not Color
MISMATCH 5:  folded=number    runtime=one         <- int overlap
MISMATCH 15: folded=float     runtime=one         <- @as(1) vs a float payload
MISMATCH 20: folded=a         runtime=lit         <- literal reachable only via a default arm
MISMATCH 24: folded=w         runtime=x           <- nested unboxed variant
6 MISMATCHES

Two of these are mechanisms worth calling out. #15: @as(1) is Int 1 and a float payload of 1.0 is Float "1." — different literal tags, one JS number. Any fix keyed on tag equality rather than runtime equality gets this wrong. #24 requires seeing through a nested unboxed constant. Your fix handles both (it bails on everything), but neither is in the test file, and #15 in particular is the kind of thing that would come back if the bail-out is ever narrowed.

I'd suggest landing this harness as a fixture whatever else happens — it's what turned one known bug into six, and it pins the fold against the actual generated dispatch rather than against a hand-written expectation.

Where the fix over-reaches

The new arm bails for every constant untagged block, but the unsound set is much narrower: it needs (a) literal-tagged constant constructors to exist at all, and (b) the payload's value to coincide with one. Both are decidable from matching_facts.literal_tags, which is already in sw_dispatch.

The clearest casualty is the canonical untagged variant:

@unboxed type t = A(int) | B(string)

literal_tags = [], so is_a_literal_case is false and dispatch is pure typeof. Overlap is impossible; folding is disabled anyway. Same for @as(null) Nothing | Value(string)null can never be a string. And in the overlapping type itself, only the overlapping values are affected: the PR's own snapshot has let foldedBlue = colorName("blue") where "blue" folded correctly before.

One knock-on that isn't obvious: lam_analysis.ml:239 uses Lambda.switch's folding power as an inlining heuristic —

| Lswitch (Lvar v, switch) ->
    size (Lambda.switch lam switch) < small_inline_size

so a fold that always returns the full switch also stops these functions being inlined. The cost isn't only the missed constant.

Why the fold couldn't have been right

Worth stating because it decides what "fixing" means here. lambda_switch carries two discriminants at different scopes:

  • the case keys (sw_consts/sw_blocks) come from the patterns in this match (matching.ml:2097)
  • sw_dispatch comes from the type declaration (matching.ml:2108, matching_facts layout)

In colorName, the keys are sw_blocks = [Color -> name] and a failaction. Nothing in the keys mentions "primary" or "secondary" — those constructors aren't in the patterns. Only literal_tags knows they exist, which is exactly why codegen needs it to emit value === "primary" || value === "secondary".

So the fold was reading a source that provably does not contain the information required. That's a representation defect rather than a coding slip, and it's why I'd rather correct the decision than remove it.

An alternative that keeps the folding

Two steps, built and passing. Diff is +135/-5 across lambda.ml, lambda.mli, translcore.ml.

1. Canonical constants. An untagged constructor has no runtime existence — js_dump.ml:974 erases it to its payload anyway. So erase it at the single construction site (translcore.ml:1196) via a normalizing smart constructor:

let const_block (tag_info : tag_info) (args : structured_constant list) =
  match (tag_info, args) with
  | Blk_constructor {runtime = {untagged = true}}, [payload] -> payload
  | _ -> Const_block (tag_info, args)

This isn't new here — const_constructor already does exactly this for integers ("a constructor represented as a number is a genuine number at runtime, so folding sees it as an ordinary integer"). It's the same principle applied to the case it was never extended to. After it, Color("primary") and Primary are the same constant and the wrong answer is unrepresentable.

Nice confirmation that this is the right layer: step 1 alone fixed the int case and kept it folded (numberName(Number(1)) -> "one"), because the erased constant landed in the existing Const_int arm, which was already value-based. The correct algorithm was already in the file, on the path where canonicalization had already happened.

2. Value-based fold. With no constructor left to consult, Lambda.switch has to classify the runtime value against sw_dispatch — literals first, then the payload's shape, the same order Dynamic_checks emits. Three-way, because soundness needs "cannot tell" kept apart from "no case matches":

type value_kind =
  | Is_literal of Variant_runtime.literal_tag  (* may be a declared literal *)
  | Not_a_literal                              (* an object; no literal is one *)
  | Unknown_value                              (* cannot say: the switch stays *)

Unknown_value covers bigint, Const_char, Const_some — conservative, not complete.

Results. All 27 differential cases agree. Every fold preserved, including foldedBlue, A(int) | B(string), and the nested case. make test (276 ounit + 547 + 744 mocha, 629 modules), make test-syntax and make checkformat pass. The stdlib's generated JS is unchanged.

One test snapshot moves, and it's an improvement rather than a regression — VariantCoercion.mjs drops a redundant binding, because an untagged constant is now an ordinary constant and gets inlined rather than bound:

-let c = 100;
-  c: c,
+  c: 100,

That one snapshot aside, nothing else in the suite changed, which says the existing tests never exercised these paths — and is why this survived two years.

On the tests in this PR

Two suggestions, offered as such:

  • The ounit test. Its real value is the tagged cases (folding didn't become over-broad) and that it pins Lambda.switch independently of whether the inliner still hands it this shape. The untagged assertions are close to a restatement of the new match arm — switch arg sw = Lswitch (arg, sw) — against hand-built Variant_runtime values, and they never check the actual symptom, which is a wrong runtime value. The differential test does check that.

  • The ast-mapping fixture. tests/syntax_tests/data/ast-mapping/ guards the frozen v0 AST bridge, and AGENTS.md asks for a fixture there when changing parsetree.ml. This PR changes only lambda.ml. @unboxed with @as("...")/@as(1) tags is also already covered by VariantConstructorTags.res in that same directory. The only thing the new snapshot records is the printer adding a leading bar. I'd drop it.

Suggested way forward

  1. Canonical constants + value-based fold, instead of the bail-out. Keeps every fold the bail-out gives up, and the inlining heuristic with it.
  2. Land the differential harness as the regression fixture — it's the thing that found the other five.
  3. Follow-up, independent: Variant_runtime.block = {runtime; block_type: block_type option} still makes untagged = true, block_type = None representable. Turning it into Tagged of tag | Untagged of block_type removes both assert falses that guard it (lam_compile.ml:187, js_dump.ml:975 — the latter carries a TODO already saying the type should have ruled it out).
  4. Unrelated but cheap: lam_analysis.ml recognizes Lswitch and Lifthenelse for the fold-on-inline heuristic but not Lstringswitch. I tried adding it and constant string switches start folding — a missed opportunity on every string switch, six lines, independent of all of the above.

Happy to turn the branch into a PR against yours, or into an alternative for side-by-side comparison — whichever is more useful to you. And if you'd rather keep this PR's shape and just narrow the bail-out, the differential test stands on its own and I'd still suggest taking it.

cknitt and others added 4 commits September 6, 2026 12:40
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
Adapt canonical constants and value-based dispatch from c32a837. Preserve safe folds while distinguishing arrays, the empty list, bigint spellings, and 32-bit integer tags. Replace the bailout tests with differential runtime coverage and value-based Lambda assertions.

Co-authored-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt
cknitt force-pushed the codex/fix-unboxed-variant-match-folding branch from 25015f8 to 7403342 Compare September 6, 2026 10:47
Signed-off-by: Christoph Knittel <ck@cca.io>
Rename the matching-only tag_type constructor to Payload_shape, leaving
Tagged/Untagged for constructor representations. Remove type annotations
that were only needed to distinguish the two Untagged constructors, and
document the matching contract. Assert that nullary constructors have
already been handled before computing a payload shape.

This completes the representation cleanup: ordinary unboxed payload
wrappers are erased during typedtree translation, while inline records
remain objects. The preceding commit bumped CMI/CMT format versions for
the changed serialized representation and rejects multi-argument unboxed
constructors before lowering. This rename preserves the serialized layout.

Validation: make test, including formatting, compiler unit tests, runtime
regressions, build tests, and docstring tests. Lstringswitch is unchanged.

Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt

cknitt commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Thanks for the detailed feedback and the implementation! I’ve incorporated your canonical-payload approach and included the representation cleanup here.

Tagged and Untagged are now separate cases, with a required runtime shape for untagged payloads. Ordinary unboxed constructors are unwrapped during typedtree translation, so they cannot form Lambda constructor blocks; inline records remain objects.

I also renamed the matching-only Untagged constructor to Payload_shape, distinguishing the matching condition from the constructor representation and removing the disambiguating type annotations.

The differential tests cover literal/payload overlap and the additional edge cases we discussed. The full test suite passes. I’ve left the Lstringswitch inlining improvement for a separate follow-up.

@cknitt

cknitt commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 3509fdbc2e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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.

Constant folding a pattern match on an unboxed variant produces an incorrect result

2 participants