Why is the Reactive Behavior tutorial taking so long? splitB

Writing the event tutorial brought out the need for several basic functions in Reactive, especially switchE. The behavior tutorial is also bringing with it a slew of necessary functions. The most surprisingly difficult to implement has been splitB.

-- |At every event occurrence, produce a behavior that is a combination of the
-- first and second where it acts like the first up to the event occurrence
-- and then acts like the second.
splitB :: Behavior a ->
          Behavior a ->
          Event b    -> 
          Event (b,Behavior a)

This post describes how this function was implemented. In doing so, we’ll explore internals of the Reactive library and come into contact with the tricky laziness issues that often come up when working with Reactive.

Some of this code will be using (fmap.fmap) style. If that’s unfamiliar, see Peter Verswyvelen’s great mini tutorial on it.

Motivation

The following graph shows a mouse’s x axis position information over a period of time with time 0 representing when the program starts. The y axis represents the x position and the x axis represents time. If you look close, you’ll notice that the graph consists of dots representing samples of the mouse position. The position of the mouse between these samples is unknown by the computer.

In Reactive, we like to think of such things as continuous. In reactive-glut, for example, the mouse’s x position is represented as a continuous line, as in the following graph. At times before the program begins, all the way to -\infty, the position is \bot.

Several useful reactive programs involve dynamic collections where a responsive entity comes to life at a certain time. In this case, we would like the entity’s knowledge of the universe to begin only after it’s creation. Aside from modeling the domain well, it sidesteps the possibility of a memory leak.

In the following graph we show what an entity might see of the x mouse position after its creation at time t.

splitB

Our solution to the above issues is a general function called splitB:

-- |At every event occurrence, produce a behavior that is a combination of the
-- first and second where it acts like the first up to the event occurrence
-- and then acts like the second.
splitB :: Behavior a ->
          Behavior a ->
          Event b    -> 
          Event (b,Behavior a)

To do the splitting as mentioned in the mouse example, we can specialize splitB to:

splitBBot :: Behavior a -> Event b -> Event (b, Behavior a)
splitBBot = splitB (pure (error "splitBBot: no data"))

A naive implementation

Lets start with a naive implementation:

splitB :: Behavior a -> Behavior a -> Event b -> Event (b, Behavior a)
splitB bLeft bRight e = fmap f (withTimeE e)
  where
    f (b,ot) = (b, iffB (fmap (ot >=) time) bLeft bRight)
 
iffB :: Behavior Bool -> Behavior a -> Behavior a -> Behavior a
iffB = liftA3 iff
 
iff a b c = if a then b else c

Semantically, the above splitB implementation is correct. Unfortunately, there is a rather large operational issue: whenever the resultant events are evaluated, a traversal of the entire bRight behavior occurs. This CPU and memory leak amounts to a reactive head explosion.

Something Better

What we need is a decent forgetter mechanism. For our first try we’ll reduce the behavior problem into a reactive problem.

unb :: BehaviorG tr tf a -> ReactiveG tr (Fun tf a)
beh :: ReactiveG tr (Fun tf a) -> BehaviorG tr tf a

The above functions convert a behavior into a reactive of time functions and vice-versa. It’s important to note that unb doesn’t have a semantic meaning and wouldn’t be used in normal code. The G’s in these signatures represent “General” in the aspect that the time types (tr and tf) are not fixed to any specific data type. The typical Event and Behavior types have tr and tf set to Double.

Now we can reduce our splitB implementation into an equivalent splitR implementation.

splitB l r e = fmap (second beh) (splitR (unb l) (unb r) e)
 
splitR :: Ord t => ReactiveG t a -> -- left
                   ReactiveG t a -> -- right
                   EventG t b -> -- Split locations
                   EventG t (b, ReactiveG t a) -- results

Lets assume we have a glueR for reactive that chops the right side of its first argument at time t, chops the left side of its second argument at time t, and glues the two pieces together:

glueR :: Ord t => ReactiveG t a ->  -- left
                  ReactiveG t a ->  -- right
                  Time t ->
                  ReactiveG t a -- combination of left and right with time t in the middle

We may now implement splitR:

splitR :: Ord t => ReactiveG t a -> -- left
                   ReactiveG t a -> -- right
                   EventG t b -> -- Split locations
                   EventG t (b, ReactiveG t a) -- results
splitR l r s = fmap snd (scanlE f (r,error "splitR, shouldn't see it") (withTimeGE s))
  where
    f (r,_) (b,t) = (c, (b,c))
      where
        c = glueR l r t

The above implementation uses scanlE to pass along the previous occurrence’s result to serve as a condensed version of the “right” reactive. We’ll deconstruct the Reactive data type to implement glueR in terms of glueE:

glueR :: Ord t => ReactiveG t a ->  ReactiveG t a -> Time t -> ReactiveG t a
glueR _ r (Max MinBound) = r
glueR l r (Max MaxBound) = l
glueR (Stepper al el) (Stepper ar er) t = Stepper al (glueE t el er)

In the above function, we see that a Reactive internally consists of an initial value and an event who’s occurrences represent possible changes.

Implementation of glueE is straightforward:

glueE :: Ord t => Time t -> EventG t a -> EventG t a -> EventG t a
glueE k = (fmap.fmap) (join . fmap (f k) . withTimeGE) eitherE
  where
    f k (e,t) | t < k     = either pure (pure mempty) e
              | otherwise = either (pure mempty) pure e

Thoughts

Our implementation of splitB certainly helps with our naive implementation’s memory and CPU leak. Unfortunately, when an occurrence is evaluated, all the behavior’s steps since the previous occurrence must still be traversed.

Much Better, but Very Tricky

Ideally, we would like each our occurrences to do no traversal when evaluated. If the prior event’s evaluation of the behavior caused concurrent evaluation of the current behavior, before it occurred, we can get this.

Lets look at a different implementation (note the different type signature) of glueE

glueE :: Ord t =>   Time t  ->
                 EventG t a ->  -- left
                 EventG t a ->  -- right
                 (EventG t a,   -- right event
                  EventG t a)   -- combination of left and right
glueE k l r = if k < min lt rt
               then (r,r)
               else if lt < rt
                      then second (mappend (once l)) (glueE k (restE l) r)
                      else first  (mappend (once r)) (glueE k l (restE r))
  where
    lt = firstETime l
    rt = firstETime r
 
-- If there is an occurrence, return the time of the first occurrence,
-- otherwise return +infty
firstETime :: Ord t => EventG t a -> Time t
firstETime = futTime . eFuture

glueE returns a copy of its right event and is completely identical semantically. However, the right event that is returned is now is written in terms of the combination. If one incrementally starts evaluating the returned right event, they are also evaluating the combination event at the same time. Think about it.

We can expand this to our glueR implementation:

glueR :: Ord t => ReactiveG t a ->  -- left
                  ReactiveG t a ->  -- right
                  Time t ->
                  ( ReactiveG t a   -- right
                  , ReactiveG t a ) -- combination of left and right
glueR _ r (Max MinBound) = (r, r)
glueR l r (Max MaxBound) = (r, l)
glueR (Stepper al el) (Stepper ar er) t = (Stepper ar *** Stepper al) (glueE t el er)

Finally, we put this together for our fully optimized splitR

splitR :: Ord t => ReactiveG t a -> -- left
                   ReactiveG t a -> -- right
                   EventG t b -> -- Split locations
                   EventG t (b, ReactiveG t a) -- results
splitR l r s = fmap g (withNextE (fmap snd (scanlE f (l,error "splitR, shouldn't see it") (withTimeGE s))))
  where
    f (cPrev,_) (b,t) = (c, (b,c,cPrev'))
      where
        (cPrev',c) = glueR l cPrev t
    g ((b,c,_), (_,_,c')) = (b,c')

The (fmap snd (scanlE f... part returns a triple (b,c,cPrev) where b is the value at the event splitter, c is the combination of the reactives at that event, and cPrev is the combination of the reactives at the previous event. cPrev is identical to c in the prior occurrence, however it has gone through that special transformation in glueR.

fmap g (withNextE... replaces the c in the triples with the cPrev in the subsequent event. The resulting 2-tuple is the answer.

Conclusion

If one has a widget of type UI -> Geometry3 we can now take a simple UI type:

data UI = UI {
  mousePosition      :: Behavior (Double,Double),
  leftButtonPressed  :: Event (),
  rightButtonPressed :: Event (),
  keyPressed         :: Event Key,
  framePass          :: Event ()
}

And define a function that will allow for new widgets to be created on command without worry of memory leak.

splitUiBot :: Ui -> Event b -> Event (b,Ui)

Hint: We’ll also need the use of a more general version of splitE.

Alright, now I can get back to writing that Behavior tutorial!

Comments (1)

Conal ElliottDecember 3rd, 2008 at 2:21 pm

Thanks, David. This post does a great job of highlighting design problems in the current Reactive API. The good news is that I’m working on a much easier to use interface, as described in an ongoing series of posts on my blog.

Leave a comment

Your comment