Skip to content

Commit c10338c

Browse files
timfennisclaude
andcommitted
fix(vectorization): address PR review and tighten compile-time checks πŸ”
OpAssignment with vec dispatch β€’ Use candidate_return so `a += (3, 4)` on a `Tuple<Int, Int>` lvalue widens against the tuple return type instead of the underlying scalar's `Int`. Without this, annotated tuple lvalues errored and unannotated ones silently widened to Any. β€’ Vec op= now emits SET_VAR (not Pop): dispatch_vec_call allocates a fresh result tuple, so the lvalue must be re-stored. The inner Rcs inside that tuple are still the same ones the element calls mutated, so aliasing semantics match scalar op= for List/HashMap stdlib ops. New test 011_vector_op_assign_aliasing.ndc pins the behaviour. β€’ Only emit function_not_found when BOTH `op` and `op=` are Binding::None β€” previously a missing bare `-` would error even when `-=` handled the call (e.g. `Map -= Map` via difference_assign). Compile-time function_not_found for incompat call sites β€’ resolve_function_binding now filters the all_by_name fallback by parameter compatibility. Concrete-Function slots whose params don't accept the call's arg types get dropped; Any-typed and variadic slots stay (we can't tell statically). When the filtered list is empty, Binding::None is returned and the analyser emits function_not_found at compile time instead of letting the call fail at runtime with a less-specific error. Regression test bug0022_incompat_dynamic_misinferred.ndc covers the case where the old behaviour silently inferred a misleading concrete return type. Empty-map value-type inference β€’ `%{}` now infers `Map<Any, Any>` (was `Map<Any, ()>` which was too narrow for almost any operation). β€’ `%{:0}` now folds the default's type into value_type, so the map infers `Map<Any, Int>` instead of `Map<Any, ()>` β€” needed so that `map[k] += 1` on a defaulted map type-checks. Runtime / VM β€’ `Callable::Vec` carries axis_len so dispatch_vec_call doesn't re-walk the args to find it. β€’ dispatch_vec_call uses split_off instead of stack-slice + truncate. Tidy β€’ analyser.rs has a single `impl Analyser` block again; free helpers moved below. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent b56b7f7 commit c10338c

8 files changed

Lines changed: 176 additions & 79 deletions

File tree

β€Žndc_analyser/src/analyser.rsβ€Ž

Lines changed: 65 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -214,22 +214,26 @@ impl Analyser {
214214
.scope_tree
215215
.resolve_function_binding(operation, &arg_types, true);
216216

217-
if let Binding::None = resolved_operation {
217+
// Either operator can handle the call: `op=` modifies in
218+
// place, `op` falls back via `a = a op b`. Only error when
219+
// both are missing β€” e.g. `Map -= Map` is fine via `-=`
220+
// even though bare `-` has no overload for maps.
221+
if matches!(resolved_assign_operation, Binding::None)
222+
&& matches!(resolved_operation, Binding::None)
223+
{
218224
self.emit(AnalysisError::function_not_found(
219225
operation, &arg_types, *span,
220226
));
221227
}
222228

223-
// Determine the result type of the operation
229+
// Determine the result type of the operation. Routed through
230+
// candidate_return so that a Resolved vec candidate widens the
231+
// lvalue with `Tuple<elem_return; max_len>` instead of the
232+
// underlying scalar's return β€” otherwise `a += (3, 4)` on a
233+
// `Tuple<Int, Int>` lvalue would try to widen with `Int`.
224234
let result_type = match resolved_operation {
225235
Binding::Resolved(res) => {
226-
if let StaticType::Function { return_type, .. } =
227-
self.scope_tree.get_type(res.var)
228-
{
229-
Some(return_type.as_ref().clone())
230-
} else {
231-
None
232-
}
236+
Some(candidate_return(&self.scope_tree, res, &arg_types))
233237
}
234238
_ => None,
235239
};
@@ -460,12 +464,16 @@ impl Analyser {
460464
}
461465

462466
if let Some(default) = default {
463-
self.analyse_or_any(default);
467+
// The default value's type is what `map[missing]` returns,
468+
// so it contributes to the value type just like a regular
469+
// entry would.
470+
let default_type = self.analyse_or_any(default);
471+
Self::fold_lub(&mut value_type, default_type);
464472
}
465473

466474
Ok(StaticType::Map {
467475
key: Box::new(key_type.unwrap_or(StaticType::Any)),
468-
value: Box::new(value_type.unwrap_or_else(StaticType::unit)),
476+
value: Box::new(value_type.unwrap_or(StaticType::Any)),
469477
})
470478
}
471479
Expression::Return { value } => {
@@ -540,6 +548,9 @@ impl Analyser {
540548
// `LUB(Tuple<...>, scalar) = Any` in the type lattice, so the
541549
// mixed case naturally falls to Any; pure-scalar Dynamic
542550
// recovers the precision PR #140 had to pessimise.
551+
// `resolve_function_binding` filters the all_by_name fallback
552+
// by signature compatibility, so by the time we get here
553+
// every candidate could plausibly accept the call's args.
543554
let return_type = candidates
544555
.iter()
545556
.map(|c| candidate_return(&self.scope_tree, c, argument_types))
@@ -556,52 +567,7 @@ impl Analyser {
556567

557568
out_type
558569
}
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-
}
588-
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-
}
603570

604-
impl Analyser {
605571
fn resolve_for_iterations(
606572
&mut self,
607573
iterations: &mut [ForIteration],
@@ -925,6 +891,49 @@ impl Analyser {
925891
}
926892
}
927893

894+
/// Returns the type a single candidate would produce for this call.
895+
///
896+
/// Examples (assume `+(Int, Int) -> Int` is the underlying scalar):
897+
/// - scalar candidate, call args `[Int, Int]` β†’ `Int`
898+
/// - vec candidate, call args `[Tuple<Int, Int>, Tuple<Int, Int>]` β†’
899+
/// `Tuple<Int, Int>` (two element calls, each returns `Int`)
900+
/// - vec candidate, call args `[Tuple<Int, Int>, Int]` β†’
901+
/// `Tuple<Int, Int>` (the scalar `Int` broadcasts)
902+
/// - vec candidate, call args `[Any, Any]` β†’ `Any` (no tuple length
903+
/// visible at compile time, so we can't say how long the result is)
904+
fn candidate_return(
905+
scope_tree: &ScopeTree,
906+
candidate: &Candidate,
907+
argument_types: &[StaticType],
908+
) -> StaticType {
909+
let StaticType::Function { return_type, .. } = scope_tree.get_type(candidate.var) else {
910+
return StaticType::Any;
911+
};
912+
let scalar_return = return_type.as_ref().clone();
913+
if !candidate.vectorized {
914+
return scalar_return;
915+
}
916+
match static_vec_axis_len(argument_types) {
917+
Some(len) => StaticType::Tuple(vec![scalar_return; len]),
918+
None => StaticType::Any,
919+
}
920+
}
921+
922+
/// Length of the first tuple-shaped argument, which is how many element
923+
/// calls a vec dispatch will make.
924+
///
925+
/// Examples:
926+
/// - `[Tuple<Int, Int>, Int]` β†’ `Some(2)`
927+
/// - `[Int, Tuple<Int, Int, Int>]` β†’ `Some(3)`
928+
/// - `[Int, Int]` β†’ `None` (vec doesn't apply)
929+
/// - `[Any, Any]` β†’ `None` (we can't tell at compile time)
930+
fn static_vec_axis_len(sig: &[StaticType]) -> Option<usize> {
931+
sig.iter().find_map(|t| match t {
932+
StaticType::Tuple(elems) if !elems.is_empty() => Some(elems.len()),
933+
_ => None,
934+
})
935+
}
936+
928937
#[derive(thiserror::Error, Debug)]
929938
#[error("{text}")]
930939
pub struct AnalysisError {

β€Žndc_analyser/src/scope.rsβ€Ž

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -563,8 +563,36 @@ impl ScopeTree {
563563
return Binding::Dynamic(combined);
564564
}
565565

566-
if !all_by_name.is_empty() {
567-
return Binding::Dynamic(all_by_name.into_iter().map(Candidate::scalar).collect());
566+
// Filter the all_by_name fallback by signature compatibility:
567+
// a concrete `Function<...>` slot whose parameters are
568+
// incompatible with the call's args is guaranteed to fail
569+
// dispatch at runtime, so leaving it in would let the analyser
570+
// synthesise a misleading concrete return for a doomed call.
571+
// `Any`-typed and variadic (`parameters: None`) slots stay β€” we
572+
// can't statically tell what those would accept.
573+
let filtered: Vec<ResolvedVar> = all_by_name
574+
.into_iter()
575+
.filter(|var| match self.get_type(*var) {
576+
StaticType::Function {
577+
parameters: Some(params),
578+
..
579+
} => {
580+
params.len() == sig.len()
581+
&& params
582+
.iter()
583+
.zip(sig)
584+
.all(|(p, a)| !p.is_incompatible_with(a))
585+
}
586+
StaticType::Function {
587+
parameters: None, ..
588+
}
589+
| StaticType::Any => true,
590+
_ => false,
591+
})
592+
.collect();
593+
594+
if !filtered.is_empty() {
595+
return Binding::Dynamic(filtered.into_iter().map(Candidate::scalar).collect());
568596
}
569597
Binding::None
570598
}

β€Žndc_vm/src/compiler.rsβ€Ž

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,17 +203,33 @@ impl Compiler {
203203
..
204204
} => {
205205
let var = resolved.expect("lvalue must be resolved");
206-
if matches!(resolved_assign_operation, Binding::Resolved(_)) {
207-
// In-place operation (e.g. |=, &=) resolved exactly: modifies
208-
// the value's Rc in place via sync_map_mutations in the bridge,
209-
// so all aliases sharing the Rc see the change. We discard the
210-
// unit return value; the variable slot already holds the
206+
if matches!(resolved_assign_operation, Binding::Resolved(c) if !c.vectorized)
207+
{
208+
// Scalar in-place op= resolved exactly: modifies the value's
209+
// Rc in place via sync_map_mutations in the bridge, so all
210+
// aliases sharing the Rc see the change. We discard the
211+
// return value; the variable slot already holds the
211212
// (now-updated) shared reference.
212213
self.compile_binding(resolved_assign_operation, span)?;
213214
self.emit_get_var(var, lv_span);
214215
self.compile_expr(*r_value)?;
215216
self.chunk.write(OpCode::Call(2), span);
216217
self.chunk.write(OpCode::Pop, span);
218+
} else if matches!(resolved_assign_operation, Binding::Resolved(c) if c.vectorized)
219+
{
220+
// Vec-resolved op=: dispatch_vec_call allocates a fresh
221+
// result tuple whose elements are the per-element scalar
222+
// returns. Store it back so we don't silently lose the
223+
// update if a future scalar op= doesn't mutate through Rc.
224+
// For the current stdlib (List/String ++=, HashMap -=)
225+
// this is functionally equivalent to Pop because the
226+
// element calls already mutated their inputs through Rc;
227+
// the stored tuple just holds those same (now-mutated) Rcs.
228+
self.compile_binding(resolved_assign_operation, span)?;
229+
self.emit_get_var(var, lv_span);
230+
self.compile_expr(*r_value)?;
231+
self.chunk.write(OpCode::Call(2), span);
232+
self.emit_set_var(var, lv_span);
217233
} else if let Binding::Dynamic(assign_candidates) =
218234
resolved_assign_operation
219235
{

β€Žndc_vm/src/vm.rsβ€Ž

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -261,8 +261,8 @@ impl Vm {
261261
{
262262
let result = match callable {
263263
Callable::Scalar(func) => self.dispatch_call(func, args),
264-
Callable::Vec(scalar_fn) => {
265-
self.dispatch_vec_call(&scalar_fn, args, span)
264+
Callable::Vec(scalar_fn, axis_len) => {
265+
self.dispatch_vec_call(&scalar_fn, args, axis_len, span)
266266
}
267267
};
268268
if let Err(mut e) = result {
@@ -805,7 +805,8 @@ impl Vm {
805805
return None;
806806
};
807807
if candidate.vectorized {
808-
vec_candidate_applies(f, args).then(|| Callable::Vec(f.clone()))
808+
let axis_len = vec_axis_len(args)?;
809+
vec_candidate_applies(f, args, axis_len).then(|| Callable::Vec(f.clone(), axis_len))
809810
} else {
810811
f.matches_value_args(args)
811812
.then(|| Callable::Scalar(f.clone()))
@@ -822,19 +823,17 @@ impl Vm {
822823
&mut self,
823824
scalar_fn: &Function,
824825
args: usize,
826+
axis_len: usize,
825827
span: Span,
826828
) -> Result<(), VmError> {
827-
let callee_idx = self.stack.len() - args - 1;
828829
let arg_start = self.stack.len() - args;
829-
830-
let axis_len = vec_axis_len(&self.stack[arg_start..])
831-
.expect("dispatch_vec_call requires at least one non-empty tuple arg");
832830
let callee_name = self.callee_name(args);
833831

834832
// Materialise arg values up front so the stack borrow doesn't conflict
835833
// with call_callback's mutable access. Tuples are cheap to clone (Rc
836834
// for the values; the Vec is the only fresh allocation).
837-
let arg_values: Vec<Value> = self.stack[arg_start..].to_vec();
835+
let arg_values: Vec<Value> = self.stack.split_off(arg_start);
836+
self.stack.pop(); // clean up the callee
838837

839838
let mut results = Vec::with_capacity(axis_len);
840839
for i in 0..axis_len {
@@ -857,7 +856,6 @@ impl Vm {
857856
results.push(result);
858857
}
859858

860-
self.stack.truncate(callee_idx);
861859
self.stack
862860
.push(Value::Object(Rc::new(Object::Tuple(results))));
863861
Ok(())
@@ -969,19 +967,15 @@ impl Vm {
969967
/// should run once per element of the broadcast axis.
970968
pub(crate) enum Callable {
971969
Scalar(Function),
972-
Vec(Function),
970+
Vec(Function, usize),
973971
}
974972

975973
/// Determines whether a vec candidate over `scalar_fn` applies to the given
976974
/// runtime argument values. Returns `true` only when at least one arg is a
977975
/// non-empty `Object::Tuple`, all tuple-shaped args share a length, and the
978976
/// scalar's parameter types accept every per-position element pair (under
979977
/// broadcast of non-tuple args).
980-
fn vec_candidate_applies(scalar_fn: &Function, args: &[Value]) -> bool {
981-
let Some(axis_len) = vec_axis_len(args) else {
982-
return false;
983-
};
984-
978+
fn vec_candidate_applies(scalar_fn: &Function, args: &[Value], axis_len: usize) -> bool {
985979
let mut pair: Vec<Value> = Vec::with_capacity(args.len());
986980
for i in 0..axis_len {
987981
pair.clear();
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
// expect-error: no function called '+' found matches the arguments
1+
// expect-error: No function called '+' found that matches the arguments
22
(1,2) + (5,3,2)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Compound assignment must use the vec return type, not the underlying
2+
// scalar's return. `a += (3, 4)` on a `Tuple<Int, Int>` lvalue must
3+
// widen-check against `Tuple<Int, Int>`, not `Int`.
4+
let a: Tuple<Int, Int> = (1, 2);
5+
a += (3, 4);
6+
assert_eq(a, (4, 6));
7+
8+
let b: Tuple<Int, Int> = (10, 20);
9+
b -= (1, 2);
10+
assert_eq(b, (9, 18));
11+
12+
// Scalar broadcast on the right-hand side.
13+
let c: Tuple<Int, Int> = (3, 4);
14+
c *= 2;
15+
assert_eq(c, (6, 8));
16+
17+
// Without an annotation the inferred lvalue type must also widen
18+
// correctly. `d` starts as `Tuple<Int, Int>` and a follow-up read
19+
// must still see the precise tuple element types.
20+
let d = (1, 2);
21+
d += (10, 20);
22+
let e: Tuple<Int, Int> = d + (100, 200);
23+
assert_eq(e, (111, 222));
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Vec-resolved op= must preserve the in-place aliasing contract that
2+
// scalar op= guarantees: inner Rc mutations stay visible through every
3+
// alias of the originals, and the variable itself still reads as the
4+
// updated value.
5+
let l1 = [1, 2];
6+
let l2 = [3, 4];
7+
let pair = (l1, l2);
8+
let outer_alias = pair;
9+
pair ++= ([5], [6]);
10+
// Inner-list mutation visible through the original bindings.
11+
assert_eq(l1, [1, 2, 5]);
12+
assert_eq(l2, [3, 4, 6]);
13+
// Outer alias still sees the same inner lists, now mutated.
14+
assert_eq(outer_alias, ([1, 2, 5], [3, 4, 6]));
15+
// pair itself reads as the updated value.
16+
assert_eq(pair, ([1, 2, 5], [3, 4, 6]));
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// expect-error: No function called 'returns_int' found that matches the arguments 'Int'
2+
// `returns_int(42)` has no compatible overload β€” the only `returns_int`
3+
// declared takes a `String`. Before the fix, `resolve_function_binding`
4+
// fell back to all-by-name and emitted `Binding::Dynamic([returns_int])`
5+
// despite the type mismatch; the analyser then LUB'd its declared return
6+
// (`Int`) and reported "found Int but expected …" at downstream
7+
// assignments, masking the real bug. Now the fallback filters concrete-
8+
// Function slots whose params don't match the call, leaving the binding
9+
// empty so the call surfaces at compile time as "no function found".
10+
fn returns_int(s: String) -> Int { 1 };
11+
let x: String = returns_int(42);

0 commit comments

Comments
Β (0)