Add Forall type for polymorphism

Introduce `StaticType::Forall` to represent universally quantified
types. This enables Hindley-Milner's let-polymorphism, allowing
functions to be generalized and reused with different concrete types.

- **Generalization at `def`:** Function values are now generalized using
  `self.generalize` when they are defined, wrapping their type in
  `Forall`. This occurs at `def` boundaries.
- **Instantiation at use:** Function types are instantiated with fresh
  type variables at each call site using `self.instantiate`. This
  ensures that different uses of the same polymorphic function do not
  interfere with each other's type inference.
- **Value restriction:** Only function-typed definitions are
  generalized. Mutable state like series and scalars remain monomorphic
  to prevent unexpected behavior.
- **Type context awareness:** The `generalize` function considers the
  current type context to avoid quantifying type variables that are
  still in use elsewhere.
  Feat: Add Forall type for polymorphism

Introduces the `Forall` type to support polymorphic functions and enable
let-polymorphism.

- `Forall(vars, body)`: Represents a universally quantified type where
  `vars` are the type variables bound by this quantification, and `body`
  is the type itself.
- `TypeChecker::generalize`: Wraps a type in `Forall` when a `def`
  boundary is encountered for function-typed values. This ensures that
  each use site of a polymorphic function gets its own instantiation.
- `TypeChecker::instantiate`: Replaces the type variables within a
  `Forall` type with fresh ones. This is performed at each call site of
  a polymorphic function.
- Updates `TypeChecker::unify` and `TypeChecker::apply_subst` to
  correctly handle `Forall` types, ensuring that bound variables within
  a `Forall` are not affected by global substitutions and that `Forall`
  types are instantiated before unification.
- Adds a new example script `examples/Propa.myc` to demonstrate basic
  usage.
- Includes a regression test
  `test_let_poly_non_series_callable_no_false_positive` to ensure that
  passing non-series callables to generic functions does not lead to
  type errors.
- Introduces `TypeChecker::bind_var` to handle lazy propagation of
  index-call constraints, crucial for correctly typing series lookups
  like `(s 0)`.
This commit is contained in:
2026-03-28 22:49:44 +01:00
parent bf12755905
commit 84abbd9721
4 changed files with 295 additions and 7 deletions
+11
View File
@@ -0,0 +1,11 @@
(do
(def last (fn [s] (s 0)))
(def f (fn [n] n))
(def r (series 5))
(push r 4)
[(last f) (last r)]
)
+239 -7
View File
@@ -95,7 +95,7 @@ impl TypeInferenceAccess for TypeChecker {
}
fn bind_typevar(&self, id: u32, ty: StaticType) {
self.subst.borrow_mut().insert(id, ty);
self.bind_var(id, ty);
}
fn apply_subst_ty(&self, ty: StaticType) -> StaticType {
@@ -174,6 +174,17 @@ pub struct TypeChecker {
/// Global substitution map: TypeVar ID → resolved StaticType.
/// Shared across all scopes within a single type-checking pass.
subst: std::cell::RefCell<HashMap<u32, StaticType>>,
/// Index-call constraints: callee_var → result_var.
///
/// When a `TypeVar` is used as a callable with a single `Int` argument —
/// the series lookback pattern `(s 0)` — we record the pairing here instead
/// of eagerly unifying `TypeVar = Series(elem)`. The constraint is evaluated
/// lazily in `bind_var`: only when the callee TypeVar is bound to `Series(inner)`
/// is the result TypeVar also bound to `inner`.
///
/// **Extension point**: other indexable types (e.g. `Stream`, future `Map`)
/// can participate by being matched in `bind_var`'s propagation arm.
index_constraints: std::cell::RefCell<HashMap<u32, u32>>,
/// Type-inference and finalization hooks keyed by global slot index.
/// Populated from the frozen RTL snapshot; empty for non-RTL call sites.
call_hooks: Rc<HashMap<u32, CallHooks>>,
@@ -188,6 +199,7 @@ impl TypeChecker {
root_types,
var_counter: Cell::new(0),
subst: std::cell::RefCell::new(HashMap::new()),
index_constraints: std::cell::RefCell::new(HashMap::new()),
call_hooks,
}
}
@@ -199,6 +211,28 @@ impl TypeChecker {
StaticType::TypeVar(id)
}
/// Binds a TypeVar to a concrete type in the substitution, then propagates
/// any pending index-call constraints registered by Step 9.
///
/// When `ty` is `Series(inner)` and `index_constraints[id]` is set, the
/// result TypeVar is also bound to `inner` — connecting the callee TypeVar
/// to its element type without the eager over-constraint of unification.
///
/// To add support for another indexable type (e.g. `Stream`), extend the
/// `match &ty` arm in the propagation block below.
fn bind_var(&self, id: u32, ty: StaticType) {
self.subst.borrow_mut().insert(id, ty.clone());
let ret_id = self.index_constraints.borrow().get(&id).copied();
if let Some(ret_id) = ret_id {
match &ty {
StaticType::Series(inner) => {
self.subst.borrow_mut().insert(ret_id, *inner.clone());
}
_ => {}
}
}
}
/// Recursively applies the substitution map to a type, replacing all
/// resolved `TypeVar`s with their concrete types.
fn apply_subst(ty: StaticType, subst: &HashMap<u32, StaticType>) -> StaticType {
@@ -227,6 +261,13 @@ impl TypeChecker {
StaticType::Tuple(elems) => {
StaticType::Tuple(elems.into_iter().map(|t| Self::apply_subst(t, subst)).collect())
}
// Substitute into the body but skip over the bound vars: they are local to
// this schema and must not be replaced by the global substitution.
StaticType::Forall(vars, body) => {
let filtered: HashMap<u32, StaticType> =
subst.iter().filter(|(k, _)| !vars.contains(k)).map(|(k, v)| (*k, v.clone())).collect();
StaticType::Forall(vars, Box::new(Self::apply_subst(*body, &filtered)))
}
other => other,
}
}
@@ -254,6 +295,10 @@ impl TypeChecker {
|| Self::occurs(var_id, &sig.ret, subst)
}
StaticType::Tuple(elems) => elems.iter().any(|t| Self::occurs(var_id, t, subst)),
// A bound var inside Forall does not count as a free occurrence.
StaticType::Forall(vars, body) => {
!vars.contains(&var_id) && Self::occurs(var_id, body, subst)
}
_ => false,
}
}
@@ -262,7 +307,7 @@ impl TypeChecker {
/// On success, the substitution is extended so that `ty1` and `ty2` become equal.
/// On failure (type mismatch or occurs check), a diagnostic error is emitted.
fn unify(&self, ty1: StaticType, ty2: StaticType, diag: &mut Diagnostics) {
let mut subst = self.subst.borrow_mut();
let subst = self.subst.borrow_mut();
let ty1 = Self::apply_subst(ty1, &subst);
let ty2 = Self::apply_subst(ty2, &subst);
@@ -273,7 +318,10 @@ impl TypeChecker {
diag.push_error(format!("Infinite type: ?{} = {}", n, ty), None);
return;
}
subst.insert(n, ty);
// Release the borrow before routing through bind_var so that
// constraint propagation can re-borrow subst without a panic.
drop(subst);
self.bind_var(n, ty);
}
(StaticType::Series(a), StaticType::Series(b)) => {
drop(subst);
@@ -296,9 +344,30 @@ impl TypeChecker {
self.unify(ta, tb, diag);
}
}
// A homogeneous Vector is assignable from a Tuple — unify each element
// with the vector's inner type. Mirrors the `is_assignable_from` coercion.
(StaticType::Tuple(elems), StaticType::Vector(inner, len))
| (StaticType::Vector(inner, len), StaticType::Tuple(elems)) => {
if elems.len() != len {
diag.push_error(
format!("Type mismatch: expected tuple of length {}, got {}", len, elems.len()),
None,
);
return;
}
drop(subst);
for e in elems {
self.unify(e, (*inner).clone(), diag);
}
}
// Any and Error are already handled by is_assignable_from — silently succeed
(StaticType::Any, _) | (_, StaticType::Any) => {}
(StaticType::Error, _) | (_, StaticType::Error) => {}
// Forall should be instantiated before unification; delegate to the body.
(StaticType::Forall(_, body), other) | (other, StaticType::Forall(_, body)) => {
drop(subst);
self.unify(*body, other, diag);
}
(a, b) => {
diag.push_error(format!("Type mismatch: expected {}, got {}", a, b), None);
}
@@ -357,11 +426,17 @@ impl TypeChecker {
}
}
/// Returns true if `ty` (or any element of a Tuple) contains a TypeVar.
/// Returns true if `ty` contains a TypeVar anywhere in its structure.
fn has_typevar_component(ty: &StaticType) -> bool {
match ty {
StaticType::TypeVar(_) => true,
StaticType::Tuple(elems) => elems.iter().any(|e| matches!(e, StaticType::TypeVar(_))),
StaticType::Tuple(elems) => elems.iter().any(Self::has_typevar_component),
StaticType::Series(inner) | StaticType::Stream(inner) | StaticType::Optional(inner) => {
Self::has_typevar_component(inner)
}
StaticType::Function(sig) => {
Self::has_typevar_component(&sig.params) || Self::has_typevar_component(&sig.ret)
}
_ => false,
}
}
@@ -410,6 +485,117 @@ impl TypeChecker {
Some(StaticType::Record(RecordLayout::get_or_create(promoted_fields)))
}
/// Collects all free TypeVar IDs that appear in `ty`, following the substitution
/// chain and skipping variables bound by `Forall`. Deduplicates via `out`.
fn collect_free_tvars(ty: &StaticType, subst: &HashMap<u32, StaticType>, out: &mut Vec<u32>) {
match ty {
StaticType::TypeVar(n) => {
if let Some(resolved) = subst.get(n) {
Self::collect_free_tvars(resolved, subst, out);
} else if !out.contains(n) {
out.push(*n);
}
}
StaticType::Series(inner) | StaticType::Stream(inner) | StaticType::Optional(inner) => {
Self::collect_free_tvars(inner, subst, out);
}
StaticType::Function(sig) => {
Self::collect_free_tvars(&sig.params, subst, out);
Self::collect_free_tvars(&sig.ret, subst, out);
}
StaticType::Tuple(elems) => {
for e in elems {
Self::collect_free_tvars(e, subst, out);
}
}
// Recurse into the body but skip the locally bound vars.
StaticType::Forall(vars, body) => {
let mut body_free = Vec::new();
Self::collect_free_tvars(body, subst, &mut body_free);
for v in body_free {
if !vars.contains(&v) && !out.contains(&v) {
out.push(v);
}
}
}
_ => {}
}
}
/// Collects all free TypeVar IDs that are currently visible in `ctx`.
/// Used by `generalize` to avoid quantifying TypeVars that are still shared
/// with other live bindings (series, scalars, outer function params).
fn ctx_free_tvars(ctx: &TypeContext, subst: &HashMap<u32, StaticType>) -> Vec<u32> {
let mut out = Vec::new();
for ty in ctx.slots.values() {
Self::collect_free_tvars(ty, subst, &mut out);
}
for ty in &ctx.upvalue_types {
Self::collect_free_tvars(ty, subst, &mut out);
}
for ty in ctx.root_types.borrow().iter() {
Self::collect_free_tvars(ty, subst, &mut out);
}
out
}
/// Generalizes a type at a `def` boundary (Algorithm W `gen` step).
/// Wraps all TypeVars that are free in `ty` but NOT free in `ctx` into a
/// `Forall`. Value restriction: only call this for `Function`-typed values.
fn generalize(&self, ty: StaticType, ctx: &TypeContext) -> StaticType {
let subst = self.subst.borrow();
let resolved = Self::apply_subst(ty, &subst);
let mut tvars_in_ty = Vec::new();
Self::collect_free_tvars(&resolved, &subst, &mut tvars_in_ty);
let ctx_tvars = Self::ctx_free_tvars(ctx, &subst);
let quantified: Vec<u32> = tvars_in_ty.into_iter()
.filter(|v| !ctx_tvars.contains(v))
.collect();
if quantified.is_empty() {
resolved
} else {
StaticType::Forall(quantified, Box::new(resolved))
}
}
/// Instantiates a `Forall` type by replacing each bound TypeVar with a
/// fresh one (Algorithm W `inst` step). No-op for non-`Forall` types.
///
/// Also remaps any index-call constraints: if `index_constraints[old] = ret`
/// and both `old` and `ret` are bound vars, the fresh copies inherit the
/// same pairing so that `bind_var` propagation keeps working at each call site.
fn instantiate(&self, ty: StaticType) -> StaticType {
let StaticType::Forall(vars, body) = ty else { return ty };
let mut local_subst: HashMap<u32, StaticType> = HashMap::new();
let mut var_mapping: HashMap<u32, u32> = HashMap::new();
for v in &vars {
let fresh = self.fresh_var();
if let StaticType::TypeVar(fresh_id) = &fresh {
var_mapping.insert(*v, *fresh_id);
}
local_subst.insert(*v, fresh);
}
// Copy index-call constraints for the freshly created TypeVars.
// Both the callee-var and its result-var must be in vars for the
// mapping to apply; partial remaps are dropped (they can't occur in
// a well-formed Forall, but the guard is cheap insurance).
let new_constraints: Vec<(u32, u32)> = {
let constraints = self.index_constraints.borrow();
vars.iter()
.filter_map(|v| {
let new_v = *var_mapping.get(v)?;
let old_ret = *constraints.get(v)?;
let new_ret = *var_mapping.get(&old_ret)?;
Some((new_v, new_ret))
})
.collect()
};
for (new_v, new_ret) in new_constraints {
self.index_constraints.borrow_mut().insert(new_v, new_ret);
}
Self::apply_subst(*body, &local_subst)
}
/// Walks the typed AST, applies the HM substitution to every type annotation,
/// and dispatches finalization hooks (e.g. schema injection for `series`).
fn finalize_node(&self, node: TypedNode, subst: &HashMap<u32, StaticType>) -> TypedNode {
@@ -881,13 +1067,20 @@ impl TypeChecker {
let val_typed = self.check_node(value, ctx, diag);
let ty = val_typed.ty.clone();
// Extract addr from pattern to register the type
// Extract addr from pattern to register the type.
// Value restriction: only Function-typed values are generalized to Forall.
// Mutable state (Series, scalars) must remain monomorphic.
if let NodeKind::Identifier {
binding: IdentifierBinding::Declaration { addr, .. },
..
} = &pattern.kind
{
ctx.set_type(*addr, ty.clone());
let stored_ty = if matches!(ty, StaticType::Function(..)) {
self.generalize(ty.clone(), ctx)
} else {
ty.clone()
};
ctx.set_type(*addr, stored_ty);
}
// For destructuring defs, check params on the pattern
@@ -949,7 +1142,10 @@ impl TypeChecker {
// Apply the current HM substitution so that TypeVars resolved in
// nested scopes (e.g. inside a `while` body) are visible here even
// when ctx.set_type could not propagate back through an upvalue address.
// Instantiate Forall types: each use site gets fresh TypeVars so that
// calls with different argument types remain independent.
let ty = Self::apply_subst(ctx.get_type(*addr), &self.subst.borrow());
let ty = self.instantiate(ty);
(
NodeKind::Identifier {
symbol: symbol.clone(),
@@ -1225,6 +1421,42 @@ impl TypeChecker {
// e.g. `(+ Float TypeVar(1))` resolves TypeVar(1) = Float.
self.unify_matched_overload(&callee_typed.ty, &args_typed.ty, diag);
// HM step 9: when a TypeVar is called with a single Int argument
// (series lookback indexing pattern), record a lazy index-call constraint
// instead of eagerly unifying `TypeVar = Series(elem)`.
//
// The constraint pair (callee_var → result_var) is stored in
// `index_constraints`. When `bind_var` later resolves callee_var to
// `Series(inner)`, it automatically binds result_var to `inner`.
//
// This avoids over-constraining the function to Series-only: passing
// any other callable (e.g. a Function) simply leaves result_var
// unresolved and the call returns `Any` — no spurious type error.
if let StaticType::TypeVar(n) = &callee_typed.ty {
let is_index_call = matches!(&args_typed.ty,
StaticType::Tuple(elems) if elems.len() == 1
&& matches!(&elems[0], StaticType::Int)
);
if is_index_call && matches!(ret_ty, StaticType::Any) {
let elem_var = self.fresh_var();
let StaticType::TypeVar(elem_id) = &elem_var else { unreachable!() };
self.index_constraints.borrow_mut().insert(*n, *elem_id);
ret_ty = elem_var;
}
}
// HM step 10: unify Function parameter types with actual argument types,
// but only when the signature still contains TypeVars to resolve.
// Skip for fully concrete signatures (e.g. fn([any any])) to avoid
// false conflicts between Tuple and Vector representations.
if let StaticType::Function(sig) = &callee_typed.ty
&& Self::has_typevar_component(&sig.params)
{
let params = sig.params.clone();
self.unify(params, args_typed.ty.clone(), diag);
ret_ty = Self::apply_subst(ret_ty, &self.subst.borrow());
}
// Dispatch post-call hooks registered by the RTL (keyed by global slot index).
// Hooks handle type-inference extensions such as:
// - series: inject a fresh TypeVar for the element type
+19
View File
@@ -376,6 +376,12 @@ pub enum StaticType {
/// An unresolved type variable used during Hindley-Milner type inference.
/// The `u32` is a unique ID assigned by the type checker. Resolved via substitution.
TypeVar(u32),
/// A universally quantified type (let-polymorphism / HM generalization).
/// Created at `def` boundaries for function values; instantiated with fresh TypeVars
/// at each use site so calls with different types remain independent.
/// Value restriction: only `Function`-typed defs are generalized — mutable state
/// (series, scalars) must remain monomorphic.
Forall(Vec<u32>, Box<StaticType>),
/// A diagnostic poison type, allowing type-checking to continue after an error.
Error,
}
@@ -436,6 +442,14 @@ impl fmt::Display for StaticType {
StaticType::Object(name) => write!(f, "{}", name),
StaticType::PolymorphicFn { .. } => write!(f, "<polymorphic-fn>"),
StaticType::TypeVar(n) => write!(f, "?{}", n),
StaticType::Forall(vars, body) => {
write!(f, "")?;
for (i, v) in vars.iter().enumerate() {
if i > 0 { write!(f, ",")?; }
write!(f, "?{}", v)?;
}
write!(f, ". {}", body)
}
StaticType::Error => write!(f, "<error>"),
}
}
@@ -485,6 +499,8 @@ impl StaticType {
|| matches!(other, StaticType::Error)
|| matches!(self, StaticType::TypeVar(_))
|| matches!(other, StaticType::TypeVar(_))
|| matches!(self, StaticType::Forall(..))
|| matches!(other, StaticType::Forall(..))
{
return true;
}
@@ -599,6 +615,9 @@ impl StaticType {
.map(|sig| sig.ret.clone()),
StaticType::PolymorphicFn { resolve_return, .. } => resolve_return(args_ty),
StaticType::TypeVar(_) => Some(StaticType::Any),
// Forall should be instantiated before resolve_call is reached.
// This fallback delegates to the body as a safety net.
StaticType::Forall(_, body) => body.resolve_call(args_ty),
// Lookback indexing: (my-series 0) → element type
StaticType::Series(inner) => {
let is_int = matches!(
+26
View File
@@ -189,3 +189,29 @@ fn test_let_poly_generic_return_used_in_field_series() {
other => panic!("Expected Float(42.0), got {:?}", other),
}
}
/// Regression test: passing a non-series callable to a generic `last` function
/// must NOT produce a type error. Before the index-constraint fix, Step 9 would
/// eagerly unify `TypeVar = Series(elem)`, making `last` Series-only and causing
/// a spurious conflict when a Function-typed variable was passed.
///
/// After the fix, `last` is inferred as `∀α β. fn(α) → β` with a lazy constraint:
/// only when `α` resolves to `Series(inner)` does `β` also resolve to `inner`.
/// For any other callable, `β` stays unbound and the return type is `Any`.
#[test]
fn test_let_poly_non_series_callable_no_false_positive() {
let env = Environment::new();
let source = r#"
(do
(def f (fn [n] (n)))
(def last (fn [s] (s 0)))
(last f)
)
"#;
let result = env.compile(source);
assert!(
!result.diagnostics.has_errors(),
"Passing a non-series callable to a generic function must not produce a type error: {}",
result.diagnostics.format_errors()
);
}