Skip to content

Commit df7b11b

Browse files
timfennisclaude
andcommitted
feat(vectorization): broaden to n-ary operator forms and recover return-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]>
1 parent 38f1757 commit df7b11b

18 files changed

Lines changed: 631 additions & 172 deletions

File tree

manual/src/reference/types/tuple.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,29 @@ assert_eq(b, (1,2,3,4,5));
4343
## Operators
4444

4545
{{#include ../../snippets/list-operators.md}}
46+
47+
## Vectorization
48+
49+
Operators broadcast element-wise over tuples. Both arguments must be tuples
50+
of the same length, or one side may be a scalar that broadcasts:
51+
52+
```ndc
53+
assert_eq((1, 2) + (3, 4), (4, 6));
54+
assert_eq(-(1, 2, 3), (-1, -2, -3));
55+
assert_eq((1, 2) + 5, (6, 7));
56+
assert_eq(("a", "b") ++ ("c", "d"), ("ac", "bd"));
57+
```
58+
59+
Vectorization only kicks in for operator syntax (`a + b`, `-x`,
60+
`a ++ b`, etc.). Regular function calls never broadcast, so
61+
`f((1, 2, 3))` passes the whole tuple to `f` and does not call `f`
62+
once per element.
63+
64+
Mixed-element tuples or length mismatches error rather than silently
65+
producing wrong results:
66+
67+
```ndc
68+
(1, 2, 3) + (4, 5) // ERROR: no overload matches
69+
(1, "a") + (2, "b") // ERROR: no overload accepts both pairs
70+
```
71+

ndc_analyser/src/analyser.rs

Lines changed: 106 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ use itertools::{Itertools, izip};
66
use ndc_core::{StaticType, TypeSignature};
77
use ndc_lexer::Span;
88
use ndc_parser::{
9-
Binding, Expression, ExpressionLocation, ForBody, ForIteration, FunctionParameter, Lvalue,
10-
NodeId,
9+
Binding, Candidate, Expression, ExpressionLocation, ForBody, ForIteration, FunctionParameter,
10+
Lvalue, NodeId,
1111
};
1212

1313
/// Side table holding semantic information keyed by AST node identity.
@@ -130,7 +130,7 @@ impl Analyser {
130130
return Ok(StaticType::Any);
131131
};
132132

133-
*resolved = Binding::Resolved(binding);
133+
*resolved = Binding::Resolved(Candidate::scalar(binding));
134134

135135
Ok(self.scope_tree.get_type(binding).clone())
136136
}
@@ -202,12 +202,17 @@ impl Analyser {
202202
let right_type = self.analyse_or_any(r_value);
203203
let arg_types = vec![left_type, right_type];
204204

205-
*resolved_assign_operation = self
206-
.scope_tree
207-
.resolve_function_binding(&format!("{operation}="), &arg_types);
205+
// OpAssignment desugars to `x = x op y` where `op` is operator-form,
206+
// so vec dispatch must be available for both the in-place and the
207+
// fallback regular operator overload.
208+
*resolved_assign_operation = self.scope_tree.resolve_function_binding(
209+
&format!("{operation}="),
210+
&arg_types,
211+
true,
212+
);
208213
*resolved_operation = self
209214
.scope_tree
210-
.resolve_function_binding(operation, &arg_types);
215+
.resolve_function_binding(operation, &arg_types, true);
211216

212217
if let Binding::None = resolved_operation {
213218
self.emit(AnalysisError::function_not_found(
@@ -219,7 +224,7 @@ impl Analyser {
219224
let result_type = match resolved_operation {
220225
Binding::Resolved(res) => {
221226
if let StaticType::Function { return_type, .. } =
222-
self.scope_tree.get_type(*res)
227+
self.scope_tree.get_type(res.var)
223228
{
224229
Some(return_type.as_ref().clone())
225230
} else {
@@ -256,15 +261,15 @@ impl Analyser {
256261
..
257262
} = &value.expression
258263
{
259-
let container_type = self.scope_tree.get_type(*target).clone();
264+
let container_type = self.scope_tree.get_type(target.var).clone();
260265
if let Some(elem_type) = container_type.index_element_type() {
261266
let widened_elem = elem_type.lub(&result_type);
262267
if widened_elem != elem_type {
263268
let new_container =
264269
container_type.with_element_type(widened_elem);
265270
let _ = self
266271
.scope_tree
267-
.update_binding_type(*target, new_container);
272+
.update_binding_type(target.var, new_container);
268273
}
269274
}
270275
}
@@ -405,14 +410,19 @@ impl Analyser {
405410
Expression::Call {
406411
function,
407412
arguments,
413+
operator_form,
408414
} => {
409415
let mut type_sig = Vec::with_capacity(arguments.len());
410416
for a in arguments {
411417
type_sig.push(self.analyse_or_any(a));
412418
}
413419

414-
let callee_type =
415-
self.resolve_function_with_argument_types(function, &type_sig, *span);
420+
let callee_type = self.resolve_function_with_argument_types(
421+
function,
422+
&type_sig,
423+
*operator_form,
424+
*span,
425+
);
416426

417427
let StaticType::Function { return_type, .. } = callee_type else {
418428
if callee_type == StaticType::Any {
@@ -483,6 +493,7 @@ impl Analyser {
483493
&mut self,
484494
ident: &mut ExpressionLocation,
485495
argument_types: &[StaticType],
496+
operator_form: bool,
486497
span: Span,
487498
) -> StaticType {
488499
let ExpressionLocation {
@@ -497,7 +508,7 @@ impl Analyser {
497508

498509
let binding = self
499510
.scope_tree
500-
.resolve_function_binding(name, argument_types);
511+
.resolve_function_binding(name, argument_types, operator_form);
501512

502513
let out_type = match &binding {
503514
Binding::None => {
@@ -508,20 +519,35 @@ impl Analyser {
508519
));
509520
return StaticType::Any;
510521
}
511-
Binding::Resolved(res) => self.scope_tree.get_type(*res).clone(),
512-
513-
Binding::Dynamic(_) => {
514-
// Dispatch is decided at runtime, so we have no sound static bound
515-
// on the result. The runtime may pick a declared overload or fall
516-
// through to elementwise (vectorized) dispatch, which can produce
517-
// a value no declared overload returns — treating the LUB of
518-
// declared returns as the result type is unsound and led to issue
519-
// #139, where `let diff = a - b` over tuples was inferred as
520-
// `Number` and a follow-up `diff * diff` then matched the numeric
521-
// overload directly and bypassed dynamic dispatch entirely.
522+
Binding::Resolved(candidate) => {
523+
let return_type = candidate_return(&self.scope_tree, candidate, argument_types);
524+
let scalar_type = self.scope_tree.get_type(candidate.var).clone();
525+
// Preserve `parameters` from the underlying scalar so any
526+
// downstream consumer that inspects the function shape sees
527+
// the original arity; only the return type changes for vec.
528+
match scalar_type {
529+
StaticType::Function { parameters, .. } => StaticType::Function {
530+
parameters,
531+
return_type: Box::new(return_type),
532+
},
533+
_ => StaticType::Any,
534+
}
535+
}
536+
Binding::Dynamic(candidates) => {
537+
// Runtime decides which candidate fires. LUB across every
538+
// candidate's inferred return type — scalars contribute their
539+
// declared return, vecs contribute `Tuple<elem_return; max_len>`.
540+
// `LUB(Tuple<...>, scalar) = Any` in the type lattice, so the
541+
// mixed case naturally falls to Any; pure-scalar Dynamic
542+
// recovers the precision PR #140 had to pessimise.
543+
let return_type = candidates
544+
.iter()
545+
.map(|c| candidate_return(&self.scope_tree, c, argument_types))
546+
.reduce(|a, b| a.lub(&b))
547+
.unwrap_or(StaticType::Any);
522548
StaticType::Function {
523549
parameters: None,
524-
return_type: Box::new(StaticType::Any),
550+
return_type: Box::new(return_type),
525551
}
526552
}
527553
};
@@ -530,7 +556,52 @@ impl Analyser {
530556

531557
out_type
532558
}
559+
}
560+
561+
/// Returns the type a single candidate would produce for this call.
562+
///
563+
/// Examples (assume `+(Int, Int) -> Int` is the underlying scalar):
564+
/// - scalar candidate, call args `[Int, Int]` → `Int`
565+
/// - vec candidate, call args `[Tuple<Int, Int>, Tuple<Int, Int>]` →
566+
/// `Tuple<Int, Int>` (two element calls, each returns `Int`)
567+
/// - vec candidate, call args `[Tuple<Int, Int>, Int]` →
568+
/// `Tuple<Int, Int>` (the scalar `Int` broadcasts)
569+
/// - vec candidate, call args `[Any, Any]` → `Any` (no tuple length
570+
/// visible at compile time, so we can't say how long the result is)
571+
fn candidate_return(
572+
scope_tree: &ScopeTree,
573+
candidate: &Candidate,
574+
argument_types: &[StaticType],
575+
) -> StaticType {
576+
let StaticType::Function { return_type, .. } = scope_tree.get_type(candidate.var) else {
577+
return StaticType::Any;
578+
};
579+
let scalar_return = return_type.as_ref().clone();
580+
if !candidate.vectorized {
581+
return scalar_return;
582+
}
583+
match static_vec_axis_len(argument_types) {
584+
Some(len) => StaticType::Tuple(vec![scalar_return; len]),
585+
None => StaticType::Any,
586+
}
587+
}
533588

589+
/// Length of the first tuple-shaped argument, which is how many element
590+
/// calls a vec dispatch will make.
591+
///
592+
/// Examples:
593+
/// - `[Tuple<Int, Int>, Int]` → `Some(2)`
594+
/// - `[Int, Tuple<Int, Int, Int>]` → `Some(3)`
595+
/// - `[Int, Int]` → `None` (vec doesn't apply)
596+
/// - `[Any, Any]` → `None` (we can't tell at compile time)
597+
fn static_vec_axis_len(sig: &[StaticType]) -> Option<usize> {
598+
sig.iter().find_map(|t| match t {
599+
StaticType::Tuple(elems) if !elems.is_empty() => Some(elems.len()),
600+
_ => None,
601+
})
602+
}
603+
604+
impl Analyser {
534605
fn resolve_for_iterations(
535606
&mut self,
536607
iterations: &mut [ForIteration],
@@ -660,8 +731,16 @@ impl Analyser {
660731
let get_args = [type_of_index_target.clone(), index_type.clone()];
661732
let set_args = [type_of_index_target.clone(), index_type, StaticType::Any];
662733

663-
*resolved_get = Some(self.scope_tree.resolve_function_binding("[]", &get_args));
664-
*resolved_set = Some(self.scope_tree.resolve_function_binding("[]=", &set_args));
734+
// Index syntax is not operator-form for vec purposes — there is no
735+
// natural element-wise broadcast story for `(list_a, list_b)[i]`.
736+
*resolved_get = Some(
737+
self.scope_tree
738+
.resolve_function_binding("[]", &get_args, false),
739+
);
740+
*resolved_set = Some(
741+
self.scope_tree
742+
.resolve_function_binding("[]=", &set_args, false),
743+
);
665744

666745
if let Some(t) = type_of_index_target.index_element_type() {
667746
Ok(t)

0 commit comments

Comments
 (0)