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.