Constraint Generation [blog/lti-english/cg][edit]

Rather than treating type parameter inference as a search problem of “finding and verifying the optimal solution” among infinite possibilities,
we reframe it as a problem of “solving for unknown boundaries” similar to solving algebraic equations.
Instead of guessing what the type parameters $\overline{X}$ might be,
we derive the conditions $\overline{X}$ must satisfy by analyzing the subtyping relation itself.

Observing that our rules contain subtyping constraints, consider the general case $S \lt: T$,
which inherently implies constraints on its components (including unknown variables).
If $X$ is an unknown variable in $T$, the structure of $S$ necessarily restricts the possible forms of $X$.
Our algorithm transforms this implicit structural restriction into a set of explicit assertions about $X$’s bounds.

However, before systematically extracting these constraints, we must first address a preliminary yet critical challenge related to variable scoping.
If not handled properly, the constraints we generate may themselves be ill-formed.
This challenge motivates the algorithm’s first concrete step: variable elimination.

1. Variable Elimination [blog/lti-english/var_elim][edit]

Suppose we want to generate constraints for variable $X$ such that type $\forall Y. () \to (Y\to Y)$ becomes a subtype of $\forall Y. () \to X$. According to the contravariant/covariant rules for function subtyping, this requires $Y \to Y \lt: X$. However, we cannot directly generate the constraint $\{ Y \to Y \lt: X \lt: \top\}$ because the type variable $Y$ is free in this constraint, yet it should be bound by $\forall Y$, resulting in a scope error.

The correct approach is to find a supertype of $Y \to Y$ that contains no $Y$ and is as precise as possible, then use it to constrain $X$. In this case, that supertype is $\bot \to \top$. The variable elimination mechanism formally accomplishes this task of “finding the most precise bound”.

  1. Promotion, $S \Uparrow^V T$: $T$ is a minimal supertype of $S$ containing no free variables from set $V$.
  2. Demotion, $S \Downarrow^V T$: $T$ is a maximal subtype of $S$ containing no free variables from set $V$.

These two relations are defined by a set of recursive rules, ensuring that for any type $S$ and variable set $V$, the resulting $T$ is unique and always computable (i.e., they are total functions).
We suggest readers pause here to consider how to design recursive rules for these relations and implement them in code (hint: for promotion, if $X \in V$ then promote it to $\top$, otherwise leave it unchanged; other cases are straightforward).

1.1. Promotion and Demotion Rules [blog/lti-english/ve_rules][edit]

  • Promotion Rules ($S \Uparrow^V T$)

    • For $\top$ and $\bot$: $$ \top \Uparrow^V \top \quad (\text{VU-Top}) $$ $$ \bot \Uparrow^V \bot \quad (\text{VU-Bot}) $$
    • For type variable $X$:
      • If $X$ belongs to the set $V$ that needs to be eliminated, promote it to $\top$. $$ \frac{X \in V}{X \Uparrow^V \top} \quad (\text{VU-Var-1}) $$
      • If $X$ is not in $V$, it remains unchanged. $$ \frac{X \notin V}{X \Uparrow^V X} \quad (\text{VU-Var-2}) $$
    • For function types:
      • Recursively demote its parameter types (contravariant position) and promote its return type (covariant position). $$ \frac{\overline{S} \Downarrow^V \overline{U} \quad T \Uparrow^V R \quad \overline{X} \notin V}{\forall\overline{X}.\overline{S} \to T \Uparrow^V \forall\overline{X}.\overline{U} \to R} \quad (\text{VU-Fun}) $$
  • Demotion Rules ($S \Downarrow^V T$)

    • Handling of $\top$ and $\bot$ is consistent with promotion rules.
    • For type variable $X$:
      • If $X$ belongs to $V$, demote it to $\bot$. $$ \frac{X \in V}{X \Downarrow^V \bot} \quad (\text{VD-Var-1}) $$
      • If $X$ is not in $V$, it remains unchanged.
    • For function types:
      • Recursively promote its parameter types and demote its return type. $$ \frac{\overline{S} \Uparrow^V \overline{U} \quad T \Downarrow^V R \quad \overline{X} \notin V}{\forall\overline{X}.\overline{S} \to T \Downarrow^V \forall\overline{X}.\overline{U} \to R} \quad (\text{VD-Fun}) $$

This can be implemented very straightforwardly in MoonBit:

pub fn Type::promotion(self : Type, v : Set[Var]) -> Type {
  match self {
    TyFun(xs, ss, t) => {
      let vP = v.iter().filter(x => !xs.contains(x)) |> Set::from_iter
      let us = ss.map(s => s.demotion(vP))
      let r = t.promotion(vP)
      TyFun(xs, us, r)
    }
    TyVar(vr) if v.contains(vr) => TyTop
    _ => self
  }
}

pub fn Type::demotion(self : Type, v : Set[Var]) -> Type {
  match self {
    TyFun(xs, ss, t) => {
      let vP = v.iter().filter(x => !xs.contains(x)) |> Set::from_iter
      let us = ss.map(s => s.promotion(vP))
      let r = t.demotion(vP)
      TyFun(xs, us, r)
    }
    TyVar(vr) if v.contains(vr) => TyBot
    _ => self
  }
}

This carefully designed rule set guarantees the correctness and optimality of variable elimination, ensured by two key lemmas:

  • Soundness: If $S \Uparrow^V T$, then $S \lt: T$ holds and $T$ contains no free variables from $V$. Dually, if $S \Downarrow^V T$, then $T \lt: S$ holds and $T$ contains no free variables from $V$.
  • Completeness: The operation finds the “best” bound. For example, in promotion, if there exists another supertype $T'$ of $S$ containing no variables from $V$, then the $T$ computed by $S \Uparrow^V T$ must be a subtype of $T'$ (that is, $T \lt: T'$), proving $T$ is the smallest among all possible options.

Constraint generation is the core engine of the local type parameter synthesis algorithm.
After ensuring scoping safety through variable elimination,
this step’s mission is to transform a subtyping judgment—for example,
$S \lt: T$ where $S$ or $T$ contains undetermined type parameters $\overline{X}$—into a set of explicit constraints on these unknowns $\overline{X}$.
We now formally define the structure of constraints in code:

  • Constraint: In this system, each constraint takes the form $S_i \lt: X_i \lt: T_i$, specifying both a lower bound $S_i$ and an upper bound $T_i$ for a single unknown type variable $X_i$.

  • Constraint Set: A constraint set $C$ is a finite mapping (implementable as a Hash Map in code) from a group of unknown variables $\overline{X}$ to their corresponding constraints. A key invariant is that the bounds ($S_i$, $T_i$) in any constraint must not contain any pending unknown variables (i.e., variables in $\overline{X}$) or any local variables to be eliminated (i.e., variables in $V$). The empty constraint set ($\emptyset$) represents the least restrictive constraint, equivalent to $\bot \lt: X_i \lt: \top$ for each $X_i$.

  • Constraint set intersection ($\wedge$) is defined as the intersection of two constraint sets $C$ and $D$, obtained by merging constraints for the same variable. The new lower bound is the least upper bound (join, $\vee$) of the original lower bounds, while the new upper bound is the greatest lower bound (meet, $\wedge$) of the original upper bounds.

2. Constraint and Constraint Set Code [blog/lti-english/cg_def_code][edit]

struct Constraints(Map[Var, Subtype])

pub fn Constraints::empty() -> Constraints {
  Constraints(Map([]))
}

fn Constraints::items(self : Constraints) -> Map[Var, Subtype] {
  let Constraints(items) = self
  items
}

pub fn Constraints::meet(
  self : Constraints,
  other : Constraints,
) -> Constraints {
  Constraints(
    union_with(self.items(), other.items(), (l, r) => {
      let { l: l1, r: r1 } = l
      let { l: l2, r: r2 } = r
      { l: l1 & l2, r: r1 | r2 }
    }),
  )
}

pub fn meets(c : Array[Constraints]) -> Constraints {
  c.iter().fold(init=Constraints::empty(), Constraints::meet)
}

The constraint generation process is formalized as a derivation relation $V \vdash_{\overline{X}} S \lt: T \Rightarrow C$, meaning: given a set of variables $V$ to avoid, the (weakest) constraint set $C$ on unknown variables $\overline{X}$ required for $S \lt: T$ to hold, where $C$ is the output of this relation.

The algorithm is defined by recursive rules, with key rules as follows (note: we always assume $\overline{X} \cap V = \emptyset$):

  • Trivial Cases: When the upper bound of the subtyping relation is $\top$ or the lower bound is $\bot$, the relation holds unconditionally, thus generating an empty constraint set $\emptyset$.
  • Upper Bound Constraint: When judging $Y \lt: S$ (where $Y \in \overline{X}$ is an unknown variable and $S$ is a known type), the algorithm generates an upper bound constraint for $Y$.
    $$ \frac{Y \in \overline{X} \quad S \Downarrow^V T \quad \text{FV}(S) \cap \overline{X} = \emptyset}{V \vdash_{\overline{X}} Y \lt: S \Rightarrow \{ \bot \lt: Y \lt: T \}} \quad (\text{CG-Upper}) $$
    Note that the variable elimination operation ($S \Downarrow^V T$) ensures the upper bound $T$ is well-formed.
  • Lower Bound Constraint: Dually, when judging $S \lt: Y$, the algorithm generates a lower bound constraint for $Y$.
    $$ \frac{Y \in \overline{X} \quad S \Uparrow^V T \quad \text{FV}(S) \cap \overline{X} = \emptyset}{V \vdash_{\overline{X}} S \lt: Y \Rightarrow \{ T \lt: Y \lt: \top \}} \quad (\text{CG-Lower}) $$
  • Function Type: When comparing function types, the algorithm recursively processes parameter and return types, then merges the resulting sub-constraint sets.
    $$ \frac{V \cup \{\overline{Y}\} \vdash_{\overline{X}} \overline{T} \lt: \overline{R} \Rightarrow \overline{C} \quad V \cup \{\overline{Y}\} \vdash_{\overline{X}} S \lt: U \Rightarrow D}{V \vdash_{\overline{X}} \forall \overline{Y}.\overline{R} \to S \lt: \forall \overline{Y}.\overline{T} \to U \Rightarrow (\bigwedge \overline{C}) \wedge D} \quad (\text{CG-Fun}) $$
    Here, constraints are accumulated through intersection ($\wedge$) of sub-constraint sets.

3. Constraint Generation Code [blog/lti-english/cg_code][edit]

/// Pre-condition: xs and v are disjoint sets of variables.
pub fn Subtype::generate(
  self : Subtype,
  xs : Array[Var],
  v : Set[Var],
) -> Constraints raise {
  match (self.l, self.r) {
    (_, TyTop) | (TyBot, _) => Constraints::empty()
    (TyVar(y1), TyVar(y2)) if y1 == y2 && !xs.contains(y1) =>
      Constraints::empty()
    (TyVar(y), s) if xs.contains(y) => {
      let t = s.demotion(v)
      Constraints(Map::from_array([(y, { l: TyBot, r: t })]))
    }
    (s, TyVar(y)) if xs.contains(y) => {
      let t = s.promotion(v)
      Constraints(Map::from_array([(y, { l: t, r: TyTop })]))
    }
    (TyFun(ys1, r, s), TyFun(ys2, t, u)) if ys1 == ys2 => {
      let vy = v.union(Set::from_array(ys1))
      let cs = t.zip(r).map(tr => { l: tr.0, r: tr.1 }.generate(xs, vy))
        |> meets()
      cs.meet({ l: s, r: u }.generate(xs, vy))
    }
    _ => raise ConstraintGenerationError
  }
}

A crucial observation is that in any invocation $V \vdash_{\overline{X}} S \lt: T \Rightarrow C$, the unknown variables $\overline{X}$ appear only on one side of $S$ or $T$. This makes the entire process a matching-modulo-subtyping problem rather than a full unification problem, ensuring the algorithm’s simplicity and determinism.