This post takes a close look at one awkward aspect of classic (non-arrow) FRP for interactive behavior, namely the need to trim (or “age”) old input. Failing to trim results in behavior that is incorrect and grossly inefficient.
Behavior trimming connects directly into the comonad interface mentioned in a few recent posts, and is what got me interested in comonads recently.
In absolute-time FRP, trimming has a purely operational significance.
Switching to relative time, trimming is recast as a semantically familiar operation, namely the generalized
drop function used in two recent posts.
In the rest of this post, I’ll adopt two abbreviations, for succinctness:
type B = Behavior type E = Event
An awkward aspect of classic FRP has to do with switching phases of behavior. Each phase is a function of some static (momentary) input and some dynamic (time-varying) input, e.g.,
sleep :: B World -> B Cat eat :: Appetite -> B World -> B Cat prowl :: Friskiness -> B World -> B Cat wake :: B World -> E Friskiness hunger :: B World -> E Appetite
As a first try, our cat prowls upon waking and eats when hungry, taking into account its surrounding world:
(<$>) here, from Control.Applicative, is a synonym for
In this context,
(<$>) :: (a -> B Cat) -> E a -> E (B Cat)
switcher function switches to new behavior phases as they’re generated by an event, beginning with a given initial behavior:
switcher :: B a -> E (B a) -> B a
mappend here merges two events into one, combining their occurrences.
When switching phases, we generally want the new phase to start responding to input exactly where the old phase left off.
If we’re not careful, however, the new phase will begin with an old input.
I’ve made exactly this mistake in defining
Consequently, each new phase will begin by responding to all of the old input and then carry on.
This meaning is both unintended and is very expensive (the dreaded “space-time” leak).
This difficulty is not unique to FRP. In functional programming, we have to be careful how we hold onto our inputs, so that they can get accessed and freed incrementally. I don’t think the difficulty arises much in imperative programming, because input (like output) is destructively altered, and programs have access only to the current state.
I’ve done it wrong above, in defining
How can I do it right?
The solution/hack I came up for Fran was to add a function that trims (“ages”) dynamic input while waiting for event occurrences.
trim :: B b -> E a -> E (a, B b)
trim b e follows
e in parallel.
At each occurrence of
e, the remainder of
b is paired up with the event data from
Now I can define the interactive, multi-phase behavior I intend:
trim world (wake world) occurs whenever
wake world does, and has as event data the cat’s friskiness on waking, plus the remainder of the cat’s world at the occurrence time.
uncurry prowl <$>” applies
prowl to each friskiness and remainder world on waking.
Similarly for the other phase.
I think this version defines the behavior I want and that it can run efficiently, assuming that
trim e b traverses
b in parallel (so that laziness doesn’t cause a space-time leak).
However, this definition is much trickier than what I’m looking for.
One small improvement is to abstract a trimming pattern:
A comonad comes out of hiding
trim functions above look a lot like snapshotting of behaviors:
snapshot :: B b -> E a -> E (a,b) snapshot_ :: B b -> E a -> E b
Indeed, the meanings of trimming and snapshotting are very alike.
They both involving following an event and a behavior in parallel.
At each event occurrence,
snapshot takes the value of the behavior at the occurrence time, while
trim takes the entire remainder from that time on.
Given this similarlity, can one be defined in terms of the other?
If we had a function to “extract” the first defined value of a behavior, we could define
b `snapshot` e = fmap (second extract) (b `trim` e) extract :: B a -> a
We can also define
snapshot, if we have a way to get all trimmed versions of a behavior — to “duplicate” a one-level behavior into a two-level behavior:
b `trim` e = duplicate b `snapshot` e duplicate :: B a -> B (B a)
If you’ve run into comonads, you may recognize
duplicate as the operations of
Comonad, dual to
It was this definition of
trim that got me interested in comonads recently.
In the style of Semantic editor combinators,
snapshot = (result.result.fmap.second) extract trim
trim = argument remainders R.snapshot
extract function is problematic for classic FRP, which uses absolute (global) time.
We don’t know with which time to sample the behavior.
With relative-time FRP, we’ll only ever sample at (local) time 0.
So far, the necessary trimming has strong operational significance: it prevents obsolete reactions and the consequent space-time leaks.
If we switch from absolute time to relative time, then trimming becomes something with familiar semantics, namely
drop, as generalized and used in two of my previous posts, Sequences, streams, and segments and Sequences, segments, and signals.
The semantic difference: trimming (absolute time) erases early content in an input; while dropping (relative time) shifts input backward in time, losing the early content in the same way as
drop on sequences.
While input trimming can be managed systematically, doing so explicitly is tedious and error prone. A follow-up post will automatically apply the techniques from this post. Hiding and automating the mechanics of trimming allows interactive behavior to be expressed correctly and without distraction.
Another post will relate input trimming to the time transformation of interactive behaviors, as discussed in Why classic FRP does not fit interactive behavior.