Tying It All Together

In the last chapter, we discussed the semantics behind what a P’’ executor would look like, and we defined five of our machine’s seven instructions. Each instruction has the type State P'' Bool, where the output Bool corresponds to whether or not the machine should continue running (if the machine has not halted).

We find ourselves missing only the Enter Loop and Exit Loop instructions, but these are non-trivial and will require some reasoning-about before we can jump in and build their machinery. Recall the specification for Enter Loop:

Enter Loop: If the Nat at the read head of the memory tape is \(0\), then advance the read head of the program tape until the “matching” Exit Loop instruction.

Determining the value of the read head of the memory tape is obviously baby stuff, by now we’re well-versed working with our tape infrastructure. But finding this “matching” Exit Loop instruction sounds like a different beast altogether.

The problem is that we’re not looking for the next Exit Loop instruction, we’re looking for one that matches. Recall that we require all Enter Loops to be paired with a matching Exit Loop. This means that moving from left to right through our program, we can’t ever exit a loop we haven’t yet entered. When using our symbols for these instructions, this is equivalent to saying “the brackets formed by [ and ] must make sense”. That means the following programs are OK:

  • []
  • [[][]][]
  • [[[[[[[[[[]]]]]]]]]]
  • [][][][][][][][][][]

But the following programs are invalid:

  • [
  • ]
  • [[[
  • [[]
  • ]][]

It’s fine for a loop to itself contain a loop, and our Enter Loop and Exit Loop semantics must respect this. In order to find the matching Exit Loop, we must keep track of how many “unmatched” Enter Loop instructions we’ve seen. Whenever we see an Enter Loop instruction, we should increase our count by one, and whenever we see an Exit Loop, we should decrease this number by one. If we ever see an Exit Loop instruction which moves this counter to \(0\), we have found the matching instruction!

Let’s work through a quick example of this just to cement it. The downwards arrow represents the [ we’re trying to find a match for. The upwards arrow is the instruction we’re currently scanning – the “prospective match”. The number in the top-right-hand corner is the number of unmatched [s we have seen so far.

\(1\)
[ [ - ] [ ] ] ¤
\(1\)
[ [ - ] [ ] ] ¤
\(2\)
[ [ - ] [ ] ] ¤
\(2\)
[ [ - ] [ ] ] ¤
\(1\)
[ [ - ] [ ] ] ¤
\(1\)
[ [ - ] [ ] ] ¤
\(2\)
[ [ - ] [ ] ] ¤
\(1\)
[ [ - ] [ ] ] ¤
\(0\)
[ [ - ] [ ] ] ¤

And we’re done. Because our “unmatched counter” got to \(0\), it must mean that our vertical arrows are now “not-unmatched”, which is to say, they are in fact matched.

We can encode this logic as a symbolic computation, gotoExit : Nat -> State P'' Unit, which moves the read head of the program tape to the matching ExitLoop instruction. The Nat input is used to describe our “unmatched counter”.

gotoExit : Nat -> State P'' Unit
gotoExit n = do
    withProgTape moveRight
    (P'' progTape _) ← get
    decide (readHead prog) n
  where
    decide : Instr -> State P'' Unit
    decide EnterLoop n        = gotoExit (n + 1)
    decide ExitLoop  Zero     = inject Unit
    decide ExitLoop  (S prev) = gotoExit prev
    decide _         n        = gotoExit n

The strategy is this: moveRight, and then look at the new instruction on the tape. If that’s an EnterLoop, increase the unmatched counter by one, and then continue going to the matching Exit Loop. If the symbol on the tape is an ExitLoop and our unmatched counter is \(0\), output a Unit. If the unmatched counter is not \(0\), just decrement it by one and continue going to the exit. In all other cases, we want to just continue moving without changing the unmatched counter.

gotoExit forms the majority of the logic we want for instrEnter, so we write it next:

instrEnter : State P'' Bool
instrEnter = do
    (P'' progTape memTape) ← get
    decide (readHead memTape)
  where
    decide : Nat -> State P'' Bool
    decide Zero    = gotoExit Zero
    decide _       = inject On

The lemma decide in instrEnter merely checks whether the read head of the memory tape is Zero, and if it is, it calls gotoExit. If not, we don’t do anything.

We need still to write instrExit, but we will leave it as an exercise to the reader. instrExit is symmetric to instrEnter, except that it it attempts to find a matching EnterLoop (rather than a matching ExitLoop) if the read head of the memory tape is \(0\).

Now that all of our instructions are defined, we need to call the correct instrX function given an Instr value from the program read head. This is trivial with pattern matching:

run : Instr -> State P'' Bool
run Halt      = instrHalt
run MoveLeft  = instrMoveLeft
run MoveRight = instrMoveRight
run Increment = instrIncrement
run Decrement = instrDecrement
run EnterLoop = instrEnter
run ExitLoop  = instrExit

and we’re finally ready to Kleisli-up all of the moving pieces. Behold:

pipeline : State P'' Unit
pipeline = do
    instr        ← read
    stillRunning ← run instr
    advanceAndRepeat stillRunning
  where
    advanceAndRepeat : Bool -> State P'' Unit
    advanceAndRepeat On  = advance » pipeline
    advanceAndRepeat Off = inject Unit

This pipeline works exactly like our specification in the last chapter – we read an instruction, and then run it. The result of run is whether or not the machine is still running, which if it is, we need to advance the program tape, and then repeat pipeline again. If the machine has halted, we do nothing more than outputting a Unit.

We’re so close! Can you taste it? The last step is to wrangle pipeline : State P'' Unit into the right type. Recall, that the actual function we wanted was execute : Tape Instr -> Tape Nat, which ran the program on a tape, and gave back the resulting memory tape.

execute : Tape Instr -> Tape Nat
execute program = second (pipeline (P'' program emptyTape))
  where
    second : (a, b) -> b
    second (_, b) = b

    emptyTape : Point m => Tape m
    emptyTape = Tape [] point []

And we’re done! We’re calling pipeline like a function here, and that’s OK because of the equation State s a = s -> (a, s). A State s a is nothing but a function from s -> (a, s). We construct a new P’’ machine by passing in the program tape we were given, and by creating a new memory tape via the emptyTape value.

There’s really nowhere left to go from here, at least as far as P’’ machines are concerned. It might not seem like it, but we now have a fully functioning computer, capable of literally everything that the machine you read this on is capable of. It’s a huge accomplishment, and you should be proud of yourself for having made it this far.

However, our journey into computer science is nowhere near being finished. We now set our sights on making a machine capable of executing symbolic computations themselves. With this in hand, we’ll be able to run the computer on itself, a mind-crushingly “meta” concept by any account. We’ll need more tools under our belts, but we’ve come a tremendous distance already.

Really, we’re just getting started.