Skip to content

fix(analyser): widen Binding::Dynamic result to Any 🪟#140

Merged
timfennis merged 4 commits into
masterfrom
bugfix/issue-139-vectorized-tuple-arith-chained
May 20, 2026
Merged

fix(analyser): widen Binding::Dynamic result to Any 🪟#140
timfennis merged 4 commits into
masterfrom
bugfix/issue-139-vectorized-tuple-arith-chained

Conversation

@timfennis
Copy link
Copy Markdown
Owner

@timfennis timfennis commented May 20, 2026

Summary

Fixes #139. Regression from #131 (the type-annotation work): vectorized tuple arithmetic crashed at runtime whenever the result of one tuple op was fed into another, e.g. let diff = a - b; diff * diff or (a - b) * (a - b).

Root cause

In resolve_function_with_argument_types, the Binding::Dynamic arm inferred the call's result as the LUB of every candidate's declared return type. That LUB is sound only when one of those candidates is statically guaranteed to fire. With dynamic dispatch the value-level dispatcher can fall through to elementwise / vectorized handling at runtime and produce a value no declared overload returns. So let diff = a - b over tuples was inferred as diff: Number (the LUB of the numeric overloads), and the follow-up diff * diff then exact-matched (Number, Number) -> Number, emitted a direct Call instead of OverloadSet dispatch, and bypassed dynamic dispatch entirely — surfacing as expected number, got Tuple<Int, Int, Int> at runtime.

The same hole shows up whenever a Tuple flows through dynamic dispatch — e.g. (id(1), id(2)) - (id(3), id(4)) where id returns Any — because the analyser still gets back a confident-but-wrong Number.

Fix

Widen the Binding::Dynamic arm's result to StaticType::Any. Runtime-dispatched calls don't have a sound static bound on their result, so the analyser stops pretending otherwise. Every cascade rung now stays on dynamic dispatch, and the VM's existing value-level dispatcher decides at runtime.

Changes

  • ndc_analyser/src/analyser.rsBinding::Dynamic arm returns Any instead of LUB-of-candidate-returns. No more special-cased vectorization detection.
  • tests/functional/programs/900_bugs/bug0021_chained_vectorized_tuple_arith.ndc — regression test covering the original repro from Vectorized tuple arithmetic breaks when result is consumed by another arithmetic op #139 plus a Tuple<Any, …> variant (fn id(x) -> Any => x to keep the type-erasure independent of stdlib evolution).

Performance

Acceptable regression — within noise on this machine. hyperfine --warmup 3 --runs 30:

program before (ms) after (ms) ratio
sieve 112.8 ± 5.7 109.1 ± 4.1 1.03× faster after
matrix_mul 58.9 ± 4.4 58.1 ± 3.8 1.01× faster after
fibonacci_typed 57.4 ± 4.5 59.1 ± 4.2 1.03× slower after

A smaller 5-run sweep over fibonacci, quicksort, hof_pipeline, ackermann, pi_approx all landed at 1.00×–1.06× in either direction with σ bigger than the delta.

Trade-off worth flagging

Binding::Dynamic sites lose their LUB-derived inlay hints — e.g. a user-defined function with (Int) -> Int and (Float) -> Float overloads called via dynamic dispatch used to surface Number on the LHS of a let, and will now surface Any (filtered from inlay hints). The LUB was always a heuristic that happened to look right; the soundness fix removes it. Happy to revisit if the LSP UX hit feels too steep.

🤖 PR description by Claude.

timfennis and others added 2 commits May 20, 2026 14:57
Adds a failing functional test (`bug0021`) that exercises the regression
from #139: vectorized tuple arithmetic crashes when the result of one
tuple op is consumed by another, because the analyser widens the dynamic
result type to `Number` and the second op resolves to a numeric overload
that bypasses the VM's vectorized fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When dynamic dispatch sees a binary call whose arguments satisfy
`supports_vectorization_with`, infer the result as `Tuple<Number, …>`
matching the tuple shape instead of taking the LUB of the candidates'
declared return types.

Before this, `let diff = a - b` over tuples inferred `diff: Number`
(the LUB of the numeric overloads), so a follow-up `diff * diff`
exact-matched the `(Number, Number) -> Number` overload, emitted a
direct `Call` instead of `OverloadSet` dispatch, and bypassed the VM's
vectorized fallback — failing at runtime with "expected number, got
Tuple<Int, Int, Int>". Mirroring the VM's vectorization rule in the
analyser keeps chained tuple arithmetic on the dynamic path.

Fixes #139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

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

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: 8f964d0f9e

ℹ️ 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 ndc_analyser/src/analyser.rs Outdated
Extend the vectorization shape detection so it also fires when tuple
elements are `Any`. Stdlib natives that return `Value` (e.g. `first`,
`last`) infer to `Any`, so a tuple built from their results gets type
`Tuple<Any, ...>`. Without this, `let c = a - b` over such a tuple
would still take the LUB path and infer `Number`, putting the follow-
up `c * c` back on the direct-call path that bypasses vectorization.

Extends bug0021 with the `(l.first, l.first) - (l.last, l.last)`
shape that reproduced this gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

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

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: 812b14f5f6

ℹ️ 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 ndc_analyser/src/analyser.rs Outdated
LUB-of-declared-returns assumed one of the candidates would fire at
runtime; that's unsound for runtime-dispatched calls (the value-level
dispatcher can fall through to elementwise / vectorized dispatch and
produce a value no overload declares). Treat Binding::Dynamic results
as Any so downstream callers stay on dynamic dispatch instead of
exact-matching a numeric overload that the value doesn't fit.

Drops the special-cased vectorization detection — the broader rule
covers every cascade depth of the issue #139 repro and any other
runtime-dispatch surprise without coupling the analyser to a specific
VM fallback path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@timfennis timfennis changed the title fix(analyser): infer tuple type for vectorizable binary ops 🧮 fix(analyser): widen Binding::Dynamic result to Any 🪟 May 20, 2026
Copy link
Copy Markdown

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

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: d00dee5ec3

ℹ️ 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 ndc_analyser/src/analyser.rs
@timfennis timfennis merged commit 3f2dbc6 into master May 20, 2026
1 check passed
@timfennis timfennis deleted the bugfix/issue-139-vectorized-tuple-arith-chained branch May 20, 2026 18:42
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.

Vectorized tuple arithmetic breaks when result is consumed by another arithmetic op

1 participant