Decidability
This is the first lecture in the course, and is about program analysis at large, and specifically about how to participate in this course.
At the beginning of the 20th century, a group of logicians was obsessed with coming up with a system for proving all mathematical truths. The problem was that a foundational crisis had emerged. Many mathematical theories had holes in them, or were able to prove theorems that are false e.g ., Russell's paradox.
To this end, researchers developed different systems for automatically proving theorems. David Hilbert introduced the Hilbert's program, and Alonzo Church developed the Lambda Calculus. The underlying idea was: if we could develop an automatic procedure for deriving all truths, we could recursively enumerate all true statements. This property is known as recursive axiomatizability.
However, they quickly ran into a problem: how can we prove that these programs
actually terminate? This is the fundamental program analysis question, and it strangely preceded the concept of programming itself.
To talk about program analysis, we first have to define what we mean when we say program. A program, in the context of this course, is an object from a language that, when executed in a machine, produces some behaviour.
A program is an object , from a language , with a step function from machine state to machine state:
When executing a program, we state with an initial state, then we often want to run the step function it until it no longer changes the state. This is called a fixpoint, or running the program to completion. The sequence of states visited by the program is called a Trace.
Program analysis is the art of extracting facts about the structure or possible behavior of a program. These facts could include does the program eventually come to a halt,
is the program well-formatted,
or will my program exhibit a bug?
In this course, we are going to investigate multiple approaches to answer these questions, ranging from manual to automatic, from syntactic to semantic, and from dynamic to static.
In summary, program analysis is:
Using automatic techniques to figure out facts about a computer program.
For simple languages, it is relatively easy to figure out what they do. For example, 1 + 2 will always execute to 3. But program analysis in general is extremely hard.
We are going to focus most of our energy on programs from languages that are Turing complete. Turing complete languages are languages in which you can write any program executable on a machine. This class of programs is also what you are mostly familiar with. Java, C, Python, etc. are all Turing complete languages. Even Brainfuck and PowerPoint are Turing complete.
Using a Turing-complete language is a double-edged sword: while it is nice that it is powerful enough to do everything, it is also powerful enough to:
fire the missiles,
not fire the missiles,
never terminate,
kill grandma, and worst of all
throw a null pointer exception!
So it would be nice to be warned if any of these things might happen. However, that turns out to be very hard.
Consider the mother of all analysis problems, the halting problem:
Given any program from the language , decide if it is going to terminate (halt) when executed on a state .
E.g ., does there exist a sequence, which eventually stabilizes: , , , ,
This problem turns out to be impossible to solve correctly for all programs of a Turing-complete language. It is, in fact, undecidable. While it is easy to say with confidence that some programs do terminate, we cannot build a mechanical procedure which can do this for every program.
The argument goes a little like this. Suppose you have a mechanical procedure for solving the halting problem; in that case, we can encode it as a program:
def does_halt(p : Program) -> bool:
# A program returns true if p haltsNow we can use this program in another program:
def main():
while does_halt(main):
print("Running")This is a weird program. We can see that if main halts, it runs forever, and if it runs forever it will eventually halt. This is of course impossible, which strongly suggests that does_halt cannot exist.
We could hope that the problem of determining whether a program terminates is special and does not affect other properties we would like to know about the program. But, sadly, this is not the case. Almost any non-trivial property you would like to know about the behavior of a program can be reduced to the halting problem. This fact is called Rice's theorem.
For example, since figuring out the halting problem is impossible, we can't say if the following problem actually fires the nukes:
def main():
something_that_might_go_forever()
fire_the_nukes()There is hope, however. In most cases, you either care that something may happen, or that it must happen. In the program from before, we can quite easily say that it may fire the nukes. If we do not want the nukes to fire, we can flag this as a bug. However, we might also build a missile launcher, in which case the nukes must be fired when you press the red button.
These kinds of analyses allow us to err on one side. Here we borrow some nomenclature from logic. Essentially, the problem is that not everything we can prove is true, and not everything that we can't prove is false. To differentiate between proveable things and true things, use the notation (provable) and (true):
In logic soundness means that every provable statement is true, and completeness means that every true statement is provable.
In a sound system, we can only prove true things. Or, if we can prove given (), then is true given ().
The dual of soundness is completeness:
In a complete system, we can prove all true things. Or if is true given () then is provable given ().
Translated into program analysis jargon, we say an analysis is sound if, when it produces a fact, then that is a true fact about the program. Furthermore, we say an analysis is complete if it can always find a fact about the program if one exists. Because the problem we are talking about is undecidable, we can't both be sound and complete.
It is also useful to talk about how a program analysis has performed on individual programs or bugs. To do this we use nomenclature from classification.
An individual proposition is either a true positive, true negative, false positive, or false negative, following the table below:
| TP | FP | |
| FN | TN |
A sound analysis, therefore, has no false positives, and a complete analysis has no false negatives.
One problem with the use of soundness and completeness in program analysis is that depending on the context and essentially the underlying question, soundness and completeness can mean the same thing. A sound analysis that reject buggy programs
is the same as a complete analysis finds bugs in programs
.
Program-based questions focus on whether the program is well-formed. Essentially, a sound analysis must only accept correct programs, and a complete analysis must accept all correct programs. This is often used with type systems and static analyses.
Program based questions, soundness and completeness. The solid blob is the set of good
programs. The dashed line is the set of programs accepted by the analysis.
Trace-based questions focus on if the program contains an execution that ends in a state which contains a bug. In that case, a sound analysis will only find correct traces, and a complete analysis will find all traces. This is mostly used in dynamic analyses.
Trace based questions, soundness and completeness.
As an extra layer of confusion, sometimes, developers and users of static analyses will talk about its ability to find bugs or vulnerabilities in the program. In this case, they use trace-based questions, e.g. does this program contain a trace which emits a bug. They will refer to a missed bug as a false negative, and a bug reported in error as a false postitive. This means that they often say that a sound analysis emits no false negatives, while a complete analysis emits no false positivies, even though this is not consistent with the theory.
To make the terminology more consistent, we will use a better distinction, which focuses only on trace questions. A may analysis will overapproximate all traces. This means that all real traces, i.e ., traces which can be followed by the program, are covered by the set of may-analysis. In contrast, a must analysis will underapproximate all traces, which means that the traces of a must analysis are guaranteed to be real. Each analysis has its own power. A must analysis will guarantee that something happens, while a may analysis can guarantee that something does not happen.
Actually, it turns out that in most real-world language settings, it is very hard to write analyses that are sound. So in this course, instead of working with trivial programs, we relax the goal, and instead of requiring our analyses to be sound or complete, we aim to produce the best result in the shortest amount of time.
[...], virtually all published wholeprogram analyses are unsound and omit conservative handling of common language features when applied to real programming languages.
— In Defense of Soundiness: A Manifesto
To this end, in our course we are going to see every analysis as a classifier, which should not only report facts but also how confident that the facts are true.
When talking about program analyses, we break them down according to their properties.
The difference between manual and automatic analysis is whether we have a well-defined procedure for analyzing the code.
In many cases, manual inspection is a crucial companion to automatic analysis. If taken to the extreme, we can actually require the user of the tool to prove to us that the code is correct. Now we only have to check the proof, in which case we are entering the world of Program Verification.
The difference between syntactic and semantic analysis is whether we focus on the structure of the program or on the meaning of the program. The structure of the program, or syntax, is often represented as a tree of nodes as recognized by the parser. In contrast, the meaning of the program, or semantics, is represented as a set of all possible traces of a program. Here, a trace means a sequence of states and operations possible by the program.
Finally, we can differentiate between dynamic and static analysis. A dynamic analysis infers the meaning of the program from a single trace, whereas a static analysis tries to predict all possible behaviors.
Dynamic analysis is often just
executing the programs, and then reporting any behavior it exhibits. A dynamic analysis often provides proof of the bad behavior. A dynamic analysis is sound if every behavior it finds is a real behavior, and complete if it can find all behaviors.
Static analyses, in contrast, consider the entire program and then report if the program is without bugs or problems. When a good static analysis says your program is good, it probably is; however, when it finds a potential bug, it often cannot prove it to you. A static analysis is sound if every program it flags exhibits some behavior, and complete if it flags all programs that contain the behavior.
It is sometimes a great idea to do a mix of dynamic and static analysis, in which case we call it a hybrid analysis.
The goal of this course is to be a practical introduction to program analysis. We are therefore sometimes going to skip some of the theory to make more room for implementation. However, we'll try to reference relevant resources when necessary.
Throughout this course, you'll find activities that we suggest you do to get started with the overarching problem:
This is a sample activity, you don't have to do anything for this.
In this course, we are going to build the best analysis that we can for the JPAMB suite. The goal of the game is to write analysis which can score the highest, and do that in the shortest amount of time.
Download the benchmark suite:
$ git clone https://github.com/kalhauge/jpamb.gitand read the README file.
The goal is to build the best program analysis, of small Java methods. You are measured on performance, and on accuracy of prediction. Here is an example:
@Case("(false) -> assertion error")
@Case("(true) -> ok")
public static void assertBoolean(boolean shouldFail) {
assert shouldFail;
}The cases above the are examples of inputs that produce different outcomes or behaviors in the method. For example false makes the method raise an Assertion Error, while true makes the method finish normally.
The goal is now to report what you think is possible behavior of the method, given any input, as well as how confident you are. The perfect solution would be:
assertion error;100% # I'm totally sure this happens
ok;100%
<the-others>;0% # I'm totally sure this never happens. In practice, due to Rice ' Theorem, there is no perfect solution, so we have to hedge our bets, and lower our confidence. Finding the perfect confidence level is hard so we have added a category system, which allows you to simply group predictions:
assertion error;yes # I think this happens
ok;maybe # This might happen, I don't know
<the-others>;no # I think this does not happenEach category is then replaced by the percentage that categorized behaviors can be emitted across all methods in the benchmark suite. You can use any combination of letters to create your categories.
Extend the regex based solution. and get as many points as you can with the following command:
$ jpamb -vv analyse syntactic-regexRemember to follow the Setup and the Python Guides.
It is recomended to do the changes in-place for now.
For example, to make the syntactic-regex analysis to also find (some) divide by zero errors, by detecting if the method contains a /.
You can replace the following lines in solutions/syntactic/src/syntatic_regex.py
for q in jpamb.QUERIES:
if q != "assertion error":
print(f"{q};skip")With the following:
divide_or_end = re.search(r"/|(^\s*})", rest, re.MULTILINE)
if not divide_or_end:
log.error("Could not find end of method or divide")
log.error(rest)
sys.exit(1)
log.debug(f"found divide {divide_or_end}")
divide_found = divide_or_end.group(0) == "/"
if divide_found:
log.debug("Found divide")
print("divide by zero;found-div")
else:
log.debug("No divide")
print("divide by zero;not-found-div")
for q in jpamb.QUERIES:
if q != "assertion error" and q != "divide by zero":
print(f"{q};skip")Now re-run the evaluator
jpamb -vv analyse syntactic-regexAnd see if the score is better.
Until next time, write the best analysis you can and upload the results to Autolab, and ponder the following:
What makes program analysis hard?
In what circumstances would you want a May vs a Must analysis?
What does it mean that an analysis is sound?