Interaction Trees
This module formalises a version of Interaction Trees. The concept of interaction trees was introduced a few years ago by Xia et al, as a formal verification framework for encoding the behaviour of effectful recursive programs that can interact with an external environment.For a more comprehensive library, check out its Rocq's official library.
The following is a translation to Agda of the main definitions related to interaction trees.
{-# OPTIONS --without-K --type-in-type --guardedness --exact-split #-} module Foundation.InteractionTrees where open import Foundation.BasicTypes
Event Signatures
An event signature describes the interface between a computation and its environment. Each event has an associated response type.
EventSig : Set₁ EventSig = Set → Set
Event signatures are also called presented as interfaces or meant to represent the instruction sets of a program1.
Interface : Set₁ Interface = Set → Set
As a simple example, consider a messaging system with basic instructions/operations:
data MessageEvent : EventSig where send : String → MessageEvent ⊤ receive : MessageEvent String create : ObjectId → MessageEvent ⊤ destroy : ObjectId → MessageEvent ⊤
A principal can send a message (returning unit) or receive one (returning a string). Objects can be created or destroyed. We can define multiple event signatures for different kinds of programs and compose them as needed.
Interaction Tree First Definition (Non-productive)
An interaction tree is a datatype that encodes the behaviour of effectful, non-deterministic, recursive programs.
We can define this datatype using coinduction and eliminate this type via copatterns.
The following is a direct translation from the original paper's definition. As
you can see, the positivity checker must be disabled as Agda detects a negative
occurrence of ITree₁ E R within the tau constructor's type argument.
module first-definition where {-# NO_POSITIVITY_CHECK #-} record ITree₁ (E : Set -> Set) (R : Set) : Set where coinductive field ret : R -> ITree₁ E R tau : ITree₁ E R -> ITree₁ E R vis : {A : Set} -> E A -> (A -> ITree₁ E R) -> ITree₁ E R
where E stands for a family of events (external interactions) and R is
the type of results.
- ret is for terminal computations that produce results,
- tau is for silent transitions used for internal computational steps, and
- vis is for a visible event of type
E Ayielding an answer of typeA. Here is where we branch via a continuation, encoding this in a function of typeA -> ITree E R. The branch factor is given by the nature of the typeA. These visible events encode interactions with the external environment, e.g. message sends or object creation.
Working with the type above is not easy due to productivity issues. We can instead define a more convenient version using a structure functor.
ITreeF Structure Functor
The structure functor ITreeF separates one layer of computation from the rest of the tree. This allows us to define the coinductive type in a way that Agda can verify as productive.
data ITreeF (E : EventSig) (R : Set) (X : Set) : Set where retF : R → ITreeF E R X tauF : X → ITreeF E R X visF : (A : Set) → E A → (A → X) → ITreeF E R X
The parameter X represents the type of the subtrees. When we tie the
recursive knot, X will be replaced with ITree E R.
Productive ITrees
An interaction tree over event signature E with return type R represents a
potentially infinite computation that can interact with the environment via
events.
record ITree (E : EventSig) (R : Set) : Set where coinductive constructor delay field observe : ITreeF E R (ITree E R) open ITree public
The delay constructor is crucial for productivity. The first definition
is non-productive because Agda fails to see an observable result if the
tree is an infinite sequence of taus, for example. The delay constructor forces one
observable step, making the definition productive.
To work with this datatype, we define convenient constructors as functions via copatterns. For ret, tau, and vis the type is the same as in the first definition ITree₁.
module _ {E : EventSig} {R : Set} where
- Return a value (terminate computation)
ret : R → ITree E R observe (ret r) = retF r
- Silent step (internal computation)
tau : ITree E R → ITree E R observe (tau t) = tauF t
- Visible event (interact with the environment)
vis : {A : Set} → E A → (A → ITree E R) → ITree E R observe (vis e k) = visF _ e k
- Trigger an event and return its response
trigger : {E : EventSig} {A : Set} → E A → ITree E A trigger e = vis e ret
Working with ITrees
Interaction trees form a monad. This allows us the compositional construction of complex computations. The monadic structure lets us sequence effectful operations, where each computation can depend on the results of previous ones.
module _ {E : EventSig}{R S : Set} where
- Bind operation. Here we take an interaction tree producing
Rand a continuationk : R → ITree E S, returns a new tree producing output of typeS.
_>>=_ : ITree E R → (R → ITree E S) → ITree E S observe (t >>= k) = bind-step (observe t) k where bind-step : ITreeF E R (ITree E R) → (R → ITree E S) → ITreeF E S (ITree E S) bind-step (retF r) k = observe (k r) bind-step (tauF x) k = tauF (x >>= k) bind-step (visF A e f) k = visF A e (λ a → f a >>= k)
- Map operation
_<$>_ : (R → S) → ITree E R → ITree E S f <$> t = t >>= (ret ∘ f)
infixl 1 _>>=_ infixr 4 _<$>_
Agda Do-Notation Support
Agda supports do-notation for monads. We instantiate the RawMonad record (defined in BasicTypes) for ITree to enable clean do-notation syntax:
ITree-Monad : {E : EventSig} → RawMonad (ITree E) ITree-Monad {E} = record { return = ret {E = E} ; _>>=_ = _>>=_ {E = E} }
With this instance, do-notation is now available anywhere ITree-Monad is opened. For example:
-- Instead of: trigger e₁ >>= λ x → trigger e₂ >>= λ y → ret (x , y)
-- We can write:
do
x ← trigger e₁
y ← trigger e₂
return (x , y)
This notation is particularly useful for writing complex AVM programs with multiple sequential operations.
Interpreting ITrees
Interpretation gives meaning to interaction trees by handling events in specific ways. A handler transforms events from one signature to another.
Handler : EventSig → EventSig → Set₁ Handler E F = {A : Set} → E A → ITree F A
Handlers are also called implemenations maps1.
Impl : EventSig → EventSig → Set₁ Impl = Handler
Handling one event in system E may require performing multiple events in
system F.
{-# TERMINATING #-} interp : {E F : EventSig} {R : Set} → Handler E F → -- application-specific ITree E R → -- program tree ------------------------------- ITree F R observe (interp h t) = interp-step h (observe t) where -- we unfold observations interp-step : {E F : EventSig} {R : Set} → Handler E F → ITreeF E R (ITree E R) → ITreeF F R (ITree F R) interp-step h (retF r) = retF r interp-step h (tauF x) = tauF (interp h x) interp-step h (visF A e k) = observe (h e >>= λ a → interp h (k a))
The interp-step helper function looks at what the tree is doing right now
(is it a better way to say this?) It handles three cases:
retF r- Tree returned a value → Keep it as-istauF x- Tree is doing internal computation (e.g. verifying keys or simply doing nothing) → Keep going, interpret the restvisF A e k- Tree is requesting an effecte→ This is where translation actually happens. Run the handlerhon eventeto get a new tree in systemF. Continue by interpreting the continuationkwith the result.
Handlers and implementation decisions
Handlers are relevant for expressing compilation decisions. Over the same interaction tree, the same program instructions, one could be interested in obtaining different outputs. For example, run one handler for executing the program off/on-chain, another for logging, another to give operational semantics.
Observational Equivalence of ITrees
Two interaction trees are equivalent if they produce the same observable
behavior. We define this via weak bisimulation. That is, weak bisimulation
allows equating interaction trees that differ only in the number of Tau
(delay/internal step) constructors before observable events (like Vis nodes
for I/O). To this end, internal computations can be ignored, like loop
unrolling, inlining, or another kind of optimization.
data _≈_ {E : EventSig} {R : Set} : ITree E R → ITree E R → Set where ret-eq : (r : R) → ret r ≈ ret r tau-left : {t₁ t₂ : ITree E R} → t₁ ≈ t₂ → tau t₁ ≈ t₂ tau-right : {t₁ t₂ : ITree E R} → t₁ ≈ t₂ → t₁ ≈ tau t₂ vis-eq : {A : Set} {e : E A} {k₁ k₂ : A → ITree E R} → ((a : A) → k₁ a ≈ k₂ a) → vis e k₁ ≈ vis e k₂ infix 4 _≈_
postulate ≈-refl : {E : EventSig} {R : Set} (t : ITree E R) → t ≈ t ≈-sym : {E : EventSig} {R : Set} {t₁ t₂ : ITree E R} → t₁ ≈ t₂ → t₂ ≈ t₁ ≈-trans : {E : EventSig} {R : Set} {t₁ t₂ t₃ : ITree E R} → t₁ ≈ t₂ → t₂ ≈ t₃ → t₁ ≈ t₃
The tau-left and tau-right rules allow us to ignore silent steps when comparing trees. Two trees are equivalent if they perform the same visible events in the same order, regardless of internal computational steps.
Module References
Referenced By
This module is referenced by:
References
This module references:
-
Mohsen Lesani, Li-Yao Xia, Anders Kaseorg, Christian J. Bell, Adam Chlipala, Benjamin C. Pierce, and Steve Zdancewic. C4: verified transactional objects. Proceedings of the ACM on Programming Languages, April 2022. doi:10.1145/3527324. ↩↩