What's a polynomial functor, and how can they be used to represent finite state machines? With Scala's strong support for functional programming, and some guidance from Niu and Spivak, we can use polynomial functors to model our state machine in a type-safe and compositional way.
Dependent lenses allow us to specify and manipulate state machines compositionally, using many of the familiar operations of polynomials.
I'm not an expert in Poly. I'm a Scala developer, and a math enthusiast attempting to interpret aspects of Poly into Scala, a simply-typed programming language. As such, criticism is appreciated.
My goals here are:
Every category has its objects and its morphisms. The category of Poly consists of polynomial functors, and natural transformations from one polynomial functor to another.
The objects of Poly are polynomial functors. What's a polynomial functor? "... an arbitrary coproduct of representables". In Scala, we can model them using familiar algebraic data types (ADTs). How so?
The claim is that "Poly categorifies highschool math". If by "algebraic" we mean cardinality and the counting of values, and we do, then:
data Foo y = Bar y y y | Baz y y | Qux | Quux
cats.data.Representable
, and we can identify the method def index[A](f: F[A]): Representation => A
as a function that returns an exponential: ARepresentation, i.e., in polynomial format of A => y
, the term yA.cats.data.RepresentableStore
we can see that type Store[S, A] =
RepresentableStore[S => *, S, A]
thus appears as a special case where both the functor and its indexing function are the same function, S => A
, i.e., yS. Yet, note that neither coproducts nor Store
themselves form a Representable
(e.g., gist), and rather, as a sum of representables, Store
is a sum of an S
number of representables S => A
, i.e., S * (S => A)
, or, SyS.So we can see how an ADT can be considered as a polynomial functor, for example, ByA:
sealed trait MyPolynomial[Y]
case class MyMonomial[A,B,Y](variable: A => Y, coefficient: B) extends MyPolynomial[Y]
And notably, we have the initial and terminal objects:
type `0`[Y] = MyMonomial[Nothing, Nothing, Y]
type `1`[Y] = MyMonomial[Unit, Nothing, Y]
"You'd never see, in highschool, arrows between polynomials, but they make sense". If the morphisms of Poly are maps from one polynomial functor to another, i.e., natural transformations, then can we model them in Scala? Yes, and no:
P[_]
and Q[_]
, then, thanks to Scala 3's polymorphic function types, it has never been easier to define a natural transformation val Ξ·: P ~> Q
.P ~> Q
being "computed by Yoneda and the universal mapping-out property of coproducts". In other words, we'll need dependent functions in order to implement any natural transformation that is not a map between monomials.The key to defining maps between polynomials is the pair of "view" and "update" functions, Ο and Ο#, required to implement their natural transformation. For example, take the two monomials mentioned above, cats.data.RepresentableStore
(let's call it Store
) and MyMonomial
:
/* Monomials p and q */
case class Store[S, Y](peek: S => Y, index: S) // Sy^S
case class MyMonomial[A, B, Y](variable: A => Y, coefficient: B) // By^A
/* A type alias, for natural transformations between p and q */
type ~>[F[_], G[_]] = [Y] =>> F[Y] => G[Y]
/* A "view" function, derived from the coefficients of p and q */
def Ο: S => B
/* An "update" function, derived from the exponents of p and q */
def `Ο#`: (S, A) => S
/* A natural transformation from p to q, formed by Ο and Ο# */
def Ξ·[S, A, B]: ([Y] =>> Store[S, Y]) ~> ([Y] =>> MyMonomial[A, B, Y]) =
[Y] =>
(store: Store[S, Y]) =>
MyMonomial[A, B, Y](
`Ο#`.curried(store.index).andThen(store.peek),
Ο(store.index)
)
Forgive the clunky type lambdas. In the polynomial morphism above, mapping one monomial to another, we can make use of the following observations:
view
and update
functions completely specify the data of the natural transformation.In other words, monomial and polynomial morphisms are, respectively, simple and dependent lenses. And with type signatures like def Ο: Sπ¦S β Bπ¦A
, we can begin to see how to describe compositional state machines in the familiar language of polynomials. We'll be investigating the rich structure and the operations that this gives us access to.
Let's look at polynomial functors in practice, beginning with the monomials: state lenses, wrappers, and wiring diagrams.
A poly map can model a Moore Machine. How so? Compare the above poly map between Store[S, Y]
and MyMonomial[A, B, Y]
to the definition of an AB-Moore machine, Moore[S, A, B]
, and note the similarity:
As we build bigger and more complex machines, we'll see that Moore machines compose nicely and have their role. However there is one feature that a Moore machine lacks: a run function. And, for that, we turn to Mealy machines.
A poly map can also model a Mealy Machine. An AB-Mealy Machine is essentially a Moore machine with a run function, i.e., an ABA-Moore Machine. That is, given a value of the state, the machine outputs a "B
", but the "B
" is a function A => B
, so that type Mealy[S, A, B] =
Moore[S, A, A => B]
. This let's us implement a run
function, forming a finite state machine ideal for stream processing. Note the similarity between the run
function and the argument to the mapAccumulate
function as we'd find in cats or fs2:
So, starting from two data types, Store[S, Y]
and MyMonomial[A, B, Y]
, we've arrived at a simple state machine with a standardized run
function. From this we can conclude:
S
, and an interface, referred to here as A ββΆβ B
, we can form a map from one to the other, yielding a dynamical system A βπ
β B
.Once the init
, readout
, and update
functions are provided, and a behavior of the state machine is thereby defined, then we have a Mealy machine, ready to process data:
Sometimes we care only about wires. That is, the interaction patterns alone, with the stateful components abstracted away, to be composed at a later step.
In the polynomial maps discussed so far, we've seen that if given a state, S
, and an interface, referred to here as A ββΆβ B
, we can form a map from one to the other, yielding a dynamical system A βπ
β B
. But poly maps can also represent wiring diagrams and enclosures, like -[βAββΆβBβ]-
, where there is a "hole" for the state lens, and each box is an interface.
When a wiring diagram is composed with a state system, the outermost interface of the composite lens is called the "wrapper interface", where it serves as an adaptor to modify the state lens:
extension [S, A1, B1, Y] (p: (Store[S, _] ~> Interface[A1, B1, _])[Y])
def andThen[A2, B2](
w: (Interface[A1, B1, _] ~> Interface[A2, B2, _])[Y]
): (Store[S, _] ~> Interface[A1, B1, _] ~> Interface[A2, B2, _], _])[Y] =
new (Store[S, _] ~> Interface[A1, B1, _] ~> Interface[A2, B2, _])[Y]:
def Ο: PolyMap.Phi[Store[S, _] ~> Interface[A1, B1, _], Interface[A2, B2, _], Y] =
p.Ο.andThen(w.Ο)
def `Ο#`: PolyMap.PhiSharp[Store[S, _] ~> Interface[A1, B1, _], Interface[A2, B2, _], Y] =
(s, a) => p.`Ο#`(s, w.`Ο#`(p.Ο(s), a))
We should quickly note some points about using Scala's simple types instead of fully dependent types:
Phi
and PhiSharp
for three purposes: to mitigate boilerplate, to derive function types, and to abstract over arity.But the main point is that, through lens composition, we've used a wiring diagram to turn an unrunnable Moore machine into a runnable Mealy machine. Once we initialize it and provide view
and update
functions, then we're ready for data:
A satisfying result. But this is only the beginning, it seems. We'll end this post having seen only monomials and only the most basic lenses. In order to see all that Poly has to offer, we'll need to explore a little further, and see just how far we can get with Scala's simple types.