Skip to content

Evaluate partial application arguments once - #8936

Draft
cknitt wants to merge 6 commits into
masterfrom
partial-application-args
Draft

cknitt wants to merge 6 commits into
masterfrom
partial-application-args

Conversation

@cknitt

@cknitt cknitt commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Fixes #8932.

Partial application f(a, ...) put its callee and arguments inside the function it creates. They were evaluated on every call of that function, and an await in them ended up in a non-async arrow:

let h = add(await p, ...)
h(1, 2) + h(3, 4)
// before: a JavaScript syntax error
let h = (extra, extra$1) => add(await p, extra, extra$1);

// after
let arg = await p;
return add(arg, 1, 2) + add(arg, 3, 4) | 0;

The callee and arguments are now bound outside the new function, as the optional-argument path (build_apply) already did; the two paths share the helper. Only variables, constants that allocate nothing and module paths such as Belt.Map.fromArray stay in place, because they read the same on every call; without that, a partial application like N.fromArray(~id=module(IntCmp), ...) would no longer be inlined at its call sites. Closures, constant blocks such as tuples, and external values are bound too: each evaluation of a closure or block is a new one, and an external value can be reassigned or be a getter.

This is a behaviour change for arguments with side effects, which now run once, where the application is, like the arguments of any other call. Generated output changes in five test files:

  • earger_curry_test: the side effects in the arguments run once, at application.
  • test_demo: the callee g1(3, 4) is evaluated once.
  • bs_array_test: an array literal argument is allocated once instead of on every call, and the calls are now inlined.
  • inline_regression_test: a closure argument becomes a top-level function, created once.
  • PartialApplicationNoRuntimeCurry: the external value hook is read once, into arg; the calls of addHookPartial are no longer inlined.

Because the function a partial application creates now shares its callee and arguments, it is generalized only when they are values: let h = first([], ...) gets a weak type, as let h = first([]) would, while first(1, ...) stays polymorphic.

It is also a prerequisite for generator functions (#8934): a yield in a partial application would be moved into the closure the same way.

Test: tests/tests/src/partial_application_eval_test.res covers single evaluation of arguments, of the callee, of a closure argument and a constant tuple argument (identity) and of an external getter, left-to-right evaluation order, and an awaited argument with the partial application used twice, and that a partial application of values stays polymorphic. super_errors/fixtures/partial_application_value_restriction.res checks that first([], ...) is not generalized. tests/ounit_tests/ounit_partial_application_tests.ml checks the Lambda: strict bindings around the generated function with the callee first, and which arguments stay inside.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Fc7LEuyEfynjaKCVEBPmTF

Why:
    `f(a, ...)` put its callee and arguments inside the function it
    creates, so they ran on every call, and an `await` in them ended up in
    a non-async arrow, a JavaScript syntax error.

They are now bound outside that function, as the optional-argument path
already did. Variables, constants, closures, external values and module
paths stay in place, so such a function can still be inlined. Fixes #8932.

JS modified (partial application):
Example:
    let add = (a, b, c) => a + b + c
    let f = async p => {
      let h = add(await p, ...)
      h(1, 2) + h(3, 4)
    }
Before (js):
    async function f(p) {
      let h = (extra, extra$1) => add(await p, extra, extra$1);
      return h(1, 2) + h(3, 4) | 0;
    }
After (js):
    async function f(p) {
      let arg = await p;
      return add(arg, 1, 2) + add(arg, 3, 4) | 0;
    }

Review: Is evaluating arguments once a behaviour change? Yes, for
arguments with side effects (earger_curry_test shows one). They now run
once, where the application is, like the arguments of any other call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fc7LEuyEfynjaKCVEBPmTF
Signed-off-by: Christoph Knittel <ck@cca.io>
@codecov

codecov Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.65517% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.14%. Comparing base (d19633d) to head (4ff19a3).

Files with missing lines Patch % Lines
...sts/ounit_tests/ounit_partial_application_tests.ml 85.71% 6 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #8936      +/-   ##
==========================================
+ Coverage   81.13%   81.14%   +0.01%     
==========================================
  Files         456      457       +1     
  Lines       63754    63805      +51     
==========================================
+ Hits        51728    51776      +48     
- Misses      12026    12029       +3     
Files with missing lines Coverage Δ
compiler/ml/translcore.ml 88.24% <100.00%> (+0.65%) ⬆️
compiler/ml/typecore.ml 86.78% <100.00%> (+0.01%) ⬆️
tests/ounit_tests/ounit_tests_main.ml 100.00% <ø> (ø)
...sts/ounit_tests/ounit_partial_application_tests.ml 85.71% <85.71%> (ø)

... 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.

@pkg-pr-new

pkg-pr-new Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript@8936

@rescript/belt

npm i https://pkg.pr.new/@rescript/belt@8936

@rescript/darwin-arm64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

npm i https://pkg.pr.new/@rescript/runtime@8936

@rescript/win32-x64

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

commit: 4ff19a3

@github-actions

Copy link
Copy Markdown

@cknitt

cknitt commented Oct 21, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 21, 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-10-21T06:42:37.116974Z 4ff19a3 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.

@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: 4882045df7

ℹ️ 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 compiler/ml/translcore.ml Outdated
Comment thread compiler/ml/translcore.ml Outdated
Each evaluation of a closure is a new one, and an external value can be
reassigned or be a getter, so neither may be re-evaluated on each call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fc7LEuyEfynjaKCVEBPmTF
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt

cknitt commented Oct 21, 2026

Copy link
Copy Markdown
Member Author

@codex review

@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: be72a815b0

ℹ️ 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 compiler/ml/translcore.ml
cknitt and others added 2 commits October 21, 2026 05:09
Now that `f(a, ...)` evaluates its callee and arguments once, the
function it creates shares them, so treating every partial application as
nonexpansive let `first([], ...)` return one array at two element types.

Typing modified (value restriction for partial application):
Example:
    let first = (x, _y) => x
    let h = first([], ...)
    let ints: array<int> = h(0)
    let strings: array<string> = h(0)
Before (rule):
    ───────────────────────── (nonexp-partial)
    nonexpansive(`f(a₁, …, aₙ, ...)`)
After (rule):
    nonexpansive(f)   nonexpansive(a₁) … nonexpansive(aₙ)
    ───────────────────────────────────────────────────── (nonexp-partial)
    nonexpansive(`f(a₁, …, aₙ, ...)`)
After (diagnostic):
    This has type: array<int>
    But it's expected to have type: array<string>

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fc7LEuyEfynjaKCVEBPmTF
Signed-off-by: Christoph Knittel <ck@cca.io>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fc7LEuyEfynjaKCVEBPmTF
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt

cknitt commented Oct 21, 2026

Copy link
Copy Markdown
Member Author

@codex review

@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: ecb2c66788

ℹ️ 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 compiler/ml/translcore.ml Outdated
A constant block such as a tuple is emitted as a new JavaScript value on
each evaluation, so only constants that allocate nothing stay in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fc7LEuyEfynjaKCVEBPmTF
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt

cknitt commented Oct 21, 2026

Copy link
Copy Markdown
Member Author

@codex review

@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: 885afb2c07

ℹ️ 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 compiler/ml/translcore.ml
The callee and allocating arguments are strict bindings around the
generated function, callee first; variables and constants stay inside.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fc7LEuyEfynjaKCVEBPmTF
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt

cknitt commented Oct 21, 2026

Copy link
Copy Markdown
Member Author

@codex review

@github-actions

Copy link
Copy Markdown

@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: 4ff19a3d27

ℹ️ 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 compiler/ml/translcore.ml
known one that can be inlined. A closure or a constant block does not:
each evaluation is a new one. Nor does an external value, which can be
reassigned or be a getter. *)
| Lconst c when not (Lambda.const_is_allocating c) -> lam

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Hoist allocating nested-option constants

When a supplied argument is a nested option such as Some(None), translation produces Lconst (Const_some (Const_js_undefined ...)); Lambda.const_is_allocating returns false because it merely recurses through Const_some, while lam_compile_const.translate_some emits Caml_option.some(undefined), which allocates a fresh object. This exemption therefore leaves the allocation inside the wrapper, so let h = first(Some(None), ...); h(0) === h(0) is false even though the argument should be evaluated once. Unlike the previously fixed Const_block case, this fresh case contains no Const_block, so the predicate or hoisting logic must also recognize runtime-allocating optional constants.

Useful? React with 👍 / 👎.

This branch has not been deployed

No deployments
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.

Partial application moves its arguments into the generated closure

1 participant