3. Some Computability Theory
In this section, we'll briefly introduce the problem that will be formalized in final project. The material is otherwise logically independent of the introduction into Lean.
We're used to computers (and math) getting ever-more powerful. It may therefore come as a surprise that there are problems that are fundamentally not solvable by any computational process!
Remarks:
-
The basic argument supporting this assertion is surprisingly simple. It was understood well before actual computer become widely available! In contrast, the question of "how difficult is it to solve a problem, assuming it is solvable at all" presents great theoretical difficulties to this day (e.g. the infamous P ≠ NP conjecture).
-
Computational undecidability is closely related to logical unprovability (Gödel and all that). We'll remark on the connection later.
3.1. The halting problem
Let's have a look at the mother of all undecidable problems: the halting problem.
Here's a motivation: As you will be painfully aware of, computer programs can sometimes "hang", i.e. enter an infinite loop. It'd be great if we had a tool that would scan the source code of a program and alert us to possible infinite loops.
You could sell this tool to Wolfram Research.
In order to get a feeling for the problem, let us look at a few pieces of code. For example:
def prog1 (input : Data) := do
IO.print "Hello World!"
The program prints a greeting and terminates (ignoring any input). In computability theory, one says that a program halts if it will terminate after finitely many steps when run on a computer.
Contrast this with prog2:
def prog2 (input : Data) := do
while True do
IO.print "...hello again..."
Executed on an idealized computer, it will go on forever, printing the string again and again.
The halting problem is:
Given a pair ⟨prog, input⟩, decide whether the program prog, executed with input data will eventually halt.
In the two examples considered, we were easily able to see that the first one does halt while the second one does not. So one might guess that the process can be automatized, i.e. that it is possible to write a computer program that decides the halting problem. But these simple examples are deceptive!
To appreciate the difficulty of the problem,
recall Goldbach's Conjecture which we briefly met before.
It claims that any even integer larger than 2 is the sum of two primes.
Now,
one can easily write a function IsGoldbachCounterExample,
which takes a natural number n as its input,
and checks in finitely many steps whether n is a counter-example to the conjecture (e.g. by going through all pairs of primes small than n).
Using this subroutine, consider the following program, which checks ever-larger numbers until it finds a counter-example:
partial def CheckGoldbach (n : ℕ) : Bool :=
if IsGoldbachCounterExample n then
false
else
CheckGoldbach (n + 1)
Clearly, CheckGoldbach halts if and only if the conjecture is wrong.
Thus to solve the halting problem, we will in particular have to settle this open problem,
a task at which the smartest mathematicians have failed for almost 300 years.
Hm.
Maybe things aren't that simple after all!
Remark:
-
In the above argument, we only used that, for every
n,IsGoldbachCounterExample nis decidable in finitely many steps. Call such a function a decidable predicate. Hence, if we could solve the halting problem, we could also decide the first-order proposition∃ n, P nfor any decidable predicateP. That's upping the ante even more!
3.2. Computers can't decide their own halting problem
For the purpose of this section, a computational model is a function eval that takes a program prog and input input, and returns that result of running prog on input.
(We'll make these notions more precise soon. For now, we'll rely on your intuition of what "computers running programs" are capable of -- very much in JPG mode).
A computational model can decide its halting problem
if there is a program decider such that
evaluating decider on ⟨prog, input⟩
returns true if prog will halt when run on input,
and false else.
The definition presumes that decider itself halts on every input (otherwise it wouldn't make sense to talk about its return value).
Such programs are called total.
We will now sketch an argument showing that, under mild assumptions, no computational model can solve its own halting problem.
In fact, the problem fails already "on the diagonal", where input = prog (and where input thus becomes redundant as an argument to decider).
Explicitly: There isn't even a total program decider such that eval decider prog returns true if and only if
prog will halt on input prog.
The proof is sometimes said to be "by contradiction", but it is constructive in the same way as the proof of √2 ∉ ℚ is.
Here's our strategy:
Given any total program candidate, we construct a program spoiler such that
-
if
eval candidate spoileris true, thenspoilerwill not halt when run with inputspoiler -
if
eval candidate spoileris false, thenspoilerwill halt when run with inputspoiler.
That's just saying that, whatever it is that candidate does,
it's not solving the halting problem,
because it fails to give the correct answer at least on input spoiler.
The construction amounts to the computer science version of a petulant toddler:
spoiler first checks what it is supposed to do (according to candidate), and then does the opposite:
def spoiler (input: Data) :=
if (eval candidate input = true) then
loop_forever
else
halt

The "mild assumptions" alluded to before are now clearer:
The argument works whenever the model of computation is powerful enough that, for every program candidate, one can express the program spoiler and have the "if-run_forever-else-halt" control flow work correctly.
3.3. A model of computation
To make the above discussion rigorous, we need a mathematical model of computation. Let's brainstorm.
Real computers are powered by central processing units that contain billions of transistors. If reasoning about the capabilities of computers would require a faithful model of a present-day CPU, we'd be in big trouble.
Real programmers don't target these transistors directly. Instead, they write code in programming languages. The reference manual of the popular python language amounts to fewer than 200 pages. That's orders of magnitude better than the schematics of a CPU -- but still way too voluminous for a mathematical model anybody would want to work with.
To make progress, let's turn things around and introduce an extremely spartan computational model. How it relates to the real world will be discussed below.
3.3.1. The BF model
For this course, we haven chosen a well-known esoteric model called BF. Its programs run on a simple virtual machine defined as follows.
The BF machine contains four arrays of natural numbers -- prog, input, output, mem --
and two natural numbers -- the instruction pointer progPos and the data pointer memPos.
Initialization:
The prog array is initialized with an arbitrary sequence of natural numbers of finite length prog.length.
As common in computer science, array indices are 0-based:
the elements of prog are accessed as prog[0] to prog[prog.length-1].
output and mem are initialized as infinite arrays, with output[i] = mem[i] = 0 for all i ∈ ℕ.
We will see that at any point of time, only a finite number of elements will differ from 0.
Hence both arrays can be stored in finite memory -- though the size might have to grow dynamically.
The input array is also infinite.
It is initialized with an arbitrary sequence of numberes, which must different from 0 in at most finitely many places.
Both the instruction pointer and the data pointer are initially set to 0.
Time evolution:
In each step of the computation, the machine checks whether progPos ≥ prog.length.
If so, it will do nothing -- the computation has halted.
If not, the machine reads prog[progPos] and then increment the instruction pointer progPos by one.
The number read will be interpreter as an instruction as per the table below.
It uses two conventions:
(1) Characters are identified with natural numbers according to the ASCII code.
(2) "Decrementing" a natural number means subtracting one, unless the number is already 0, in which case we do nothing.
The BF machine understands the following instructions:
-
'>': Increment the data pointermemPos. -
'<': Decrement the data pointermemPos. -
'+': Increment the value ofmematmemPos. -
'-': Decrement the value ofmematmemPos. -
'.': Appendmem[memPos]tooutput. -
',': Write the value ofinput[0]tomematmemPos. Then shift all values of the input array to the left by one position (overwriting the value at0in the process). -
'[': Ifmem[memPos]=0, check whether there is an indextargetsuch thatprog[target]is a closing bracket matching the opening one atprog[progPos]. If so, set the instruction pointer totarget + 1. In any other case, do nothing. -
']': Check whether there is an indextargetsuch thatprog[target]is an opening bracket matching the closing one atprog[progPos]. If so, set the instruction pointer totarget. In any other case, do nothing. -
In all other cases, do nothing.
The matching of brackets is done according to the usual rules.
Examples: In [..[..]..], the outer brackets match each other, as do the inner brackets.
The first bracket in [..[..] has no match.
Confused by the jump commands? You should read the construction as
while (mem[memPos]) [ ... ]
i.e. as a while-loop that executes the code between the brackets as long as the current memory cell is non-zero. The requirements that brackets must match means that such loops can be nested.
The above procedure is applied iteratively, until progPos reaches prog.length (if ever).
Remark:
-
In some variants of the BF machine, the arrays have a finite maximal size and/or each element has a finite maximal value. We can easily emulate this behavior by modifying the
>and+instructions.
Given its bare-bones structure, one might expect that BF is not very expressive. However, as discussed later, under a reasonable definition of "computable", anything that's computable at all is computable in BF!
3.3.2. Examples
Soon enough we'll write our own BF interpreter (in Lean). Until then, you can run BF code at one of the many online services that cater to this essential need. Here's one.
(1) The code ",." reads the first input character, prints it, and halts.
(2) A little more exicting is this BF re-implementation of the Unix echo command.
It reads and prints the input up to the first 0:
, // read first input character into memory
. // output it
[ // while first memory cell isn't 0
,. // read and print the next character
] //
// halt
The comments are part of the code.
They will be ignored, as they don't contain any of the commands <>+-.,[].
Exercises:
-
Write a program that reads five characters and outputs them in reverse order.
-
Write a program that reverses the input up to the first
0. -
Write a program that goes into an infinite loop if and only if the first input is not
0. -
Write something creative!
3.4. Universality
See Frank's notes.