What's a dependent dynamical system? With Scala 3's support for ADTs and match types, and some guidance from Niu and Spivak, we can use polynomial functors to model complex state machines in a type-safe and compositional way.
We'll use tensor products, aliased by the ⊗
symbol, to wire up multiple lenses in a single interaction pattern.
The usual caveats apply: 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:
Among the many interesting products in Poly, the tensor product, or ⊗
, provides a way to combine two polynomial functors into one. Ultimately, this allows us to interpret morphisms between such tensored functors as representing multiple state machines running in a single system. We can take as prototypical the examples of a monomial state lens and a monomial wiring diagram:
To describe the mechanism of tensoring, I might suggest "systematic tupling": of objects, of morphisms, of positions, of directions, of inputs, of outputs. It is covered in day 3 of the Polynomial Functors course, in this video, and a larger example is described here. For the purpose of our explorations, we can use match types to accomplish this in Scala 3:
We can see at a glance that the products formed are symmetric monoidal. But, in terms of state machines, what is the effect of tensoring two state lenses into a single system?
When tensored functors are used to define a lens, the result is parallelism. For example, we can tensor together two "integer doublers" and run them as one. In the following example, a Moore machine is used, rather than a Mealy machine, simply in order to reveal the implementation of the run
function, but a Mealy machine could just as easily be used:
val m1: Moore[Store[Boolean, _] ~> Interface[Int, Int => Int, _]] =
Moore(false, s => i => i + i, (s, i) => s)
val m2: Moore[Store[Boolean, _] ~> Interface[Int, Int => Int, _]] =
Moore(false, s => i => i + i, (s, i) => s)
val m3: Moore[
(Store[Boolean, _] ⊗ Store[Boolean, _]) ~>
(Interface[Int, Int => Int, _] ⊗ Interface[Int, Int => Int, _])
] =
m1 ⊗ m2
val l: List[(Int, Int)] =
List((1, 11), (2, 22), (3, 33)).mapAccumulate(m3.init)(
(s, a) => (m3.update(s, a), (m3.readout(s)._1(a._1), m3.readout(s)._2(a._2)))
)._2
// List((2, 22), (4, 44), (6, 66))
Tensored lenses can also be used to define serial systems. By composing a tensored state lens with a wrapper interface, a parallel system can be wired in series:
val m: Moore[
(Store[Boolean, _] ⊗ Store[Boolean, _]) ~>
(Interface[Int, Long, _] ⊗ Interface[Long, String, _])
] =
(Moore(
false,
s => if s then 1L else 0L,
(s, i) => if i > 1 then true else false
) ⊗
Moore(
false,
s => if s then "true" else "false",
(s, i) => if i > 0L then true else false)
)
val w: Wiring[
(Interface[Int, Long, _] ⊗ Interface[Long, String, _]) ~>
(Interface[Int, Int => String, _])
] =
Wiring(ba => a => ba._2, (ba, a) => (a, ba._1))
val r: Mealy[
(Store[Boolean, _]) ⊗ (Store[Boolean, _]) ~>
(Interface[Int, Long, _] ⊗ Interface[Long, String, _]) ~>
Interface[Int, Int => String, _]
] =
m.andThen(w).asMealy
val l: List[String] =
List(1, 2, 3, 4).mapAccumulate(r.init)(r.run)._2
// List("false", "false", "false", "true")
It is because Poly is a cartesian closed category that we can shuffle around types when defining our interfaces. Specifically, we have available to us an implicit set of operations familiar to most programmers: swap, project, discard, and duplicate. Together with the tensor product, they allow us to specify many wiring patterns, such as feedback systems, as well as dependently wired systems.
We can discard values and/or use them conditionally, based on system output. In effect, we can vary which wires are "connected" at a given state. In the following example, a company switches suppliers based on current stock:
We can also define, as types, wiring diagrams that can only be implemented by sending output back as input, allowing us to model feedback within a dynamical system:
So, as we can see, even single-mode, monomial lenses can exhibit complex and varying behaviors. But it also raises the question: what else does Poly put into reach?