Skip to content

Commit d00dee5

Browse files
timfennisclaude
andcommitted
refactor(analyser): widen Binding::Dynamic result to Any
LUB-of-declared-returns assumed one of the candidates would fire at runtime; that's unsound for runtime-dispatched calls (the value-level dispatcher can fall through to elementwise / vectorized dispatch and produce a value no overload declares). Treat Binding::Dynamic results as Any so downstream callers stay on dynamic dispatch instead of exact-matching a numeric overload that the value doesn't fit. Drops the special-cased vectorization detection — the broader rule covers every cascade depth of the issue #139 repro and any other runtime-dispatch surprise without coupling the analyser to a specific VM fallback path. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 812b14f commit d00dee5

1 file changed

Lines changed: 10 additions & 63 deletions

File tree

ndc_analyser/src/analyser.rs

Lines changed: 10 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -479,42 +479,6 @@ impl Analyser {
479479
}
480480
}
481481

482-
/// Returns the tuple length a vectorized call would produce, if `left` and
483-
/// `right` together have a shape the VM's vectorized fallback could handle:
484-
/// two equal-length tuples, or one tuple and one scalar. `Any` is treated
485-
/// as a potentially-numeric element / scalar — at static-analysis time we
486-
/// don't know what it holds, so we stay permissive and let the dynamic
487-
/// dispatch decide at runtime.
488-
fn maybe_vectorize_len(left: &StaticType, right: &StaticType) -> Option<usize> {
489-
fn could_be_number(t: &StaticType) -> bool {
490-
t.is_number() || matches!(t, StaticType::Any)
491-
}
492-
fn tuple_of_potential_numbers(t: &StaticType) -> Option<usize> {
493-
match t {
494-
StaticType::Tuple(elems)
495-
if !elems.is_empty() && elems.iter().all(could_be_number) =>
496-
{
497-
Some(elems.len())
498-
}
499-
_ => None,
500-
}
501-
}
502-
match (left, right) {
503-
(StaticType::Tuple(_), StaticType::Tuple(_)) => {
504-
let l = tuple_of_potential_numbers(left)?;
505-
let r = tuple_of_potential_numbers(right)?;
506-
(l == r).then_some(l)
507-
}
508-
(StaticType::Tuple(_), other) => {
509-
tuple_of_potential_numbers(left).filter(|_| could_be_number(other))
510-
}
511-
(other, StaticType::Tuple(_)) => {
512-
tuple_of_potential_numbers(right).filter(|_| could_be_number(other))
513-
}
514-
_ => None,
515-
}
516-
}
517-
518482
fn resolve_function_with_argument_types(
519483
&mut self,
520484
ident: &mut ExpressionLocation,
@@ -546,35 +510,18 @@ impl Analyser {
546510
}
547511
Binding::Resolved(res) => self.scope_tree.get_type(*res).clone(),
548512

549-
Binding::Dynamic(candidates) => {
550-
// Mirror the VM's vectorized fallback (see `Vm::try_vectorized_call`):
551-
// a binary call on a tuple-shaped argument may produce a tuple at
552-
// runtime, even though no declared overload returns one. Preserve
553-
// that shape so chained ops like `(a - b) * (a - b)` stay on the
554-
// dynamic-dispatch path instead of resolving to a numeric overload
555-
// that the value doesn't actually fit. `Any` is treated as a
556-
// potentially-numeric element so tuples whose elements come from
557-
// stdlib natives (which infer to `Any`) are also caught.
558-
let vectorized_return = (argument_types.len() == 2)
559-
.then(|| Self::maybe_vectorize_len(&argument_types[0], &argument_types[1]))
560-
.flatten()
561-
.map(|len| StaticType::Tuple(vec![StaticType::Number; len]));
562-
563-
let return_type = vectorized_return.unwrap_or_else(|| {
564-
candidates
565-
.iter()
566-
.map(|c| self.scope_tree.get_type(*c).clone())
567-
.filter_map(|t| match t {
568-
StaticType::Function { return_type, .. } => Some(*return_type),
569-
_ => None,
570-
})
571-
.reduce(|a, b| a.lub(&b))
572-
.unwrap_or(StaticType::Any)
573-
});
574-
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.
575522
StaticType::Function {
576523
parameters: None,
577-
return_type: Box::new(return_type),
524+
return_type: Box::new(StaticType::Any),
578525
}
579526
}
580527
};

0 commit comments

Comments
 (0)