Verified Self-Explaining Computation

07/12/2019 ∙ by Jan Stolarek, et al. ∙ 0

Common programming tools, like compilers, debuggers, and IDEs, crucially rely on the ability to analyse program code to reason about its behaviour and properties. There has been a great deal of work on verifying compilers and static analyses, but far less on verifying dynamic analyses such as program slicing. Recently, a new mathematical framework for slicing was introduced in which forward and backward slicing are dual in the sense that they constitute a Galois connection. This paper formalises forward and backward dynamic slicing algorithms for a simple imperative programming language, and formally verifies their duality using the Coq proof assistant.



There are no comments yet.


page 1

page 2

page 3

page 4

This week in AI

Get the week's most popular data science and artificial intelligence research sent straight to your inbox every Saturday.

1 Introduction

The aim of mathematical program construction is to proceed from (formal) specifications to (correct) implementations. For example, critical components such as compilers, and various static analyses they perform, have been investigated extensively in a formal setting [10]. However, we unfortunately do not yet live in a world where all programs are constructed in this way; indeed, since some aspects of programming (e.g. exploratory data analysis) appear resistant to a priori specification, one could debate whether such a world is even possible. In any case, today programs “in the wild” are not always mathematically constructed. What do we do then?

One answer is provided by a class of techniques aimed at explanation, comprehension or debugging, often based on run-time monitoring, and sometimes with a pragmatic or even ad hoc flavour. In our view, the mathematics of constructing well-founded (meta)programs for explanation are wanting [4]. For example, dynamic analyses such as program slicing have many applications in comprehending and restructuring programs, but their mathematical basis and construction are far less explored compared to compiler verification [2, 3].

Dynamic program slicing is a runtime analysis that identifies fragments of a program’s input and source code – known together as a program slice – that were relevant to producing a chosen fragment of the output (a slicing criterion[8, 21]. Slicing has a very large literature, and there are a wide variety of dynamic slicing algorithms. Most work on slicing has focused on imperative or object-oriented programs.

One common application of dynamic slicing is program debugging. Assume we have a program with variables x, y, and z and a programmer expects that after the program has finished running these variables will have respective values 1, 2, and 3. If a programmer unfamiliar with the program finds that after execution, variable y contains 1 where she was expecting another value, she may designate y as a slicing criterion, and dynamic slicing will highlight fragments of the source code that could have contributed to producing the incorrect result. This narrows down the amount of code that the programmer needs to inspect to correct a program. In this tiny example, of course, there is not much to throw away and the programmer can just inspect the program — the real benefit of slicing is for understanding larger programs with multiple authors. Slicing can also be used for program comprehension, i.e. to understand the behaviour of an already existing program in order to re-engineer its specification, possibly non-existent or not up-to-date.

In recent years a new semantic basis for dynamic slicing has been proposed [15, 17]. It is based on the concept of Galois connections as known from order and lattice theory. Given lattices and , a Galois connection is a pair of functions and such that ; then is the lower adjoint and is the upper adjoint. Galois connections have been advocated as a basis for mathematical program construction already, for example by Backhouse [1] and Mu and Oliveira [12]. They showed that if one can specify a problem space and show that it is one of the component functions of a Galois connection (the “easy” part), then optimal solutions to the problem (the “hard” part) are uniquely determined by the dual adjoint. A simple example arises from the duality between integer multiplication and division: the Galois connection expresses that is the greatest integer such that .

Whereas Galois connections have been used previously for constructing programs (as well as other applications such as program analysis), here we consider using Galois connections to construct programs for program slicing. In our setting, we consider lattices of partial inputs and partial outputs of a computation corresponding to possible input and output slicing criteria, as well as partial programs corresponding to possible slices—these are regarded as part of the input. We then define a forward semantics (corresponding to forward slicing) that expresses how much of the output of a program can be computed from a given partial input. Provided the forward semantics is monotone and preserves greatest lower bounds, it is the upper adjoint of a Galois connection, whose lower adjoint computes for each partial output the smallest partial input needed to compute it — which we consider an explanation. In other words, forward and backward slicing are dual in the sense that forward slicing computes “as much as possible” of the output given a partial input, while backward slicing computes “as little as needed” of the input to recover a partial output.

Figure 1: Input and output lattices and Galois connection corresponding to expression evaluated with input and output . Dotted lines with arrows pointing left calculate the lower adjoint, those pointing right calculate the upper adjoint, and lines with both arrows correspond to the induced isomorphism between the minimal inputs and maximal outputs. Several additional elements of the input lattice are omitted.

Figure 1 illustrates the idea for a small example where the “program” is an expression , the input is an initial store containing and the output is the pair . Partial inputs, outputs, and programs are obtained by replacing subexpressions by “holes” (), as illustrated via (partial) lattice diagrams to the left and right. In the forward direction, computing the first component succeeds if both and are available while the second component succeeds if is available; the backward direction computes the least input and program slice required for each partial output. Any Galois connection induces two isomorphic sublattices of the input and output, and in Figure 1 the elements of these sublattices are enclosed in boxes with thicker lines. In the input, these elements correspond to minimal explanations: partial inputs and slices in which every part is needed to explain the corresponding output. The corresponding outputs are maximal in a sense that their minimal explanations do not explain anything else in the output.

The Galois connection approach to slicing has been originally developed for functional programming languages [15] and then extended to functional languages with imperative features [17]. So far it has not been applied to conventional imperative languages, so it is hard to compare directly with conventional slicing techniques. Also, the properties of the Galois connection framework in [15, 17] have only been studied in pen-and-paper fashion. Such proofs are notoriously tricky in general and these are no exception; therefore, fully validating the metatheory of slicing based on Galois connections appears to be an open problem.

In this paper we present forward and backward slicing algorithms for a simple imperative language Imp and formally verify the correctness of these algorithms in Coq. Although Imp seems like a small and simple language, there are nontrivial technical challenges associated with slicing in the presence of mutable state, so our formalisation provides strong assurance of the correctness of our solution. To the best of our knowledge, this paper presents the first formalisation of a Galois connection slicing algorithm for an imperative language. Compared with Ricciotti et al. [17], Imp is a much simpler language than they consider, but their results are not formalised; compared with Léchenet et al. [9], we formalise dynamic rather than static slicing.

In Section 2 we begin by reviewing the syntax of Imp, giving illustrative examples of slicing, and then reviewing the theory of slicing using the Galois connection framework, including the properties of minimality and consistency. In Section 3 we introduce an instrumented, tracing semantics for Imp and present the forward and backward slicing algorithms. We formalise all of the theory from Section 3 in Coq and prove their duality. Section 4 highlights key elements of our Coq development, with full code available online [19]. Section 5 provides pointers to other related work.

2 Overview

2.1 Imp slicing by example

Arithmetic expressions
Boolean expressions
Imperative commands
State ( fresh)
Figure 2: Imp syntax

For the purpose of our analysis we use a minimal imperative programming language Imp used in some textbooks on programming languages, e.g. [13, 16, 22]111In the literature Imp is also referred to as WHILE.. Imp contains arithmetic and logical expressions, mutable state (a list of mappings from variable names to numeric values) and a small core of imperative commands: empty instruction (skip), variable assignments, instruction sequencing, conditional if instructions, and while loops. An Imp program is a series of commands combined using a sequencing operator. Imp lacks more advanced features, such as functions or pointers.

Figure 2 shows Imp syntax. We use letter to denote natural number constants and to denote program variables. In this presentation we have omitted subtraction () and multiplication () from the list of arithmetic operators, and less-or-equal comparison () from the list of boolean operators. All these operators are present in our Coq development and we omit them here only to make the presentation in the paper more compact. Otherwise the treatment of subtraction and multiplication is analogous to the treatment of addition, and treatment of is analogous to .

Dynamic slicing is a program simplification technique that determines which parts of the source code contributed to a particular program output. For example, a programmer might write this simple program in Imp:

if (y = 1) then { y := x + 1 }
           else { y := y + 1 } ;
z := z + 1

and after running it with an input state might (wrongly) expect to obtain output state . However, after running the program the actual output state will be with value of y differing from the expectation.

We can use dynamic slicing to debug this program by asking for an explanation which parts of the source code and the initial input state contributed to incorrect output value of y. We do this by formulating a slicing criterion, where we replace all values that we consider irrelevant in the output state (i.e. don’t need an explanation for them) with holes (denoted with ):

and a slicing algorithm might produce a program slice:

if (y = 1) then { \(\Box\) }
           else { y := y + 1 } ; \(\Box\)

with a sliced input state . This result indicates which parts of the original source code and input state could be ignored when looking for a fault (indicated by replacing them with holes), and which ones were relevant in producing the output indicated in the slicing criterion. The result of slicing narrows down the amount of code a programmer has to inspect to locate a bug. Here we can see that only the input variable y was relevant in producing the result; x and z are replaced with a  in the input state, indicating their irrelevance. We can also see that the first branch of the conditional was not taken (unexpectedly!) and that in the second branch y was incremented to become . With irrelevant parts of the program hidden away it is now easier to spot that the problem comes from a mistake in the initial state. The initial value of y should be changed to so that the first branch of the conditional is taken and then y obtains an output value of as expected.

Consider the same example, but a different output slicing criterion . In this case, a correctly sliced program is as follows:

\(\Box\) ; z := z + 1

with the corresponding sliced input , illustrating that the entire first conditional statement is irrelevant. However, while the conclusion seems intuitively obvious, actually calculating this correctly takes some work: we need to ensure that none of the assignments inside the taken conditional branch affected z, and conclude from this that the value of the conditional test y = 1 is also irrelevant to the final value of z.

2.2 A Galois connection approach to program slicing

Example in Section 2.1 relies on an intuitive feel of how backward slicing should behave. We now address the question of how to make that intuition precise and show that slicing using the Galois connection framework introduced by Perera et al. [15] offers an answer.

Consider these two extreme cases of backward slicing behaviour:

  1. for any slicing criterion backward slicing always returns a full program with no holes inserted;

  2. for any slicing criterion backward slicing always returns a , i.e. it discards all the program code.

Neither of these two specifications is practically useful since they don’t fulfil our intuitive expectation of “discarding program fragments irrelevant to producing a given fragment of program output”. The first specification does not discard anything, bringing us no closer to understanding which code fragments are irrelevant. The second specification discards everything, including the code necessary to produce the output we want to understand. We thus want a backward slicing algorithm to have two properties:

  • consistency: backward slicing retains code required to produce output we are interested in.

  • minimality: backward slicing produces the smallest partial program and partial input state that suffice to achieve consistency.

Our first specification above does not have the minimality property; the second one does not have the consistency property. To achieve these properties we turn to order and lattice theory.

We begin with defining partial Imp programs (Figure 3) by extending Imp syntax presented in Figure 2 with holes (denoted using  in the semantic rules). A hole can appear in place of any arithmetic expression, boolean expression, or command. In the same way we allow values stored inside a state to be mapped to holes. For example:

is a partial state that maps variable x to and variable y to a hole. We also introduce operation that takes a state and creates a partial state with the same domain as but all variables mapped to . For example if then . A partial state that maps all its variables to holes is referred to as an empty partial state.

Partial arithmetic expr.
Partial boolean expr.
Partial commands
Partial state
Figure 3: Partial Imp syntax. All elements of syntax from Figure 2 remain unchanged, only  are added.

Having extended Imp syntax with holes, we define partial ordering relations on partial programs and partial states that consider holes to be syntactically smaller than any other subexpression. Figure 4 shows the partial ordering relation for arithmetic expressions. Definitions for ordering of partial boolean expressions and partial commands are analogous. Ordering for partial states is defined element-wise, thus requiring that two states in the ordering relation have identical domains, i.e. store the same variables in the same order.

Figure 4: Ordering relation for partial arithmetic expressions.

For every Imp program , a set of all partial programs smaller than forms a complete finite lattice, written with being the top and  the bottom element of this lattice. Partial states, arithmetic expressions, and boolean expressions form lattices in the same way. Moreover, a pair of lattices forms a (product) lattice, with the ordering relation defined component-wise:

Figure 5: Join operation for arithmetic expressions.

Figure 5 shows definition of the join (least upper bound, ) operation for arithmetic expressions. Definitions for boolean expressions and imperative commands are analogous. A join exists for every two elements from a complete lattice formed by a program or state [6, Theorem 2.31].

Assume we have a program paired with an input state that evaluates to an output state . We can now formulate slicing as a pair of functions between lattices:

  • forward slicing: Forward slicing can be thought of as evaluation of partial programs. A function takes as its input a partial program and a partial state from a lattice formed by pairing a program and state . outputs a partial state belonging to a lattice formed by . The input to the forward slicing function is referred to as a forward slicing criterion and output as a forward slice.

  • backward slicing: Backward slicing can be thought of as “rewinding” a program’s execution. A function takes as its input a partial state from the lattice formed by the output state . outputs a pair consisting of a partial program and a partial state, both belonging to a lattice formed by program and state . Input to a backward slicing function is referred to as a backward slicing criterion and output as a backward slice.

A key point above (discussed in detail elsewhere [17]) is that for imperative programs, both and depend not only on but also on the particular execution path taken while evaluating on . (In earlier work on slicing pure functional programs [15], traces are helpful for implementing slicing efficiently but not required for defining it.) We make this information explicit in Section 3 by introducing traces that capture the choices made during execution. We will define the slicing algorithms inductively as relations indexed by , but in our Coq formalisation and are represented as dependently-typed functions where is a proof term witnessing an operational derivation.

A pair of forward and backward slicing functions is guaranteed to have both the minimality and consistency properties when they form a Galois connection [6, Lemmas 7.26 and 7.33].

Definition 1 (Galois connection).

Given lattices , and two functions , , we say and form a Galois connection (written ) when . We call a lower adjoint and an upper adjoint.

Importantly, for a given Galois connection , function uniquely determines and vice versa [6, Lemma 7.33]. This means that our choice of  (i.e. definition of how to evaluate partial programs on partial inputs) uniquely determines the backward slicing function  that will be minimal and consistent with respect to , provided we can show that  and  form a Galois connection. There are many strategies to show that two functions and form a Galois connection, or to show that or in isolation has an upper or respectively lower adjoint. One attractive approach is to show that preserves least upper bounds, or dually that preserves greatest lower bounds (in either case, monotonicity follows as well). This approach is indeed attractive because it allows us to analyse just one of or and know that its dual adjoint exists, without even having to write it down. Indeed, in previous studies of Galois slicing [15, 17], this characterisation was the one used:  was shown to preserve greatest lower bounds to establish the existence of its lower adjoint , and then efficient versions of  were defined and proved correct.

For our constructive formalisation, however, we really want to give computable definitions for both  and  and prove they form a Galois connection, so while preservation of greatest lower bounds by  is a useful design constraint, proving it does not really save us any work. Instead, we will use the following equivalent characterisation of Galois connections [6, Lemma 7.26]:

  1. and are monotone

  2. deflation property holds:

  3. inflation property holds:

We use this approach in our Coq mechanisation. We will first prove a general theorem that any pair of functions that fulfils properties (1)–(3) above forms a Galois connection. We will then define forward and backward slicing functions for Imp programs and prove that they are monotone, deflationary, and inflationary. Once this is done we will instantiate the general theorem with our specific definitions of forward and backward slicing to arrive at the proof that our slicing functions form a Galois connection. This is the crucial correctness property that we aim to prove. We also prove that existence of a Galois connection between forward and backward slicing functions implies consistency and minimality properties. Note that consistency is equivalent to the inflation property.

3 Dynamic program slicing

3.1 Tracing semantics

Arithmetic traces
Boolean traces
Command traces
Figure 6: Trace syntax

Following previous work [15, 17], we employ a tracing semantics to define the slicing algorithms. Since dynamic slicing takes account of the actual execution path followed by a run of a program, we represent the execution path taken using an explicit trace data structure. Traces are then traversed as part of both the forward and backward slicing algorithms. That is, unlike tracing evaluation, forward and backward slicing follow the structure of traces, rather than the program. Note that we are not really inventing anything new here: in our formalisation, the trace is simply a proof term witnessing the derivability of the operational semantics judgement. The syntax of traces is shown in Figure 6. The structure of traces follows the structure of language syntax with the following exceptions:

  • the expression trace records both the variable name and a value that was read from program state ;

  • for conditional instructions, traces record which branch was actually taken. When the if condition evaluates to true we store traces of evaluating the condition and the then branch; if it evaluates to false we store traces of evaluating the condition and the else branch.

  • for while loops, if the condition evaluates to false (i.e. loop body does not execute) we record only a trace for the condition. If the condition evaluates to true we record traces for the condition (), a single execution of the loop body () and the remaining iterations of the loop ().

Figure 7: Imp arithmetic expressions evaluation
Figure 8: Imp boolean expressions evaluation
Figure 9: Imp command evaluation.

Figures 79 show evaluation rules for arithmetic expressions, boolean expressions, and imperative commands, respectively222We overload the notation to mean one of three evaluation relations. It is always clear from the arguments which relation we are referring to.. Traces are written in grey colour and separated with a double colon () from the evaluation result. Arithmetic expressions evaluate to numbers (denoted ). Boolean expressions evaluate to either true or false (jointly denoted as ). Operators with or subscripts should be evaluated as mathematical and logical operators respectively to arrive at an actual value; this is to distinguish them from the language syntax. Commands evaluate by side-effecting on the input state, producing a new state as output (Figure 9). Only arithmetic values can be assigned to variables and stored inside a state. Assignments to variables absent from the program state are treated as no-ops. This means all variables that we want to write and read must be included (initialised) in the initial program state. We explain reasons behind this decision later in Section 4.3.

3.2 Forward slicing

In this and the next section we present concrete definitions of forward and backward slicing for Imp programs. Readers may prefer to skip ahead to Section 3.4 for an extended example of these systems at work first. Our slicing algorithms are based on the ideas first presented in [17]. Presentation in Section 2.2 views the slicing algorithms as computable functions and we will implement them in code as such. However for the purpose of writing down the formal definitions of our algorithms we will use a relational notation. It is more concise and allows easier comparisons with previous work.

Figures 1012 present forward slicing rules for the Imp language333We again overload and arrows in the notation to denote one of three forward/backward slicing relations. This is important in the rules for boolean slicing, whose premises refer to the slicing relation for arithmetic expressions, and command slicing, whose premises refer to slicing relation for boolean expressions.. As mentioned in Section 2.2, forward slicing can be thought of as evaluation of partial programs. Thus the forward slicing relations take a partial program, a partial state, and an execution trace as an input and return a partial value, either a partial number (for partial arithmetic expressions), a partial boolean (for partial logical expressions) or a partial state (for partial commands). For example, we can read as “Given trace , in partial environment the partial command forward slices to partial output .”

Figure 10: Forward slicing rules for Imp arithmetic expressions.
Figure 11: Forward slicing rules for Imp boolean expressions.
Figure 12: Forward slicing rules for Imp commands.

A general principle in the forward slicing rules for arithmetic expressions (Figure 10) and logical expressions (Figure 11) is that “holes propagate”. This means that whenever  appears as an argument of an operator, application of that operator forward slices to a . For example, forward slices to a  and so does . In other words, if an arithmetic or logical expression contains at least one hole it will reduce to a ; if it contains no holes it will reduce to a proper value. This is not the case for commands though. For example, command forward slices to , even though it contains a hole in the (not taken) else branch.

A rule worth attention is one for forward slicing of variable reads:

It is important here that we read the return value from and not recorded in a trace. This is because is a partial state and also part of a forward slicing criterion. It might be that maps to , in which case we must forward slice to  and not to . Otherwise minimality would not hold.

Forward slicing rules for arithmetic and logical expressions both have a universal rule for forward slicing of holes that applies regardless of what the exact trace value is:

There is no such rule for forward slicing of commands (Figure 12). There we have separate rules for forward slicing of holes for each possible trace. This is due to the side-effecting nature of commands, which can mutate the state through variable assignments. Consider this rule for forward slicing of assignments w.r.t. a  as a slicing criterion444When some partial value is used as a slicing criterion we say that we “slice w.r.t. ”.:

When forward slicing an assignment w.r.t. a  we need to erase (i.e. change to a ) variable in the state , which follows the principle of “propagating holes”. Here having a trace is crucial to know which variable was actually assigned during the execution. Rules for forward slicing of other commands w.r.t. a  traverse the trace recursively to make sure that all variable assignments within a trace are reached. For example:

In this rule, trace is traversed recursively to arrive at a state that is then returned as the final product of the rule. Notice how trace is not traversed. This is because boolean expressions (and arithmetic ones as well) do not have side effects on the state and so there is no need to traverse them.

The problem of traversing the trace recursively to handle side-effects to the state can be approached differently. Authors of [17] have formulated a single rule, which we could adapt to our setting like this:

In this rule means “all state locations written to inside trace ” and means erasing (i.e. mapping to a ) all locations in that are mentioned in . Semantically this is equivalent to our rules – both approaches achieve the same effect. However, we have found having separate rules easier to formalise in a proof assistant.

3.3 Backward slicing

Backward slicing rules are given in Figures 1315. These judgements should be read left-to-right, for example, should be read as “Given trace and partial output state , backward slicing yields partial input and partial command .” Their intent is to reconstruct the smallest program code and initial state that suffice to produce, after forward slicing, a result that is at least as large as the backward slicing criterion. To this end, backward slicing crucially relies on execution traces as part of input, since slicing effectively runs a program backwards (from final result to source code).

Figures 13 and 14 share a universal rule for backward slicing w.r.t. a hole.

This rule means that to obtain an empty result it always suffices to have an empty state and no program code. This rule always applies preferentially over other rules, which means that whenever a value, such as or , appears as a backward slicing criterion we know it is not a . Similarly, Figure 15 has a rule:

Figure 13: Backward slicing rules for Imp arithmetic expressions.
Figure 14: Backward slicing rules for Imp boolean expressions.
Figure 15: Backward slicing rules for Imp commands.

It means that backward slicing w.r.t. a state with an empty domain (i.e. containing no variables) returns an empty partial state and an empty program. Of course having a program operating over a state with no variables would be completely useless – since a state cannot be extended with new variables during execution we wouldn’t observe any effects of such a program. However, in the Coq formalisation, it is necessary to handle this case because otherwise Coq will not accept that the definition of backward slicing is a total function.

In the rule for backward slicing of variable reads (third rule in Figure 13) it might seem that stored inside a trace is redundant because we know what is from the slicing criterion. This is a way of showing that variables can only be sliced w.r.t. values they have evaluated to during execution. So for example if evaluated to it is not valid to backward slice it w.r.t. .

The rule for backward slicing of addition in Figure 13 might be a bit surprising. Each of the subexpressions is sliced w.r.t. a value that this expression has evaluated to (, ), and not w.r.t. . It might seem we are getting and out of thin air, since they are not directly recorded in a trace. Note however that knowing and allows to recover and at the expense of additional computations. In the actual implementation we perform induction on the structure of evaluation derivations, which record values of and , thus allowing us to avoid extra computations. We show and in our rules but avoid showing the evaluation relation as part of slicing notation. This is elaborated further in Section 4.4.

Recursive backward slicing rules also rely crucially on the join () operation, which combines smaller slices from slicing subexpressions into one slice for the whole expression.

There are two separate rules for backward slicing of variable assignments (rules 3 and 4 in Figure 15). If a variable is mapped to a  it means it is irrelevant. We therefore maintain mapping to a  and do not reconstruct variable assignment instructions. If a variable is relevant though, i.e. it is mapped to a concrete value in a slicing criterion, we reconstruct the assignment instruction together with an arithmetic expression in the RHS. We also join state required to evaluate the RHS with . It is crucial that we erase in prior to joining. Firstly, if x is assigned, its value becomes irrelevant prior to the assignment, unless x is read during evaluation of the RHS (e.g. we are slicing an assignment x := x + 1). In this case x will be included in but its value can be different than the one in . It is thus necessary to erase x in to make a join operation possible.

At this point, it may be helpful to review the forward rules for assignment and compare with the backward rules, illustrated via a small example. Suppose we have an assignment z := x + y, initially evaluated on , and yielding result state . The induced lattice of minimal inputs and maximal outputs consists of the following pairs:

where so that each line above abbreviates two concrete relationships; the lattice has the shape of a cube. Because is not read or written by z := x + y, it is preserved if present in the forward direction or if required in the backward direction. Because z is written but not read, its initial value is always irrelevant. To obtain the backward slice of any other partial output, such as , find the smallest maximal partial output containing it, and take its backward slice, e.g. .

In the backward slicing rules for if instructions, we only reconstruct a branch of the conditional that was actually taken during execution, leaving a second branch as a . Importantly in these rules state is a minimal state sufficient for an if condition to evaluate to a true or false value. That state is joined with state , which is a state sufficient to evaluate the reconstructed branch of an if.

Rules for while slicing follow a similar approach. It might seem that the second rule for slicing while is redundant because it is a special case of the third while rule if we allowed . Indeed, that is the case on paper. However, for the purpose of a mechanised formalisation we require that these two rules are separate. This shows that formalising systems designed on paper can indeed be tricky and require modifications tailored to solve mechanisation-specific issues.

Readers might have noticed that whenever a backward slicing rule from Figure 15 returns  as an output program, the state returned by the rule will be identical to the input state. One could then argue that we should reflect this in our rules by explicitly denoting that input and output states are the same, e.g.

While it is true that in such a case states will be equal, this approach would not be directly reflected in the implementation, where slicing is implemented as a function and a result is always assigned to a new variable. However, it would be possible to prove a lemma about equality of input and output states for  output programs, should we need this fact.

3.4 An Extended Example of Backward Slicing

We now turn to an extended example that combines all the programming constructs of Imp555This example is adapted from [9].: assignments, sequencing, conditionals, and loops. Figure 16 shows a program that divides integer a by b, and produces a quotient q, remainder r, and result res that is set to if b divides a and to otherwise.

r := a;
while ( b <= r ) do {
  q := q + 1;
  r := r - b
if ( ! (r = 0) )
then { res := 0 }
else { res := 1 }

(a) Original program.

r := a;
while ( b <= r ) do {
  r := r - b
if ( ! (r = 0) )
then { \(\Box\) }
else { res := 1 }

(b) Backward slice w.r.t. .
Figure 16: Slicing a program that computes whether divides .

To test whether divides we set , in the input state. The remaining variables q, r and res are initialised to (Figure 15(a)). The while loop body is executed twice; the loop condition is evaluated three times. Once the loop has stopped, variable q is set to and variable r to . Since the if condition is false we execute the else branch and set res to . Figure 17 shows the execution trace.

Figure 17: Trace of executing an example program for and .

We now want to obtain an explanation of res. We form a slicing criterion by setting (this is the value at the end of execution); all other variables are set to .

We begin by reconstructing the if conditional. We apply the second rule for slicing (Figure 15(b)). This is because , i.e. the body of this branch, backward slices to an assignment res := 1, and not to a  (in which case the first rule for slicing would apply). Assignment in the else branch is reconstructed by applying the second rule for assignment slicing. Since the value assigned to res is a constant it does not require presence of any variables in the state. Therefore state is empty. Moreover, variable res is erased in state ; joining of and results in an empty state, which indicates that the code inside the else branch does not rely on the program state. However, to reconstruct the condition of the if we need a state that contains variable r. From the trace we read that , and so after reconstructing the conditional we have a state where and all other variables, including res, map to .

We now apply the third rule for sequence slicing and proceed with reconstruction of the while loop. First we apply a trivial rule. The rule basically says that there is no need to reconstruct a while loop that does not execute – it might as well not be in a program. Since the final iteration of the while loop was reconstructed as a , we reconstruct the second iteration using the second backward slicing rule, i.e. the one where we have as the first premise. We begin reconstruction of the body with the second assignment . Recall that the current state assigns to r. The RHS is reconstructed using the second rule for backward slicing of assignments we have already applied when reconstructing else branch of the conditional. An important difference here is that r appears both in the LHS and RHS. Reconstruction of RHS yields a state where and (both values read from a trace), whereas the current state contains . Here it is crucial that we erase r in the current state before joining. We apply third rule of sequence slicing and proceed to reconstruct the assignment to q using the first rule for assignment slicing (since in the slicing criterion). This reconstructs the assignment as a . We then reconstruct the first iteration of the loop using the third slicing rule, since it is the case that . Assignments inside the first iteration are reconstructed following the same logic as in the second iteration, yielding a state where , , and other variables map to .

Finally, we reconstruct the initial assignment r := a. Since r is present in the slicing criterion, we yet again apply the second rule for assignment slicing, arriving at a partial input state and a partial program shown in Figure 15(b).

4 Formalisation

In the previous sections we defined the syntax and semantics of the Imp language, and provided definitions of slicing in a Galois connection framework. We have implemented all these definitions in the Coq proof assistant [20] and proved their correctness as formal theorems. The following subsections outline the structure of our Coq development. We provide references to the source code by providing the name of file and theorem or definition as (filename.v: theorem_name, definition_name). We will use * in abbreviations like *_monotone to point to several functions ending with _monotone suffix. The whole formalisation is around 5.2k lines of Coq code (not counting the comments). Full code is available online [19].

4.1 Lattices and Galois connections

Our formalisation is built around a core set of definitions and theorems about lattices and Galois connections. Most importantly we define:

  • that a relation that is reflexive, antisymmetric and transitive is a partial order (Lattice.v: order). When we implement concrete definitions of ordering relations we require a proof that these implementations indeed have these three properties, e.g. (ImpPartial.v: order_aexpPO).

  • what it means for a function to be monotone (Lattice.v: monotone):

  • consistency properties as given in Section 2.2 (Lattice.v: inflation, def-lation).

  • a Galois connection of two functions between lattices and (see Definition 1 in Section 2.2) (Lattice.v: galoisConnection).

We then prove that:

  • existence of a Galois connection between two functions implies their consistency and minimality (Lattice.v: gc_implies_consistency, gc_implies_minimality).

  • two monotone functions with deflation and inflation properties form a Galois connection (Lattice.v: cons_mono__gc).

Throughout the formalisation we operate on elements inside lattices of partial expressions (, commands () or states (). We represent values in a lattice with an inductive data type prefix666Name comes from a term “prefix set” introduced in [17] to refer to a set of all partial values smaller than a given value. So a prefix set of a top element of a lattice denotes a set of all elements in that (complete) lattice. (PrefixSets.v: prefix) indexed by the top element of the lattice and the ordering relation777In order to make the code snippets in the paper easier to read we omit the ordering relation when indexing prefix.. Values of prefix data type store an element from a lattice together with the evidence that it is in the ordering relation with the top element. Similarly we define an inductive data type prefixO (PrefixSets.v: prefixO) for representing ordering of two elements from the same lattice. This data type stores the said two elements together with proofs that one is smaller than another and that both are smaller than the top element of a lattice.

4.2 Imp syntax and semantics

All definitions given in Figures 25 are directly implemented in our Coq formalisation.

Syntax trees for Imp (Figure 2), traces (Figure 6) and partial Imp (Figure 3) are defined as ordinary inductive data types in (Imp.v: aexp, bexp, cmd), (Imp.v: aexpT, bexpT, cmdT) and (ImpPartial.v: aexpP, bexpP, cmdP), respectively. We also define functions to convert Imp expressions to partial Imp expressions by rewriting from normal syntax tree to a partial one (ImpPartial.v: aexpPartialize, bexpPartialize, cmdPartialize).

Evaluation relations for Imp (Figures 79) and ordering relations for partial Imp (Figure 4) are defined as inductive data types with constructors indexed by elements in the relation (Imp.v: aevalR, bevalR, cevalR and ImpPartial.v: aexpPO, bexpPO, comPO), respectively. For each ordering relation we construct a proof of its reflexivity, transitivity, and antisymmetry, which together proves that a given relation is a partial order (ImpPartial.v: order_aexpPO, order_bexpPO, order_comPO).

Join operations (Figure 5) are implemented as functions (ImpPartial.v: aexpLUB, bexpLUB, comLUB). Their implementation is particularly tricky. Coq requires that all functions are total. We know that for two elements from the same lattice a join always exists, and so a join function is a total one. However, we must guarantee that a join function only takes as arguments elements from the same lattice. To this end a function takes three arguments: top element of a lattice and two prefix values , indexed by the top element . So for example if is a variable name , we know that each of and is either also a variable name or a

. However, Coq does not have a built-in dependent pattern match and this leads to complications. In our example above, even if we know that

is a variable name we still have to consider cases for and being a constant or an arithmetic operator. These cases are of course impossible, but it is the programmer’s responsibility to dismiss them explicitly. This causes further complications when we prove lemmas about properties of join, e.g.:

This proof is done by induction on the top element of a lattice, where , , and are all smaller than that element. The top element limits the possible values of , , and but we still have to consider the impossible cases and dismiss them explicitly.

4.3 Program state

Imp programs operate by side-effecting on a program state. Handling of the state was one of the most tedious parts of the formalisation.

State is defined as a data type isomorphic to an association list that maps variables to natural number values (ImpState.v: state). Partial state is defined in the same way, except that it permits partial values, i.e. variables can be mapped to a hole or a numeric value (ImpState.v: stateP). We assume that no key appears more than once in a partial state. This is not enforced in the definition of stateP itself, but rather defined as a separate inductive predicate (ImpState.v: statePWellFormed) that is explicitly passed as an assumption to any theorem that needs it. We also have a statePartialize function that turns a state into a partial state. This only changes representation from one data type to another, with no change in the state contents.

For partial states we define an ordering relation as a component-wise ordering of partial values inside a state (ImpState.v: statePO). This assumes that domains of states in ordering relation are identical (same elements in the same order), which allows us to formulate lemmas such as:

This lemma says that if a partial state is larger than an state with empty domain then itself must have an empty domain.

We also define a join operation on partial states, which operates element-wise on two partial states from the same lattice (ImpState.v: stateLUB).

As already mentioned in Section 3.1, the domain of the state is fixed throughout the execution. This means that updating a variable that does not exist in a state is a no-op, i.e. it returns the original state without any modifications. This behaviour is required to allow comparison of states before and after an update. Consider this lemma:

It says that if a partial state is smaller than and the value stored in state under key is greater than then we can assign to key in and the ordering between states will be maintained. A corner-case for this theorem is when the key is absent from the states and . Looking up a non-existing key in a partial state returns a . If did not exist in (and thus as well) then would return  and so could only be a  (per second assumption of the theorem). However, if we defined semantics of update to insert a non-existing key into the state, rather than be a no-op, the conclusion of the theorem would not hold because domain of would contain and domain of would not, thus making it impossible to define the ordering between the two states.

The approach described above is one of several possible design choices. One alternative approach would be to require evidence that the key being updated exists in the state, making it impossible to attempt update of non-existent keys. We have experimented with this approach but found explicit handling of evidence that a key is present in a state very tedious and seriously complicating many of the proofs. In the end we decided for the approach outlined above, as it allowed us to prove all the required lemmas, with only some of them relying on an explicit assumption that a key is present in a state. An example of such a lemma is:

which says that if we update key in a partial state with value and then immediately lookup in the updated state we will get back the value we just wrote to . However, this statement only holds if is present in . If it was absent the update would return without any changes and then lookup would return , which makes the theorem statement false. Thus this theorem requires passing explicit evidence that in order for the conclusion to hold.

Formalising program state was a tedious task, requiring us to prove over sixty lemmas about the properties of operations on state, totalling over 800 lines of code.

4.4 Slicing functions

Slicing functions implement rules given in Figures 1015. We have three separate forward slicing functions, one for arithmetic expressions, one for logical expressions and one for imperative commands (ImpSlicing.v: aexpFwd, bexpFwd, comFwd). Similarly for backward slicing (ImpSlicing.v: aexpBwd, bexpBwd, comBwd).

In Section 2.2 we said that forward and backward slicing functions operate between two lattices. If we have an input (an arithmetic or logical expression or a command) with initial state that evaluates to output (a number, a boolean, a state) and records a trace then is a forward slicing function parametrized by that takes values from lattice generated by to values in lattice generated by . Similarly is a backward slicing function parametrized by that takes values from lattice generated by to values in lattice generated by . Therefore our implementation of forward and backward slicing functions has to enforce that:

  1. evaluates to and records trace

  2. forward slicing function takes values from lattice generated by to lattice generated by

  3. backward slicing function takes values from lattice generated by to lattice generated by

To enforce the first condition we require that each slicing function is parametrized by inductive evidence that a given input evaluates to and records trace . We then define input and output types of such slicing functions as belonging to relevant lattices, which is achieved using the prefix data type described in Section 4.1. This enforces the conditions above. For example, the type signature of the forward slicing function for arithmetic expressions looks like this:

Fixpoint aexpFwd {st : state} {a : aexp}
   {v : nat} {t : aexpT}
   (ev : t :: a, st \\ v):
   (prefix a * prefix st) -> prefix v.

Here ev is evidence that arithmetic expression a with input state st evaluates to a natural number v and records an execution trace t. The t :: a, st \\  v syntax is a notation for the evaluation relation. The first four arguments to aexpFwd are in curly braces, denoting they are implicit and can be inferred from the type of ev. The function then takes values from the lattice generated by (a, st) and returns values in the lattice generated by v.

In the body of a slicing function we first decompose the evaluation evidence with pattern matching. In each branch we implement logic corresponding to relevant slicing rules defined in Figures 1015. Premises appearing in the rules are turned into recursive calls. If necessary, results of these calls are analysed to decide which rule should apply. For example, when backward slicing sequences we analyse whether the recursive calls return holes or expressions to decide which of the rules should apply.

The implementation of the slicing functions faces similar problems as the implementation of joins described in Section 4.2. When we pattern match on the evaluation evidence, in each branch we are restricted to concrete values of the expression being evaluated. For example, if the last step in the evaluation was an addition, then we know the slicing criterion is a partial expression from a lattice formed by expression . Yet we have to consider the impossible cases, e.g. having an expression that is a constant, and dismiss them explicitly. Moreover, operating inside a lattice requires us not to simply return a result, but also provide a proof that this result is inside the required lattice. We rely on Coq’s refine tactic to construct the required proof terms. All of this makes the definitions of slicing functions very verbose. For example, forward slicing of arithmetic expressions requires over 80 lines of code with over 60 lines of additional boilerplate lemmas to dismiss the impossible cases.

For each slicing function we state and prove a theorem that it is monotone (ImpSlicing.v: *_monotone). For each pair of forward and backward slicing functions we state theorems that these pairs of functions have deflation and inflation properties (ImpSlicing.v: *_deflating, *_inflating), as defined in Section 2.2. Once these theorems are proven we create instances of a general theorem cons_mono__gc, described in Section 4.1, which proves that our definitions form a Galois connection and are thus a correctly defined pair of slicing functions. We also create instances of the gc_implies_minimality theorem, one instance for each slicing function. This provides us with a formalisation of all the correctness properties, proving our main result:

Theorem 1.

Suppose . Then there exist total, monotone functions and . Moreover,