-
Notifications
You must be signed in to change notification settings - Fork 266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Inconsistencies related to subtleties with higher-order state #2750
Comments
This could potentially happen in other subtle contexts so it is probably best to think of the general problem |
It is worth thinking about carefully, but I think that indeed this issue and #2178 and #1379 might be symptoms of the same fundamental issue. If so, #2178 and #1379 are convoluted and make it look like the problem might have to do with some advanced features like traits or reads/requires, when it might in fact more fundamental, as shown in this example. |
OK. I propose to track these jointly here. |
My opinion FWIW (admittedly biased by a desire to see my pet issue #2178 get attention): The example in this issue is almost the same as the first example in #1379, with If we enforce strict positivity on class fields, we fix #1379 but not #2178. If we disallow storing lambdas that read memory on the heap, we fix #2178 (hopefully!) but not #1379. There may well be a more "general problem of interaction of lambda expressions and heaps in the context of verification", but to establish that, I'd want to see either an example that isn't fixed by the combination of the two already proposed changes or an idea for a single change that would fix both #1379 and #2178. As it stands, I don't see any evidence of commonality between #1379 and #2178 to justify combining them for issue tracking purposes. |
I'll take some of that back. I take the point that we don't have a soundness proof of Dafny with lambdas and the heap, and at some point, we'll probably get more benefit out of working on such a proof and seeing what new restrictions we need to impose for it to go through than addressing individual unsound examples with plausible individual fixes as we find them. But will work on a soundness proof happen anytime soon? In the meantime, is it appropriate to go ahead and impose restrictions that fix some known unsound examples, even if they might not be part of the comprehensive solution we'd choose to complete a soundness proof and might even force users to do work adapting to breaking changes that could have been avoided if we went right to the comprehensive solution? Are we making the perfect the enemy of the good? And what's the best way to represent this whole situation in the issue tracker? I don't know. One option would be to keep all 3 issues open: #1379 and #2178 to track their specific examples and proposed fixes and this issue to track broader study of soundness of lambdas and the heap. That would convey no implication one way or the other about which approach might actually be pursued first. |
One more thought I want to add: I'm hopeful that if we require strict positivity for const class fields (which makes sense because they're basically like datatype fields) and we require that mutable fields don't directly or indirectly contain lambdas that read memory, then we can get away without requiring strict positivity for mutable fields. Waving my hands a bit, each of the unsound examples we've seen seems to involve a cycle of fields of some kind. So we have two cases:
If a user needs a non-positive field, they can declare it mutable even if they have no intent to actually mutate it if they can comply with the restriction on lambdas that read memory. @jtristan Does this address your concern about "expressiveness of the language"? |
I have an ideaLet me add a comment here, since you raise the true fact that we don't have a soundness proof for lambdas and for mutable fields. Dafny has this principle that it normally detect potential cycles in methods and functions, and requires a decrease clause in that case to ensure termination. In each of this issue #1379 and #2178, the "lambda" (or function) is not checking for termination because it (wrongly) assumes that the function it's calling is "surely on different termination cycle" ( If we were to write the problem using always a (potentially partially applied) function rather than a lambda in the code -- lambdas are just partially applied functions where the first inputs are implicitly given, then the error seems more obvious:
and for #2178:
So we have to assume that, for every call to a lambda stored in a field, it could be the function itself, or a function in the group of mutually recursive functions in which this function is part of, or points to it. It does not need to have the same signature as the function itself. But it requires a termination check. Ok the idea nowLet's imagine that every lambda has a "decreases" clause that becomes a "decreases" ghost function field on it (the one that returns the .termination computation in a set which does not have infinite descending sequences). Then, all the code above will fail because, since Dafny cannot recognize the lambda itself, it will add the termination check automatically:
and that will obviously fail, the model indicating that if x.f == rec, then it does not terminate.
Now, that function is no longer a
Bingo, we have an error. The only way to solve this error would be to add a precondition to the closure that refers to the implicit
Now something interesting happens. Well-formedness check will ensure it's no longer possible to call CaveatsHere are the potentials problems of my approach:
|
In this comment, I'm going to focus on #2178 because I'm convinced that #1379 should just be fixed by requiring strict positivity for const class fields. My concern about Mikaël's proposal: How do you prevent the following without causing too much of a hassle in legitimate code? method Knot() ensures false {
var r := new Ref<() ~> bool>(() decreases 0 => false);
var tf := () reads r, r.target.reads()
decreases r.target.decreases() + 1 // !!!
// Now the precondition holds by construction; we could even omit the `requires` clause.
requires r.target.decreases() < decreases() => (
if r.target.requires() then !r.target()
else false
);
r.target := tf;
assert tf() == !tf();
assert false;
} Compare to /* applies a transformation function on the sequence */
function method {:opaque} Map<T,R>(f: (T ~> R), s: seq<T>): (result: seq<R>)
requires forall i {:trigger s[i]} :: 0 <= i < |s| ==> f.requires(s[i])
decreases max(set i | 0 <= i < |s| :: f.decreases(i) + 1), |s| // <==
ensures |result| == |s|
ensures forall i {:trigger result[i]}:: 0 <= i < |s| ==> result[i] == f(s[i]);
reads set i, o {:trigger o in f.reads(s[i])} | 0 <= i < |s| && o in f.reads(s[i]):: o
{
if |s| == 0 then []
else [f(s[0])] + Map(f, s[1..])
} Some ideas that might work:
However, a broader concern is that the proposal would require extra boilerplate to deal with |
Knowing this, how would you change your example to create a problem? |
Before I can give a definite answer, I might need you to fill in the rest of the details of your proposal, i.e.:
But a first attempt at an answer: For every lexicographic tuple, there exists a greater tuple, right? (Or do we allow ⊤ by itself as a lexicographic tuple?) If so, it seems fragile to rely on there not being any way within the language to compute a greater tuple. For instance, I think a "let-such-that" would probably work if there were a way to make it deterministic. In any case, my other point stands that regardless of the soundness of your proposal, I think it will be a pain for users of lambdas. |
I just tried the following experiment: function f(): int
decreases true
{
g()
}
function g(): int
decreases 1
{
f()
} it does not complain about type checking, only that it cannot prove that the expressions decrease.
We can probably circumvent this problem by having a special uninterpreted ghost predicate
There is one biggest lexicographic tuple, and it's the empty lexicographic tuple.
As you stated it yourself, if a lambda is not reading anything in memory, it won't need any decreases clause, because it cannot loop into itself. You cannot create the example at the top of this page using a datatype because of positivity either.
Otherwise, there might be latent soundness issues that users have to be aware of, so I'd argue we should issue this breaking change if Dafny cannot guarantee termination. |
(I'm finally following up on this thread. I've been busy with real work!) Hi Mikaël, Re the first point, about soundness of the proposal: I think I can update my previous example to comply with the rules for lexicographic tuples. I'm assuming that function decreaseNat(n: nat): () decreases n { () }
method Knot() ensures false {
var r := new Ref<() ~> ()>(() decreases 0 => ());
var tf := () reads r, r.target.reads()
decreases
(
if (exists n :: r.target.decreases() == decreaseNat.decreases(n))
// The let-such-that is deterministic since we assume lexicographic tuple
// construction is injective.
then (var n :| r.target.decreases() == decreaseNat.decreases(n); n) + 1
else 0
)
=> ();
// The `decreases` clause consists of a single `nat` no matter which branch of the `if` is taken.
assert (exists n :: tf.decreases() == decreaseNat.decreases(n));
r.target := tf;
// Because r.target == tf.
assert (exists n :: r.target.decreases() == decreaseNat.decreases(n));
// That was exactly the `if` condition, so we know the `then` branch is taken.
// Define `n` the same way as in the `then` branch:
var n :| r.target.decreases() == decreaseNat.decreases(n);
// Now the `decreases` clause simplifies to `n + 1`, so we should be able to deduce:
assert tf.decreases() == decreaseNat.decreases(n + 1);
// But r.target == tf, so:
assert decreaseNat.decreases(n) == decreaseNat.decreases(n + 1);
// By injectivity:
assert n == n + 1;
assert false;
} Have I made a mistake? (Unfortunately, I can't easily test code that uses a language extension that hasn't yet been implemented.) If not, at what point would you block the above example? Re the second point, about the amount of hassle the proposal might cause for legitimate code:
OK, I didn't realize from your original post that you intended to exempt some cases from the new restriction; that helps. And I agree we should close any soundness gaps. However, we don't know at this point that your proposal is the only possible way to do that; we still have my alternative proposal from #2178. We should compare them on the basis of (1) how confident we are that they actually solve the soundness problem and (2) how much of a hassle they would present to legitimate code. I'm not sure about (1); I believe John is studying it. For (2), I'm not familiar with the full range of real-world Dafny code that might be affected by any new restriction, but one example I think is important is |
Great and clever thinking. This is an elaborated version of your What comes in my my mind now is that
The only way to invoke method Knot() ensures false {
var r := new Ref<() ~> bool>(() decreases 0 => false);
var tf := () reads r, r.target.reads() decreases 1 requires r.target.decreases.decreases() < decreases() requires r.target.decreases() < decrease() => (
if r.target.requires() then !r.target() // ok
else false
);
r.target := tf;
assert tf() == !tf();
assert false;
} But then, we cannot guarantee that Now I'm convinced the easiest and the least disruptive way to fix this unsoundness for now (before we even consider a decreases clause on closures), after reading all of your proposals, is to disable storing lambdas that can read memory as a field of some class. |
Sorry if the following is naive, as I haven't followed the details :/ I would love to understand precisely why lambdas behave differently from trait functions with regards to termination. Intuitively, lambdas and traits should be interchangeable, since lambdas can (well, should be able to) be desugared into instances of a class that stores the state captured by the lambda. I'm guessing the heart of the issue is that termination is proved against the spec of the trait for trait functions, whereas for lambdas we try to prove that the lambda can always be called, in any context? Maybe it would be interesting to take a few real-world examples of code that uses |
That's basically my understanding from what I've seen so far, though I imagine there are others here who could speak with much more authority on this.
Can you clarify what Dafny feature you're trying to evaluate the feasibility of removing? The ability to use |
Minor point:
If the user opts to bypass termination checking, all bets are off for soundness; this isn't our problem. If we want, we can make |
A further remark: Lambdas can be called from any context, but I imagine that the biggest use case by far is to call them during execution of the same function that created them via a helper such as An interesting sub-case is when a function datatype IntTree = Internal(children: seq<IntTree>) | Leaf(x: int)
function mapSeq<A, B>(f: A --> B, s: seq<A>): seq<B>
requires forall x | x in s :: f.requires(x) {
seq(|s|, i requires 0 <= i < |s| => f(s[i]))
}
function sumOfIntSeq(s: seq<int>): int {
if |s| == 0 then 0 else s[0] + sumOfIntSeq(s[1..])
}
function sumOfIntTree(t: IntTree): int
decreases t {
match t {
case Leaf(x) => x
case Internal(children) => sumOfIntSeq(mapSeq(
t2 requires t2 < t => sumOfIntTree(t2), // <==
children))
}
} The upshot for our work here: if we keep this feature for lambdas that don't read memory (which I sure hope we do), that may reduce the cost of also keeping it for lambdas that do read memory, subject to any other restrictions we impose for soundness. |
For the record and future tests, a variant using arrays and datatypes datatype Pack<T> = Pack(ghost c: T)
method m()
ensures false
{
var r := new (Pack<() ~> bool>)[1];
r[0] := Pack(() => false);
var tf := Pack(() reads r, r[0].c.reads => (
if r[0].c.requires() then !r[0].c() else false
));
r[0] := tf;
assert r[0].c() == !r[0].c();
assert false;
} |
Wow what a great example. I got inspired. Let me throw a way to solve this unsoundness with the least disruptions in the short-term: Prevent invocations of expressions of type Here is why i think it should work. But when we look at the call Hence, if we encounter an expression that looks like if r[0].c.requires() then
assert (r[0].c).reads() == {}; // inserted automatically
!r[0].c() else false Now, why would such a restriction work in any case? If we call a closure Notes:
|
I'm also curious about one thing: how common is it to store a ghost value of type ~> in memory? My intuition is that it is not common at all, but I would be curious to hear from people who have seen more Dafny code than me. |
Good catch. I tried to remove the ghostness of this example, but then I need to put the requires in the requires clause of the closure itself, and it's no longer possible to call it. datatype Pack<T> = Pack(c: T)
method m()
{
var r := new (Pack<() ~> bool>)[1];
r[0] := Pack(() => false);
var tf := Pack(() reads r, r[0].c.reads requires r[0].c.requires() => (
!r[0].c()
));
r[0] := tf;
print r[0].c(); // A precondition might not hold.
} |
Asking in this context since @RustanLeino connected this issue and #3152: is there a reason we SHOULDN'T at least add the missing check for strict positivity for classes? I can see that it wouldn't be sufficient to address this larger class of unsoundness, but I assert that the missing check is simply a bug given the current design of the language. |
Mikaël, re your latest proposed solution for the heap variant of the issue: I don't think it's going to work as well as you imagine. I still think my proposal (to prohibit storing The first issue with your proposal that needs clarification: A call from one class Ref<T> {
ghost var target: T
constructor(target_init: T)
ensures target == target_init {
target := target_init;
}
}
trait AbstractLambdaCaller {
function call(l: () ~> bool): bool reads *
}
method ProtoKnot(c: AbstractLambdaCaller) returns (ghost l: () ~> bool)
ensures l.requires() && l() == c.call(l)
{
var r := new Ref<() ~> bool>(() => false);
l := () reads * => c.call(r.target);
r.target := l;
}
class NegatingLambdaCaller extends AbstractLambdaCaller {
function call(l: () ~> bool): bool reads * {
if l.requires() then !l() else false
}
}
method Knot() ensures false {
var c: NegatingLambdaCaller := new NegatingLambdaCaller;
var l := ProtoKnot(c);
assert l.requires() && l() == c.call(l);
assert l() == !l();
} Let's suppose you have some reasonable solution to the above issue. Now consider the following code to apply a function function TwoLevelMap<T,R>(f: T ~> R, s: seq<seq<T>>): seq<seq<R>>
requires forall i, j | 0 <= i < |s| && 0 <= j < |s[i]| :: f.requires(s[i][j])
reads set i, j, o | 0 <= i < |s| && 0 <= j < |s[i]| && o in f.reads(s[i][j]) :: o
{
var f2 := (si)
requires forall j | 0 <= j < |si| :: f.requires(si[j])
reads set j, o | 0 <= j < |si| && o in f.reads(si[j]) :: o
=> Map(f, si);
Map(f2, s)
} I believe the basic version of your proposal would reject this code because
Would this rule help in the class Ref<T> {
ghost var target: T
constructor(target_init: T)
ensures target == target_init {
target := target_init;
}
}
method Knot() ensures false {
var r := new Ref<(()) ~> bool>(u => false);
var tf := u reads r, r.target.reads(()) => (
if Map(r.target.requires, [()])[0] then !Map(r.target, [()])[0] else false
);
r.target := tf;
assert tf(()) == !Map(r.target, [()])[0];
assert tf(()) == !tf(());
assert false;
} So I guess if we use this rule, the analysis would have to be interprocedural? So: Unless the special rule applies, it seems that my proposal has an expressiveness advantage in the Regarding those other design criteria:
Mikaël, do you have any other concerns about my proposal? I get the sense that you're dissatisfied with it, given that you keep suggesting alternatives. |
I'm always in favor of soundness vs. my potential dissatisfaction, so I'm truly grateful your are putting so much effort defending your proposal as well as finding flaws in mine, that's the best way we can find a solution we both agree on.
The only reason why I keep looking for alternatives to your solution is that I have seen in industrial user's code the storage of closures of type A ~> B as ghost const of traits that don't have any implementation (they model some externs), so your proposal would break their code with no obvious way to recover from it. trait {:extern} {:compile false} Mapper<B, B2(!new)> {
ghost const valueConverter: B ~> B2
ghost const underlyingMap<int, B>
ghost const modelMap<int, B2>
predicate Valid() {
forall k :: k in underlyingMap ==>mapBase[k] == valueConverter(underlyingMap[k])
}
} Even though in this case it seems like you can put the value converter as an explicit function, what if this is provided by the constructor in derived classes? If code is not building other closures based on
In your example, the faulty closure
I was thinking of situations like this: var f1: A ~> B = ...
var f2: A ~> B = ..... f1(a);, I thought that, because f1 was statically built before f2 in the same context, there would be no way f1 can actually call f2 recursively. However, if f1 reads the memory as well, it could be that f2 is be assigned to that memory later so that f1 can actually call f2 indirectly, as your example states it. So my note is not acceptable.
The basic version of my proposal would not reject the code However, my proposal would actually have (new necessary condition) to reject the definition of That remark, along with the problem of the strict positivity (which happens even if there is no memory reads), makes me think that there might be no way to get around forbidding storing a closure that reads memory as a field. But that's not enough, John's example is storing closures that read memory indirectly. datatype Pack<T> = Pack(ghost c: T ~> bool) // Not on the heap, yay !
method m()
ensures false
{
var r := new (Pack<()>)[1]; // Ouch, how do we detect simply that we are storing a lambda that reads the memory on the heap?
r[0] := Pack(() => false);
var tf := Pack(() reads r, r[0].c.reads => (
if r[0].c.requires() then !r[0].c() else false
));
r[0] := tf;
assert r[0].c() == !r[0].c();
assert false;
} Alternatives to consider.Forbidding closures that can read memory as fields of objects is a pity though, because when we model JavaScript, it's extremely common to store closures that can read heap on an object, we do this all the time. To me, although I might be wrong, it still feels like it should be equivalent to have a closure stored on the heap be the same as a function. Here are alternatives:
|
While we discuss potential solutions, I would also like to understand what the fundamental problem is. Note that non-termination does not, in general, lead to inconsistencies. For example, the following code is valid and reasonable Dafny code:
Because of the decrease * clause, we only get partial correctness: if the program terminates, then it will satisfy the postcondition. Note that any method that calls f must have a decreases * clause. What we do need to be careful about is that any term that can be used as part of a specification be strongly normalizing. f is not a problem because Dafny forbids using f as a term in a specification. Likewise, creating a Landin's knot is not inherently problematic. The actual problem is when it end up being used as part of a specification. So, preventing Landin's know from being defined might be a solution, but perhaps it is worth also considering whether we should expect code that defines a Landin knot to come with a decreases * clause. |
What ways do you think we could prevent Landin's knot from being defined? Also, you might be suggesting something along the lines of adding "decreases *" to closures that can read the heap? |
Q1: the best solution I have seen so far is what Matt has been sketching |
Another example from Rustan, interesting because of the different signature for the constructor:
|
Still catching up on this thread. But here is a verified proof of
|
@mattmccutchen-amazon how would your proposal deal with the case above? It's a lambda that does not read the heap nor has preconditions. I actually looked at this example, and it's very interesting how it's possible to tweak it to encode mutually recursive functions without the need of any decreases clause. class Y {
const even: Y -> int -> bool
const odd: Y -> int -> bool
constructor()
ensures even == ((y: Y) => (x: int) =>
if x == 0 then true else if x > 0 then y.odd(y)(x - 1) else y.odd(y)(x + 1))
ensures odd == ((y: Y) => (x: int) =>
!y.even(y)(x))
{
even := (y: Y) => (x: int) =>
if x == 0 then true else if x > 0 then y.odd(y)(x - 1) else y.odd(y)(x + 1);
odd := (y: Y) => (x: int) =>
!y.even(y)(x);
}
}
method Main() {
var y := new Y();
assert y.even(y)(4);
} With regular predicates, you would have to manually provide decreases clauses to check termination 😱 predicate even(x: int)
decreases if x > 0 then x else -x, 1
{
if x == 0 then true else if x > 0 then odd(x - 1) else odd(x + 1)
}
predicate odd(x: int)
decreases if x > 0 then x else -x
{
!even(x)
} |
Oh actually, it's another instance of #1379 which has a separate fix, i.e. checking the type cardinality. |
@jtristan 's code-snippet in the style of #2750 (comment), i.e. https://leino.science/papers/krml280.html :
|
Another variation of the previous example:
|
In https://leino.science/papers/krml280.html Rustan points out that in F* the problem is solved by omitting the injectivity property of the datatype constructor
|
Another version, which generalises the problem from
|
Watch out, I don't think this last one is a valid example of a deficiency with Dafny. All it is showing is that if you make inconsistent assumptions, then you can prove false.
|
To clarify, here is one of the many instantiations of above (abstract) assumptions, which are sufficient to derive an inconsistency.
|
I haven't followed all the details - I know Marco Servetto had a discussion of this at the Unsound workshop in OOPSLA Auckland last month --- https://unsound-workshop.org I wanted to reiterate @mattmccutchen-amazon's point here #2750 (comment)
I don't think it's just "appropriate" - it's essential to at least have the option. I'm working with Dafny and for various reasons may be pushing the envelope (or not, I don't know enough to know). I know there's no proof, but for me often it's critical that Dafny not give out false positives - or else why bother? Especially if I'm doing weird s***. I don't want to wander accidently into unsoundness. Where there are known unsoundnesses(sic) I need to know. If there are overly-conservative checks that rule out lots of stuff, well I'd rather either turn the checks off, or rewrite my code to avoid the problem than have dafny tell me something verifies when it doesn't. If there are too many restrictions, or they're too tight, turn them off by default, or give people the option of {:termination false} or whatever. (and btw, since I primarily work with objects, if I needed to use lambdas - so far I don't - they'll need to be able to "read the heap") |
You say that it is essential to have the option and I agree, but there's actually more than 1 option, and so I am curious to hear which one you would prefer. For concreteness, let's consider the very first example in this thread. The first question is: what is the problem? Is the problem that the proof should have been rejected because the method was not declared with a decreases * clause and/or we consider it to be invalid, or is the problem that Dafny should have required the declaration of a decreases * clause? With that in mind, the option we could provide could be one of two things:
Both have pros and cons, and far-reaching consequences, but I am curious to know which one you would find more satisfying and why. |
I'm not sure I can see the difference between those two options: forcing "the user" to be explicit that the specification is partial seems to require rejecting the program if they're not so explicit..? Or do you mean banning even partial specifications with no opt-out? Hopefully there's no need to choose (given the plethora of options, attributes, command-line switches, and now cli verb options Dafny as grown :-). But if I have to choose, for what I'm doing, I'd rather err on the side of soundness. (I want to sent papers in to POPL* saying I've got a Dafny model that verifies - *POPL - not really POPL (ça doit être Coq) but OOPSLA at least. |
Two more examples. First, an example that hints that we could have arbitrary long cycles:
Second, an example that shows that it is possible to create a code knot even without a constructor.
|
Another simple useful to keep in mind to devise a solution:
|
A solution was implemented in #4348 . It is possible to relax the checks to reject less programs, but it's unclear to me that there is any demand for such expressiveness at this point. |
In the following example, we prove that 0 equals 1 by defining a diverging function.
For explanatory purposes, the proof is much more detailed than it needs to be.
The idea is to make a recursive call not syntactically obvious by defining a lambda expression
that calls a method that has been defined as the lambda expression.
The text was updated successfully, but these errors were encountered: