Nathan Taylor · About · Blog · Resume · Blogroll · RSS

FRP in Lean: Composing invariant-transforming combinators

How do our proofs change as we execute an FRP program?

Sorry that it’s been awhile since my last post! Big life stuff happening over here!

Last time we designed a mechanism to accumulate stateful computation on Signals and Events. This design ended up looking a lot like a tiny version of the step-based reactive systems we designed in part 1, so some of you may have been wondering what the point of doing that is. We’ll answer that today: in this post we’ll see how we can compose such proof-carrying computations using the FRP combinators we already know and love.

Before we do that, though, let’s do some light refactoring. At the moment, our FRP namespace is polluted with both ordinary, non proof-checking combinators (like, FRP.map) as well as ones that do make statements about, say, safety properties (like FRP.accumulate, which was the meat of the previous post in this series). Let’s create a sub-namespace to isolate the more complicated proof-carrying ones.

namespace FRP
...
def Refining.accumulate
  -- Given a property over some state ...
  {inv: StateProp β}
  -- an initial state,
  (init : { s: β // inv s})
  -- a transition function when no event fires...
  (onNone: { s: β // inv s }  { s': β // inv s' })
  -- a transition function when an event _does_ fire...
  (onSome: α  { s: β // inv s}  {s': β // inv s'})
  -- and an event...
  (ev: Event α)
  -- ...produce a single refined value, made up of a `Signal β`, and
  -- a safety proof over all time steps.
  : { sig : Signal β // ((LTL.atom inv)) sig } :=

  -- `switch t` produces the next state, depending on whether the event
  -- fired at the given timestep
  let switch (t: Time) : {s: β // inv s}  {s': β // inv s'} :=
    match ev t with
    | none => onNone
    | some a => onSome a

  -- `step_at` takes `t` steps through `switch`; at each time step, it
  -- produces a β alongside its proof of .preserving `inv`
  let step_at :{s: β // inv s} := 
    fun t => Nat.rec init (fun n s => switch n s) t
 
  -- Reorganize the signal of refined values into a refined signal.
  let vals : □ β := fun t => (step_at t).val
  let safety :  t, inv (vals t) := fun t => (step_at t).property
  ⟨ vals, (always_atom_iff vals).mp safety ⟩

/- Our non-refining combinators can now be implemented in terms
   of the refining ones, just with trivial proofs of `fun t => True` 
 -/

def accumulate
  (init : β) (onNone: β  β) (onSome: α  β  β) (ev: Event α)
    : Signal β := Refining.accumulate
    ⟨init, by trivial(fun s => ⟨onNone s, by trivial)
    (fun e s => ⟨onSome e s, by trivial)
    ev

end FRP

Notice that, as the name suggests, button presses are accumulated from that point on.

Note that I’m using always_atom_iff, which I encouraged you to write in the previous post. If you haven’t yet, no better time than the present! This theorem’s type is (sig : Signal β) : ( t, inv (sig t)) ((LTL.atom inv)) sig; a biconditional that states “making a statement about a Signal at every time step is interchangeable with making the equivalent statement in temporal logic”.

Warmup: better notation for refined Signals.

In the previous post, we introduced using Lean’s refinement type system, and used them to make statements about properties of Signals. Last time, we said, strictly informally:

  1. (Signal β) // inv” is saying "here’s a Signal that produces βs, and a proof of a safety property inv; we might call this “a refined Signal”;
  2. Signal (β // inv)” is saying "here’s a Signal that produces (β // inv)s; that is to say, at every time step, we get a value and a proof of some property about that time step’s value. We might call this “a Signal of refinements”.

Syntactically, though, it was a bit of a mess. Our program was littered with gnarly-looking Signal types. For instance, accumulate’s return type was:

def accumulate
  ...
  : { sig : (□ β) // ((LTL.atom inv)) sig }

(This type refines a Signal β with an LTL safety property, so it’s an example of the first type of refined signal.) Let’s create a type alias for this refined signal to make this a bit less convoluted-looking.

+ abbrev RSignal (α : Type) (inv : StateProp α) := { s : Signal α // ((LTL.atom inv)) s }

 def accumulate
   ...
-  : { sig : (□ β) // ((LTL.atom inv)) sig }
+  : RSignal β inv

This is already cleaner, but we’ve lost the // operator and thus the connection to refinement types. That’s where Lean’s macro system can once again help us out.

A syntax transformer for RSignals

One of the things I really like about Lean is that we are free to make up notation! When we introduced the LTL <-> FRP correspondence, we used the notation special form to imbue with meaning. We also saw that Lean’s macro system is richer than, say, C’s, since it was smart enough to choose the right syntax depending on whether the type context was in LTL-land or FRP-land.

Here’s how we might write a syntax transformer for refined signals and signals of refinements using that notation directive:

Truly, why am I powerless in the presence of metaprogramming? I blame my Scheme upbringing.

 notation:max  "□ "       α:max                       => Signal α
+notation      "□ " " ( " α:51     " // " inv:51 " )" => Signal { s : α // inv s }
+notation      "□ "       α:51     " // " inv:51      => RSignal α inv
+notation "( " "□ "       α:51 ")" " // " inv:51      => RSignal α inv

 def accumulate
   ...
-  : RSignal β inv := ...
+  : □ β // inv := ...

In addition to accumulate returning a (□ β) // inv, inside the body of that function, the step_at helper can be typed as (β // inv).

 def accumulate
  ...
  : (□ β) // inv := 
  ...
- let step_at :{s: β // inv s} := 
+ let step_at :(β // inv) := 
    fun t => Nat.rec init (fun n s => switch n s) t

  let vals : □ β := fun t => (step_at t).vals
  let safety :  t, inv (vals t) := fun t => (step_at t).property

  ⟨ vals, (always_atom_iff vals).mp safety ⟩

When I made this transformation, it hinted at something deeper that I’d missed before: seems like if we agree that the syntax of (□ β) // inv is close to that of (β // inv), then perhaps we could agree that the semantics of the two are related, too.

Metaprogramming our way to a better syntax

Before we do that, though, let’s tidy up our syntax transformation.

If you’re truly uninterested in the finer points of hygenic macros, I wouldn’t fault you for skipping to the next section in this post.

If the above note didn’t dissuade you: hello, fellow metaprogramming enjoyer! Okay, in Lean, the notation form auto-generates three things for us:

  1. A syntax directive that, essentially, adds a new kind of AST node to Lean’s parser;
  2. A @[macro] directive that specifies the semantics of what expanding this new piece of syntax should mean;
  3. An unexpander to pretty-print the syntax, for error reporting and such.

Notice that (□ β) // inv is the same as □ β // inv, owing to operator precedence, so I’ve written new syntax for both. I’ll endeavour to be explicit when it’s significant or otherwise appropriate to do so, since we’ll see some combinators where it’s really worth seeing (□ β) // inv versus (β // inv) on the page.

In yacc or bison or another traditional parser generator, we might express this like so:

  /* All four productions live in the `term` non-terminal */
  term
    : '□' a                    { Signal a }         /* standalone */
    | '□' '(' a '//' inv ')'   { Signal { x : a // inv x } }  /* pointwise */
    | '□' a '//' inv           { RSignal a inv }    /* refined */
    | '(' '□' a ')' '//' inv   { RSignal a inv }    /* parens-refined */
    | ... ;

This expresses both the syntax and semantics of our notation. Since a traditional parser operates at compile-time, we typically wouldn’t expect the parser generator to give us an unexpander.

This notation … kind of works, but it’s kind of brittle. The problem is that we’ve actually introduced an ambiguity in our syntax. If you’re familiar with shift/reduce errors in classical parsing, it’s not totally dissimilar: Both X and X // inv share a common prefix; when Lean parses X, should it commit to a Signal X right away, or should we keep lexing in case it should actually expand to an RSignal with an invariant?

What we’d prefer to have instead is a new non-terminal:

  sig : '□' term ;              /* an unambiguous production for □ */

And then express our grammar in terms of that new “kind of AST”:

  term
    : sig                   { Signal α }
    | "□" "(" a "//" p ")"  { Signal { x : α // p x } }
    | sig "//" inv          { RSignal α inv }
    | "(" sig ")" "//" inv  { RSignal α inv }
    | ... ;

We’ll tell Lean’s parser about our new non-terminal with declare_syntax_cat, instruct it to treat it as “a kind of term”, and then specify all the different syntactic forms that a signal AST can take.

declare_syntax_cat signalTree
syntax (name := signalRaw) "□ " term:max : signalTree
syntax (name := signalLift) signalTree : term

syntax (name := pointwiseRefined) "□ " "(" term:51        " // " term    ")" : term
syntax (name := refinedSig)                signalTree     " // " term:51     : term
syntax (name := outerRefinedSig)       "(" signalTree ")" " // " term        : term

We gave each piece of syntax a name, so we can refer to them in their respective macro directives. Each of those are straightforward to implement once you look up the syntax for Lean’s syntax transformers:

macro_rules (kind := signalLift)
  | `(□ $α) => `(Signal)

macro_rules (kind := refinedSig)
  | `(□ $α // $inv) => `(RSignal $α $inv)

macro_rules (kind := outerRefinedSig)
  | `((□ $α) // $inv) => `(RSignal $α $inv)

macro_rules (kind := pointwiseRefined)
  | `((// $inv)) => `(Signal { x :// $inv x })

We can now use our syntax in place of Signal and RSignal in the way we would expect!

#check  □ Int  // (· > 0)   -- RSignal Int fun x => x > 0 : Type
#check (Int) // (· > 0)   -- RSignal Int fun x => x > 0 : Type
#check  □ (Int // (· > 0))  -- Signal { x // (fun x => x > 0) x } : Type

Splitting and collecting refined Signals

With this new notation, it’s easier to see what accumulate is doing internally: with step_at, we collate together all our refined values, across all time steps, into a (β // inv). (That’s a “signal of refinements”). Once we’ve got that, we turn all the individual local proofs of inv holding into a global safety property, producing a final refined signal of type (□ β) // inv (a refined signal).

As I worked on an earlier draft of this post, I found myself not only repeating this operation, but also performing the inverse operation, which takes a single (□ β) // inv and “shards out” its safety property to produce a (β // inv). This suggested to me that there was some sort of underlying primitive that I’d missed in previous posts.

Indeed! It turns out that if we construct a way to go between (□ β) // inv and (β // inv)s, we can simplify a lot of the FRP combinators we need.

Here’s the skeleton of what we’re after in this section:

My original plan was to call these “fork” and “join”, but if we keep this series going long enough, we might find a better use for those terms :-)

-- forks a signal with a global safety property into one where
-- the invariant is proved locally at each time step.
def Signal.split   : (□ β) // inv ->(β // inv) :=
  sorry -- TODO

-- collates a signal's local invariant proofs into a signal that
-- maintains the invariant as a global safety property.
def Signal.collect :(β // inv) -> (□ β) // inv :=
  sorry -- TODO

Signal.split shards out a safety property into pointwise statements

The rough shape of split will be the following: We consume a refined Signal, and produce a new Signal such that at every time step, we somehow produce a β and a proof that that β satisfies the invariant, and then glue them together to make a “signal of refinements”.

  def Signal.split (sig: (□ β) // inv) :(β // inv) :=
-    sorry -- TODO
+    fun t =>...val, prf⟩ -- TODO: something roughly like:

How can we construct a val? It has to come out of sig somehow, since that’s the only way for us to produce βs. Recalling that sig.val is a □ β, getting a β just from applying t seems like as good an idea as any!

  def Signal.split (sig: (□ β) // inv) :(β // inv) :=
+    let vals : □ β := sig.val
-    fun t =>...val, prf⟩ -- TODO: something roughly like:
+    fun t => ⟨vals t, prf⟩ -- TODO: something roughly like:

Similarly, sig.property is our safety property, ensuring inv will always hold over vals. This is a proof that our LTL formula holds at every timestep.

The beauty of the LTL-FRP correspondence is that by Curry-Howard, a proof of ((LTL.atom inv)) vals is a function we can call with some timestep i, to get a proof that p holds at t=i! So, the body of always is identical to fun i => p (drop i t). This means sig.property can be applied: sig.property i gives us a proof that the invariant holds at time i.

 def Signal.split (sig: (□ β) // inv) :(β // inv) :=
   let vals : □ β := sig.val
+  let safety : ((LTL.atom inv)) := sig.property
-  fun t => ⟨vals t, prf⟩ -- TODO something like:
+  fun t => ⟨vals t, safety t⟩

Bad news, though, it doesn’t work!

Type mismatch
  sig.property
has type
  □ (LTL.atom inv) sig.val
but is expected to have type
   (t : Time), inv (vals t)

Argh, but feels like we’re close. safety t almost produces the proof that we need: it gives us a LTL.atom inv (drop t vals), the global safety property, but what we in fact need is the pointwise proof at time t. Luckily, that’s what always_atom_iff gives us! fun t => (always_atom_iff vals).mpr sig.property t first converts the safety property into its pointwise form, and then applies t to it. (If you’re feeling fancy you can write this as a point-free function composition as I’ve done below).

So, our final split is:

 def Signal.split (sig: (□ β) // inv) :(β // inv) :=
   let vals : □ β := sig.val
-  let safety : ((LTL.atom inv))  := sig.property
+  let safety : ( t, inv (vals t)) := (always_atom_iff vals).mpr sig.property
   fun t => ⟨ vals t, safety t ⟩

Signal.collect compiles a Signal with a safety property

Let’s write the operation that does the opposite: given a Signal of refinements, collect that infinite sequence of proofs into a refined Signal.

def Signal.collect (sig:(β // inv)) : (□ β) // inv := 
  let vals : □ β := sorry 
  let safety : ((LTL.atom inv)) vals := sorry 
  ⟨vals, safety⟩

(Note the asymmetry between split and collect’s bodies: split needed to return a top-level Signal and thus the returned expression was a fun t => ..., whereas the refinement pair is the top-level construct for collect.)

We’ll proceed in the same way as before: we’ll extract a □ β and a t, inv (vals t) from the input signature. We’ll apply always_atom_iff, in the left-to-right direction this time to transform the latter from a quantified statement to an LTL proposition.

 def Signal.collect (sig:(β // inv)) : (□ β) // inv := 
-  let vals : □ β := sorry 
-  let safety : ((LTL.atom inv)) vals := sorry 
+  let vals : □ β := fun t => (sig t).val
+  let safety : ((LTL.atom inv)) vals := 
+    (always_atom_iff vals).mp (fun t => (sig t).property)
   ⟨ vals, safety ⟩

Notice that accumulate can be nicely simplified with collect; we construct the pointwise Signal using the recursor for Nat, and then glue it all together with collect.

def accumulate
   ...

-  let vals : □ β := fun t => (step_at t).val
-  let safety :  t, inv (vals t) := fun t => (step_at t).property
-
- ⟨ vals, (always_atom_iff vals).mp safety ⟩
-
+  Signal.collect step_at

Lifting unrefined functions into refined Signals

The Signal boundary can also be where proofs about ordinary, non-verified functions can reside. Here’s a silly, and probably wildly-bad, pseudo-random number generator. You might implement this function in any programming language: it’s just a function from Ints to Ints, no proofs, no dependent types, utterly pedestrian ;-)

def lcg (x : Int) : Int := (5 * x + 17) % 256

If you remember our first combinator, scan, we can lift this into an old school, unrefined Signal if we also supply it a starting seed value:

I notice that in this case we always flip-flop between even and odd numbers, suggesting that we should probably not use this for cryptographic purposes anytime soon.

def prng : Signal Int := FRP.scan lcg 97
#eval List.range 10 |>.map prng -- [97, 246, 223, 108, 45, 242, 203, 8, 57, 46]

Hopefully you will not push back too hard on me if I asserted that every random number will be nonnegative but not to exceed 256. We could formalise this by lifting prng into a signal-of-refinements!

+ abbrev unsignedMax (K : Int) : StateProp Int := fun x => 0  x  x < K

- def prng : Signal Int := FRP.scan lcg 97
+ def prng :(Int // unsignedMax 256) := 
+   FRP.scan (fun ⟨x, hx⟩ => ⟨lcg x, by sorry) 
+97, by sorry

Might be worth making sure you understand all our sorry placeholders before proceeding: FRP.scan now consumes and produces a refined pair of type Int // unsignedMax 256 in its first argument, and needs to consume an initial such pair in its second argument.

Filling out the initial argument isn’t that hard: we’ll give it our seed value, and trivial is enough to prove that 0 <= 97 < 256 right out the gate.

  def prng : Signal Int := FRP.scan lcg 97
  def prng :(Int // unsignedMax 256) := 
    FRP.scan (fun ⟨x, hx⟩ => ⟨lcg x, by sorry) 
-97, by sorry+97, by trivial

The proof that lcg x is within bounds is pretty easy to solve, too: we just have to unfold the definitions of unsignedMax and lcg to get at the raw 0 (5 * x + 17) % 256 (5 * x + 17) % 256 < 256 goal, and then lia kills it for us.

  def prng : Signal Int := FRP.scan lcg 97
  def prng :(Int // unsignedMax 256) := 
-   FRP.scan (fun ⟨x, hx⟩ => ⟨lcg x, by sorry) 
+   FRP.scan (fun ⟨x, hx⟩ => ⟨lcg x, by simp [unsignedMax, lcg]; lia) 
 97, by trivial

And of course we know how to turn this into a refined signal! Just pipe the whole thing into Signal.collect

- def prng :(Int // unsignedMax 256) := 
+ def prng :Int // unsignedMax 256 := 
    FRP.scan (fun ⟨x, hx⟩ => ⟨lcg x, by sorry) 
    FRP.scan (fun ⟨x, hx⟩ => ⟨lcg x, by simp [unsignedMax, lcg]; lia) 
 97, by trivial+   |> Signal.collect

This is great, we have a nice tight safety property for prng; we’re also free to change the implementation of lcg to have different constant values, and so long as we never introduce the possibility of generating larger values, prng will still typecheck.

Refined versions of FRP combinators

Now that we have a syntactic separation between non-refined and refined FRP combinators, and a bit of experience splitting and collecting RSignals, let’s port some of the FRP combinators to their refined counterparts.

const is a collection

Let’s warm up by porting FRP.const into the refined world. Here’s the original implementation:

def Signal.const (v: α) : □ α := fun _ => v

Pretty simple: at all time steps t, produce that constant value. By contrast, RSignal.const will consume a single value paired with a refinement - a { a : α // inv a } - and produce a □ α // inv.

Here’s our function signature with that in mind: It’ll now take a refined value, and produce a refined signal with the same property.

def RSignal.const (a : { a : α // inv a } ) : □ α // inv :=
  -- TODO

The wrong thing to do is to simply produce the constant Signal (fun _ => a) like before. Reason is: that’s a pointwise refinement: it produces <val, proof> pairs at every t, whereas what we want is a single, global <val, proof> pair.

Luckily, though, we just wrote a combinator to turn one into the other! Signal.collect really does all the heavy lifting here.

 def RSignal.const {inv: StateProp α} (a : { a : α // inv a } ) : □ α // inv :=
-  -- TODO
+  Signal.collect (fun _ => a)

Choosing good invariants for a RSignal.const

Here’s how we use RSignal.const in practice: suppose I have a constant hex value I want to lift into a refined Signal. What’s the type of RSignal.const ⟨0xFFFF, by lia?

I’ll tell you later why I decided to call this ss.

def ss :Int // sorry /- TODO? -/ := RSignal.const ⟨0xFFFF, by lia

Really, the invariant can be anything that lia is able to prove for us, because we let inv be an implicit parameter to the function itself, and that proposition needs to be discharged by lia.

The least interesting inv is the StateProp that says nothing at all: “no matter what the state value is, produce the proposition True”. This is the weakest of all StateProps. It’s a bit like just using the unrefined FRP.const combinator; it’s also a bit like, in OOP, assigning some object to a variable of type Object, in that all information about the type is thrown out.

- def ss :Int // sorry /- TODO? -/     := RSignal.const ⟨0xFFFF, by lia+ def ss :Int // Function.const _ True := RSignal.const ⟨0xFFFF, by lia

There’s a nice parallel with stats here. Just like how Fun.const True admits all values, the uniform distribution over all values, assigns equal weights to all hypotheses is the least useful prior. By contrast, x = 0xFFFF admitting exactly one value is like a Dirac function putting all its mass on a single outcome.

Two more interesting invariants: we could also assert the fact that the value always is the constant we gave it. This is the strongest of all StateProps that we could reasonably expect lia to prove for us; whereas Function.const _ True “admits everything”, this invariant admits one fact, that the constant value never deviates.

- def ss :Int // Function.const _ True := RSignal.const ⟨0xFFFF, by lia+ def ss :Int // fun i => i = 0xFFFF   := RSignal.const ⟨0xFFFF, by lia

Here’s one that I’m actually going to use later on in this post: it’s definitely the case that 0 <= i < 0xFFFF, so we could assert that this value would always fit into a 16-bit hardware register.

+ abbrev signedHalf (B : Int) : StateProp Int := fun x => -B  x  x < B
+ abbrev unsignedMax (M : Int) : StateProp Int := fun x => 0  x  x < M

- def ss :Int // fun i => i = 0xFFFF := RSignal.const ⟨0xFFFF, by lia+ def ss :Int // unsignedMax (2^16)  := RSignal.const ⟨0xFFFF, by lia

Wouldn’t it be great if Lean could infer which invariant is most useful for us given the context in which we use it? Sadly, this goes back to the fact that dependent type inference is undecidable. But, having to think about what the right invariant is isn’t really the end of the world.

map splits, applies, then collects

Annoyingly, only when writing this implementation did I realise that Lean’s Functor implementation is, depending on how you look at it, too demanding or not demanding enough, to accept RSignal.map. The whole point of a functor is that it provides a way to map between any two types (which you can see in the typeclass definition) - in (α β) F α F β, α and β can be any type. The problem here is that our definition of RSignal.map constrains us specifically to refined types, so Lean rejects it as being insufficiently general for Functor’s purposes.

Okay, let’s do Signal.map next. Here’s the original signature.

def Signal.map (f: α  β) (s : □ α) : □ β := fun t => f (s t)

We’ll now have two invariants: a precondition that must hold for the input type, and a postcondition, that does the same for the output type. pre and post will appear not just in the input and output Signals but in the mapping function: f will assume that pre holds, and under that hypothesis, guarantee that post will hold.

OK, so in summary, our signature looks like this:

 def RSignal.map
+  {pre: StateProp α}
+  {post: StateProp β}
-  (f: α  β) 
-  (s : □ α) 
-  : □ β := ...
+  (f: {a: α // pre a}  {b : β // post b}) 
+  (s: □ α // pre) 
+  :   □ β // post := 
   ...

Let’s write the body of map. Roughly, our goal is going to be: "decompose the input Signal into its piecewise parts, apply the function on each part, and recombine into a new refined Signal.

Signal.split s gives us a (α // inv_a), which f can be applied to at every timestep. fun t => f (Signal.split s t) gives us a (β // inv_b), and then Signal.combine stitches the pointwise invariants back into a safety property. So, we’re left with functionally a one-liner:

 def RSignal.map
   {pre: α  Prop}
   {post: β  Prop}
   (f: {a: α // pre a}  {b : β // post b})
   (s: □ α // pre)
   : □ β // post :=
+  Signal.collect (fun t => f (Signal.split s t))

Transforming values and properties, pointwise

What can we do with a refined map that we couldn’t before? Let’s suppose we wanted to take our prng signal from earlier, which we proved was always going to return a value on [0, 256). Suppose this is actually an 8-bit pointer into an 8-bit ROM bank, like you’d see on NES or Game Boy hardware. Fixing the ROM base address as some constant, we want to consume a signal that gives us the offset into the bank and produces the full 16-bit pointer value.

We’ll implement this using RSignal.map: the only question is what the body of the function passed to it should be.

def bankedMemory (page : {p : Int // unsignedMax (2^8) p})
  : (Int // unsignedMax (2^8))  (Int // unsignedMax (2^16)) :=
  RSignal.map 
    (fun ... /- TODO -/) 

Let’s follow the types. f is a pointwise transformation of the input value and proof, so it’ll take a tuple as argument…

def bankedMemory (page : {p : Int // unsignedMax (2^8) p})
  : (Int // unsignedMax (2^8))  (Int // unsignedMax (2^16)) :=
  RSignal.map 
-     (fun ... /- TODO -/) 
+     (fun ⟨off, h_off⟩ => sorry /- TODO -/)

… and similarly produce a tuple with the right padding, and a proof. (It’s worth pausing and pondering here to make sure you can express the types for off and h_off.)

Some simple arithmetic will do the bitwise- padding job, and lia is smart enough to prove that we’ve stayed within 2^16. (You should definitely try changing the value of unsignedMax and changing the bitwise operation and see if Lean starts complaining!)

def bankedMemory (page : {p : Int // unsignedMax (2^8) p})
  : (Int // unsignedMax (2^8))  (Int // unsignedMax (2^16)) :=
    RSignal.map 
-     (fun ⟨off, h_off⟩ => sorry /- TODO -/)
+     (fun ⟨off, h_off⟩ => ⟨page * 256 + off, by lia)

RSignal.map2 joins two values and two proofs

RSignal.map2 is not fundamentally different, either, just now with three invariants: two assumptions for the two input Signals and one postcondition for the output Signal. Similarly, the mapping function can assume both conditions are true.

If RSignal.map does not get us to a Functor, then RSignal.map2 extremely does not get us to an Applicative. For Applicative, pure : α F α has to produce a refined value out of nothing but an α, there’s no inv_a lying around to use as the proof term, and a StateProp is too rich of a dependent type to be inferred from context by Lean. Alas!

You should email me if you have thoughts on how to make this better.

def RSignal.map2
  (f: {a: α // inv_a a}  {b : β // inv_b b}  {c : γ // inv_c c})
  (s1: □ α // inv_a) 
  (s2: □ β // inv_b)
  : □ γ // inv_c :=
  (fun t => f (Signal.split s1 t) (Signal.split s2 t)) |> Signal.collect

Even after we got proper virtual address spaces and memory protection, segmentation was still used for things like thread-local storage (so on each thread switch, a different base pointer, for the new thread’s TLS segment, would be swapped in.

Also: In one of my favourite examples of “use what ya got”, when researchers started figuring out how to virtualise x86 - that is, to run entire OSs under a hypervisor on an architecture that did not actually support virtualisation - they reused those segment registers to isolate the hypervisor from the guest OSes.

Let’s try and use map2 to implement, of all things, segmentation for real-mode on early x86 processors. Segmentation can be thought of as an early predecessor to virtual memory: the Intel 8086 chipset had a 20-bit address space (with 1MiB of addressable memory), so the bus would have 20 data lines, labeled A0 through to A19. However, its registers could only hold 16-bit values, and so therefore pointers could nominally only point to 64KiB of memory space. So, to increase the addressability of the hardware, pointers were in fact actually 16 bit offsets into a 16-bit segment, where the base address of the segment was stored in a separate register.

Because segments were all aligned on a 4-bit boundary, mapping a segment-offset pair to a final memory address involved shifting the segment left by 4 bits to get the true 20 bit base pointer, to which the offset pointer is added.

So, our map2 signal ought to consume two Int // unsignedMax (2^16)s, one for the segment register’s value and the other for the pointer (the offset into that segment), and ultimately produce a Int // unsignedMax (2^20). Unfortunately, Lean won’t let us prove this, and with good reason - it’s not correct!

When lia tries to discharge the proof that base * 16 + off < 2^20, it’s going to use properties of both input arguments (namely, base < 2^16 and off < 2^16). Problem is, these two propositions contradict the thing we want to prove. Here’s the output we get from the lia solver:

def segment_8086 : (Int // unsignedMax (2^16)) 
                   (Int // unsignedMax (2^16)) 
                   (Int // unsignedMax (2^20)) :=
  RSignal.map2 (fun ⟨base, hb⟩ ⟨off, ho⟩  => ⟨base * 16 + off, by lia)
`grind` failed
base : Int
off : Int
val : Int
val_1 : Int
 False
...
linear constraints ▼
      [assign] base := 61441
      [assign] off := 65520
      [assign] val := 0
      [assign] val_1 := 0
      [assign]2 ^ 16:= 0 "

We’re not used to seeing counterexamples in Lean, but lia’s solver lets us see one! From the return type, no matter what values of base and off we have, our computation must be < 0x100000; however, base = 0xF001 and off = 0xFFFF, and critically, 61441 * 16 + 65520 = 0x100000! lia’s solver found us the minimum counterexample.

What’s the maximum counterexample? When base = 0xFFFF and offset = 0xFFFF, highest possible 16-bit values for the base and offset pointers almost make up for a full additional segment; 0x10FFEF is just 16 bytes short of a that additional 2^16. Of course, neither 0x100000 nor 0x10FFEF are valid addresses on the 8086; in both cases we’d wrap around to 0x00000 and 0x00FFEF, respectively.

We can widen the return type to account for this additional 2^16 - 16 term, and see that lia now discharges our proof, even though of course the hardware didn’t have that additional segment to address.

  def segment_8086 : (Int // unsignedMax (2^16)) 
                     (Int // unsignedMax (2^16)) 
-                    (Int // unsignedMax (2^20) :=
+                    (Int // unsignedMax (2^20 + 2^16 - 16)) :=
    RSignal.map2
    (fun ⟨base, hb⟩ ⟨off, ho⟩ =>(base * 16 + off), by lia)

We can certainly model that real-world wraparound behaviour, since we know that lia knows all about modular arithmetic:

def segment_8086 : (Int // unsignedMax (2^16)) 
                   (Int // unsignedMax (2^16)) 
                   (Int // unsignedMax (2^20)) :=
  RSignal.map2
  (fun ⟨base, hb⟩ ⟨off, ho⟩ =>(base * 16 + off) % (2^20), by lia)

Here, by specifying the implementation more precisely, we were able to be looser about the type signature of the output of map2.

weaken “downcasts” a Signal’s invariant

Something that’s nice about our refined Signal formulation is that we don’t just compose transformations on data as values flow through the reactive program, but we can also transform proofs about those values. Let’s wrap this post up with a final combinator that lets us start with a signal that maintains a strong invariant, and safely loosens it.

Modeling the 286’s A20 line

We can also model the design change that went into the 80286. That CPU now let us address 24-bits of memory (a whole 16 MiB’s worth, wowee zowee!) So, we now have four more bus lines, A20 through to A23.

So now, our signal will consume that 24-bit base address from the GDT, and a standard 16-bit pointer which forms the offset into the segment.

You should start getting itchy about potential overflows about now.

def segment_286_ish : (Int // unsignedMax (2^24)) 
                      (Int // unsignedMax (2^16)) 
                      (Int // unsignedMax (2^24)) := ...

The problem was this: The curse of designing a followup to a successful CPU is that you’ll have tons of existing software that can’t break on the new design. (Unless you’re Apple, I guess, in which case, just move everybody off Motorola and then PowerPC and then Intel anyway!) So, Intel had to carry the burden of maintaining backwards compatibility with software that assumed that we’d wrap around on address 0x100000.

A classic DOS programming trick involved overflowing the segment + offset calculation to compute pointers at the top of the address space. (This post covers why you might want to actually do this in practice.) Wrap-around worked on the 8086, until suddenly we had a larger address space with the 286!

example : (0xF01D * 16 + 0xFEF0) % 2^20 = 0x000C0 := by lia
example : (0xF01D * 16 + 0xFEF0)         0x000C0 := by lia

The bus line that carried bit 20 (and thus enables addresses like 0x100000) was, on the 286 (and, frankly, on chipsets up until Haswell) had to be manually enabled. This way, legacy software could still have the wrap-around semantics maintained, while new software could opt-into the larger address space. (In practice, enabling that wire is one of the first things any semi-modern OS would do when it first boots.)

Here’s what it means for the A20 line to be in its initial disabled state and in its raised state:

Note that a20Disabled doesn’t say anything about constraining the highest-order bits 21, 22, or 23. But that’s okay: There’s no way that segment_8086 could ever touch those bits.

-- when A20 is up, the full address space is accessible
abbrev a20Enabled (ptr : Int) : Prop := unsignedMax (2^24) ptr

-- when A20 is down (legacy mode), bit 20 will always have to be 0
-- but other bits are passed through unaltered.
abbrev a20Disabled (ptr : Int) : Prop :=
  unsignedMax (2^24) ptr  (ptr / 2^20) % 2 = 0

Proving bus behaviour equivalence between the 8086 and the 286

The whole point of being able to toggle the A20 line on and off is that the same bus signals ought to be identical to to the 8086, when it’s off. In other words, we should be able to take a Int // unsignedMax (2^20) and use it where we expect a Int // a20Disabled, in the same way that we should be able to take an object of type Dog and use it where we expect an Animal.

Unfortunately, this sort of “downcast” of a signal’s safety property is too nontrivial for Lean to just let us do automatically:

def a2o_masked_from_8086 : (□ Int // a20Disabled) := segment_8086 ss ss

Type mismatch
  segment_8086 ss ss
has type
  RSignal Int (unsignedMax (2 ^ 20))
but is expected to have type
  RSignal Int a20Disabled

This is annoying because it stands to reason that lia or some other arithmetic tactic could demonstrate this to Lean, but there’s no way to slot that proof in anywhere. RSignal.weaken is the combinator that lets us do that.

OK, so weaken is going to consume and produce an RSignal of the same base type, but with a different safety property.

def RSignal.weaken 
  {P Q : StateProp α}
  (s : □ α // P) 
  :    □ α // Q := sorry --TODO

For this combinator to be well-formed, how do P and Q need to relate? Well, anything that needs to be true about P, the property we are given, needs to also be true with respect to Q, the property we’re returning. (For instance, for numbers for which 0 <= a < 256 is true, 0 <= a < 2048 is also true). In other words, for every a, if P a, then Q a; we need to know this implication holds!

 def RSignal.weaken 
   {P Q : StateProp α}
+  (h :  a, P a  Q a)
   (s : □ α // P) 
   :    □ α // Q := sorry --TODO

The body of weaken is not hard to see once you remember that map lets us manipulate the local safety proof as well as the value itself, and that h’s universal quantifier means we can apply an a to it like a function call:

 def RSignal.weaken 
   {P Q : StateProp α}
   (h :  a, P a  Q a)
   (s : □ α // P) 
   :    □ α // Q :=
+   RSignal.map (fun ⟨val, p⟩ => ⟨val, h val p⟩)

And just as we expect, lia will let us weaken an 8086 address-producing signal to one that the 286 can dereference.

def a20_off_is_8086 (sig :Int // unsignedMax (2^20)) :Int a20Disabled :=
  RSignal.weaken (by lia) sig

What we can’t do is go the other way: an arbitrary physical address for a 286 may not be well-defined on the 8086, and lia is happy to give us a counterexample to show us one such address:

def i8086_from_286 (sig_286 :Int // a20Disabled) :Int // unsignedMax (2^20) :=
  RSignal.weaken (by lia) sig_286

`grind` failed
linear constraints ▼
    [assign] a := 2097152

(The counterexample, 2097152, is 2^21, aka 0x200000 aka 0b1000000000000000000000, which is, indeed, a value that doesn’t fit into 20 bits, and yet still has bit 20 unset.)

We also can’t, once we’ve enabled the A20 line, get back to the 8086-compatible address by weakening it either. For this to happen, we’d need to be able to prove that bit 20 is zero, which of course may or may not be true for some arbitrary 24-bit value.

def a20_down_from_a20_up (sig_286 :Int // a20Enabled) :Int // a20Disabled :=
  RSignal.weaken (by lia) sig_286

`grind` failed
linear constraints ▼
    [assign] a := 1048576

(Here, the counterexample Lean generates for us is literally 2^20, which certainly has the 20th bit set!)

We could, of course, enforce that bit 20 is always zero, in which case the proof does go through.

def a20_down_from_a20_up (sig_286 :Int // a20Enabled) :Int // a20Disabled :=
  RSignal.map (fun ⟨ptr, inv⟩ =>
    -- ptr && !(1 << 20)
    ⟨ptr - 2^20 * (ptr / 2^20 % 2), by lia)
    sig_286

Next time

Phew! I think I can safely say I have cornered the market on Lean blog posts that model pre-ia32 Intel architecture semantics :-)

OK, that was a lot of fun. Thanks for making it all the way to the end.

We’re on the home stretch of this series, I think! We should have enough mechanism built to actually start writing interesting reactive programs. The plan for next time is, inspired by this podcast I listened to last week, to implement Hoare logic, which is a classic way of modeling stateful, imperative programs formally, and seeing how connectives in hoare logic map to FRP (and thus LTL). We’ll use hoare logic to implement a simple systolic array in our FRP library, and prove some hopefully interesting properties about it.

See you then.