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

@timfennis timfennis commented May 20, 2026

Copy link
Copy Markdown
Owner

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) <[email protected]>
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) <[email protected]>

@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: 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) <[email protected]>

@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: 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) <[email protected]>
@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

@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: 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
timfennis added a commit that referenced this pull request May 23, 2026
…rn-type precision 📐

Implements docs/design/vectorization.md (RFC steps 1-5 + 7; step 6
compiler unrolling deferred).

Parser marks operator-form calls with operator_form: bool. Scope
resolution synthesises vec variants for each scalar overload of a per-
position LUB-collapsed sig. Each Candidate now records vectorized: bool;
runtime dispatch iterates one candidate list (scalars first, vecs after)
and checks every element pair against the underlying scalar's parameter
types — fixing the existing first-pair-only bug where (1, "a") + (2, "b")
would crash on element 1.

Return-type inference: scalar bindings keep their declared return; vec
bindings yield Tuple<elem_return; max_len>; Dynamic LUBs across all
candidates' inferred returns, naturally recovering precise scalar LUB
for the common case PR #140 had to pessimise to Any.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
timfennis added a commit that referenced this pull request May 24, 2026
Captures the design space for broadening vectorization beyond binary
numeric ops. Proposes an operator-syntax marker on the AST as the gate,
lifts the arity-2 and numeric-element guards in the VM, and makes
vectorization a property of the binding to recover the type precision
PR #140 sacrificed for soundness.

Status: Draft. No implementation work follows from landing this file.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
timfennis added a commit that referenced this pull request May 24, 2026
…rn-type precision 📐

Implements docs/design/vectorization.md (RFC steps 1-5 + 7; step 6
compiler unrolling deferred).

Parser marks operator-form calls with operator_form: bool. Scope
resolution synthesises vec variants for each scalar overload of a per-
position LUB-collapsed sig. Each Candidate now records vectorized: bool;
runtime dispatch iterates one candidate list (scalars first, vecs after)
and checks every element pair against the underlying scalar's parameter
types — fixing the existing first-pair-only bug where (1, "a") + (2, "b")
would crash on element 1.

Return-type inference: scalar bindings keep their declared return; vec
bindings yield Tuple<elem_return; max_len>; Dynamic LUBs across all
candidates' inferred returns, naturally recovering precise scalar LUB
for the common case PR #140 had to pessimise to Any.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
timfennis added a commit that referenced this pull request May 24, 2026
After #144 landed, master had two `bug0021_*` files —
`bug0021_chained_vectorized_tuple_arith.ndc` (from the original #140
work, in master longer) and `bug0021_combinations_lazy_source.ndc`
(just merged via #144). Renumber the combinations test to bug0022
since chained-vectorized got there first, and bump this branch's
incompat-Dynamic test to bug0023.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
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