4.6 Normalisations functions and re-usability of the provers
4.7.3 Termination
Termination is another important property because no one likes to wait for the output of a program that will never come because the program is stuck in an infinite loop. We use computer programs to automate tasks and we want the tasks to be effectively done after a finite amount of time. In order to gain guarantee about the termination, Idris is equipped with a totality checker. A function is total if it is defined for all possible inputs and if it is guarantee to always terminate, so totality is more than just
termination as it also contains coverage. In dependently typed theories, totality checkers check the termination by looking at the size of the arguments in recursive calls. If all recursive calls always happen on a term strictly smaller than the original one, then we have the guarantee that the sequence of recursive calls will terminate at some point. Because it is difficult for the totality checker to automatically determine a good notion of size, this check is often implemented by simply checking that recursive calls happen on a strict subterm of the original argument for at least one argument n, and in this case the function is said to be a structural recursion on n. Therefore,the semantic notion of termination is approximated by a more restrictive syntactic notion. That will necessary lead to false positives, i.e. situations where the totality checker reports a function as potentially not terminating, when this function is in fact always terminating. This situation happened multiple times during the
development of our hierachy of tactics. Here is an example that belongs to the group level : it is the function that pushes negations inside the parenthesis, following the rule−(a+b) = (−b) + (−a) :
propagateNeg : {c:Type} → (p:Group c)
→ {g:Vect n c} → {c1:c} → (ExprG p g c1)
→ (c2 ** (ExprG p g c2, c1~=c2))
propagateNeg p (NegG (PlusG e1 e2)) = let (r_ih1 ** (e_ih1, p_ih1)) =
(propagateNeg p (NegG e1)) in let (r_ih2 ** (e_ih2, p_ih2)) =
(propagateNeg p (NegG e2)) in
-- Careful : - (a + b) = (-b) + (-a) in a group -- and not (-a) + (-b) in general.
((Plus r_ih2 r_ih1)
** (PlusG e_ih2 e_ih1, ?MpropagateNeg_1)) propagateNeg p (NegG (NegG e)) =
let (r_ih1 ** (e_ih1, p_ih1)) = propagateNeg p e in (r_ih1 ** (e_ih1, ?MpropagateNeg_2))
propagateNeg {c} p (NegG e) =
-- Here ’e’ can only be a constant or a variable -- as we’ve treated Plus and Neg before
(_ ** (NegG e, set_eq_undec_refl {c} _)) propagateNeg p (PlusG e1 e2) =
let (r_ih1 ** (e_ih1, p_ih1)) = (propagateNeg p e1) in
let (r_ih2 ** (e_ih2, p_ih2)) =
(propagateNeg p e2) in ((Plus r_ih1 r_ih2) ** (PlusG e_ih1 e_ih2, ?MpropagateNeg_3)) propagateNeg {c} p e =
(_ ** (e, set_eq_undec_refl {c} _))
This function is reported as non-total by Idris’ totality checker because in the first pattern (where the input is (NegG (PlusG e1 e2))), the
recursive calls are made on the arguments (NegG e1)and(NegG e2)
tion will always terminate, because at some point, the negations will only be in front of atoms (constants and variables), and in this case the result is produced directly by returning the input expression (that’s the third pattern).
Here is another example. In the normalisation functions, we’ve some- times faced situations where we had to apply a treatment f on the original expression x0until the point where applying f again would give the same
result. For example, at the ring level, we have a function developwhich develops one time the products of sums, and then we use a function
develop_fix to fully develop the polynomial by applying develop
as many times as needed. A function f_f ix that does this job is said to compute a fixpoint of f, i.e. it produces a solution to the equation
x= f x. Here, the fixpoint that we compute is the fixpoint starting from the input value x018 . We’ve needed these fixpoints when we had to
apply a treatment on a specific pattern (or on a set of patterns), but with the possibility that the application of the treatment could create a new instance of the pattern that precisely needs to be rewritten. There is also the case of nested patterns where we can not treat them all at the same time, and we have to start the treatment by one of them, leaving the other one for later (i.e. for the next passes). The idea for writing these f_f ix
functions is to apply the one-pass treatment f once, and then to inspect if some simplifications have been done by this application, i.e. to check if the current result has changed. If so, f_f ixis applied again recursively. If not, the current result is returned. Below is the scheme (for rings) of how such fixpoints can be computed for a specific function f.
f_fix : {c:Type} → (p:Ring c)
→ {g:Vect n c} → {c1:c} → (ExprR p g c1)
→ (c2 ** (ExprR p g c2, c1~=c2))
f_fix p e =
-- Apply the one-pass treatment f once let (r_1 ** (e_1, p_1)) = f p e in
-- Look for syntactical equality : have we done -- some simplification in the last pass ?
18Formally, the resultxverifies∃n, fn x
case exprR_eq p _ e e_1 of
-- Previous and current terms are the same : -- we stop here
Just pr => (r_1 ** (e_1, p_1))
-- Previous and current are different : -- we apply the fixpoint again
Nothing => let (r_ih1 ** (e_ih1, p_ih1)) = f_fix p e_1 in
(r_ih1 ** (e_ih1, ?M_f_fix_1))
The termination of a function that follows the scheme of f_f ix de- pends on what the one-pass treatment f is doing. For example, if f
replaces all instances of a specific pattern in the original expression, then even if f occasionally creates an instance of this pattern, we might know that overall, at some point, they will all be replaced and the function will terminate. Therefore, only a smart and fine-grained analysis can realise that functions such as develop_fix terminate. The syntactic and over-pessimistic totality checker cannot do automatically this kind of analysis. We could do some hard work for encoding a good notion of size, and proving that it decreases at every recursive call. However, as was the case for completeness, developing such a proof in the system is not worth the effort. So far, we haven’t seen any example of a term that would make our normalisation function run into an infinite loop. If this situation happens at some point, we will simply fix the normalisation function. There is only one case where having a non-terminating function could be dangerous, and that’s in the case where this function would be used inside the proof of correctness, i.e. for proving the desired equivalence
x'y (or in some auxiliary proofs used in this proof). Because of the Curry-Howard correspondence, proofs are functions, but these functions absolutely need to be total in order to maintain the system sound. We make the following claims about termination :
• Even if some functions that produce computational content (i.e. not proofs) can’t be tagged as total in the system, we always have good reasons for trusting that they will terminate, as it is the case for the functiondevelop_fix. This reason can be a complicated
semantic notion that would be hard to encode in the system in order to formally prove the termination, but that is theoretically doable : we have never written a function that we know could potentially never stop on stop inputs.
• The proof ofx'y–that is being built automatically by our machinery– never uses a potentially non-terminating function as a proof, be- cause that would make the system unsound. In fact, all the lemmas used in the proof x'y can be tagged as total. Therefore, if our tactics generate a proof of x'y, we can be sure that this is a valid proof.
Finally, let’s emphasize the fact that our machinery runs during the type-checking of the user’s program, and not during its runtime. The reason is that our machinery produces something with no real computational content : it generates a proof that only has to be type- checked in order to obtain the guarantee that it conveys. Therefore, the termination of our tactics, even if important, can not affect the termination of the user’s programs.