-
Notifications
You must be signed in to change notification settings - Fork 247
Steel: Framing
This page presents the handling of the separation logic framing in Steel.
The frame rule is at the core of separation logic, enabling modular reasoning. In its simplest form,
it is defined as follows: If a computation e admits the separation logic Hoare Triple {P} e {Q},
then a framed version {P * F} e {Q * F} is derivable for any frame F.
The composition rule is also standard: If we have two computations e1 and e2 with triples {P1} e1 {Q1} and {P2} e2 {Q2}, and if Q1 ==> P2, then the triple {P1} e1; e2 {Q2} is admissible.
In a simple setting, proving the equivalence of separation logic assertions (that we will name slprops from now on) is straightforward. Let us consider P = p_1 * p_2 * p_3 and Q = q_1 * q_2.
(slprop, *, emp), where emp is the empty separation logic assertion forms a commutative monoid,
hence any rearrangement of p_1, p_2, p_3, such as for instance p_3 * p_1 * p_2 is equivalent to P.
As such, we can rearrange the slprops inside both P and Q in some canonical order, and then efficiently check whether q_1, q_2 is a subset of p_1, p_2, p_3. If so, we can conclude that P ==> Q.
Determining separation logic implication in Steel is unfortunately harder: slprops are dependently-typed,
and they are also allowed to contain program implicits that have not yet been inferred. To simplify the problem, we will instead consider separation logic equivalence.
Even though the separation logic in Steel is affine, we leave it to the user to explicitly drop unwanted
slprops to derive an implication from an equivalence.
The rest of this page presents our (partial) decision procedure to automate frame inference, as well as program implicits resolution when used in slprops. First, we use layered effects to automatically insert
frames when needed, and collect a list of separation logic constraints.
These constraints are then deferred to a tactic, which chooses the next constraint to be solved, and calls
a decision procedure attempting to prove equivalence.
If this decision procedure succeeds, program implicits and frames in the constraint are resolved.
Framing is often required when composing two computations, such as {P} e1 {Q} and {R} e2 {S}. We can frame e1 with an fp:slprop, and e2 with an fp':slprop to obtain {P * fp} e1; e2 {S * fp'} as long as Q * fp ==> R * fp'. For instance, if e1 and e2 are reads to two different references r1 and r2,
we wish to automatically infer the triple {pts_to r1 * pts_to r2} read r1; read r2 {pts_to r1 * pts_to r2} in Steel.
But inserting a frame at each composition leads to having to infer many frames, and applying the frame rule several times to the same computation. For instance, when composing e1, e2 and e3, we would first add a frame for the composition of e2 and e3, and then another for the composition of e1 and e2; e3. Instead, we wish to only frame each computation once.
To distinguish between framed and not-yet framed computations, we define two effects SteelF and Steel.
In the first case, computation types will be of the form SteelF a (pre * ?f) (fun x -> post x * ?f) where ?f:slprop is a metavariable to be solved. In the Steel case, computation types do not contain metavariables. SteelF is only intended as an internal artifact; users should always specify their functions using the Steel effect.
TODO: Show signatures, relation between the two effects from framing note
Important invariant: Apart from the return application, all SteelF functions have at most one metavariable.
When reasoning about concurrent programs and invariants, Steel relies on a model of total, atomic actions, which are a sub-language of Steel functions where general recursion is enabled. In the framework, atomic computations have their own effect SteelAtomic which is a subeffect of Steel.
The specifics of the SteelAtomic effect are orthogonal to the framing problem, and SteelAtomic and Steel share their separation logic structure. We define two effects SteelAtomic and SteelAtomicF similarly to Steel and SteelF with the appropriate polymonadic binds and subcomps. The separation logic constraints collected are identical to the ones for general Steel computations, and also deferred to the same tactic.
Goals deferred to the tactic are of three sorts: uvars to be inferred (for instance ?f:slprop),
equalities due to the layered effects, and slprop equivalences due to framing and composition.
By definition of the effects, all framing uvars to be inferred appear in at least one equality or equivalence constraint. We thus first filter them out to present a proofstate with only constraints to be solved.
Equalities are the most brittle constraints, and correspond to indirections in the layered effects.
It is not sufficient for two slprops to be equivalent, they need to be definitionally equal for
an equality constraint to be discharged. Therefore, they need to be solved first, to remove all indirections
before deciding equivalence.
Given the calculus given previously, almost equalities are of the form ?u1 = ?u2 or ?u1 x = ?u2.
Thanks to a recent change in the F* unifier, these equalities can all be solved without needlessly
restricting the scopes of ?u1 or ?u2.
The only exception occurs when returning a Pure value at the end of the function; the corresponding equality has the shape ?u1 x = ?u2 y, which might lead to scoping issues when trying to unify naively.
To circumvent this issue, we apply the following scheduling. We first remove indirections by solving all equalities but the one associated to a possible return, that we can identify thanks to its shape. We then solve the equivalence constraint in a subcomp application, therefore inferring the dependent slprop associated to the returned Pure value. We identify the subcomp constraints by annotating them in the effect definition. We finally solve the remaining return equalities to remove the last indirections.
At this stage, we are left with only slprop equivalences to prove.
Because all effectful functions require a top-level annotation, we can first ensure that the inferred computation type for the whole body matches the top-level annotation through subcomp.
The corresponding constraint is solved first.
Once this is done, we traverse the list of constraints looking for one that contains at most one slprop metavariable. The existence of such a constraint relies on an invariant from our calculus: apart from
constraints related to a return computation, specifications in SteelF contain at most one metavariable.
Since we already solved the dependent slprop associated to the return previously, all constraints now have at most two slprops, one in each term in the equivalence.
Furthermore, after solving subcomp, the outermost constraint only contains at most one slprop. A forward symbolic execution would therefore give us a scheduling where each constraint has at most one slprop, which ensures that finding such a constraint by simply traversing the list is always possible.
Once we picked an equivalence constraint to solve, we need to decide whether the two terms are actually equivalent, inferring the implicits they might contain along the way.
We are attempting to decide whether two slprops p and q are equivalent. We first split p and q into their atomic subresources. If p = p_0 * p_1, we reify p as the list of "atoms" [0, 1] where 0 corresponds to p_0 and 1 to p_1. Similarly, if q = q_0 * q_1, we reify q as [2, 3].
If for instance p_0 and q_1 were exactly the same (not just unifiable, but syntactically equal), they would be associated to the same atom.
When reifying slprops, we keep track in a map of what slprop each atom is associated to.
Given a list of atoms l and such a map am, we thus can define a canonical denotation of l as an slprop as follows:
let rec mdenote l am = match l with | [] -> emp | hd::tl -> (select hd am) * mdenote tl am
We prove once and for all that the result of the denotation is equivalent to the initial slprop before reification through Associative-Commutative rewriting. Similarly, we also prove that any permutation of
the list l yields an equivalent denotation.
To prove that p and q are equivalent, we will now try to find permutations of their representations such that the corresponding denotations can be unified. Since atoms are simply natural numbers here, proving that a list of atoms is a permutation of another is straightforward: We can put both lists in canonical form (i.e. as sorted lists), use normalization to remove all intermediate function calls and reduce them to concrete lists, and check whether the two lists are equal.
We now need to create permutations of l1 and l2, the representations of p and q respectively, such that their denotations could be unified with each other.
Our scheduler ensures that there is at most one slprop metavariable in p and q. We ensure that this metavariable can only ever occur in q, rewriting our goal using the symmetry of equivalence if needed.
We will keep two accumulators l1_del and l2_del which correspond to the atoms that have been handled
already. The (implicitly) maintained invariant is that l1_del and l2_del can be unified at all times, and that l1_del@l1 (resp. l2_del@l2) is a permutation of the initial l1 (resp. l2) at all times.
Our first step is to check whether slprops in both p and q match exactly, i.e. if atoms are the same in both representations. This is done using the function trivial_cancels. If it is the case, we remove these atoms from the lists, and input them in l1_del and l2_del.
We now iterate over what remains in the lists l1 and l2.
For each element in l1, we will try to find a candidate for unification in l2.
We consider the head of the list of l1, that corresponds to an t:slprop.
Because of our scheduling invariant, we know that t is not a metavariable, but it might contain
unresolved program implicits (for instance if it is pts_to r ?perm v).
We traverse the list l2, trying to unify t with all slprops in l2 which are not metavariables (as t should always be able to unify with the frame metavariable). Unification attempts are done in a separate context (using a try/with) which ensures that they do not pollute the current proofstate.
We have three cases:
-
tcould only unify with oneslprop: We found a unique candidate, we movetand the corresponding candidate tol1_delandl2_delrespectively. -
tdid not unify with anyslpropinl2: it should likely be part of the frame inl2 -
tdid unify with more than oneslprops: we cannot conclude about the right candidate fort.
In the last two cases, we did not successfully find a unification candidate for t. We skip t for now, and will come back to it later; in the last case, it might be that another element in l1 might be a more restricted version of t, and will only have one possible candidate.
For each element in l1, we try to find an appropriate candidate as defined in the previous subsection, moving the element and the associated candidate in l2 to l1_del and l2_del respectively when the search is successful.
During our iteration, we have several cases to consider:
- If
l1is empty, we found equivalent terms for all elements inp. Ifl2is also empty, we are done. Ifl2only contains one element, which happens to be the frame metavariable, we are also done as it can be unified withemp. In all other cases, there are leftoverslprops inl2, and we raise an error. - If
l1is not empty, butl2only contains one element which happens to be the frame metavariable, we are also done: Allslprops left inl1are the frame inl2. - Else, we try to traverse the whole of
l1. While doing this traversal, either we find a candidate for some element inl1, thus reducingl1, or we do not make any progress, and fail; more annotations are required from the user.
TODO
Core idea: implicit dynamic frames equalities depend on the slprops. We separate the goals, solve the ones related to slprops and can then solve the ones related to implicit dynamic frames with straightforward unification using an appropriate scheduling.
Assume p: n:even -> slprop
Unification through trefl of n:nat, _:squash (is_even n) |- p 2 == ?u fails because trefl is called without smt on.
This can happen if for instance we have f (n:even) : SteelT unit (p n) (fun _ -> ...),
but call it with let n:nat = 2 in f 2.
A workaround would be let n:even = 2 in f 2: If we have exactly the right type, unification succeeds.
But this can become verbose and tedious quickly.
TODO: Try Guido's patch: We consider as valid candidates for unification terms that unify, but with some purely logical facts remaining left for SMT. When finally calling trefl after the permutations were built, we call it with smt on to discharge such logical facts, enabling refinement subtyping.
Happens often when considering references to PCMs.
A PCM will often have its compose that relies on a pattern matching.
If compose appears in one of the slprop indices, unification will fail. If we have for instance pts_to r (compose v0 v1) == pts_to r (compose ?u1 ?u2) as a constraint, trefl will unfold compose. Again, without SMT, unification will fail because it will not be able to prove that all branches in both cases match.
TODO: Try Guido's patch? Not sure if it would help here. It might just better to be careful in the style of Steel functions we propose.
We currently do not support a framing subcomp from Steel to Steel. There was some limitation in layered effects about adding a frame, this was conflicting with the if_then_else combinator
TODO: One example is if_then_else (see the cond combinator), but it's something more general in
pattern matching when the Steel signatures might depend on the branches.