Hindley-Milner type inference

Algorithm W infers a type for every expression without type annotations. The part that makes it a good showcase for Elpi is that it turns some inferred types into schemes, universally quantified over the type variables used only locally: let id = λx.x in (id one, id empty) has to accept id at two different types. What follows walks through the core of it. The HDR thesis, §2.7, works through the same example in much more detail, subsection by subsection: syntax, typing rules, generalization, a full execution trace, and bidirectional type inference.

../code/hindley-milner.elpi:

  1% Algorithm W, with let-generalization deferred through a constraint: a type
  2% scheme is only generalized once we know everything that was unified into
  3% it, which happens after type-checking the let-bound expression, not before.
  4% Adapted from tests/sources/w.elpi.
  5
  6% terms
  7data term.
  8symb app term -> term -> term.
  9symb lam (term -> term) -> term.
 10symb let term -> ty -> (term -> term) -> term.
 11
 12% monotypes: type constructors applied to arguments (#) and the arrow (===>)
 13% (===> and # are recognised as infix automatically, by token family)
 14data tye.
 15symb (===>) tye -> tye -> tye.
 16symb (#)    tye -> tye -> tye.
 17
 18% type schemes: mono wraps a monotype, all quantifies a type variable
 19data ty.
 20symb all  (tye -> ty) -> ty.
 21symb mono tye -> ty.
 22
 23% a handful of constants and their (possibly polymorphic) schemes
 24symb one, plus, size, empty, comma  term.
 25symb integer, list, pair            tye.
 26
 27% `w` stays relational: its last rule turns a type *scheme* into an instance
 28% by dispatching on whether the (output) type came back as `mono` or `all`,
 29% which the determinacy checker cannot follow through an output argument.
 30% In practice every top-level call has a single, principal type.
 31pred w term -> ty.
 32w one    (mono integer).
 33w plus   (mono (integer ===> integer ===> integer)).
 34w size   (all x\ mono (list # x ===> integer)).
 35w empty  (all x\ mono (list # x)).
 36w comma  (all x\ all y\ mono (x ===> y ===> (pair # x # y))).
 37
 38w (app F X) (mono R) :-
 39  w F (mono (A ===> R)),
 40  w X (mono A).
 41
 42w (lam F) (mono (A ===> R)) :-
 43  pi x\ w x (mono A) ==> w (F x) (mono R).
 44
 45w (let F FP B) (mono TC) :-
 46  w F (mono FT),
 47  % FP is generalized only once, by the CHR rule below, after this branch
 48  % of the search is done computing constraints on FT
 49  declare_constraint (overbar (mono FT) FP) [],
 50  pi x\ w x FP ==> w (B x) (mono TC).
 51
 52w X (mono T) :- w X (all Poly), specialize (all Poly) T.
 53
 54func specialize ty -> tye.
 55specialize (all F) T :- specialize (F FRESH_) T.
 56specialize (mono X) X.
 57
 58% overbar generalizes a monotype into a type scheme, once its context Gamma
 59% (the hypothetical `w` rules in scope) is fully known. `w ?-` is the context
 60% filter: it keeps those `w` rules in the context a suspended `overbar`
 61% constraint carries, so the rule below can see Gamma as `G`.
 62func overbar ty -> ty.
 63constraint w ?- overbar {
 64  rule \ (G ?- overbar T T1)
 65       | (generalize G T POLYT) <=> (T1 = POLYT).
 66}
 67
 68func generalize list (pred), ty -> ty.
 69generalize G (mono T) ALL :-
 70  free-ty (mono T) [] VT,
 71  free-gamma G [] VG,
 72  filter VT (x\ not (mem VG x)) Q,
 73  quantify Q T ALL.
 74
 75func free-ty ty, list any -> list any.
 76free-ty (mono X) L L1 :- free X L L1.
 77free-ty (all F) L L1 :- pi x\ free-ty (F x) L L1.
 78
 79func free-gamma list (pred), list any -> list any.
 80free-gamma [] L L.
 81free-gamma [w _ T|X] L L2 :- free-ty T L L1, free-gamma X L1 L2.
 82
 83func free tye, list any -> list any.
 84free (A # B) L L2 :- free A L L1, free B L1 L2.
 85free (A ===> B) L L2 :- free A L L1, free B L1 L2.
 86free (uvar X _) L L1 :- if (mem L X) (L1 = L) (L1 = [X|L]).
 87
 88func copy-ty ty -> ty.
 89copy-ty (mono X1) (mono X2) :- copy X1 X2.
 90copy-ty (all F1) (all F2) :- pi x\ copy x x =!=> copy-ty (F1 x) (F2 x).
 91
 92func copy tye -> tye.
 93copy (A ===> B) (A1 ===> B1) :- copy A A1, copy B B1.
 94copy (A # B) (A1 # B1) :- copy A A1, copy B B1.
 95copy (uvar _ _ as X) X.
 96
 97func quantify list tye, tye -> ty.
 98quantify [] X (mono X1) :- copy X X1.
 99quantify [X|XS] T (all x\ T2 x) :-
100  quantify XS T T1,
101  pi x\ copy (uvar X _) x =!=> copy-ty T1 (T2 x).
102
103% generic list helpers
104func filter list A, (pred A) -> list A.
105filter [] _ [].
106filter [X|XS] P [X|YS] :- P X, !, filter XS P YS.
107filter [_|XS] P YS :- filter XS P YS.
108
109func mem list A, A.
110mem [X|_] X :- !.
111mem [_|XS] X :- mem XS X.
112
113main :-
114  % let id = \x.x in plus (id one) (size (id empty))
115  % `id` is generalized to a type scheme and used at two different types
116  P = let (lam x\ x) IdTy (id\ app (app plus (app id one))
117                                  (app size (app id empty))),
118  w P TP, print "well typed, of type" TP,
119  print "id generalized to" IdTy.
well typed, of type mono integer
id generalized to all c0 \ mono (c0 ===> c0)

IdTy, the type scheme inferred for id, prints as all c0 \ mono (c0 ===> c0), universally quantified over the one type variable used only locally, the scheme that then lets id be applied to both an integer and a list # integer in the same body.

A monotype is a type with no quantifiers: an arrow ===>, an applied type constructor # (as in list # integer), or a type variable, itself an ordinary Elpi unification variable, an existential hole (Constraints). A scheme is mono T (no quantification) or all F, universally quantifying one more variable and continuing as F applied to it. specialize goes the other way: given a scheme, it produces one monotype by picking a fresh unification variable for every all, the same mechanism pi uses for a fresh bound name, here applied to open a scheme’s quantifiers into fresh holes to be filled in by whatever unifies with them at this particular use.

The interesting rule is let:

w (let F FP B) (mono TC) :-
  w F (mono FT),
  declare_constraint (overbar (mono FT) FP) [],
  pi x\ w x FP ==> w (B x) (mono TC).

FP, the scheme eventually given to the let-bound name, is not computed on the spot. Generalizing FT means comparing its free type variables against the free type variables of Γ, the set of hypothetical w rules currently in scope: the ==> rules nested pi/let``s have added so far. Γ is not a first-class value an ordinary goal can inspect, which is beyond what ``pi/==> alone can do (Logic Programming). A constraint handling rule can: its sequent pattern (G ?- goal) binds G to the very context a suspended goal carries with it (Constraint handling rules, The constraint store). So generalization is written as a suspended overbar constraint, resolved by one CHR rule once the goal it is attached to is later matched:

constraint w ?- overbar {
  rule \ (G ?- overbar T T1)
       | (generalize G T POLYT) <=> (T1 = POLYT).
}

The w ?- before the clique is the context filter: without it, G would keep only overbar’s own (empty) set of clique rules; naming w there keeps the hypothetical w rules, Γ, in the context the suspended overbar constraint carries, which is what generalize reads.

generalize collects the free variables of the monotype and of Γ, and quantifies those that are free in the former but not the latter, the ones the surrounding context makes no assumption about.