I’m doing Advent of Code again this year, and part 1 of today’s problem reminded me immediately of some of the problems I’m doing in my Program Construction module at UCD. In the class, we cover the foundations of Edsger W. Dijsktra’s Structured Programming. It teaches you how to formally verify your program by finding the pre-conditions and post-conditions, then deriving and proving theorems that build up towards the final program.
It’s a very different style of thinking about programming than most people are used to, but I’ll try my best to explain the notation and the logic I’m using.
We start out by writing our post-condition. This is what we want to be true once our program has finished running — in other words, it’s what we want to calculate. We’re going to use this funky-looking syntax called Quantified Notation.
As an intro, here’s a basic quantified expression:
is the syntax we’ll use for accessing the element of some array . This is simply shorthand for this longer expression:
For those of you more familiar with functional programming, you’ll find that this is just a reduction over a list. is the list in question and is the operation we want to use to combine each element with the accumulator. However, program construction is designed around an imperative language, and so we need an index variable to keep track of our position in the array. We also have to specify the range of , which is from to the length of the array.
With that exposition out of the way, here’s the postcondition for our AoC problem (for a single bank of batteries). You should go read the problem statement over on the AoC website for this to make sense :)
This is a quantification over two variables: and . It’s essentially the same as before, but when we reduce using the max operator (), we have to reduce over for every possible combination of and , such that is greater than . And yeah, that’s exactly what we want: we want the two batteries to turn on such that the concatenation of their joltages is the maximum possible. Note that we’re assuming here that we’ve already parsed each bank of batteries into an array of integers .
Now we get to build our domain model. It’s the collection of definitions and theorems that we can use later on when constructing our program.
Model
(0)
We extract our post-condition into a reusable function defined over all from — the minimum valid length of an array for this calculation — to , the actual length of our array.
Now observe:
So now we know that:
(1)
Now that we have our “base case”, or the initial value of our accumulator, we can look into using associativity to find given .
Observe again:
This gives us
(2)
and
(3)
Did you see how I sneakily used (3) up there before defining it properly? Let’s actually simplify .
Observe once more:
So:
(3)
And:
(6)
What happened to (4) and (5), you ask? Well, we have to derive the base case and associative case for just like we did for . We’ll need our theorems first, though.
Observe:
And so:
(7)
And once more:
Which gives us:
(8)
And now back to . You know the drill, observe:
And similarly:
So the last pieces of our model are:
(4)
(5)
Lovely. We now have everything we need to go and construct our program loop.
Program Loop
Let’s first rewrite our postcondition in terms of the theorems from our model.
We can then strengthen this postcondition by rewriting it like so:
Strengthen is a funny name, but that’s all there is to it. We’re pulling out of . Why? Because every loop has 3 fundamental things:
- Invariants: these are the things that are always true during the program
- Variant: a measure of how much work there is left to do
- Guard: a boolean check that lets you know when to break out of the loop; that is, when your variant has bottomed out because there is no more work left
By splitting our post-condition into two parts, we can use the first part as our invariant and the second as our loop guard.
Invariants
Let’s say we have a variable , and is always equal to , for whatever value is at the moment. This is an invariant.
However, the definition of depends on , which depends on .
Let’s get some variables involved for those too:
So our invariants are:
- (P0)
- (P1)
Establish the invariants
We can “establish” our invariants by initialising our variables to values such that the equalities we defined as invariants are true. We know the values of , , and from when we derived them. So, let’s set and initialise , , and to those values.
Loop Guard
Remember how I said we could use the part as our guard? I was lying, just a little bit. If you were paying attention during those derivations, you’ll have noticed that is defined for , but and are defined for , and and are only defined for .
This is because you can’t calculate . There simply aren’t any more elements in the array, and so is not defined at . Since the definition of comes from , we don’t define at either. And since is not defined at , cannot be defined at . Similarly for and .
And so, our loop actually can’t go all the way up to . It can only go up to (exclusive). That is, we will break out of the loop once becomes .
If you paid attention to the invariants bit, you’ll realise that this means we’ll only have and after the loop. We want though, but this isn’t a problem. From (2), we know how to find from and .
Anyway, this is our loop guard:
Variant
And our corresponding variant is:
When becomes 0, we exit the loop.
Calculating the loop body
We’re starting off with and we want at the end of the loop. A logical way to get this to happen is to increment by 1 in each iteration. The important thing, however, is that we have to make sure our invariants remain true after incrementing .
We don’t know what to set , , and to, but we can find out. Let’s set them to some temporary variables and solve for them.
Now we know exactly how to update each variable within the loop. Home stretch now!
Writing our program
// establish invariants
{n, r, d, e := 2, 10 * f.0 + f.1, 10 * max(f.0, f.1) + f.2, max(f.0, f.1)}
// loop body
; do n != N - 1 ->
n, r, d, e := n + 1, max(r, d), 10 * max(e, f.n) + f.(n + 1), max(e, f.n)
od
// calculate C.N from C.(N - 1) and D.(N - 1)
; r := max(r, d)
// postcondition achieved!
{r = C.N}The above program is written in Guarded Command Language, another invention of Dijkstra’s. Dijkstra was adamant that it never be implemented for a real computer:
“Finally, in order to drive home the message that this introductory programming course is primarily a course in formal mathematics, we see to it that the programming language in question has not been implemented on campus so that students are protected from the temptation to test their programs.” ~ EWD1036
So let’s translate it to a real programming language. I’ve been solving AoC in Gleam so far. This presents a little challenge, as Gleam is a functional programming language, while GCL is imperative, so we can’t do a 1-to-1 translation. However, with a little bit of cleverness, this is what we get.
pub fn pt_1(input: List(List(Int))) {
input
|> list.map(fn(bank) {
let assert [first, second, ..rest] = bank
let assert Ok(third) = list.first(rest)
let #(r, d, _) =
rest
|> list.window_by_2
|> list.fold(
#(
10 * first + second,
10 * int.max(first, second) + third,
int.max(first, second),
),
fn(acc, el) {
let #(r, d, e) = acc
let #(f_n, f_n_1) = el
#(int.max(r, d), 10 * int.max(e, f_n) + f_n_1, int.max(e, f_n))
},
)
int.max(r, d)
})
|> int.sum
}Yeah, LOL. LMAO, even. Absolutely not. I’m not quantifying over 12 variables. It feels like it should theoretically be possible, but I don’t want to find out. I just did it in the most straightforward way possible.
First, BIG shout-out to Mr. Henry McLoughlin, who taught me Program Construction. He’s a simply lovely person and his enthusiasm for his subject is infectious. I don’t know if I’d have enjoyed the module as much if anyone else taught it. Henry, if you’re reading this, you’re awesome! Thank you so much.
I guessed what Part 2 would be as soon as I read Part 1, and so if I was aiming
for speed, I should have just written do_pt_2 for the general case and reused
it across both parts.
I would probably have had an easier time of it too.
However, 1. I wanted to use what I learned in class and 2. I had fun doing it
this way.
I think barely anyone else would have done it this way.
It has the advantage of being rigorously proved to work, but at the cost of being harder to understand at first glance. I can see how this is useful for high-stakes software where the extra expenditure of mental energy on making sure the code is 100% watertight is worth it, but this is probably not what I’d reach for during everyday programming. It did result in a very, very terse program though, which is super impressive.
The eagle-eyed among you might say that we don’t really need both
d and e, in the loop, as d is derived from e.
This is true.
Program construction doesn’t always produce the most efficient programs.
That is between the programmer and the compiler to figure out.
Oh, the title. Yes. That seemed like a lot of thinking, you might object. It probably was if you’re not familiar with Program Construction yet, but once you’ve derived a couple of these theorems, you’ll find that there is no thinking involved. Not in the sense that once you’re good at something, you can do it almost mechanically, but in the sense that there’s only one way this could have gone. Starting from that post-condition, the theorems we proved fall out automatically as we continue expanding our model, and the same can be said for our loop body. Program construction is really easy in that way, because all you’re doing is following the program derivation to its logical
end.