From df19b0ad22467d3e0aea04c1aba1246a6dddc226 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Wed, 20 May 2026 14:57:34 +0200 Subject: [PATCH 1/4] =?UTF-8?q?test:=20reproduce=20issue=20#139=20chained?= =?UTF-8?q?=20vectorized=20tuple=20arithmetic=20=F0=9F=A7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../bug0021_chained_vectorized_tuple_arith.ndc | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 tests/functional/programs/900_bugs/bug0021_chained_vectorized_tuple_arith.ndc diff --git a/tests/functional/programs/900_bugs/bug0021_chained_vectorized_tuple_arith.ndc b/tests/functional/programs/900_bugs/bug0021_chained_vectorized_tuple_arith.ndc new file mode 100644 index 00000000..49ca994a --- /dev/null +++ b/tests/functional/programs/900_bugs/bug0021_chained_vectorized_tuple_arith.ndc @@ -0,0 +1,17 @@ +// BUG#0021 (issue #139): vectorized tuple arithmetic broke when the result +// of one tuple op was fed into another. The analyser inferred the result of +// `a - b` as `Number` (the LUB of the dynamic candidates' return types), so +// `diff * diff` resolved statically to a `(Number, Number) -> Number` +// overload via `Binding::Resolved` and bypassed OverloadSet dispatch — and +// therefore the VM's vectorized fallback. The native then bailed with +// "expected number, got Tuple". +let a = (1, 2, 3); +let b = (4, 5, 6); + +assert_eq(a * b, (4, 10, 18)); + +let diff = a - b; +assert_eq(diff, (-3, -3, -3)); +assert_eq(diff * diff, (9, 9, 9)); + +assert_eq((a - b) * (a - b), (9, 9, 9)); From 8f964d0f9ef83386f7a22387f28f73a646491bb9 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Wed, 20 May 2026 17:38:45 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix(analyser):=20infer=20tuple=20type=20for?= =?UTF-8?q?=20vectorizable=20binary=20ops=20=F0=9F=A7=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When dynamic dispatch sees a binary call whose arguments satisfy `supports_vectorization_with`, infer the result as `Tuple` 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". 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) --- ndc_analyser/src/analyser.rs | 40 ++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/ndc_analyser/src/analyser.rs b/ndc_analyser/src/analyser.rs index e77d0394..d6738edc 100644 --- a/ndc_analyser/src/analyser.rs +++ b/ndc_analyser/src/analyser.rs @@ -511,15 +511,37 @@ impl Analyser { Binding::Resolved(res) => self.scope_tree.get_type(*res).clone(), Binding::Dynamic(candidates) => { - let return_type = candidates - .iter() - .map(|c| self.scope_tree.get_type(*c).clone()) - .filter_map(|t| match t { - StaticType::Function { return_type, .. } => Some(*return_type), - _ => None, - }) - .reduce(|a, b| a.lub(&b)) - .unwrap_or(StaticType::Any); + // Mirror the VM's vectorized fallback (see `Vm::try_vectorized_call`): + // a binary call on tuple-of-number arguments produces a tuple at + // runtime, even though no declared overload returns one. Preserve + // that shape so chained ops like `(a - b) * (a - b)` stay on the + // dynamic-dispatch path instead of resolving to a numeric overload + // that the value doesn't actually fit. + let vectorized_return = if argument_types.len() == 2 + && argument_types[0].supports_vectorization_with(&argument_types[1]) + { + let len = match (&argument_types[0], &argument_types[1]) { + (StaticType::Tuple(l), StaticType::Tuple(r)) => l.len().max(r.len()), + (StaticType::Tuple(l), _) => l.len(), + (_, StaticType::Tuple(r)) => r.len(), + _ => unreachable!("supports_vectorization_with requires a tuple side"), + }; + Some(StaticType::Tuple(vec![StaticType::Number; len])) + } else { + None + }; + + let return_type = vectorized_return.unwrap_or_else(|| { + candidates + .iter() + .map(|c| self.scope_tree.get_type(*c).clone()) + .filter_map(|t| match t { + StaticType::Function { return_type, .. } => Some(*return_type), + _ => None, + }) + .reduce(|a, b| a.lub(&b)) + .unwrap_or(StaticType::Any) + }); StaticType::Function { parameters: None, From 812b14f5f6d6165ecf6e98ac399eb3729de10137 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Wed, 20 May 2026 17:48:57 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(analyser):=20treat=20tuple-of-Any=20as?= =?UTF-8?q?=20vectorizable=20=F0=9F=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. 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) --- ndc_analyser/src/analyser.rs | 59 ++++++++++++++----- ...bug0021_chained_vectorized_tuple_arith.ndc | 12 ++++ 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/ndc_analyser/src/analyser.rs b/ndc_analyser/src/analyser.rs index d6738edc..a7fb9593 100644 --- a/ndc_analyser/src/analyser.rs +++ b/ndc_analyser/src/analyser.rs @@ -479,6 +479,42 @@ impl Analyser { } } + /// Returns the tuple length a vectorized call would produce, if `left` and + /// `right` together have a shape the VM's vectorized fallback could handle: + /// two equal-length tuples, or one tuple and one scalar. `Any` is treated + /// as a potentially-numeric element / scalar — at static-analysis time we + /// don't know what it holds, so we stay permissive and let the dynamic + /// dispatch decide at runtime. + fn maybe_vectorize_len(left: &StaticType, right: &StaticType) -> Option { + fn could_be_number(t: &StaticType) -> bool { + t.is_number() || matches!(t, StaticType::Any) + } + fn tuple_of_potential_numbers(t: &StaticType) -> Option { + match t { + StaticType::Tuple(elems) + if !elems.is_empty() && elems.iter().all(could_be_number) => + { + Some(elems.len()) + } + _ => None, + } + } + match (left, right) { + (StaticType::Tuple(_), StaticType::Tuple(_)) => { + let l = tuple_of_potential_numbers(left)?; + let r = tuple_of_potential_numbers(right)?; + (l == r).then_some(l) + } + (StaticType::Tuple(_), other) => { + tuple_of_potential_numbers(left).filter(|_| could_be_number(other)) + } + (other, StaticType::Tuple(_)) => { + tuple_of_potential_numbers(right).filter(|_| could_be_number(other)) + } + _ => None, + } + } + fn resolve_function_with_argument_types( &mut self, ident: &mut ExpressionLocation, @@ -512,24 +548,17 @@ impl Analyser { Binding::Dynamic(candidates) => { // Mirror the VM's vectorized fallback (see `Vm::try_vectorized_call`): - // a binary call on tuple-of-number arguments produces a tuple at + // a binary call on a tuple-shaped argument may produce a tuple at // runtime, even though no declared overload returns one. Preserve // that shape so chained ops like `(a - b) * (a - b)` stay on the // dynamic-dispatch path instead of resolving to a numeric overload - // that the value doesn't actually fit. - let vectorized_return = if argument_types.len() == 2 - && argument_types[0].supports_vectorization_with(&argument_types[1]) - { - let len = match (&argument_types[0], &argument_types[1]) { - (StaticType::Tuple(l), StaticType::Tuple(r)) => l.len().max(r.len()), - (StaticType::Tuple(l), _) => l.len(), - (_, StaticType::Tuple(r)) => r.len(), - _ => unreachable!("supports_vectorization_with requires a tuple side"), - }; - Some(StaticType::Tuple(vec![StaticType::Number; len])) - } else { - None - }; + // that the value doesn't actually fit. `Any` is treated as a + // potentially-numeric element so tuples whose elements come from + // stdlib natives (which infer to `Any`) are also caught. + let vectorized_return = (argument_types.len() == 2) + .then(|| Self::maybe_vectorize_len(&argument_types[0], &argument_types[1])) + .flatten() + .map(|len| StaticType::Tuple(vec![StaticType::Number; len])); let return_type = vectorized_return.unwrap_or_else(|| { candidates diff --git a/tests/functional/programs/900_bugs/bug0021_chained_vectorized_tuple_arith.ndc b/tests/functional/programs/900_bugs/bug0021_chained_vectorized_tuple_arith.ndc index 49ca994a..dc116e84 100644 --- a/tests/functional/programs/900_bugs/bug0021_chained_vectorized_tuple_arith.ndc +++ b/tests/functional/programs/900_bugs/bug0021_chained_vectorized_tuple_arith.ndc @@ -15,3 +15,15 @@ assert_eq(diff, (-3, -3, -3)); assert_eq(diff * diff, (9, 9, 9)); assert_eq((a - b) * (a - b), (9, 9, 9)); + +// Same hazard but the tuple elements are statically `Any`. `id` here erases +// type information so the tuples below are `Tuple`; this +// mirrors what happens with stdlib natives whose `Value` return type infers +// to `Any`. The vectorization detection must treat `Any` as a potentially- +// numeric element so chained ops still go through dynamic dispatch. +fn id(x) -> Any => x; +let av = (id(1), id(2), id(3)); +let bv = (id(4), id(5), id(6)); +let cv = av - bv; +assert_eq(cv, (-3, -3, -3)); +assert_eq(cv * cv, (9, 9, 9)); From d00dee5ec3536cfe565e7ef371fe73cd60cdd4f8 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Wed, 20 May 2026 18:26:29 +0200 Subject: [PATCH 4/4] refactor(analyser): widen Binding::Dynamic result to Any MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ndc_analyser/src/analyser.rs | 73 +++++------------------------------- 1 file changed, 10 insertions(+), 63 deletions(-) diff --git a/ndc_analyser/src/analyser.rs b/ndc_analyser/src/analyser.rs index a7fb9593..626e8d94 100644 --- a/ndc_analyser/src/analyser.rs +++ b/ndc_analyser/src/analyser.rs @@ -479,42 +479,6 @@ impl Analyser { } } - /// Returns the tuple length a vectorized call would produce, if `left` and - /// `right` together have a shape the VM's vectorized fallback could handle: - /// two equal-length tuples, or one tuple and one scalar. `Any` is treated - /// as a potentially-numeric element / scalar — at static-analysis time we - /// don't know what it holds, so we stay permissive and let the dynamic - /// dispatch decide at runtime. - fn maybe_vectorize_len(left: &StaticType, right: &StaticType) -> Option { - fn could_be_number(t: &StaticType) -> bool { - t.is_number() || matches!(t, StaticType::Any) - } - fn tuple_of_potential_numbers(t: &StaticType) -> Option { - match t { - StaticType::Tuple(elems) - if !elems.is_empty() && elems.iter().all(could_be_number) => - { - Some(elems.len()) - } - _ => None, - } - } - match (left, right) { - (StaticType::Tuple(_), StaticType::Tuple(_)) => { - let l = tuple_of_potential_numbers(left)?; - let r = tuple_of_potential_numbers(right)?; - (l == r).then_some(l) - } - (StaticType::Tuple(_), other) => { - tuple_of_potential_numbers(left).filter(|_| could_be_number(other)) - } - (other, StaticType::Tuple(_)) => { - tuple_of_potential_numbers(right).filter(|_| could_be_number(other)) - } - _ => None, - } - } - fn resolve_function_with_argument_types( &mut self, ident: &mut ExpressionLocation, @@ -546,35 +510,18 @@ impl Analyser { } Binding::Resolved(res) => self.scope_tree.get_type(*res).clone(), - Binding::Dynamic(candidates) => { - // Mirror the VM's vectorized fallback (see `Vm::try_vectorized_call`): - // a binary call on a tuple-shaped argument may produce a tuple at - // runtime, even though no declared overload returns one. Preserve - // that shape so chained ops like `(a - b) * (a - b)` stay on the - // dynamic-dispatch path instead of resolving to a numeric overload - // that the value doesn't actually fit. `Any` is treated as a - // potentially-numeric element so tuples whose elements come from - // stdlib natives (which infer to `Any`) are also caught. - let vectorized_return = (argument_types.len() == 2) - .then(|| Self::maybe_vectorize_len(&argument_types[0], &argument_types[1])) - .flatten() - .map(|len| StaticType::Tuple(vec![StaticType::Number; len])); - - let return_type = vectorized_return.unwrap_or_else(|| { - candidates - .iter() - .map(|c| self.scope_tree.get_type(*c).clone()) - .filter_map(|t| match t { - StaticType::Function { return_type, .. } => Some(*return_type), - _ => None, - }) - .reduce(|a, b| a.lub(&b)) - .unwrap_or(StaticType::Any) - }); - + Binding::Dynamic(_) => { + // Dispatch is decided at runtime, so we have no sound static bound + // on the result. The runtime may pick a declared overload or fall + // through to elementwise (vectorized) dispatch, which can produce + // a value no declared overload returns — treating the LUB of + // declared returns as the result type is unsound and led to issue + // #139, where `let diff = a - b` over tuples was inferred as + // `Number` and a follow-up `diff * diff` then matched the numeric + // overload directly and bypassed dynamic dispatch entirely. StaticType::Function { parameters: None, - return_type: Box::new(return_type), + return_type: Box::new(StaticType::Any), } } };