I’m a fan and long-term user of the Isabelle/HOL proof assistant. More recently I have been studying Lamport’s TLA+ system which is a popular choice for writing specifications of systems. This post is a slightly tidied-up version of some notes about my early experiences of playing with the implementation of TLA within Isabelle/HOL, to record a handful of obstacles I hit and some techniques I found useful.

Addendum: this post has grown into a series of posts, which continues to expand:

Introduction

TLA is a simple linear-temporal logic that is expressive enough to describe the evolution of a system over time. Linear-temporal logics typically allow formulae to be written that express relationships between the state of the system at arbitrary times, whereas in TLA one can only really talk about actions: relationships between pairs of successive states. This makes things a whole load simpler without much practical effect on the expressiveness of the logic. Additionally there are some constraints on the kinds of formulae that can be written that force specifications to be invariant under stuttering, which makes it much more straightforward to compose specifications out of other, smaller, specifications, reflecting the decomposition of a system into its subsystems.

There are two main tools (that I’m aware of) that help to investigate the validity of a TLA+ specification: the TLC model checker, and the TLAPS proof system. TLC is capable of completely verifying properties of finite models, and does a pretty good job of exploring infinite models too. TLAPS is a proof system that promises the ability to formally prove properties of TLA+ models, allowing for a more rigorous verification than a model-checker such as TLC can offer.

TLAPS

I’ve never used TLAPS but I have read about it a bit. Since I’m currently interested in TLA (the logic) and much more familiar with Isabelle, I thought it’d be more useful to play around with TLA in Isabelle rather than try and learn about it whilst also learning TLAPS. That said, I noted down some questions about TLAPS that I’d like to answer in due course.

Firstly, at the time of writing, the TLAPS website says:

The current release of TLAPS does not perform temporal reasoning, and it does not handle some features of TLA+. […] TLAPS is now suitable for verifying safety properties of non-trivial algorithms.

I’m interested in proofs of both safety and liveness properties, and Ron Pressler says that the website is out of date and gives some simple examples of temporal reasoning. What are TLAPS’s limits these days? Can it, for instance, use custom induction principles in liveness proofs?

Next, the Practical Hints section of the TLAPS manual says the following, on the use of ASSUME and AXIOM :

Asserting facts is dangerous, because it’s easy to make a mistake and assert something false, making your entire proof unsound. Fortunately, you can use the TLC model checker to avoid such mistakes.

It goes on to suggest the following assumption:

ASSUME GCDProperty3 == \A p, q \in Nat \ {0}: (p < q) => GCD(p, q) = GCD(p, q-p)

Is this written as an assumption to keep the example short or can TLAPS not prove properties like this? Relying on TLC to verify that this kind of property holds for many instances is often fine, and it’s very valuable to be able to quickly check such statements while you’re developing a specification, but I find it unsatisfactory that it’s not eventually proved. I once spent nearly a year developing some theory that turned out to be worthless due to a plausible, yet false, assumption on the first page, so perhaps I am just unreasonably averse to making the same sort of mistake again.

Furthermore, TLAPS is really just a front-end to a variety of provers, including Isabelle, and this property is a specialisation of lemma gcd_diff1 in Isabelle’s GCD theory which is part of its extensive library of formalised mathematics, so it feels as if TLAPS should be able to use this lemma directly instead of relying on a user-specified assumption. It’d be interesting to find out whether this is possible or not.

This also raises the question of how the integration between TLAPS and Isabelle works, and particularly whether it’s possible to automatically carry definitions across from TLA+ in a way that lets me continue to use Isabelle’s rich proof system directly.

Isabelle

The purpose of this article wasn’t really to talk about TLAPS, but this sets the context for my excitement at discovering that Isabelle includes a formalisation of (typed) TLA without any obvious restrictions on the supported features of TLA or its integration with the rest of Isabelle’s other mathematical libraries. In fact, this formalisation supports so-called “raw” TLA, which is a superset of TLA that is not stuttering-invariant. Stuttering-invariance is a useful property of specifications as it makes them easier to compose, but it seems likely that it’s sound and useful to be able to work in the “raw”, stuttering-sensitive, superset when doing proofs.

Example: Termination Detection

The TLAPS distribution includes some examples, and one which caught my eye was a formalisation of Dijkstra’s distributed termination detection algorithm. The setup is a fixed network of nodes each of which, if active, can activate other nodes by passing messages. The purpose of the algorithm is to detect if all nodes have become inactive. It works by passing a token from node to node (i.e. in a ring) which collects information about the existence of active nodes. It is a little subtle because an inactive node that’s already passed the token on may be activated by a node that’s not yet received the token and which becomes inactive before it does so. The full description in EWD840 is worth reading for more details.

The example in the TLAPS distribution includes a proof of its safety property, which says that the algorithm only detects termination if all nodes are indeed inactive:

TerminationDetection == terminationDetected => \A i \in Nodes : ~ active[i] ... THEOREM Spec => []TerminationDetection

It also includes a statement, but no proof, of a liveness property which says that if all nodes are inactive then the algorithm eventually detects termination:

Liveness == (\A i \in Nodes : ~ active[i]) ~> terminationDetected

I thought it’d be a cute exercise to see what this algorithm looked like formalised in Isabelle, and to see what a proof of that liveness property looked like. I posted a Gist of the resulting theory and will walk through some of its highlights below.

Initial experiences

I initially found it quite hard work to use the TLA theory, at least partly because of all the clever syntactic tricks it pulls. In Isabelle, TLA formulae look like this:

"⊢ S ⟶ ((B ∧ ¬C) ↝ C)"

The turnstile ( ⊢ ) denotes that the following formula is valid, i.e., true in all possible worlds. But there is overloading going on here: sometimes a “world” is a single state; others are pairs of consecutive states; and yet others are behaviours which are, morally, infinite sequences of states. The formula above is about a behaviour because it involves the leads-to ( ↝ ) operator, but the operands of the leads-to operator may be single-state or state-pair formulae.

This led to a certain amount of frustration, because some theorems about formulae like the one above might only be applicable at certain types, and the type constraints aren’t clear from the usual output. While I was exploring it helped a lot to make Isabelle be much more verbose with these declarations:

declare [[show_types]] declare [[show_consts]]

A further source of confusion was that many of the things that look like functions are in fact just more syntactic tricks: the names of previously-declared functions are written in black text, but things like Init and Enabled are a kind of turquoise colour because they’re not really functions. This means you can’t (easily) search for theorems that mention them in the Query tab, since Init on its own is not a well-formed term so searching for that yields a syntax error.

Definitions

The Isabelle version of TLA is typed, unlike Lamport’s TLA, so after a few utility lemmas the theory defines some types:

axiomatization NodeCount :: nat where NodeCount_positive: "NodeCount > 0" typedef Node = "{0..<NodeCount}" using NodeCount_positive by auto

This defines a finite type Node as a subtype of the natural numbers. I think it would all still have worked to just use nat throughout, although by using a separate type you can keep from proliferating the assertion that node numbers are <NodeCount . Because Node is a separate type from nat , we need to explicitly define things like an ordering and an induction principle to be used later.

datatype TerminationState = MaybeTerminated | NotTerminated

This type is used instead of colours (respectively white and black) for the state of the token and of the nodes.

axiomatization isNodeActive :: "(Node ⇒ bool) stfun" and nodeState :: "(Node ⇒ TerminationState) stfun" and tokenPosition :: "Node stfun" and tokenState :: "TerminationState stfun" where ewd_basevars: "basevars (isNodeActive, nodeState, tokenPosition, tokenState)"

This declares the variables along with their types. The stfun type constructor is short for state function and indicates that each of these depends on the state of the system. The basevars axiom effectively says that these variables are all independent of each other, and so their values can be freely changed by actions.

definition StartState :: stpred where "StartState == PRED tokenPosition = #FirstNode ∧ tokenState = #NotTerminated"

The definition of the initial state looks like this. Its type, stpred , means state predicate, and is short for bool stfun . Recall that tokenPosition and tokenState are the base variables; the # prefix on FirstNode and NotTerminated “lifts” these constant values to become (constant) state functions. The PRED thing is more syntactic trickery to let Isabelle know that the following syntax should be parsed as a state predicate and not as an ordinary expression; this is necessary for the # prefixes to work, and also note that the conjunction ( ∧ ) is an overloaded version of the usual one that applies at each state separately.

definition InitiateProbe :: action where "InitiateProbe == ACT (($tokenPosition = #FirstNode) ∧ ($tokenState = #NotTerminated ∨ id<$nodeState,#FirstNode> = #NotTerminated) ∧ tokenPosition$ = #LastNode ∧ tokenState$ = #MaybeTerminated ∧ (unchanged isNodeActive) ∧ (updatedFun nodeState FirstNode (const MaybeTerminated)))" definition PassToken :: "Node ⇒ action" where "PassToken i == ACT (($tokenPosition = #i) ∧ ($tokenPosition ≠ #FirstNode) ∧ (¬ id<$isNodeActive,#i> ∨ id<$nodeState,#i> = #NotTerminated ∨ $tokenState = #NotTerminated) ∧ (tokenPosition$ = PreviousNode<$tokenPosition>) ∧ (tokenState$ = (if id<$nodeState,#i> = #NotTerminated then #NotTerminated else $tokenState)) ∧ (unchanged isNodeActive) ∧ (updatedFun nodeState i (const MaybeTerminated)))"

The definitions of the actions that perform a termination probe look like this. The type action is a synonym for bool trfun , i.e. a boolean transition function, a predicate on pairs of states. The ACT thing is the analogue of PRED above, and # symbols lift constants as before.

In TLA+, transition functions are defined in terms of primed and unprimed variables, but in the Isabelle translation the same concepts use a suffix and prefix $ symbol respectively. For instance, $tokenPosition = #FirstNode is an enabling condition, indicating that the token must be at the first node before this transition, and tokenPosition$ = #LastNode indicates that the transition moves the token to the last node. As in TLA+, unchanged X is shorthand for $X = X$ .

The updatedFun nodeState FirstNode (const MaybeTerminated) expression is my own (poor) reflection of [nodeState EXCEPT ![FirstNode] = MaybeTerminated] defined locally because it doesn’t look like this is in the library and I’m not good enough at Isabelle’s syntactic trickery try and get closer to the TLA+ syntax at this point.

Fixed functions can be applied to (up to 3) variable arguments using the angle-bracket syntax f<x,y> . In this specification the variables nodeState and isNodeActive are themselves functions, but nodeState<#FirstNode> does not work since the function outside the angle brackets is a variable and not a fixed function: it is part of the system’s changing state. Using id , the identity function, works around this: id f x = f x , so id<f,x> applies the (variable) function f to the (variable) argument x .

definition SendMsg :: "Node ⇒ action" where "SendMsg i == ACT id<$isNodeActive,#i> ∧ (∃ j. #j ≠ #i ∧ (updatedFun isNodeActive j (const True)) ∧ (updatedFun nodeState i (ACT if #i < #j then #NotTerminated else id<$nodeState,#i>))) ∧ unchanged (tokenPosition, tokenState)" definition Deactivate :: "Node ⇒ action" where "Deactivate i == ACT id<$isNodeActive,#i> ∧ (updatedFun isNodeActive i (const False)) ∧ unchanged (tokenPosition, tokenState, nodeState)"

The definitions of the actions that may occur outside of the control of the termination detection algorithm are similar. In SendMsg the guard #i < #j can, according to the paper, be replaced by #True without affecting its correctness.

definition Controlled :: action where "Controlled ≡ ACT (InitiateProbe ∨ (∃ n. PassToken n))" definition Environment :: action where "Environment ≡ ACT (∃ n. SendMsg n ∨ Deactivate n)" definition Next :: action where "Next ≡ ACT (Controlled ∨ Environment)" definition Fairness :: temporal where "Fairness ≡ TEMP WF(Controlled)_(isNodeActive,nodeState,tokenPosition,tokenState)" definition Spec :: temporal where "Spec ≡ TEMP (Init StartState ∧ □[Next]_(isNodeActive,nodeState,tokenPosition,tokenState) ∧ Fairness)"

The remainder of the specification is also similar. Note that Fairness and Spec have type temporal indicating that they are predicates on behaviours (infinite sequences of actions). The WF operator indicates weak fairness: if Controlled is eventually always enabled then it eventually executes, changing the value of at least one of the subscripted variables.

Notice that Spec is in the standard form for a TLA+ formula, describing its initial state, a stuttering-invariant transition relation, and some fairness rules.

definition TerminationDetected :: stpred where "TerminationDetected ≡ PRED (tokenPosition = #FirstNode ∧ tokenState = #MaybeTerminated ∧ id<nodeState,#FirstNode> = #MaybeTerminated ∧ ¬ id<isNodeActive,#FirstNode>)"

Finally, here is the definition of how the algorithm detects termination. It should be straightforward to see that this condition can be checked locally on FirstNode .

Safety

definition AllNodesInactive :: stpred where "AllNodesInactive ≡ PRED (∀ n. ¬ id<isNodeActive,#n>)" definition SafetyInvariant where "SafetyInvariant ≡ PRED (∀ n. (tokenPosition < #n) ⟶ ¬ id<isNodeActive,#n>) ∨ (∃ n. #n ≤ tokenPosition ∧ id<nodeState,#n> = #NotTerminated) ∨ tokenState = #NotTerminated" lemma safety: shows "⊢ Spec ⟶ □(TerminationDetected ⟶ AllNodesInactive)" proof - ...

The algorithm’s safety condition is that it detects termination only if all nodes really are inactive. As in the paper and the TLAPS presentation, it proceeds by showing a safety invariant and then showing that this invariant implies the appropriate condition:

have "⊢ Spec ⟶ □SafetyInvariant" proof invariant ...

The invariant proof method is appropriate for goals of this form, where SafetyInvariant must be of type stpred . This was one case where the overloading was confusing: other goals of the same syntactic form, but with an action or temporal expression instead, cannot be reduced by this method.

The resulting subgoals are:

goal (2 subgoals): 1. ⋀sigma. Spec sigma ⟹ sigma ⊨ Init SafetyInvariant 2. ⋀sigma. Spec sigma ⟹ sigma ⊨ stable SafetyInvariant

This looks a lot like an induction proof: show that the initial state satisfies the invariant, and then show that it is stable (i.e. once it becomes true it remains true forever). The proofs of these goals are just definition-unwinding; the second requires a case analysis on which transition within Next is actually taken, which takes up some space.

After this step is complete, it remains to show that the invariant implies the safety property:

moreover have "⊢ □SafetyInvariant ⟶ □(TerminationDetected ⟶ AllNodesInactive)" unfolding SafetyInvariant_def proof (intro STL4, clarsimp, intro conjI impI) ...

The TLA axiom STL4 is ⊢ ?F ⟶ ?G ⟹ ⊢ □?F ⟶ □?G , in which the types of F and G are again important. After this step the result follows quickly:

ultimately show ?thesis by (simp add: Valid_def)

Liveness

The liveness property we’re looking for is that if AllNodesInactive then eventually TerminationDetected . I roughly expected to show this by showing that the token always ends up back at the first node, and then accounting for the fact that the first time it arrives there after it might be in the NotTerminated state, so it has to go round once more to check that everything really is terminated.

After a bit of mucking around trying to prove this I discovered that this isn’t how the algorithm works. This is one of the things that I like about proof (as opposed to model checking): it forces you to understand the system you’re working on, and you can’t just get away with writing down plausible statements without thinking about why they’re true. Conversely it does mean you don’t need a deep understanding before you can start work on a system: you can just wade in with a rough idea of how the proof is going to go, and when you get to the bit that doesn’t work then you can learn and iterate.

This algorithm may actually sometimes need to send the token round twice more after all the nodes have become inactive, not once. The first pass sets all the nodes to MaybeTerminated but the token could still be contaminated by their previous states, and the second pass results in a clean token too.

These three phases of the algorithm’s endgame can be characterised like this:

definition AllNodesInactiveAndTokenAt where "AllNodesInactiveAndTokenAt n ≡ PRED (AllNodesInactive ∧ tokenPosition = #n)" definition NodeCleaningRunAt where "NodeCleaningRunAt n ≡ PRED (AllNodesInactiveAndTokenAt n ∧ id<nodeState,#FirstNode> = #MaybeTerminated ∧ (∀ m. #n < #m ⟶ id<nodeState,#m> = #MaybeTerminated))" definition TokenCleaningRunAt where "TokenCleaningRunAt n ≡ PRED (AllNodesInactiveAndTokenAt n ∧ tokenState = #MaybeTerminated ∧ (∀ m. id<nodeState,#m> = #MaybeTerminated))"

The liveness proof can then go through by showing that each phase results in the token back at the first node, which leads onto the next phase, and at the end of the final phase termination is detected. Actually even this is not quite how it works, because if termination is detected at the end of a phase then it just stops and doesn’t carry on to the next phase. This little lemma captures this behaviour nicely:

lemma imp_leadsto_triangle_excl: assumes AB: "⊢ S ⟶ (A ↝ B)" assumes BC: "⊢ S ⟶ ((B ∧ ¬C) ↝ C)" shows "⊢ S ⟶ (A ↝ C)"

The proof rule WF1 was an important ingredient in the liveness proof. Its statement is a little intimidating:

lemma WF1: "⟦ ⊢ $P ∧ N ⟶ P` ∨ Q`; ⊢ ($P ∧ N) ∧ <A>_v ⟶ Q`; ⊢ $P ∧ N ⟶ $(Enabled(<A>_v)) ⟧ ⟹ ⊢ □N ∧ WF(A)_v ⟶ (P ↝ Q)"

To unpack this, first notice that the conclusion is in approximately the form we want. The □N ∧ WF(A)_v expression looks like part of the specification, where N is the transition predicate and WF(A)_v is a fairness condition. The first precondition says that if P holds and any transition occurs then either P still holds or else Q holds. The second says that if P holds and an A transition occurs then this results in Q . The third says that while P holds, A transitions can always occur. The fairness of A transitions ensures that an A transition eventually occurs. In short, WF1 is useful for showing that a single step of the algorithm eventually occurs. It’s useful to specialise WF1 as follows:

lemma step: assumes "⊢ P ⟶ AllNodesInactive" assumes "⊢ P ⟶ ¬ TerminationDetected" assumes "⊢ $P ∧ unchanged (isNodeActive, nodeState, tokenPosition, tokenState) ⟶ P$" assumes "⊢ $P ∧ Controlled ⟶ Q$" shows "⊢ Spec ⟶ (P ↝ Q)"

It remains to repeatedly apply this lemma in order to show that the algorithm eventually detects termination. The high-level structure of the liveness proof is as follows:

lemma liveness: "⊢ Spec ⟶ (AllNodesInactive ↝ TerminationDetected)" proof - have "⊢ Spec ⟶ (AllNodesInactive ↝ AllNodesInactiveAndTokenAt FirstNode)" ... moreover have "⊢ Spec ⟶ ((AllNodesInactiveAndTokenAt FirstNode ∧ ¬ TerminationDetected) ↝ NodeCleaningRunAt LastNode)" ... moreover have "⋀n. ⊢ Spec ⟶ (NodeCleaningRunAt n ↝ NodeCleaningRunAt FirstNode)" ... moreover have "⊢ Spec ⟶ (NodeCleaningRunAt FirstNode ∧ ¬ TerminationDetected ↝ TokenCleaningRunAt LastNode)" ... moreover have "⋀n. ⊢ Spec ⟶ (TokenCleaningRunAt n ↝ TerminationDetected)" ... ultimately show ?thesis by (metis imp_leadsto_transitive imp_leadsto_triangle_excl) qed

The second and fourth statement show that each phase leads onto the next, and work by applying the step lemma and unfolding definitions. The first, third and fifth statements show that each phase terminates with the token at the first node again. They’re quantified over the token position, n , even though it’d be sufficient to prove them where n = LastNode , because they have to be done by induction on the token position and the extra generality doesn’t hurt. Apart from the complication introduced by needing to use induction, they also simply involve applying the step lemma and unfolding definitions.

Closing thoughts

After some initial struggles with the different types of validity statements, and general confusion about the meanings of the available proof rules and tactics, I quite enjoyed this exercise. It’s pleasing to be able to prove results like this from the ground up, with all the power of Isabelle (e.g. induction over the set of nodes, and its full library of existing results) to help along the way.

It’s only fair to note that my initial attempts at proving this liveness result were nothing like as neat as the finished result. There were many false starts, and I proved a number of results that I thought would be useful but which turned out not to be, before I stumbled across rule WF1 and worked out how to use it. I first got to the liveness result via a rather messy route and only then was it possible to step back and see the higher-level structure. The step lemma, specialising WF1 , only came about after noticing the duplication in each usage of WF1 , and even then it went through a number of iterations before ending up in its final form.

There are a few warts here, compared to how things are written in TLA+, particularly the need to lift constants using # , and to say things like PRED and ACT when writing state predicates and actions. This is not entirely unexpected, given the mismatch between Isabelle/HOL’s strong typing and the untyped nature of TLA+. I also miss the ability to write long conjunctions and disjunctions as a list bulleted with ∧ or ∨ , using whitespace sensitivity instead of parentheses.

TLAPS proofs are much shorter than the proofs I’ve written here. My Isabelle style is perhaps quite verbose, as I’ve found this to be useful when porting proofs from one version to the next; denser proofs and ones that heavily rely on a particular sequence of tactics tend to need more rework. The verbosity seems to help to find common pieces of reasoning that can be factored out later. I also prefer to spell out a proof instead of using an automatic tactic when the automation would take a long time (more than a few hundred milliseconds) to succeed, because this gets in the way of refactoring proofs later. It’ll be interesting to see how my style changes when working in TLAPS.

If there are any TLAPS fans reading this then I’d love to see how this liveness proof looks in TLAPS.