diff --git a/docs/design/vectorization.md b/docs/design/vectorization.md new file mode 100644 index 00000000..f91c1010 --- /dev/null +++ b/docs/design/vectorization.md @@ -0,0 +1,370 @@ +# RFC: Vectorization scope + +Status: Implemented in PR [#141] (commits `df7b11b`, `e367145`). RFC steps +1–5 and 7 shipped together; step 6 (compiler unrolling of `Resolved` vec +candidates) is deferred — see the [Compiler optimisation](#compiler-optimisation) +section. + +[#141]: https://github.com/timfennis/andy-cpp/pull/141 + +## Summary + +Extend vectorization beyond binary numeric operators. Gate the broader +scope on operator syntax so stdlib function calls never accidentally +vec. Make vectorization a property of the binding to recover the type +precision PR [#140] sacrificed for soundness. + +## Motivation + +PR [#140] widened `Binding::Dynamic` to `Any` to fix issue [#139]. The +analyser had been using the LUB of declared overload returns as the +result type. The value-level dispatcher could falsify that LUB by +falling through to vectorized dispatch, producing a runtime value no +overload declared. Widening to `Any` restored soundness. It also +pessimised every dynamic-binding caller, including ones that have no +path to vec. + +Three gaps remain: + +1. The VM only vec's binary calls, so unary `-(1, 2, 3)` errors. Unary + tuple-neg is a near-term goal. +2. The VM only vec's tuples of numbers. `("a", "b") ++ ("c", "d")` + errors even though `++(String, String)` exists. +3. The analyser can't distinguish a vec-eligible call from a regular + one. `f(x)` where `f` is a regular function loses the LUB-derived + return type even though no vec path exists. + +This RFC closes all three. + +[#139]: https://github.com/timfennis/andy-cpp/issues/139 +[#140]: https://github.com/timfennis/andy-cpp/pull/140 + +## Design + +### Operator-syntax marker on the AST + +The parser desugars `a + b` (infix) and `-x` (prefix) into +`Expression::Call` with the same shape as `foo(a, b)`. The syntactic +origin is lost. Preserve it as a flag: + +```rust +Expression::Call { + function: Box, + arguments: Vec, + operator_form: bool, +} +``` + +Both infix and prefix-unary parser paths set `operator_form: true`. +Regular call parsing sets `false`. Downstream layers read the flag +without knowing which operator names are special. + +Prefix-unary needs the same treatment as infix; "operator-syntax" +covers both. + +### Dispatch rule + +Vec candidates live alongside scalar overloads in the candidate list. +`find_overload` searches the augmented list in one pass; the dedicated +`try_vectorized_call` fallback step goes away: + +1. `find_overload(candidates, args)` over scalar overloads followed by + their vec variants. +2. If `None`, error. + +Scalar overloads come first in the list; the first-match-wins semantic +gives "first-class wins, vec falls back" for free. + +### Vec variants as candidates + +Concrete example. The `+` operator has scalar overloads roughly like: + +- `+(Int, Int) -> Int` +- `+(Float, Float) -> Float` +- `+(Number, Number) -> Number` + +When scope resolution looks up `+` for an `operator_form` call, the +candidate list it considers includes each scalar overload **plus** a +synthesised vec variant of each: + +``` +search space for `+` (operator_form = true): + 1. scalar +(Int, Int) -> Int + 2. scalar +(Float, Float) -> Float + 3. scalar +(Number, Number) -> Number + 4. vec +(Int, Int) — matches Tuple args + 5. vec +(Float, Float) — matches Tuple args + 6. vec +(Number, Number) +``` + +Each vec entry points to its scalar counterpart's slot. The +`vectorized: bool` on the candidate is the only thing distinguishing +the two. For a regular call (`operator_form: false`), scope skips the +vec-variant synthesis and the search space contains only the registered +scalar overloads. + +A vec candidate matches the call's arguments when: + +- At least one argument is `Tuple<…>` (statically) or an + `Object::Tuple` (at runtime). +- All tuple-shaped args have the same length. +- Each per-position element matches the underlying scalar overload's + parameter type. Scalar args in non-tuple positions broadcast. + +Static and runtime checks differ in how they realise the per-position +rule. The analyser collapses each tuple-shaped arg to the LUB of its +element types (so `Tuple` becomes `Number` for the lookup +sig) and then queries the existing overload-resolution helpers +(`find_function` for exact-subtype, `find_function_candidates` for +loose). The runtime checks every element pair individually against the +scalar's parameter types — that's what catches mixed-element tuples +like `(1, "a") + (2, "b")` that today's probe-first dispatch crashes +on at element 1. + +This search space drives both `Resolved` and `Dynamic` bindings: + +- If the analyser can pin exactly one candidate at compile time (the + arg types match one entry and rule out the others), it emits + `Resolved(candidate)`. No runtime dispatch. +- If multiple candidates remain in play because one or more args are + `Any`, it emits `Dynamic(candidate_list)` and the full list goes to + runtime. + +Worked examples on the search space above: + +| call | arg types | binding | candidate | result | +|---------------------------------------|----------------------------------------|-----------|-------------------------------------|-------------------------| +| `1 + 2` | `Int`, `Int` | Resolved | #1 scalar | `Int` | +| `1.0 + 2` | `Float`, `Int` | Resolved | #3 scalar (numeric coercion) | `Number` | +| `(1, 2) + (3, 4)` | `Tuple`, `Tuple` | Resolved | #4 vec | `Tuple` | +| `(1, 2) + 5` | `Tuple`, `Int` | Resolved | #4 vec (right scalar broadcasts) | `Tuple` | +| `(1.0, 2.0) + (3, 4)` | `Tuple`, `Tuple` | Resolved | #6 vec | `Tuple` | +| `a + b` where `a, b: Any` | `Any`, `Any` | Dynamic | full list #1-#6 carried to runtime | `Any` | +| `(a, b) + (c, d)` where `a..d: Any` | `Tuple`, `Tuple` | Dynamic | vec variants compatible; list carried | `Any` | +| `foo((1, 2))` (regular call) | per `foo` | per `foo` | scope synthesised no vec variants | per `foo` | + +The `(1, 2) + (3, 4)` row is the case worth highlighting. Both arg +types are statically `Tuple`, so the analyser picks vec +candidate #4 at compile time. Once the [compiler unrolling +optimisation](#compiler-optimisation) lands, this case emits unrolled +element calls directly with no `OverloadSet` construction. As shipped +the candidate is pushed via a single-entry overload set and dispatched +through the same value-level path Dynamic uses; the analyser-side win +is the precise `Tuple` result type. + +The Any rows are where `Dynamic` shows up: the analyser cannot narrow +the search space, so it carries the candidate list forward and lets the +value-level dispatcher pick at runtime. That dispatcher iterates the +same list the analyser built; scalars first, then vec variants, first +match wins. + +Invoking a vec candidate (whether at compile time via Resolved or at +runtime via Dynamic) calls the scalar counterpart once per element +pair and gathers results into a tuple. For `(1, 2) + (3, 4)` the +candidate is #4, so the calls are `+(1, 3)` and `+(2, 4)` through the +slot of scalar `+(Int, Int)`, producing `(4, 6)`. + +Synthesis happens at scope-resolution time, gated on `operator_form`. +The runtime never needs to know which entries were synthetic — it +dispatches over the same list the analyser produced. + +### Runtime broadening + +`try_vectorized_call` is gone. `find_overload` walks the augmented +candidate list once and routes the call: + +- Scalar candidate: `Function::matches_value_args` checks the args + directly; success returns `Callable::Scalar(func)`. +- Vec candidate: every element pair must satisfy the underlying scalar's + parameter types (not just the first pair — the old probe-based + dispatch silently miscoupled mixed-type tuples). Success returns + `Callable::Vec(scalar_fn)`, which `dispatch_vec_call` invokes once per + axis position, broadcasting non-tuple args. + +The n-ary broadcast rule: any tuple-shaped arg defines the axis; all +tuple-shaped args must have equal length; scalars broadcast. Empty +tuples and length mismatches decline the vec match and the call falls +through to the regular "no overload found" error. + +After these changes: + +- `-(1, 2, 3)` vec's to `(-1, -2, -3)`. +- `("a", "b") ++ ("c", "d")` vec's to `("ac", "bd")` via + `++(String, String)`. +- `([1], [2]) ++ ([3], [4])` vec's to `([1, 3], [2, 4])` via + `++(List, List)`. + +### Analyser binding shape + +Vec is a property of the candidate, not of the binding. Each entry in +the candidate list carries a kind: + +```rust +struct Candidate { + var: ResolvedVar, // pointer to the scalar overload's slot + vectorized: bool, // true → synthesised vec variant +} + +enum Binding { + None, + Resolved(Candidate), + Dynamic(Vec), +} +``` + +`Resolved(Candidate { vectorized: false })` is the common scalar +dispatch case. `Resolved(Candidate { vectorized: true })` happens when +exactly one vec variant matches the static arg types — the analyser +has pinned the call to a specific element overload. + +Type inference per binding shape: + +| binding | result type | +|------------------------------------------|--------------------------------------------| +| `Resolved` to a scalar candidate | overload's declared return | +| `Resolved` to a vec candidate | `Tuple` | +| `Dynamic` | LUB across every candidate's inferred return | + +The analyser computes each candidate's contribution (declared return +for scalars, `Tuple` for vecs where `max_len` is +the statically known broadcast axis) and LUBs them. In the all-scalar +case this recovers the LUB precision PR #140 had to pessimise. In the +mixed-candidate case the type lattice naturally collapses +`LUB(Tuple<…>, scalar)` to `Any` because tuples only join with tuples +of equal arity — no special-casing needed. + +Vec candidate return type uses **uniform LUB collapse**: each tuple-arg +contributes the LUB of its element types in a single position, and the +result tuple is filled with the scalar overload's return repeated +`max_len` times. So `(Int, Float) + (Float, Int)` → `Tuple`, not the per-element-precise `Tuple`. The +per-position alternative was considered (see [Alternatives](#alternatives-considered)) +and rejected for simplicity. + +Vec candidate resolution mirrors the scalar path's two-stage lookup: +an exact-subtype `find_function` hit on the synthetic sig wins over +the looser `find_function_candidates` set. This is what makes +`Tuple - Tuple` resolve to `Tuple` +instead of LUB'ing every compatible `-` overload into `Tuple`. + +### Compiler optimisation + +A `Resolved` binding to a vec candidate admits compile-time resolution. +The compiler knows the tuple length and which scalar overload the +candidate points to. Emit unrolled element calls plus `MakeTuple` +instead of `OverloadSet` dispatch: + +```text +(1, 2) + (3, 4) → LoadConst 1; LoadConst 3; Call +(Int,Int); + LoadConst 2; LoadConst 4; Call +(Int,Int); + MakeTuple 2 +``` + +The unrolled path skips OverloadSet construction and runtime dispatch. +This optimisation is independent of correctness; it has not yet landed. +As shipped, `Resolved(vec)` flows through a single-entry overload set +and the same dispatch path as `Dynamic`. + +## Implementation + +Steps 1–5 and 7 shipped together as one PR. Step 6 is deferred. + +1. ✅ Parser: `operator_form: bool` added to `Expression::Call`. Derived + `Clone`/`Debug` propagate it automatically. +2. ✅ Scope: vec candidates synthesised via a per-position LUB-collapsed + sig when `operator_form` is true. Both the loose-compatibility set + and the exact-subtype match are tracked so `Resolved(vec)` mirrors + `Resolved(scalar)`'s precision. +3. ✅ Runtime: `try_vectorized_call` removed; vec dispatch lives in + `find_overload` and `dispatch_vec_call`. N-ary broadcast settled + (any tuple-shaped arg defines the axis; equal-length required; + non-tuple args broadcast). +4. ✅ Runtime: element-call failures wrapped with `"while vectorising + '' at index N"`. +5. ✅ Analyser: type inference produces per-candidate types and LUBs + them, recovering scalar LUB precision and giving precise tuple + types for `Resolved(vec)`. +6. ⏸ Compiler: unrolled emission for `Resolved(vec)` not yet + implemented. Today the analyser-side win (precise return type) lands; + the runtime still dispatches through the overload-set path. +7. ✅ `BinaryOperator::supports_vectorization` and the + `StaticType::supports_vectorization{,_with}` helpers deleted. + +## Alternatives considered + +### Universal vec + +Drop the operator-syntax gate. Any call with tuple-shaped args can vec +when no overload matches. + +Rejected. Every higher-order stdlib function (`map`, `filter`, `fold`, +…) would need a first-class `Tuple` overload registered or risk silent +vec'ing into nonsense. `map((1, 2, 3), f)` becomes +`(map(1, f), map(2, f), map(3, f))`, calling `map` on scalars. The +mitigation is a stdlib audit on every new HOF addition, easy to miss. +The operator-syntax marker gets the same expressive power for the +operator cases without that audit burden. + +### Leak `BinaryOperator::supports_vectorization` into the analyser + +Have the analyser consult the parser's curated operator list by name. + +Rejected. The curated list becomes load-bearing in two crates (parser +and analyser). The operator-syntax marker keeps the fact where the +parser generates it; downstream layers read it without knowing which +operator names are special. + +## Open questions + +### P1: silent semantic shift on operator overloads — open + +Adding `fn +(t: Tuple, u: Tuple) -> X` would shift `(1, 2) + (3, 4)` +from vec to first-class dispatch. The hazard is bounded to operator +overloads because regular calls never vec. The set of operator names +is small, fixed, and known to the parser. No mitigation shipped; +stdlib discipline is the de-facto safeguard for now. + +### P2: element-call errors lose outer-call context — resolved + +Element-call failures are now wrapped with `"while vectorising +'' at index N: "`, so the outer call name and the failing +index appear in the error message. + +### Length mismatch error — accepted as "no function found" + +`(1, 2, 3) + (4, 5)`: the vec candidate match declines (length +mismatch on the tuple axis) and the call falls through to the regular +`"no function called '+' found matches the arguments: (Tuple, Tuple)"` error. A dedicated `"vec arity mismatch"` +message would be friendlier but isn't required for correctness. + +### Empty tuple — errors as recommended + +`() + ()`: `synthetic_vec_sig` rejects empty tuples, so no vec +candidate is synthesised. The call falls through to `"no function +found"` rather than returning `()`. + +### Per-position scalar resolution for vec candidates — resolved (LUB collapse) + +`(Int, Float) + (Float, Int)` resolves to a single vec candidate via +per-position LUB, so the result type is `Tuple` rather +than the per-element-precise `Tuple`. The precision loss +only affects operator-form calls over genuinely heterogeneous tuples, +which in practice are rare (product-style tuples like `("Tim", 35, +"NL")` never trigger vec at all), so the candidate-list simplicity won +out. Revisit if the imprecision starts to bite. + +### Unrolling ceiling — pending + +Relevant once step 6 (compiler unrolling) lands. Suggested threshold +N ≤ 8; confirm with bench data when implementing. + +## References + +- Issue [#139]: original regression. +- PR [#140]: soundness fix (`Binding::Dynamic` → `Any`). +- PR [#141]: implementation of this RFC. +- `ndc_vm/src/vm.rs::dispatch_vec_call`: runtime vec dispatch. +- `ndc_analyser/src/scope.rs::synthetic_vec_sig`: per-position LUB sig + used for static vec candidate lookup. diff --git a/manual/src/reference/types/tuple.md b/manual/src/reference/types/tuple.md index 3a047e45..45d9d16b 100644 --- a/manual/src/reference/types/tuple.md +++ b/manual/src/reference/types/tuple.md @@ -43,3 +43,29 @@ assert_eq(b, (1,2,3,4,5)); ## Operators {{#include ../../snippets/list-operators.md}} + +## Vectorization + +Operators broadcast element-wise over tuples. Both arguments must be tuples +of the same length, or one side may be a scalar that broadcasts: + +```ndc +assert_eq((1, 2) + (3, 4), (4, 6)); +assert_eq(-(1, 2, 3), (-1, -2, -3)); +assert_eq((1, 2) + 5, (6, 7)); +assert_eq(("a", "b") ++ ("c", "d"), ("ac", "bd")); +``` + +Vectorization only kicks in for operator syntax (`a + b`, `-x`, +`a ++ b`, etc.). Regular function calls never broadcast, so +`f((1, 2, 3))` passes the whole tuple to `f` and does not call `f` +once per element. + +Mixed-element tuples or length mismatches error rather than silently +producing wrong results: + +```ndc +(1, 2, 3) + (4, 5) // ERROR: no overload matches +(1, "a") + (2, "b") // ERROR: no overload accepts both pairs +``` + diff --git a/ndc_analyser/src/analyser.rs b/ndc_analyser/src/analyser.rs index 626e8d94..61f67a1c 100644 --- a/ndc_analyser/src/analyser.rs +++ b/ndc_analyser/src/analyser.rs @@ -1,13 +1,13 @@ use std::collections::HashMap; use std::fmt::Debug; -use crate::scope::{ScopeTree, TypeBinding}; +use crate::scope::{ScopeTree, TypeBinding, VecResolution}; use itertools::{Itertools, izip}; use ndc_core::{StaticType, TypeSignature}; use ndc_lexer::Span; use ndc_parser::{ - Binding, Expression, ExpressionLocation, ForBody, ForIteration, FunctionParameter, Lvalue, - NodeId, + Binding, Candidate, Expression, ExpressionLocation, ForBody, ForIteration, FunctionParameter, + Lvalue, NodeId, }; /// Side table holding semantic information keyed by AST node identity. @@ -130,7 +130,7 @@ impl Analyser { return Ok(StaticType::Any); }; - *resolved = Binding::Resolved(binding); + *resolved = Binding::Resolved(Candidate::scalar(binding)); Ok(self.scope_tree.get_type(binding).clone()) } @@ -202,29 +202,39 @@ impl Analyser { let right_type = self.analyse_or_any(r_value); let arg_types = vec![left_type, right_type]; - *resolved_assign_operation = self - .scope_tree - .resolve_function_binding(&format!("{operation}="), &arg_types); + // OpAssignment desugars to `x = x op y` where `op` is operator-form, + // so vec dispatch must be available for both the in-place and the + // fallback regular operator overload. + *resolved_assign_operation = self.scope_tree.resolve_function_binding( + &format!("{operation}="), + &arg_types, + true, + ); *resolved_operation = self .scope_tree - .resolve_function_binding(operation, &arg_types); - - if let Binding::None = resolved_operation { + .resolve_function_binding(operation, &arg_types, true); + + // Either operator can handle the call: `op=` modifies in + // place, `op` falls back via `a = a op b`. Only error when + // both are missing — e.g. `Map -= Map` is fine via `-=` + // even though bare `-` has no overload for maps. + if matches!(resolved_assign_operation, Binding::None) + && matches!(resolved_operation, Binding::None) + { self.emit(AnalysisError::function_not_found( operation, &arg_types, *span, )); } - // Determine the result type of the operation + // Determine the result type of the operation. Routed through + // candidate_return so that a Resolved vec candidate widens the + // lvalue with `Tuple` instead of the + // underlying scalar's return — otherwise `a += (3, 4)` on a + // `Tuple` lvalue would try to widen with `Int`. let result_type = match resolved_operation { Binding::Resolved(res) => { - if let StaticType::Function { return_type, .. } = - self.scope_tree.get_type(*res) - { - Some(return_type.as_ref().clone()) - } else { - None - } + let scalar_type = self.scope_tree.get_type(res.var).clone(); + Some(candidate_return(&scalar_type, res, &arg_types)) } _ => None, }; @@ -256,7 +266,7 @@ impl Analyser { .. } = &value.expression { - let container_type = self.scope_tree.get_type(*target).clone(); + let container_type = self.scope_tree.get_type(target.var).clone(); if let Some(elem_type) = container_type.index_element_type() { let widened_elem = elem_type.lub(&result_type); if widened_elem != elem_type { @@ -264,7 +274,7 @@ impl Analyser { container_type.with_element_type(widened_elem); let _ = self .scope_tree - .update_binding_type(*target, new_container); + .update_binding_type(target.var, new_container); } } } @@ -405,14 +415,19 @@ impl Analyser { Expression::Call { function, arguments, + operator_form, } => { let mut type_sig = Vec::with_capacity(arguments.len()); for a in arguments { type_sig.push(self.analyse_or_any(a)); } - let callee_type = - self.resolve_function_with_argument_types(function, &type_sig, *span); + let callee_type = self.resolve_function_with_argument_types( + function, + &type_sig, + *operator_form, + *span, + ); let StaticType::Function { return_type, .. } = callee_type else { if callee_type == StaticType::Any { @@ -450,7 +465,10 @@ impl Analyser { } if let Some(default) = default { - self.analyse_or_any(default); + // The default is what `map[missing]` returns, so it + // contributes to the value type just like a regular entry. + let default_type = self.analyse_or_any(default); + Self::fold_lub(&mut value_type, default_type); } Ok(StaticType::Map { @@ -483,6 +501,7 @@ impl Analyser { &mut self, ident: &mut ExpressionLocation, argument_types: &[StaticType], + operator_form: bool, span: Span, ) -> StaticType { let ExpressionLocation { @@ -497,7 +516,7 @@ impl Analyser { let binding = self .scope_tree - .resolve_function_binding(name, argument_types); + .resolve_function_binding(name, argument_types, operator_form); let out_type = match &binding { Binding::None => { @@ -508,20 +527,31 @@ impl Analyser { )); return StaticType::Any; } - Binding::Resolved(res) => self.scope_tree.get_type(*res).clone(), - - 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. + Binding::Resolved(candidate) => { + let scalar_type = self.scope_tree.get_type(candidate.var).clone(); + let return_type = candidate_return(&scalar_type, candidate, argument_types); + // Preserve `parameters` from the underlying scalar so any + // downstream consumer that inspects the function shape sees + // the original arity; only the return type changes for vec. + match scalar_type { + StaticType::Function { parameters, .. } => StaticType::Function { + parameters, + return_type: Box::new(return_type), + }, + _ => StaticType::Any, + } + } + Binding::Dynamic(candidates) => { + let return_type = self.dynamic_return_type( + name, + candidates, + argument_types, + operator_form, + span, + ); StaticType::Function { parameters: None, - return_type: Box::new(StaticType::Any), + return_type: Box::new(return_type), } } }; @@ -531,6 +561,102 @@ impl Analyser { out_type } + /// Compute the return type for a `Binding::Dynamic` call. The candidate + /// list can contain a mix of scalar overloads (from the loose scalar + /// walk or the `all_by_name` fallback) and vec variants (from per-position + /// resolution or the Any-fallback). Each subset is typed independently: + /// + /// - **Mixed scalar + vec**: at runtime the dispatcher will pick either + /// shape depending on actual values. The LUB of a scalar return and + /// a `Tuple<…>` collapses to `Any` in our type lattice, so we don't + /// bother computing it and just return `Any`. + /// - **Pure vec**: re-run per-position resolution to get precise + /// per-position result types and build the result tuple. If any + /// position has no compatible overload, emit `function_not_found` + /// and recover with `Any`. + /// - **Pure scalar from a compat-filtered walk**: LUB declared returns + /// (precision recovery). If any candidate is incompatible — which + /// only happens when the binding came from the `all_by_name` fallback + /// with no compat-filtered matches — widen to `Any` so we don't + /// synthesise a precise return for a call that's likely to fail at + /// runtime. + fn dynamic_return_type( + &mut self, + name: &str, + candidates: &[Candidate], + argument_types: &[StaticType], + operator_form: bool, + span: Span, + ) -> StaticType { + let has_vec = candidates.iter().any(|c| c.vectorized); + let has_scalar = candidates.iter().any(|c| !c.vectorized); + + if has_vec && has_scalar { + return StaticType::Any; + } + + if has_vec { + return match self + .scope_tree + .resolve_vec_candidates(name, argument_types) + { + Some(VecResolution::Static { axis_len, positions }) => { + let mut element_types = Vec::with_capacity(axis_len); + let mut had_empty = false; + for pos_candidates in &positions { + if pos_candidates.is_empty() { + had_empty = true; + break; + } + let pos_type = pos_candidates + .iter() + .filter_map(|var| match self.scope_tree.get_type(*var) { + StaticType::Function { return_type, .. } => { + Some(return_type.as_ref().clone()) + } + _ => None, + }) + .reduce(|a, b| a.lub(&b)) + .unwrap_or(StaticType::Any); + element_types.push(pos_type); + } + if had_empty { + self.emit(AnalysisError::function_not_found( + name, + argument_types, + span, + )); + StaticType::Any + } else { + StaticType::Tuple(element_types) + } + } + Some(VecResolution::AnyFallback(_)) | None => StaticType::Any, + }; + } + + // Pure scalar Dynamic. The candidate list either came from the + // loose-compat walk (every entry is compat-filtered) or from the + // `all_by_name` fallback (entries may not match). Drop to Any when + // any candidate is incompatible so we don't claim a precise return + // for a doomed call. + let _ = operator_form; + let all_compat = candidates + .iter() + .all(|c| scalar_candidate_is_compat(&self.scope_tree, c, argument_types)); + if !all_compat { + return StaticType::Any; + } + candidates + .iter() + .filter_map(|c| match self.scope_tree.get_type(c.var) { + StaticType::Function { return_type, .. } => Some(return_type.as_ref().clone()), + _ => None, + }) + .reduce(|a, b| a.lub(&b)) + .unwrap_or(StaticType::Any) + } + fn resolve_for_iterations( &mut self, iterations: &mut [ForIteration], @@ -660,8 +786,16 @@ impl Analyser { let get_args = [type_of_index_target.clone(), index_type.clone()]; let set_args = [type_of_index_target.clone(), index_type, StaticType::Any]; - *resolved_get = Some(self.scope_tree.resolve_function_binding("[]", &get_args)); - *resolved_set = Some(self.scope_tree.resolve_function_binding("[]=", &set_args)); + // Index syntax is not operator-form for vec purposes — there is no + // natural element-wise broadcast story for `(list_a, list_b)[i]`. + *resolved_get = Some( + self.scope_tree + .resolve_function_binding("[]", &get_args, false), + ); + *resolved_set = Some( + self.scope_tree + .resolve_function_binding("[]=", &set_args, false), + ); if let Some(t) = type_of_index_target.index_element_type() { Ok(t) @@ -846,6 +980,69 @@ impl Analyser { } } +/// Whether a scalar candidate's parameter types could accept the call's +/// argument types. Vec candidates aren't checked here — they're added by +/// per-position resolution which already verified compatibility at each +/// position, so they're trusted unconditionally. +/// +/// Examples (with `fn typed(s: String) -> Int` registered as `typed`): +/// - scalar candidate for `typed`, call args `[Int]` → `false` +/// - scalar candidate for `typed`, call args `[Any]` → `true` +/// - scalar candidate for `+(Int, Int)`, call args `[Int]` → `false` +/// (arity mismatch) +fn scalar_candidate_is_compat( + scope_tree: &ScopeTree, + candidate: &Candidate, + argument_types: &[StaticType], +) -> bool { + if candidate.vectorized { + return true; + } + let StaticType::Function { + parameters: Some(params), + .. + } = scope_tree.get_type(candidate.var) + else { + return false; + }; + if params.len() != argument_types.len() { + return false; + } + params + .iter() + .zip(argument_types) + .all(|(p, a)| !p.is_incompatible_with(a)) +} + +/// Static return type for a single candidate of a call. +/// +/// Examples (assume `+(Int, Int) -> Int` is the underlying scalar): +/// - scalar candidate, call args `[Int, Int]` → `Int` +/// - vec candidate, call args `[Tuple, Tuple]` → +/// `Tuple` (the same scalar fires for every position) +/// - vec candidate, call args `[Any, Any]` → `Any` (no tuple length +/// visible at compile time, so we can't say how long the result is) +fn candidate_return( + scalar_type: &StaticType, + candidate: &Candidate, + argument_types: &[StaticType], +) -> StaticType { + let StaticType::Function { return_type, .. } = scalar_type else { + return StaticType::Any; + }; + let scalar_return = return_type.as_ref().clone(); + if !candidate.vectorized { + return scalar_return; + } + match argument_types.iter().find_map(|t| match t { + StaticType::Tuple(elems) if !elems.is_empty() => Some(elems.len()), + _ => None, + }) { + Some(len) => StaticType::Tuple(vec![scalar_return; len]), + None => StaticType::Any, + } +} + #[derive(thiserror::Error, Debug)] #[error("{text}")] pub struct AnalysisError { diff --git a/ndc_analyser/src/scope.rs b/ndc_analyser/src/scope.rs index 14eecade..8f9ec57a 100644 --- a/ndc_analyser/src/scope.rs +++ b/ndc_analyser/src/scope.rs @@ -1,7 +1,74 @@ use ndc_core::StaticType; -use ndc_parser::{Binding, CaptureSource, ResolvedVar}; +use ndc_parser::{Binding, Candidate, CaptureSource, ResolvedVar}; use std::fmt::{Debug, Formatter}; +/// Vec dispatch resolution at the analyser layer. +/// +/// `Static` carries per-position candidate lists when every tuple-shaped +/// argument's length is known statically. The runtime can use these +/// directly for per-pair dispatch; the analyser uses them to compute +/// precise per-position result types. +/// +/// `AnyFallback` is the case where no arg is statically tuple-shaped but +/// at least one is `Any` — vec might still apply at runtime if those +/// Any-typed values turn out to be tuples. We carry all arity-matching +/// callable overloads so the runtime can narrow per pair; the analyser +/// can't say anything precise about the result type beyond `Any`. +pub(crate) enum VecResolution { + Static { + axis_len: usize, + /// For each position, scalar overload `ResolvedVar`s whose params + /// accept that position's element types. Priority-ordered + /// (exact-subtype first if any). Empty means no overload accepts + /// that position — caller surfaces as `function_not_found`. + positions: Vec>, + }, + AnyFallback(Vec), +} + +/// Result of walking the scope chain looking up a scalar signature. +/// +/// Mirrors the precedence semantics the previous `resolve_function_binding` +/// inlined: exact subtype match (short-circuits the walk), then +/// first-scope-wins loose-compatibility candidates, then the +/// `all_by_name` fallback that gathers same-named callable bindings even +/// when their signatures don't match (used for runtime narrowing of +/// `Any`-typed callees, upvalues, and similar cases). +struct ScalarWalk { + exact: Option, + loose: Option>, + all_by_name: Vec, +} + +/// If every per-position candidate list contains exactly one entry and they +/// all point to the same `ResolvedVar`, return it. This is the case where +/// `Binding::Resolved(vec)` is safe: one underlying scalar handles every +/// element pair. +fn unique_single_scalar(positions: &[Vec]) -> Option { + let first = positions.first()?.first().copied()?; + for pos in positions { + if pos.len() != 1 || pos[0] != first { + return None; + } + } + Some(first) +} + +/// Union of per-position candidate lists, preserving the order in which +/// entries are first encountered. Used to build the merged candidate list +/// the runtime carries for heterogeneous vec dispatch. +fn union_preserve_order(positions: &[Vec]) -> Vec { + let mut out: Vec = Vec::new(); + for pos in positions { + for c in pos { + if !out.contains(c) { + out.push(*c); + } + } + } + out +} + #[derive(Debug, Clone)] pub(crate) enum TypeBinding { Inferred(StaticType), @@ -355,30 +422,99 @@ impl ScopeTree { /// because a different overload may be an exact match in an outer scope) /// 3. Compatible-type candidates → remember first set found (for `Binding::Dynamic`) /// 4. All same-named bindings → accumulate as last-resort fallback - pub(crate) fn resolve_function_binding(&mut self, ident: &str, sig: &[StaticType]) -> Binding { + /// + /// When `operator_form` is true, synthesises vec variants from a per-position + /// LUB-collapsed signature so the runtime can dispatch tuple-broadcast forms + /// like `(1, 2) + (3, 4)` against the underlying scalar overloads. + pub(crate) fn resolve_function_binding( + &mut self, + ident: &str, + sig: &[StaticType], + operator_form: bool, + ) -> Binding { + // Scalar walk first. An exact subtype match short-circuits the whole + // call — first-class scalar dispatch always wins over vec. + let walk = self.scalar_scope_walk(ident, sig); + if let Some(exact) = walk.exact { + return Binding::Resolved(Candidate::scalar(exact)); + } + + // Per-position vec resolution, if this is an operator-form call. + let vec_resolution = if operator_form { + self.resolve_vec_candidates(ident, sig) + } else { + None + }; + + let scalar_loose = walk.loose.unwrap_or_default(); + + // Try Resolved(vec): every position pins to a single scalar AND + // they all agree. No scalar competes (would force Dynamic). + if scalar_loose.is_empty() + && let Some(VecResolution::Static { positions, .. }) = &vec_resolution + && let Some(unique) = unique_single_scalar(positions) + { + return Binding::Resolved(Candidate::vec(unique)); + } + + // Collect vec candidates (union across positions for Static; the flat + // list for AnyFallback). Preserve relative order so the runtime's + // per-pair lookup is stable. + let vec_candidates: Vec = match &vec_resolution { + Some(VecResolution::Static { positions, .. }) => union_preserve_order(positions), + Some(VecResolution::AnyFallback(candidates)) => candidates.clone(), + None => Vec::new(), + }; + + if !scalar_loose.is_empty() || !vec_candidates.is_empty() { + let combined: Vec = scalar_loose + .into_iter() + .map(Candidate::scalar) + .chain(vec_candidates.into_iter().map(Candidate::vec)) + .collect(); + return Binding::Dynamic(combined); + } + + if !walk.all_by_name.is_empty() { + return Binding::Dynamic( + walk.all_by_name + .into_iter() + .map(Candidate::scalar) + .collect(), + ); + } + Binding::None + } + + /// Walks the scope chain looking up a scalar signature, gathering the + /// usual precedence-ordered candidate sets. Exact subtype match + /// short-circuits the walk (returned in `exact`); otherwise the + /// first-scope-wins loose-compatibility set populates `loose`, and + /// `all_by_name` accumulates same-named callable bindings across every + /// scope for the runtime-narrowing fallback path. + fn scalar_scope_walk(&mut self, ident: &str, sig: &[StaticType]) -> ScalarWalk { let mut scope_ptr = self.current_scope_idx; let mut env_scopes: Vec = Vec::default(); - let mut loose_candidates: Option> = None; + let mut loose: Option> = None; let mut all_by_name: Vec = Vec::new(); loop { - // 1. Exact match on a local → return immediately if let Some(slot) = self.scopes[scope_ptr].find_function(ident, sig) { - return Binding::Resolved(self.resolve_found_local(ident, slot, &env_scopes)); + return ScalarWalk { + exact: Some(self.resolve_found_local(ident, slot, &env_scopes)), + loose: None, + all_by_name: Vec::new(), + }; } - // 2. Upvalues with matching name — collect as candidates but continue - // walking, because the upvalue may be a different overload (e.g. - // different arity) and the exact match could be in a parent scope. for uv_slot in self.scopes[scope_ptr].find_upvalues_by_name(ident) { all_by_name.push(self.resolve_found_upvalue(ident, uv_slot, &env_scopes)); } - // 3. Compatible candidates (keep only the first scope's matches — shadowing) - if loose_candidates.is_none() { + if loose.is_none() { let candidates = self.scopes[scope_ptr].find_function_candidates(ident, sig); if !candidates.is_empty() { - loose_candidates = Some( + loose = Some( candidates .into_iter() .map(|slot| self.resolve_found_local(ident, slot, &env_scopes)) @@ -387,7 +523,6 @@ impl ScopeTree { } } - // 4. All same-named bindings (accumulate across all scopes) let slots = self.scopes[scope_ptr].find_all_callable_slots_by_name(ident); all_by_name.extend( slots @@ -395,22 +530,23 @@ impl ScopeTree { .map(|slot| self.resolve_found_local(ident, slot, &env_scopes)), ); - // Advance to parent scope if let Some(parent_idx) = self.scopes[scope_ptr].parent_idx { if self.scopes[scope_ptr].creates_environment { env_scopes.push(scope_ptr); } scope_ptr = parent_idx; } else { - // Fall through to globals if let Some(slot) = self.global_scope.find_function(ident, sig) { - return Binding::Resolved(ResolvedVar::Global { slot }); + return ScalarWalk { + exact: Some(ResolvedVar::Global { slot }), + loose: None, + all_by_name: Vec::new(), + }; } - - if loose_candidates.is_none() { + if loose.is_none() { let candidates = self.global_scope.find_function_candidates(ident, sig); if !candidates.is_empty() { - loose_candidates = Some( + loose = Some( candidates .into_iter() .map(|slot| ResolvedVar::Global { slot }) @@ -418,25 +554,117 @@ impl ScopeTree { ); } } - all_by_name.extend( self.global_scope .find_all_callable_slots_by_name(ident) .into_iter() .map(|slot| ResolvedVar::Global { slot }), ); - break; } } - if let Some(candidates) = loose_candidates { - return Binding::Dynamic(candidates); + ScalarWalk { + exact: None, + loose, + all_by_name, } - if !all_by_name.is_empty() { - return Binding::Dynamic(all_by_name); + } + + /// Per-position priority-ordered scalar candidate list for one sig. + /// `exact` (if any) comes first, then `loose` with the exact entry + /// deduplicated. Empty when no overload accepts the signature — the + /// caller decides whether to surface as an error. + pub(crate) fn candidates_for_position_sig( + &mut self, + ident: &str, + sig: &[StaticType], + ) -> Vec { + let walk = self.scalar_scope_walk(ident, sig); + let mut result: Vec = Vec::new(); + if let Some(e) = walk.exact { + result.push(e); } - Binding::None + if let Some(loose) = walk.loose { + for c in loose { + if !result.contains(&c) { + result.push(c); + } + } + } + result + } + + /// Resolve vec candidates for an operator-form call. + /// + /// `Static` case: at least one arg is statically a non-empty tuple and + /// all tuple-shaped args share that length. Per-position lookups give + /// the scalar overload(s) compatible at each position. + /// + /// `AnyFallback` case: no arg is statically tuple-shaped but at least + /// one is `Any`, so the runtime might still find tuples there. Collect + /// every arity-matching callable so per-pair dispatch can narrow. + /// + /// `None`: no tuple-shaped or Any args — vec cannot apply. + pub(crate) fn resolve_vec_candidates( + &mut self, + ident: &str, + argument_types: &[StaticType], + ) -> Option { + if let Some((axis_len, positions)) = self.resolve_vec_static(ident, argument_types) { + return Some(VecResolution::Static { axis_len, positions }); + } + + if !argument_types + .iter() + .any(|t| matches!(t, StaticType::Any)) + { + return None; + } + + let permissive_sig: Vec = vec![StaticType::Any; argument_types.len()]; + let candidates = self.candidates_for_position_sig(ident, &permissive_sig); + if candidates.is_empty() { + return None; + } + Some(VecResolution::AnyFallback(candidates)) + } + + /// Per-position vec resolution against statically known tuple shapes. + /// Returns `(axis_len, positions)` or `None` if no statically tuple-shaped + /// arg is involved (empty tuples and length mismatches also disqualify). + fn resolve_vec_static( + &mut self, + ident: &str, + argument_types: &[StaticType], + ) -> Option<(usize, Vec>)> { + let mut axis: Option = None; + for arg in argument_types { + if let StaticType::Tuple(elems) = arg { + if elems.is_empty() { + return None; + } + match axis { + None => axis = Some(elems.len()), + Some(n) if n == elems.len() => {} + _ => return None, + } + } + } + let axis_len = axis?; + + let mut positions = Vec::with_capacity(axis_len); + for i in 0..axis_len { + let pos_sig: Vec = argument_types + .iter() + .map(|arg| match arg { + StaticType::Tuple(elems) => elems[i].clone(), + other => other.clone(), + }) + .collect(); + positions.push(self.candidates_for_position_sig(ident, &pos_sig)); + } + Some((axis_len, positions)) } pub(crate) fn create_local_binding( @@ -877,4 +1105,54 @@ mod tests { let middle_idx = tree.current_scope_idx; assert_eq!(tree.scopes[middle_idx].upvalues.len(), 1); } + + // ---------------------------------------------------------------------- + // Pure helpers from the vec-dispatch refactor. End-to-end behaviour is + // covered by functional tests under tests/functional/programs/013_vector_math/. + // ---------------------------------------------------------------------- + + #[test] + fn unique_single_scalar_homogeneous() { + // Every position has one entry pointing to the same slot — the + // "homogeneous vec" case that becomes Binding::Resolved(vec). + let v = ResolvedVar::Global { slot: 7 }; + let positions = vec![vec![v], vec![v], vec![v]]; + assert_eq!(unique_single_scalar(&positions), Some(v)); + } + + #[test] + fn unique_single_scalar_heterogeneous_pins() { + // Positions pin to different scalars — must be promoted to Dynamic. + let a = ResolvedVar::Global { slot: 7 }; + let b = ResolvedVar::Global { slot: 8 }; + let positions = vec![vec![a], vec![b]]; + assert_eq!(unique_single_scalar(&positions), None); + } + + #[test] + fn unique_single_scalar_multi_entry_position() { + // A position with more than one candidate isn't pinned — Dynamic. + let a = ResolvedVar::Global { slot: 7 }; + let b = ResolvedVar::Global { slot: 8 }; + let positions = vec![vec![a, b], vec![a]]; + assert_eq!(unique_single_scalar(&positions), None); + } + + #[test] + fn unique_single_scalar_empty_position() { + // An empty position means no overload accepts those element types — + // can't pin Resolved. + let a = ResolvedVar::Global { slot: 7 }; + let positions = vec![vec![a], Vec::new()]; + assert_eq!(unique_single_scalar(&positions), None); + } + + #[test] + fn union_preserve_order_dedups_across_positions() { + let a = ResolvedVar::Global { slot: 1 }; + let b = ResolvedVar::Global { slot: 2 }; + let c = ResolvedVar::Global { slot: 3 }; + let positions = vec![vec![a, b], vec![b, c], vec![a, c]]; + assert_eq!(union_preserve_order(&positions), vec![a, b, c]); + } } diff --git a/ndc_bin/src/highlighter.rs b/ndc_bin/src/highlighter.rs index 110d6962..2888e053 100644 --- a/ndc_bin/src/highlighter.rs +++ b/ndc_bin/src/highlighter.rs @@ -123,6 +123,7 @@ fn collect_function_spans(expr: &ExpressionLocation, spans: &mut AHashSet Expression::Call { function, arguments, + .. } => { if let Expression::Identifier { .. } = &function.expression { spans.insert(function.span.offset()); diff --git a/ndc_core/src/static_type.rs b/ndc_core/src/static_type.rs index 3e13ed78..8fc82ab2 100644 --- a/ndc_core/src/static_type.rs +++ b/ndc_core/src/static_type.rs @@ -561,14 +561,6 @@ impl StaticType { Self::Tuple(vec![]) } - #[must_use] - pub fn supports_vectorization(&self) -> bool { - match self { - Self::Tuple(values) => values.iter().all(|v| v.is_number()), - _ => false, - } - } - pub fn is_number(&self) -> bool { matches!( self, @@ -576,25 +568,6 @@ impl StaticType { ) } - #[must_use] - pub fn supports_vectorization_with(&self, other: &Self) -> bool { - match (self, other) { - (Self::Tuple(l), Self::Tuple(r)) - if { - l.len() == r.len() - && self.supports_vectorization() - && other.supports_vectorization() - } => - { - true - } - (tup @ Self::Tuple(_), maybe_num) | (maybe_num, tup @ Self::Tuple(_)) => { - tup.supports_vectorization() && maybe_num.is_number() - } - _ => false, - } - } - // BRUH pub fn is_incompatible_with(&self, other: &Self) -> bool { !self.is_subtype(other) && !other.is_subtype(self) diff --git a/ndc_lsp/src/visitor.rs b/ndc_lsp/src/visitor.rs index c87c1e6a..021718b1 100644 --- a/ndc_lsp/src/visitor.rs +++ b/ndc_lsp/src/visitor.rs @@ -133,6 +133,7 @@ fn walk_expression(visitor: &mut impl AstVisitor, expr: &ExpressionLocation) { Expression::Call { function, arguments, + .. } => { walk_expression(visitor, function); for arg in arguments { diff --git a/ndc_parser/src/expression.rs b/ndc_parser/src/expression.rs index 03b4fcae..9cea14f1 100644 --- a/ndc_parser/src/expression.rs +++ b/ndc_parser/src/expression.rs @@ -22,8 +22,8 @@ impl NodeId { #[derive(Debug, Eq, PartialEq, Clone)] pub enum Binding { None, - Resolved(ResolvedVar), - Dynamic(Vec), // figure it out at runtime + Resolved(Candidate), + Dynamic(Vec), // figure it out at runtime } #[derive(Debug, Eq, PartialEq, Clone, Copy)] @@ -41,6 +41,32 @@ impl ResolvedVar { } } +/// A function overload candidate. `vectorized` is set on synthesised vec +/// variants whose `var` points to the underlying scalar overload's slot. +#[derive(Debug, Eq, PartialEq, Clone, Copy)] +pub struct Candidate { + pub var: ResolvedVar, + pub vectorized: bool, +} + +impl Candidate { + #[must_use] + pub fn scalar(var: ResolvedVar) -> Self { + Self { + var, + vectorized: false, + } + } + + #[must_use] + pub fn vec(var: ResolvedVar) -> Self { + Self { + var, + vectorized: true, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum CaptureSource { Local(usize), @@ -120,6 +146,8 @@ pub enum Expression { /// The function to call, could be an identifier, or any expression that produces a function as its value function: Box, arguments: Vec, + /// True when this Call was desugared from operator syntax (`a + b`, `-x`). + operator_form: bool, }, Tuple { values: Vec, @@ -288,6 +316,7 @@ impl Lvalue { Expression::Call { function, arguments, + .. } if is_index_call(function, arguments) => true, Expression::List { values } | Expression::Tuple { values } => values .iter() @@ -316,6 +345,7 @@ impl TryFrom for Lvalue { Expression::Call { function, mut arguments, + .. } if is_index_call(&function, &arguments) => { let index = arguments.remove(1); let container = arguments.remove(0); diff --git a/ndc_parser/src/lib.rs b/ndc_parser/src/lib.rs index cae9582f..198b87f4 100644 --- a/ndc_parser/src/lib.rs +++ b/ndc_parser/src/lib.rs @@ -3,7 +3,7 @@ mod operator; mod parser; pub use expression::{ - Binding, CaptureSource, Expression, ExpressionLocation, ForBody, ForIteration, + Binding, Candidate, CaptureSource, Expression, ExpressionLocation, ForBody, ForIteration, FunctionParameter, Lvalue, NodeId, ResolvedVar, }; pub use operator::{BinaryOperator, LogicalOperator, UnaryOperator}; diff --git a/ndc_parser/src/operator.rs b/ndc_parser/src/operator.rs index 17b9bbcc..6405c2b3 100644 --- a/ndc_parser/src/operator.rs +++ b/ndc_parser/src/operator.rs @@ -82,22 +82,6 @@ pub enum BinaryOperator { ShiftLeft, } -impl BinaryOperator { - pub fn supports_vectorization(&self) -> bool { - matches!( - self, - Self::Plus - | Self::Minus - | Self::Multiply - | Self::Divide - | Self::FloorDivide - | Self::CModulo - | Self::EuclideanModulo - | Self::Exponent - ) - } -} - #[derive(Debug, Eq, PartialEq, Copy, Clone)] pub enum LogicalOperator { And, diff --git a/ndc_parser/src/parser.rs b/ndc_parser/src/parser.rs index d913a67a..26ab92a8 100644 --- a/ndc_parser/src/parser.rs +++ b/ndc_parser/src/parser.rs @@ -207,6 +207,7 @@ impl Parser { .to_location(operator_token_loc.span), ), arguments: vec![left, right], + operator_form: true, } .to_location(new_span); @@ -220,6 +221,7 @@ impl Parser { .to_location(not_token.span), ), arguments: vec![left], + operator_form: true, } .to_location(new_span.merge(not_token.span)); } @@ -252,6 +254,7 @@ impl Parser { .to_location(operator_span), ), arguments: vec![left, right], + operator_form: true, } .to_location(new_span)); } @@ -509,6 +512,7 @@ impl Parser { .to_location(operator_span), ), arguments: vec![right], + operator_form: true, } .to_location(span)) } else { @@ -640,6 +644,7 @@ impl Parser { .to_location(token_span), ), arguments: vec![right], + operator_form: true, } .to_location(span.merge(token_span))) } else { @@ -673,6 +678,7 @@ impl Parser { expression: Expression::Call { function: Box::new(expr), arguments, + operator_form: false, }, span: span.merge(arguments_span), id: NodeId::next(), @@ -716,6 +722,7 @@ impl Parser { .to_location(identifier_span), ), arguments, + operator_form: false, }, span: tuple_span .unwrap_or(identifier_span) @@ -783,6 +790,7 @@ impl Parser { .to_location(bracket_span), ), arguments: vec![expr, index_expression], + operator_form: false, }, span, id: NodeId::next(), diff --git a/ndc_vm/src/compiler.rs b/ndc_vm/src/compiler.rs index dddc0aa5..703deb4b 100644 --- a/ndc_vm/src/compiler.rs +++ b/ndc_vm/src/compiler.rs @@ -203,17 +203,33 @@ impl Compiler { .. } => { let var = resolved.expect("lvalue must be resolved"); - if matches!(resolved_assign_operation, Binding::Resolved(_)) { - // In-place operation (e.g. |=, &=) resolved exactly: modifies - // the value's Rc in place via sync_map_mutations in the bridge, - // so all aliases sharing the Rc see the change. We discard the - // unit return value; the variable slot already holds the + if matches!(resolved_assign_operation, Binding::Resolved(c) if !c.vectorized) + { + // Scalar in-place op= resolved exactly: modifies the value's + // Rc in place via sync_map_mutations in the bridge, so all + // aliases sharing the Rc see the change. We discard the + // return value; the variable slot already holds the // (now-updated) shared reference. self.compile_binding(resolved_assign_operation, span)?; self.emit_get_var(var, lv_span); self.compile_expr(*r_value)?; self.chunk.write(OpCode::Call(2), span); self.chunk.write(OpCode::Pop, span); + } else if matches!(resolved_assign_operation, Binding::Resolved(c) if c.vectorized) + { + // Vec-resolved op=: dispatch_vec_call allocates a fresh + // result tuple whose elements are the per-element scalar + // returns. Store it back so we don't silently lose the + // update if a future scalar op= doesn't mutate through Rc. + // For the current stdlib (List/String ++=, HashMap -=) + // this is functionally equivalent to Pop because the + // element calls already mutated their inputs through Rc; + // the stored tuple just holds those same (now-mutated) Rcs. + self.compile_binding(resolved_assign_operation, span)?; + self.emit_get_var(var, lv_span); + self.compile_expr(*r_value)?; + self.chunk.write(OpCode::Call(2), span); + self.emit_set_var(var, lv_span); } else if let Binding::Dynamic(assign_candidates) = resolved_assign_operation { @@ -342,6 +358,7 @@ impl Compiler { Expression::Call { function, arguments, + operator_form: _, } => { let function_span = function.span; self.compile_expr(*function)?; @@ -496,7 +513,19 @@ impl Compiler { fn compile_binding(&mut self, resolved: Binding, span: Span) -> Result<(), CompileError> { match resolved { Binding::None => return Err(CompileError::unresolved_binding(span)), - Binding::Resolved(var) => self.emit_get_var(var, span), + Binding::Resolved(candidate) => { + if candidate.vectorized { + // Vec-resolved candidates flow through the overload-set path so + // the runtime dispatcher can pick the vec entry. The unrolled + // emission optimisation lives behind RFC step 6 (deferred). + let idx = self + .chunk + .add_constant(Value::Object(Rc::new(Object::OverloadSet(vec![candidate])))); + self.chunk.write(OpCode::Constant(idx), span); + } else { + self.emit_get_var(candidate.var, span); + } + } Binding::Dynamic(candidates) => { let idx = self .chunk diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index 7491b7e7..f821a590 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -8,7 +8,7 @@ use ndc_core::compare::FallibleOrd; use ndc_core::hash_map::{DefaultHasher, HashMap}; use ndc_core::int::Int; use ndc_core::num::Number; -use ndc_parser::ResolvedVar; +use ndc_parser::Candidate; use ordered_float::OrderedFloat; use std::cell::RefCell; use std::cmp::{Ordering, Reverse}; @@ -48,7 +48,7 @@ pub enum Object { default: Option, }, Function(Function), - OverloadSet(Vec), + OverloadSet(Vec), Iterator(SharedIterator), Deque(RefCell>), MinHeap(RefCell>>), diff --git a/ndc_vm/src/vm.rs b/ndc_vm/src/vm.rs index 6ae2959d..5800c35d 100644 --- a/ndc_vm/src/vm.rs +++ b/ndc_vm/src/vm.rs @@ -8,7 +8,7 @@ use crate::value::{CompiledFunction, Function, NativeFunc}; use crate::{ClosureFunction, Object, UpvalueCell, Value}; use ndc_core::hash_map::{DefaultHasher, HashMap}; use ndc_lexer::Span; -use ndc_parser::{CaptureSource, ResolvedVar}; +use ndc_parser::{Candidate, CaptureSource, ResolvedVar}; use std::cell::RefCell; use std::hash::{Hash, Hasher}; use std::io::Write; @@ -255,16 +255,21 @@ impl Vm { } OpCode::Call(args) => { let args = *args; - if let Some(func) = self + if let Some(callable) = self .resolve_callee(args) .map_err(|msg| VmError::new(msg, span))? { - if let Err(mut e) = self.dispatch_call(func, args) { + let result = match callable { + Callable::Scalar(func) => self.dispatch_call(func, args), + Callable::Vec { + candidates, + axis_len, + } => self.dispatch_vec_call(&candidates, args, axis_len, span), + }; + if let Err(mut e) = result { e.span.get_or_insert(span); return Err(e); } - } else if let Some(result) = self.try_vectorized_call(args, span)? { - self.stack.push(result); } else { let arg_types: Vec<_> = self.stack[self.stack.len() - args..] .iter() @@ -739,14 +744,13 @@ impl Vm { }]; } - /// Resolves the callee on the stack to a concrete `Function`. Returns `Ok(None)` - /// when the callee is an overload set and no candidate matches the argument - /// types — the caller should then try a vectorized fallback. Returns `Err` when - /// the callee is not callable at all. - fn resolve_callee(&self, args: usize) -> Result, String> { + /// Resolves the callee on the stack to a `Callable`. Returns `Ok(None)` + /// when the callee is an overload set and no candidate matches the + /// argument types. Returns `Err` when the callee is not callable at all. + fn resolve_callee(&self, args: usize) -> Result, String> { match &self.stack[self.stack.len() - args - 1] { Value::Object(obj) => match obj.as_ref() { - Object::Function(f) => Ok(Some(f.clone())), + Object::Function(f) => Ok(Some(Callable::Scalar(f.clone()))), Object::OverloadSet(candidates) => { let start = self.stack.len() - args; Ok(self.find_overload(candidates, &self.stack[start..])) @@ -771,8 +775,8 @@ impl Vm { match &self.stack[self.stack.len() - args - 1] { Value::Object(obj) => match obj.as_ref() { Object::Function(f) => f.name().map(str::to_string), - Object::OverloadSet(candidates) => candidates.first().and_then(|var| { - let value = self.resolve_var(var, frame_pointer); + Object::OverloadSet(candidates) => candidates.first().and_then(|candidate| { + let value = self.resolve_var(&candidate.var, frame_pointer); let Value::Object(obj) = value else { return None; }; @@ -788,91 +792,103 @@ impl Vm { } /// Searches an overload set for the first candidate whose type signature - /// accepts the given argument types. - fn find_overload(&self, candidates: &[ResolvedVar], args: &[Value]) -> Option { + /// accepts the given argument types. Scalar candidates use direct param + /// matching; vec candidates check that every element pair (under tuple + /// broadcast) satisfies the underlying scalar's parameter types. + fn find_overload(&self, candidates: &[Candidate], args: &[Value]) -> Option { let frame_pointer = self.frames.last().expect("no frame").frame_pointer; - candidates.iter().find_map(|var| { - let value = self.resolve_var(var, frame_pointer); + let mut vec_scalars: Vec = Vec::new(); + for candidate in candidates { + let value = self.resolve_var(&candidate.var, frame_pointer); let Value::Object(obj) = value else { - return None; + continue; }; let Object::Function(f) = obj.as_ref() else { - return None; + continue; }; - f.matches_value_args(args).then(|| f.clone()) - }) - } - - /// Applies a binary operator element-wise over numeric tuples. - /// - /// Returns `Some(result_tuple)` when both arguments (or one argument and - /// one scalar) are numeric tuples of compatible shape and an inner function - /// can be found for the element types. Returns `None` when vectorization - /// does not apply. - fn try_vectorized_call(&mut self, args: usize, span: Span) -> Result, VmError> { - if args != 2 { - return Ok(None); + if candidate.vectorized { + vec_scalars.push(f.clone()); + } else if f.matches_value_args(args) { + // Scalars are first-match-wins and take precedence over vec. + return Some(Callable::Scalar(f.clone())); + } } - let callee_idx = self.stack.len() - args - 1; + if vec_scalars.is_empty() { + return None; + } + let axis_len = vec_axis_len(args)?; + Some(Callable::Vec { + candidates: vec_scalars, + axis_len, + }) + } - // Use a block to scope all shared borrows of self.stack so they are - // dropped before the &mut self calls (call_callback, truncate) below. - let (inner_fn, pairs) = { - // P5: borrow candidates rather than cloning the Vec. - let candidates: &[ResolvedVar] = match &self.stack[callee_idx] { - Value::Object(obj) => match obj.as_ref() { - Object::OverloadSet(candidates) => candidates, - _ => return Ok(None), - }, - _ => return Ok(None), - }; + /// Dispatches a vec-resolved call by walking the broadcast axis and + /// looking up a scalar overload per element pair. Non-tuple args + /// broadcast through unchanged. Element-call errors are wrapped with + /// `"while vectorising '' at index N"` to preserve outer-call + /// context. When no candidate accepts a given element pair, surfaces + /// `"no overload accepts element N: (…)"` so the user sees which + /// position failed. + fn dispatch_vec_call( + &mut self, + candidates: &[Function], + args: usize, + axis_len: usize, + span: Span, + ) -> Result<(), VmError> { + let arg_start = self.stack.len() - args; + let callee_name = self.callee_name(args); - let left = &self.stack[self.stack.len() - 2]; - let right = &self.stack[self.stack.len() - 1]; - - // Check shape, build a two-element probe for overload lookup, and - // extract the element pairs all in one pass — avoiding the redundant - // as_numeric_tuple calls that a separate vectorization_pairs would do. - let left_tup = as_numeric_tuple(left); - let right_tup = as_numeric_tuple(right); - let (probe, pairs): ([Value; 2], Vec<(Value, Value)>) = match (left_tup, right_tup) { - (Some(ls), Some(rs)) if ls.len() == rs.len() => ( - [ls[0].clone(), rs[0].clone()], - ls.iter().cloned().zip(rs.iter().cloned()).collect(), - ), - (None, Some(rs)) if left.is_number() => ( - [left.clone(), rs[0].clone()], - rs.iter().map(|r| (left.clone(), r.clone())).collect(), - ), - (Some(ls), None) if right.is_number() => ( - [ls[0].clone(), right.clone()], - ls.iter().map(|l| (l.clone(), right.clone())).collect(), - ), - _ => return Ok(None), - }; + // Materialise arg values up front so the stack borrow doesn't conflict + // with call_callback's mutable access. Tuples are cheap to clone (Rc + // for the values; the Vec is the only fresh allocation). + let arg_values: Vec = self.stack.split_off(arg_start); + self.stack.pop(); // clean up the callee - let Some(inner_fn) = self.find_overload(candidates, &probe) else { - return Ok(None); - }; + let mut results = Vec::with_capacity(axis_len); + for i in 0..axis_len { + let element_args: Vec = arg_values + .iter() + .map(|arg| vec_element_at(arg, i)) + .collect(); - (inner_fn, pairs) - }; + let scalar = candidates + .iter() + .find(|f| f.matches_value_args(&element_args)) + .ok_or_else(|| { + let element_types = element_args + .iter() + .map(|v| v.static_type().to_string()) + .collect::>() + .join(", "); + let name = callee_name.as_deref().unwrap_or("?"); + VmError::new( + format!( + "no overload of '{name}' accepts element {i}: ({element_types})" + ), + span, + ) + })?; - let mut results = Vec::with_capacity(pairs.len()); - for (l, r) in pairs { - let v = self - .call_callback(inner_fn.clone(), vec![l, r]) + let result = self + .call_callback(scalar.clone(), element_args) .map_err(|mut e| { + let prefix = match &callee_name { + Some(name) => format!("while vectorising '{name}' at index {i}: "), + None => format!("while vectorising at index {i}: "), + }; + e.message = format!("{prefix}{}", e.message); e.span.get_or_insert(span); e })?; - results.push(v); + results.push(result); } - // Replace callee + args on the stack with the result tuple. - self.stack.truncate(callee_idx); - Ok(Some(Value::Object(Rc::new(Object::Tuple(results))))) + self.stack + .push(Value::Object(Rc::new(Object::Tuple(results)))); + Ok(()) } /// Pops a value from the stack and pushes `size` unpacked elements back. @@ -976,21 +992,51 @@ impl Vm { } } -/// If `value` is a tuple whose elements are all numeric, returns a reference to -/// its element vec. Returns `None` for empty tuples, non-tuples, or tuples -/// that contain non-numeric elements. -fn as_numeric_tuple(value: &Value) -> Option<&Vec> { - let Value::Object(obj) = value else { - return None; - }; - let Object::Tuple(elems) = obj.as_ref() else { - return None; - }; - if !elems.is_empty() && elems.iter().all(|e| e.is_number()) { - Some(elems) - } else { - None +/// What `resolve_callee` hands back. A `Scalar` callable runs once with +/// the full argument set; a `Vec` callable holds the full set of scalar +/// overloads available for vec dispatch — `dispatch_vec_call` resolves +/// one per element position via the same `matches_value_args` lookup the +/// scalar path uses. +pub(crate) enum Callable { + Scalar(Function), + Vec { + candidates: Vec, + axis_len: usize, + }, +} + +/// Finds the broadcast-axis length for a vec call: the shared length of +/// every tuple-shaped argument. Empty tuples and length mismatches return +/// `None` so vec dispatch declines and the call falls through to the +/// regular "no overload found" error. +pub(crate) fn vec_axis_len(args: &[Value]) -> Option { + let mut axis: Option = None; + for arg in args { + if let Value::Object(obj) = arg + && let Object::Tuple(elems) = obj.as_ref() + { + if elems.is_empty() { + return None; + } + match axis { + None => axis = Some(elems.len()), + Some(n) if n == elems.len() => {} + _ => return None, + } + } + } + axis +} + +/// Pick the per-element value for vec position `i`: tuple args contribute +/// `tuple[i]`; non-tuple args broadcast through unchanged. +pub(crate) fn vec_element_at(arg: &Value, i: usize) -> Value { + if let Value::Object(obj) = arg + && let Object::Tuple(elems) = obj.as_ref() + { + return elems[i].clone(); } + arg.clone() } impl CallFrame { diff --git a/tests/functional/programs/013_vector_math/003_vector_error2.ndc b/tests/functional/programs/013_vector_math/003_vector_error2.ndc index 49542e40..ecbea070 100644 --- a/tests/functional/programs/013_vector_math/003_vector_error2.ndc +++ b/tests/functional/programs/013_vector_math/003_vector_error2.ndc @@ -1,3 +1,5 @@ -// expect-error: no function called '+' found matches the arguments -// This looks valid, but isn't +// expect-error: No function called '+' found that matches the arguments +// This looks valid, but isn't — position 2 has nested tuples and `+` has +// no overload that accepts `Tuple`. Per-position resolution catches +// this at compile time now. (1,1,(1,)) + (1,1,(1,)) diff --git a/tests/functional/programs/013_vector_math/004_vector_unary.ndc b/tests/functional/programs/013_vector_math/004_vector_unary.ndc new file mode 100644 index 00000000..944f8bba --- /dev/null +++ b/tests/functional/programs/013_vector_math/004_vector_unary.ndc @@ -0,0 +1,9 @@ +// Unary operators broadcast across a tuple. +assert_eq(-(1, 2, 3), (-1, -2, -3)); +assert_eq(-(1.5, 2.5), (-1.5, -2.5)); + +// Bitwise negation also vec's. +assert_eq(~(1, 2, 3), (-2, -3, -4)); + +// Boolean negation across a tuple of bools. +assert_eq(!(true, false, true), (false, true, false)); diff --git a/tests/functional/programs/013_vector_math/005_vector_non_numeric.ndc b/tests/functional/programs/013_vector_math/005_vector_non_numeric.ndc new file mode 100644 index 00000000..0e26637f --- /dev/null +++ b/tests/functional/programs/013_vector_math/005_vector_non_numeric.ndc @@ -0,0 +1,7 @@ +// Non-numeric vec: `++` over tuples of strings dispatches per-element. +assert_eq(("a", "b") ++ ("c", "d"), ("ac", "bd")); +assert_eq(("hello", "foo") ++ (" world", "bar"), ("hello world", "foobar")); + +// Vec over tuples of lists. +assert_eq(([1], [2]) ++ ([3], [4]), ([1, 3], [2, 4])); +assert_eq(([1, 2], []) ++ ([3], [4, 5]), ([1, 2, 3], [4, 5])); diff --git a/tests/functional/programs/013_vector_math/006_vector_mixed_elements.ndc b/tests/functional/programs/013_vector_math/006_vector_mixed_elements.ndc new file mode 100644 index 00000000..728ba096 --- /dev/null +++ b/tests/functional/programs/013_vector_math/006_vector_mixed_elements.ndc @@ -0,0 +1,6 @@ +// expect-error: No function called '+' found that matches the arguments +// Per-position resolution catches this at compile time: position 1 has +// `(String, String)` element types and no `+(String, String)` overload +// exists. Position 0 would resolve cleanly to `+(Int, Int)`, but a +// position-with-no-overload makes the whole call fail at analysis time. +(1, "a") + (2, "b") diff --git a/tests/functional/programs/013_vector_math/007_regular_call_no_vec.ndc b/tests/functional/programs/013_vector_math/007_regular_call_no_vec.ndc new file mode 100644 index 00000000..7525d507 --- /dev/null +++ b/tests/functional/programs/013_vector_math/007_regular_call_no_vec.ndc @@ -0,0 +1,9 @@ +// A regular function call must NOT vec over its tuple argument. +// `id((1, 2, 3))` returns the tuple verbatim; if vec leaked through +// regular call syntax this would call `id` element-wise instead. +fn id(x) { x }; +assert_eq(id((1, 2, 3)), (1, 2, 3)); + +// Same shape but the argument is a tuple of tuples — proves the call +// shape isn't being unwrapped. +assert_eq(id(((1, 2), (3, 4))), ((1, 2), (3, 4))); diff --git a/tests/functional/programs/013_vector_math/008_vector_chain_precision.ndc b/tests/functional/programs/013_vector_math/008_vector_chain_precision.ndc new file mode 100644 index 00000000..63daa1c3 --- /dev/null +++ b/tests/functional/programs/013_vector_math/008_vector_chain_precision.ndc @@ -0,0 +1,13 @@ +// The analyser used to widen any operator call to `Any` after PR #140 to +// keep dispatch sound, which pessimised every follow-up. With per-binding +// vec tracking we can pin the result type, so chained operator calls keep +// dispatching to the precise scalar overload. +let v = (1, 2) + (3, 4); +let w = v + (10, 20); +let x = w * (2, 1); +assert_eq(x, (28, 26)); + +// Same chain mixing scalar broadcast with full tuple form. +let a = (1, 2) + 10; +let b = a * (3, 4); +assert_eq(b, (33, 48)); diff --git a/tests/functional/programs/013_vector_math/009_vector_exact_match_precision.ndc b/tests/functional/programs/013_vector_math/009_vector_exact_match_precision.ndc new file mode 100644 index 00000000..d9b003cd --- /dev/null +++ b/tests/functional/programs/013_vector_math/009_vector_exact_match_precision.ndc @@ -0,0 +1,25 @@ +// Vec dispatch should pick the most specific scalar overload by subtype +// (mirroring scalar dispatch's `find_function` precedence), not collapse +// to the LUB of every compatible overload. `Tuple - Tuple` must infer as `Tuple`, not `Tuple`. +let a: Tuple = (1, 2); +let b: Tuple = (3, 4); +let c: Tuple = a - b; +let d: Tuple = c * c; +assert_eq(d, (4, 4)); + +// Chained: `+` keeps the precise element type through several operators. +let e: Tuple = a + b; +let f: Tuple = e + (10, 20); +assert_eq(f, (14, 26)); + +// Any args fall to LUB (no scalar `-(Any, Any)` exists, so vec dispatch +// can't pin a single overload at compile time): `Tuple - +// Tuple` infers as `Tuple`, the LUB across +// every numeric overload's return type. +let l: List = [1, 2]; +let p: Tuple = (l.first, l.first); +let q: Tuple = (l.last, l.last); +let r: Tuple = p - q; +let s: Tuple = r * r; +assert_eq(s, (1, 1)); diff --git a/tests/functional/programs/013_vector_math/010_vector_op_assignment.ndc b/tests/functional/programs/013_vector_math/010_vector_op_assignment.ndc new file mode 100644 index 00000000..88881cd5 --- /dev/null +++ b/tests/functional/programs/013_vector_math/010_vector_op_assignment.ndc @@ -0,0 +1,23 @@ +// Compound assignment must use the vec return type, not the underlying +// scalar's return. `a += (3, 4)` on a `Tuple` lvalue must +// widen-check against `Tuple`, not `Int`. +let a: Tuple = (1, 2); +a += (3, 4); +assert_eq(a, (4, 6)); + +let b: Tuple = (10, 20); +b -= (1, 2); +assert_eq(b, (9, 18)); + +// Scalar broadcast on the right-hand side. +let c: Tuple = (3, 4); +c *= 2; +assert_eq(c, (6, 8)); + +// Without an annotation the inferred lvalue type must also widen +// correctly. `d` starts as `Tuple` and a follow-up read +// must still see the precise tuple element types. +let d = (1, 2); +d += (10, 20); +let e: Tuple = d + (100, 200); +assert_eq(e, (111, 222)); diff --git a/tests/functional/programs/013_vector_math/011_vector_op_assign_aliasing.ndc b/tests/functional/programs/013_vector_math/011_vector_op_assign_aliasing.ndc new file mode 100644 index 00000000..e53757ba --- /dev/null +++ b/tests/functional/programs/013_vector_math/011_vector_op_assign_aliasing.ndc @@ -0,0 +1,16 @@ +// Vec-resolved op= must preserve the in-place aliasing contract that +// scalar op= guarantees: inner Rc mutations stay visible through every +// alias of the originals, and the variable itself still reads as the +// updated value. +let l1 = [1, 2]; +let l2 = [3, 4]; +let pair = (l1, l2); +let outer_alias = pair; +pair ++= ([5], [6]); +// Inner-list mutation visible through the original bindings. +assert_eq(l1, [1, 2, 5]); +assert_eq(l2, [3, 4, 6]); +// Outer alias still sees the same inner lists, now mutated. +assert_eq(outer_alias, ([1, 2, 5], [3, 4, 6])); +// pair itself reads as the updated value. +assert_eq(pair, ([1, 2, 5], [3, 4, 6])); diff --git a/tests/functional/programs/013_vector_math/012_vector_heterogeneous.ndc b/tests/functional/programs/013_vector_math/012_vector_heterogeneous.ndc new file mode 100644 index 00000000..bb9efd17 --- /dev/null +++ b/tests/functional/programs/013_vector_math/012_vector_heterogeneous.ndc @@ -0,0 +1,19 @@ +// Per-position vec dispatch: positions can resolve to different scalar +// overloads. Element 0 dispatches `++(List, List)`; element 1 dispatches +// `++(String, String)`. The result tuple has different element types per +// position. +assert_eq( + ([1, 2, 3], "foo") ++ ([4, 5, 6], "bar"), + ([1, 2, 3, 4, 5, 6], "foobar") +); + +// Same shape, longer axis with three distinct overloads per position. +assert_eq( + ([1], "a", [10]) ++ ([2], "b", [20]), + ([1, 2], "ab", [10, 20]) +); + +// Mixed numerics: position 0 stays Int; position 1 needs Number coercion +// because of the Float on the left. +let mixed = (1, 1.5) + (2, 3); +assert_eq(mixed, (3, 4.5)); diff --git a/tests/functional/programs/013_vector_math/013_vector_per_position_no_overload.ndc b/tests/functional/programs/013_vector_math/013_vector_per_position_no_overload.ndc new file mode 100644 index 00000000..85bac436 --- /dev/null +++ b/tests/functional/programs/013_vector_math/013_vector_per_position_no_overload.ndc @@ -0,0 +1,5 @@ +// expect-error: No function called '+' found that matches the arguments +// Position 1 has element types `(Bool, Bool)` and `+` has no overload +// that accepts booleans. The per-position lookup catches this at compile +// time even though position 0's `(Int, Int)` would resolve cleanly. +let r = (1, true) + (2, false); diff --git a/tests/functional/programs/900_bugs/bug0021_combinations_lazy_source.ndc b/tests/functional/programs/900_bugs/bug0022_combinations_lazy_source.ndc similarity index 100% rename from tests/functional/programs/900_bugs/bug0021_combinations_lazy_source.ndc rename to tests/functional/programs/900_bugs/bug0022_combinations_lazy_source.ndc diff --git a/tests/functional/programs/900_bugs/bug0023_incompat_dynamic_misinferred.ndc b/tests/functional/programs/900_bugs/bug0023_incompat_dynamic_misinferred.ndc new file mode 100644 index 00000000..ad3c244f --- /dev/null +++ b/tests/functional/programs/900_bugs/bug0023_incompat_dynamic_misinferred.ndc @@ -0,0 +1,10 @@ +// expect-error: mismatched types: found Any but expected String +// `returns_int(42)` has no compatible overload — the only `returns_int` +// declared takes a `String`. Before the fix, the analyser LUB'd every +// candidate's declared return regardless of compatibility, so this call +// was inferred as `Int` and the assignment errored with the misleading +// "found Int but expected String". The Dynamic-LUB now drops to Any +// when any candidate's params can't accept the call's args, so the +// type mismatch surfaces against `Any` instead. +fn returns_int(s: String) -> Int { 1 }; +let x: String = returns_int(42);