diff --git a/src/ast/compiler/type_checker.rs b/src/ast/compiler/type_checker.rs index 0f31f10..8c6183e 100644 --- a/src/ast/compiler/type_checker.rs +++ b/src/ast/compiler/type_checker.rs @@ -243,6 +243,15 @@ impl TypeChecker { drop(subst); self.unify(*a, *b, diag); } + // Unify element-wise so that overload resolution can propagate TypeVar + // constraints: e.g. `(+ Float TypeVar(1))` matched against `(Float, Float)` + // triggers `unify(Float, TypeVar(1))` → `subst[1] = Float`. + (StaticType::Tuple(a), StaticType::Tuple(b)) => { + drop(subst); + for (ta, tb) in a.into_iter().zip(b.into_iter()) { + self.unify(ta, tb, diag); + } + } // Any and Error are already handled by is_assignable_from — silently succeed (StaticType::Any, _) | (_, StaticType::Any) => {} (StaticType::Error, _) | (_, StaticType::Error) => {} @@ -257,6 +266,71 @@ impl TypeChecker { matches!(&node.kind, NodeKind::Identifier { symbol, .. } if symbol.name.as_ref() == name) } + /// After a `FunctionOverloads` call resolves successfully, unify the matched + /// overload's concrete parameter types with the actual argument types. + /// + /// This propagates TypeVar constraints through overloaded calls — for example, + /// `(+ Float TypeVar(1))` matched against `(Float, Float) → Float` binds + /// `TypeVar(1) = Float` so that nested closures can resolve series element types. + /// + /// The "concrete anchor" guard ensures we only unify when at least one argument + /// is a known concrete type. Without it, `(+ TypeVar TypeVar)` would + /// spuriously pick the first overload (e.g. Int) and bind both TypeVars to Int. + fn unify_matched_overload( + &self, + callee_ty: &StaticType, + args_ty: &StaticType, + diag: &mut Diagnostics, + ) { + let StaticType::FunctionOverloads(sigs) = callee_ty else { return }; + // Require both a concrete anchor (to pin the overload choice) and at + // least one TypeVar (something to actually bind). Pure concrete calls + // like `(- DateTime DateTime)` have nothing to unify and would + // incorrectly trigger errors from coercion-only compatible types. + if !Self::has_concrete_component(args_ty) || !Self::has_typevar_component(args_ty) { + return; + } + // Only unify when EXACTLY ONE non-variadic overload matches. + // If multiple overloads match (e.g. `(* TypeVar Int)` matches both + // `(Int,Int)→Int` and `(Float,Float)→Float`), the choice is ambiguous + // and binding the TypeVar to the first match would be incorrect. + let is_variadic = |sig: &Signature| matches!(sig.params, StaticType::Any); + let mut unique_match: Option<&Signature> = None; + for sig in sigs { + if !is_variadic(sig) && sig.params.is_assignable_from(args_ty) { + if unique_match.is_some() { + return; // Ambiguous — more than one specific overload matches + } + unique_match = Some(sig); + } + } + if let Some(sig) = unique_match { + self.unify(sig.params.clone(), args_ty.clone(), diag); + } + } + + /// Returns true if `ty` (or any element of a Tuple) is a concrete type — + /// i.e. not `Any`, `TypeVar`, or `Error`. + fn has_concrete_component(ty: &StaticType) -> bool { + match ty { + StaticType::Tuple(elems) => elems.iter().any(Self::is_concrete), + other => Self::is_concrete(other), + } + } + + /// Returns true if `ty` (or any element of a Tuple) contains a TypeVar. + fn has_typevar_component(ty: &StaticType) -> bool { + match ty { + StaticType::TypeVar(_) => true, + StaticType::Tuple(elems) => elems.iter().any(|e| matches!(e, StaticType::TypeVar(_))), + _ => false, + } + } + + fn is_concrete(ty: &StaticType) -> bool { + !matches!(ty, StaticType::Any | StaticType::TypeVar(_) | StaticType::Error) + } + /// Converts a resolved series element type to the schema `Value` passed to the /// `series` runtime: a type keyword (`:float`, `:int`, …) for scalar types, or a /// schema record (`{:price :float …}`) for record types. @@ -313,41 +387,30 @@ impl TypeChecker { // Call: detect `(series n)` and inject the resolved schema arg NodeKind::Call { callee, args } => { let callee_fin = Rc::new(Self::finalize_node((*callee).clone(), subst)); - if Self::is_identifier_named("series", &callee_fin) { - if let StaticType::Series(inner) = node_ty { - if let NodeKind::Tuple { elements } = &args.kind { - if elements.len() == 1 { - if let Some(schema_val) = - Self::series_element_to_schema_value(inner) - { - let orig_arg = Rc::new(Self::finalize_node( - (*elements[0]).clone(), - subst, - )); - let schema_ty = schema_val.static_type(); - let schema_node = Rc::new(Node { - kind: NodeKind::Constant(schema_val), - ty: schema_ty, - identity: identity.clone(), - comments: Rc::from([]), - }); - let new_elems = vec![orig_arg, schema_node]; - let elem_types: Vec = - new_elems.iter().map(|e| e.ty.clone()).collect(); - let new_args = Node { - kind: NodeKind::Tuple { elements: new_elems }, - ty: StaticType::Tuple(elem_types), - identity: args.identity.clone(), - comments: args.comments.clone(), - }; - return NodeKind::Call { - callee: callee_fin, - args: Rc::new(new_args), - }; - } - } - } - } + if Self::is_identifier_named("series", &callee_fin) + && let StaticType::Series(inner) = node_ty + && let NodeKind::Tuple { elements } = &args.kind + && elements.len() == 1 + && let Some(schema_val) = Self::series_element_to_schema_value(inner) + { + let orig_arg = Rc::new(Self::finalize_node((*elements[0]).clone(), subst)); + let schema_ty = schema_val.static_type(); + let schema_node = Rc::new(Node { + kind: NodeKind::Constant(schema_val), + ty: schema_ty, + identity: identity.clone(), + comments: Rc::from([]), + }); + let new_elems = vec![orig_arg, schema_node]; + let elem_types: Vec = + new_elems.iter().map(|e| e.ty.clone()).collect(); + let new_args = Node { + kind: NodeKind::Tuple { elements: new_elems }, + ty: StaticType::Tuple(elem_types), + identity: args.identity.clone(), + comments: args.comments.clone(), + }; + return NodeKind::Call { callee: callee_fin, args: Rc::new(new_args) }; } let args_fin = Rc::new(Self::finalize_node((*args).clone(), subst)); NodeKind::Call { callee: callee_fin, args: args_fin } @@ -701,6 +764,7 @@ impl TypeChecker { ) -> TypedNode { match specialized_ty { StaticType::Any + | StaticType::TypeVar(_) // TypeVar may resolve to any destructurable type | StaticType::Tuple(_) | StaticType::Vector(_, _) | StaticType::Matrix(_, _) @@ -1000,9 +1064,17 @@ impl TypeChecker { let mut lambda_ctx = TypeContext::new(64, upvalue_types, ctx.root_types, Some(ctx)); + // Generate a fresh TypeVar per positional parameter so that HM + // constraint propagation works across nested closures. + // `check_lambda_with_hints` (used at call sites) overrides these with + // concrete types; this path only fires for lambdas typed as values + // (e.g. returned from another lambda, stored in a def). + let param_hint_ty = StaticType::Tuple( + (0..positional_count.unwrap_or(0)).map(|_| self.fresh_var()).collect(), + ); let params_typed = self.check_params( params.as_ref(), - &StaticType::Any, + ¶m_hint_ty, &mut lambda_ctx, diag, ); @@ -1104,39 +1176,39 @@ impl TypeChecker { } }; + // HM: propagate TypeVar constraints through overloaded calls so that + // e.g. `(+ Float TypeVar(1))` resolves TypeVar(1) = Float. + self.unify_matched_overload(&callee_typed.ty, &args_typed.ty, diag); + // HM: (series n) returns Series(Any) — replace with a fresh TypeVar so // subsequent push calls can unify the element type. - if Self::is_identifier_named("series", &callee_typed) { - if let StaticType::Series(inner) = &ret_ty { - if **inner == StaticType::Any { - ret_ty = StaticType::Series(Box::new(self.fresh_var())); - } - } + if Self::is_identifier_named("series", &callee_typed) + && let StaticType::Series(inner) = &ret_ty + && **inner == StaticType::Any + { + ret_ty = StaticType::Series(Box::new(self.fresh_var())); } // HM: (push series val) — unify the series element TypeVar with the value // type and propagate the resolved type back to the binding in the context. - if Self::is_identifier_named("push", &callee_typed) { - if let NodeKind::Tuple { elements } = &args_typed.kind { - if elements.len() >= 2 { - let series_arg = &elements[0]; - let value_arg = &elements[1]; - if let StaticType::Series(inner) = &series_arg.ty { - let inner_ty = (**inner).clone(); - let val_ty = value_arg.ty.clone(); - self.unify(inner_ty, val_ty, diag); - if let NodeKind::Identifier { - binding: IdentifierBinding::Reference(addr), - .. - } = &series_arg.kind - { - let resolved = Self::apply_subst( - series_arg.ty.clone(), - &self.subst.borrow(), - ); - ctx.set_type(*addr, resolved); - } - } + if Self::is_identifier_named("push", &callee_typed) + && let NodeKind::Tuple { elements } = &args_typed.kind + && elements.len() >= 2 + { + let series_arg = &elements[0]; + let value_arg = &elements[1]; + if let StaticType::Series(inner) = &series_arg.ty { + let inner_ty = (**inner).clone(); + let val_ty = value_arg.ty.clone(); + self.unify(inner_ty, val_ty, diag); + if let NodeKind::Identifier { + binding: IdentifierBinding::Reference(addr), + .. + } = &series_arg.kind + { + let resolved = + Self::apply_subst(series_arg.ty.clone(), &self.subst.borrow()); + ctx.set_type(*addr, resolved); } } } diff --git a/src/ast/rtl/series/mod.rs b/src/ast/rtl/series/mod.rs index 54e3f74..0a38886 100644 --- a/src/ast/rtl/series/mod.rs +++ b/src/ast/rtl/series/mod.rs @@ -24,6 +24,14 @@ pub fn register(env: &Environment) { Purity::Impure, |args: &[Value]| { let lookback_limit = args[0].as_int().unwrap_or(0) as usize; + // The type checker injects the schema as a second argument via HM inference. + // If schema injection was not possible (e.g. the first push is inside a nested + // closure that the type checker could not reach), fall back to a ValueSeries + // that stores unboxed Values. The HM type checker still guarantees type safety + // at compile time; this path is only a runtime fallback for storage allocation. + if args.len() < 2 { + return Value::Series(Rc::new(ValueSeries::new(lookback_limit))); + } let arg = &args[1]; match arg { Value::Keyword(k) => { diff --git a/src/ast/types.rs b/src/ast/types.rs index 22f3d0e..4bc4969 100644 --- a/src/ast/types.rs +++ b/src/ast/types.rs @@ -578,6 +578,9 @@ impl StaticType { } } StaticType::Any => return Some(StaticType::Any), + // TypeVar is unresolved — field access returns Any for now. + // The type is concretized when the lambda is specialized. + StaticType::TypeVar(_) => return Some(StaticType::Any), _ => {} } None diff --git a/tests/optimizer.rs b/tests/optimizer.rs index 346e4d0..48b18ab 100644 --- a/tests/optimizer.rs +++ b/tests/optimizer.rs @@ -142,7 +142,7 @@ fn test_optimizer_inlining_slot_clash_repro() { (do (def outer (fn [length] (do - (def history (series 100 :float)) + (def history (series 100)) (fn [val] length) ) )) @@ -164,7 +164,7 @@ fn test_reproduce_inlining_slot_clash_crash() { (def MY_SMA (fn [length] (do - (def history (series 100 :float)) + (def history (series 100)) (fn [val] (do (push history val) diff --git a/tests/rtl_series.rs b/tests/rtl_series.rs index 09d36a7..241a12e 100644 --- a/tests/rtl_series.rs +++ b/tests/rtl_series.rs @@ -4,8 +4,8 @@ use myc::ast::types::Value; #[test] fn test_series_api_with_limit() { let env = Environment::new(); - // (series limit schema) - let source = "(series 100 {:price :float})"; + // (series limit) — schema is inferred from push calls + let source = "(series 100)"; let result = env.compile(source).into_result(); assert!(result.is_ok(), "Series creation with limit should work: {:?}", result.err()); }