Spilling and functional syntax

A rule that threads a value from one call straight into the next has to name that value, and a name like Tmp or Aux1 carries no information: it is there only to plumb the two calls together. Spilling removes the need for that name.

pred f int -> int.
f X R :- foo X Tmp, bar Tmp R.  % the plumbing variable Tmp

Wrapping a partially-applied call in braces, { E } (Terms), lifts the goal E out to just before the smallest enclosing predicate call and puts a fresh variable in its place, one for each argument E is not given (its output arguments, so one in the common case):

f X R :- bar {foo X} R.         % the same, spilled

If E has several missing arguments, { E } becomes that many variables at once:

pred h int -> int, int.
g X R :- h X Tmp1 Tmp2, k Tmp1 Tmp2 R.   % h has two outputs: Tmp1 Tmp2
g X R :- k {h X} R.                      % {h X} stands for the two of them

For this to work Elpi has to know the spilled predicate’s arity, from its pred / func signature (Type declarations); an undeclared predicate cannot be spilled.

The spilled call is inserted just before the closest call recognised as a predicate, walking backward, inside out. elpi -print FILE prints a program after spilling (and other compilation passes), so any doubt about where a particular { } ended up can be checked directly.

Here {std.rev L} becomes a fresh variable produced by an std.rev goal inserted right before std.append:

../code/palindrome.elpi:

1% Example used in the "Spilling" chapter.
2% The spilled call {std.rev L} is rewritten to a fresh variable bound by an
3% std.rev goal inserted just before std.append.
4
5pred make-palindrome list A -> list A.
6make-palindrome L Result :- std.append L {std.rev L} Result.
7
8main :- make-palindrome [1, 2, 3] R, print R.
[1, 2, 3, 3, 2, 1]

Spilling in the head of a rule

{ } may also appear in a rule’s head even though a head is not a goal sequence and cannot simply join a , conjunction the way a spill in the body can. The spilled goal is relocated to just after :-, in front of whatever body the rule already had;:

func copy tm -> tm.
copy (app A B) (app {copy A} {copy B}).
% becomes
copy (app A B) (app R S) :- copy A R, copy B S.

Spilling in the head is restricted to an output argument.

Spilling without { }

{ } marks explicitly which under-applied call to spill. The :autospill rule attribute (Rule attributes) spills every under-applied call in the rule instead, wherever it appears, exactly as if each had its own { }:

:autospill
main :- std.append (std.append [1] [2]) [3] L, L = [1,2,3].

A call is spilled automatically when it is applied to all of its input arguments but none of its outputs arguments. A call that already supplies every argument, outputs included, is left untouched – and so is a call already surrounded by { }, even though it too supplies all its inputs and no outputs.

The :autospill attribute is turned on automatically by the functional rule syntax (Inference rules and queries) — a rule head written with -> to separate inputs from outputs. A recursive call is the typical beneficiary, since its own result is exactly the value still missing from it: below, map F XS stands for its own missing output:

../code/autospill-map.elpi:

1func map (func A -> B), list A -> list B.
2map _ [] -> [].
3map F [X|XS] -> [F X|map F XS].

This is the same predicate as the version written out with every argument in place and an ordinary body:

../code/map.elpi:

1func map (func A -> B), list A -> list B.
2map _ [] [].
3map F [X|XS] [Y|YS] :- F X Y, map F XS YS.

Spilling into a binder

Under pi and in the conclusion of ==>, the spilled goal is placed inside the binder, so its fresh variable can still mention the names and hypotheses in scope there. A pretty-printer that recurses under lam spills {pp (F x)} right where the fresh x and its pp x "x" hypothesis are visible:

../code/spilling-under-binder.elpi:

 1% Spilling reaches into a binder: in the `lam` rule, `{pp (F x)}` is lifted
 2% to a `pp (F x) R` goal placed *inside* the `pi x\ ... ==>`, so it can use the
 3% fresh `x` and the `pp x "x"` hypothesis. The `app` rule spills twice in one
 4% `is`, both lifted before the enclosing call.
 5
 6data tm.
 7symb app tm -> tm -> tm.
 8symb lam (tm -> tm) -> tm.
 9
10func pp tm -> string.
11pp (app F X) S :- S is "(" ^ {pp F} ^ " " ^ {pp X} ^ ")".
12pp (lam F) S :- pi x\ pp x "x" ==> S is "\\x." ^ {pp (F x)}.
13
14main :- pp (lam f\ app f f) S, print S.
\x.(x x)

elpi -print FILE shows where the spilled goal landed: in the lam rule it sits after the ==>, inside the pi and under its own sigma, equivalent to writing

pp (lam F) S :- pi x\ pp x "x" ==> sigma R\ (pp (F x) R, S is "\x." ^ R).

{ } may spill in the premise (the hypothesis) of ==> too. Spilling in the premise is handled the same way as spilling in a rule’s own head (above) and the premise becomes a rule with premises of its own and the fresh variable used for spilling is quantified with pi around the entire premise:

main :- p {succ 1} ==> p X, print X.

is elaborated to:

main :- (pi R\ p R :- succ 1 R) ==> p X, print X.

Spilling out of a term-level lambda

A { } inside a lambda that is passed as data, not the body of a pi or an implication, goes the other way: the goal is lifted out past the lambda and the fresh variable is η-expanded so it can still depend on the bound name. apply (x\ {f x}) becomes

main :- apply (x\ {f x}).
% becomes
main :- pi c\ f c (R c), apply (x\ R x).

The spilled f c runs once, before apply, with c a fresh constant. This is only meaningful when f does not actually need c’s value; a { } under a term-level lambda that genuinely depends on the bound variable is usually a mistake.

Spilling a conjunction

When { } wraps a conjunction, only its last conjunct is spilled, and that conjunct’s missing output is what becomes the fresh variable. The earlier conjuncts run in place, typically to bind a variable the last one then reads:

foo :- pi x\ f {g x Y, h Y}.
% becomes
foo :- pi x\ sigma S\ (g x Y, h Y S), f S.

g x Y runs first and binds Y; h Y is the spilled conjunct, so h Y S computes the value S that takes the place of the whole { … }.

Spilling specification

This sections is a deliberately simplified mockup of the real compiler pass in src/compiler/spilling.ml and gives an informal account of how spilling is performed. The input syntax contains a spill_call node that is eliminated during spilling (spill_term, reached from the entry point spill_rule); spill_bound is an auxiliary node representing the output of a spilled predicate call.

../code/spilling_spec.elpi:

  1% A simplified mockup of the compiler's spilling algorithm, in Elpi itself.
  2data qkind.
  3symb piK    qkind.
  4symb sigmaK qkind.
  5
  6data term.
  7% core syntax of data
  8symb con string -> term.             % constants
  9symb app list term -> term.          % f X Y
 10symb lam (term -> term) -> term.     % x\
 11
 12% injection of predicate call inside terms, always missing one argument
 13symb spill_call string -> list term -> term. % {p X}
 14% temporary node: output of a spilled call
 15symb spill_bound string -> term.
 16
 17% syntax of goals and rules, rules are pi-closed
 18data goal.
 19typeabbrev goals (list goal).
 20symb quantifier   qkind -> (term -> goals) -> goal. % pi x\, sigma x\
 21symb implication  goal -> goals -> goal.            % h ==>  g
 22symb goal         string -> list term -> goal.      % p X Y
 23
 24% auxiliary data: each spilled call is a goal and the one variable used
 25% for its missing (output) argument
 26data spilled.
 27typeabbrev spills (list spilled).
 28symb spilled string -> goal -> spilled.
 29
 30% ------------------------------------------------------------------
 31% Helpers
 32% ------------------------------------------------------------------
 33
 34func flip_quantifier qkind -> qkind.
 35flip_quantifier piK sigmaK.
 36flip_quantifier sigmaK piK.
 37
 38func fresh_name -> string.
 39fresh_name Name :- new_int N, Name is "%Spill" ^ int_to_string N.
 40
 41func copy_term term -> term.
 42copy_term (con S) (con S) :- !.
 43copy_term (spill_bound S) (spill_bound S) :- !.
 44copy_term (app Args) (app Args') :- !, std.map Args copy_term Args'.
 45copy_term (lam F) (lam F') :- !, pi x\ copy_term (F x) (F' x).
 46copy_term (spill_call N Args) (spill_call N Args') :- !, std.map Args copy_term Args'.
 47copy_term X X :- name X.
 48
 49func copy_goal goal -> goal.
 50copy_goal (quantifier K F) (quantifier K F') :-
 51  pi x\ std.map (F x) copy_goal (F' x).
 52copy_goal (implication H B) (implication H' B') :-
 53  copy_goal H H', std.map B copy_goal B'.
 54copy_goal (goal S B) (goal S B') :-
 55  std.map B copy_term B'.
 56
 57func bind_var_in_goals string, goals -> (term -> goals).
 58bind_var_in_goals V G G' :- pi Y\ copy_term (spill_bound V) Y =!=> std.map G copy_goal (G' Y).
 59
 60func bind_name_in_goal term, goal -> (term -> goal).
 61bind_name_in_goal V G G' :- name V, pi Y\ copy_term V Y =!=> copy_goal G (G' Y).
 62
 63% ------------------------------------------------------------------
 64% Main algorithm
 65% ------------------------------------------------------------------
 66func spill_term qkind, term -> spills, term.
 67
 68spill_term _ T [] T :- (T = con _ ; name T), !.
 69
 70spill_term Q (app Args) Spills (app Args') :- !,
 71  spill_term_list Q Args Spills Args'.
 72
 73spill_term Q (lam F) Sbody' (lam F') :- !,
 74  pi x\
 75  spill_term Q (F x) (Sbody x) (Body' x),
 76  lift_out_of_lambda x (Sbody x) (Body' x) Sbody' (F' x).
 77
 78spill_term Q (spill_call Name Args) AllSpills (spill_bound Fresh) :- !,
 79  spill_term_list Q Args Sargs Args',
 80  fresh_name Fresh,
 81  std.append Args' [spill_bound Fresh] CallArgs,
 82  std.append Sargs [spilled Fresh (goal Name CallArgs)] AllSpills.
 83
 84func spill_term_list qkind, list term -> spills, list term.
 85spill_term_list _ [] [] [].
 86spill_term_list Q [X|XS] Spills [Y|YS] :-
 87  spill_term Q X Sx Y, spill_term_list Q XS Rxs YS,
 88  std.append Sx Rxs Spills.
 89
 90func spill_goal qkind, goal -> goal.
 91
 92spill_goal Q (quantifier K F) (quantifier K F') :- !,
 93  pi x\ std.map (F x) (spill_goal Q) (F' x).
 94
 95spill_goal Q (implication Head Body) (implication Head' Body') :- !,
 96  flip_quantifier Q Q',
 97  spill_rule Q' Head Head',
 98  std.map Body (spill_goal Q) Body'.
 99
100spill_goal Q (goal Name Args) G' :- !,
101  spill_call_args Q Name Args Sargs Call,
102  add_spilled Q Sargs [Call] [G'].
103
104func spill_call_args qkind, string, list term -> spills, goal.
105spill_call_args Q Name Args Sargs (goal Name Args') :- !,
106  spill_term_list Q Args Sargs Args'.
107
108func quantify_spill qkind, spilled, goal -> goal.
109quantify_spill Q (spilled Var _) Acc Res :-
110  place_spilling_quantifier Q Var [Acc] Res.
111
112func spill_rule qkind, goal -> goal.
113
114spill_rule Q (quantifier piK x\[Inner x]) (quantifier piK x\[Inner' x]) :- !,
115  pi x\ spill_rule Q (Inner x) (Inner' x).
116
117spill_rule Q (implication (goal Name Args) Body) Result :- !,
118  spill_call_args Q Name Args Sh Head',
119  flip_quantifier Q Q',
120  std.map Body (spill_goal Q') Body0,
121  std.fold-right Sh Body0 place_spilled_call Body',
122  std.fold-right Sh (implication Head' Body') (quantify_spill Q) Result.
123
124spill_rule Q (goal Name Args) Result :- !,
125  spill_call_args Q Name Args Sh T',
126  if (Sh = [])
127     (Result = T')
128     (std.fold-right Sh [] place_spilled_call Body',
129      std.fold-right Sh (implication T' Body') (quantify_spill Q) Result).
130
131func place_spilled_call spilled, goals -> goals.
132place_spilled_call (spilled _ Goal) Rest [Goal|Rest] :- !.
133
134func place_spilling_quantifier qkind, string, goals -> goal.
135place_spilling_quantifier Q Var Gs (quantifier Q Gs') :-
136  bind_var_in_goals Var Gs Gs'.
137
138func add_spilled_one qkind, spilled, goals -> goals.
139add_spilled_one Q (spilled Var _ as S) GL [SG] :- !,
140  place_spilled_call S GL SGL,
141  place_spilling_quantifier Q Var SGL SG.
142
143func add_spilled qkind, spills, goals -> goals.
144add_spilled Q Spills Rest Result :- !, std.fold-right Spills Rest (add_spilled_one Q) Result.
145
146func apply_spilled_var_to (func A -> A), term, string, A -> A.
147apply_spilled_var_to F X V T T' :-
148  (copy_term (spill_bound V) (app [spill_bound V,X])) =!=>
149  (pi Rest RestX\
150    copy_term (app [spill_bound V|Rest]) (app [spill_bound V|RestX]) :- std.append Rest [X] RestX) =!=>
151  F T T'.
152
153func apply_spilled_vars_to (func A -> A), term, spilled, A -> A.
154apply_spilled_vars_to F X (spilled Var _) T T' :- apply_spilled_var_to F X Var T T'.
155
156func lift_out_of_lambda term, spills, term -> spills, term.
157lift_out_of_lambda X Spills Body Spills' Body' :- !,
158  std.fold Spills Body (apply_spilled_vars_to copy_term X) Body',
159  std.map Spills (s\r\ sigma Var G G0 G'\
160    s = spilled Var G,
161    bind_name_in_goal X G G0,
162    (pi y\ apply_spilled_vars_to copy_goal y s (G0 y) (G' y)),
163    r = spilled Var (quantifier piK y\[G' y]))
164  Spills'.

Related work. Similar transformations exist in other systems under different names, although none of them handles lambda-tree syntax. Here some pointers:

  • Picat replaces a function call f(t1,...,tn) by a new variable V, and inserts a new predicate call p(t1,...,tn,V) before the goal in which the function call occurs (Zhou, PADL 2017).

  • Curry’s intermediate representation, FlatCurry, normalizes nested function calls the same way via let-insertion (y:ins(x,ys) becomes let {z = ins(x,ys)} in y:z) (Antoy, Hanus, Jost & Libby, Declare 2019).

  • A-normal form, the general functional-compiler ancestor of this family: flatten nested expressions by let-binding every intermediate result (Sabry & Felleisen, LFP 1992; Flanagan, Sabry, Duba & Felleisen, PLDI 1993).

  • Prolog’s CLP(FD) flattens arithmetic expressions the same way, scoped to arithmetic: X #= Y+Z*W compiles to a sequence of primitive constraint goals over fresh variables via goal_expansion/2 (Triska, FLOPS 2012).