Algebra-Driven Design, Part 4: The Initial Encoding, and Generating a Thousand Tests
Following Chapters 2 and 5 of Algebra-Driven Design by Sandy Maguire (tile algebra originally Peter Henderson’s, Functional Geometry, 1982). Parts 2 and 3 built the design. This one finally writes some Scala.
The goal here is emphatically not a good implementation. It’s an obviously correct one — slow, naive, and impossible to argue with — whose real purpose is to serve as an oracle for the test suite that will let us write a fast one in part 5.
🔗Every algebra comes with a free implementation
There’s an implementation you always get for nothing: make each constructor a data constructor. Object-oriented programmers know it as the interpreter pattern; the literature calls it the initial encoding.
enum Tile[A]:
case Pure(a: A)
case Cw(t: Tile[A])
case Ccw(t: Tile[A])
case FlipH(t: Tile[A])
case FlipV(t: Tile[A])
case Beside(l: Tile[A], r: Tile[A])
case Above(t: Tile[A], b: Tile[A])
case Quad(a: Tile[A], b: Tile[A], c: Tile[A], d: Tile[A])
case Swirl(t: Tile[A])
case Ap[X, B](f: Tile[X => B], x: Tile[X]) extends Tile[B]
No computation happens. We build a tree that mirrors the expression the user wrote, and interpret it later.
rasterize doesn’t appear, because it isn’t a constructor — it’s an observation, the way
data gets out. It’ll be a function that pattern-matches on this tree.
Note Ap. It introduces X, which doesn’t appear in the result type, so X is
existential — once a value goes into an Ap, its intermediate type is gone forever. In
Scala 3 that needs the explicit extends Tile[B] form. This is a GADT (see the
GADTs and variance post for the mechanics), and Scala 3
handles it without ceremony, though we’ll hit a rough edge shortly.
🔗The laws don’t hold yet
A one-to-one mapping between algebra and data is not enough. flipH ∘ flipH = id is a law,
and FlipH(FlipH(t)) is plainly not t.
Fix: enforce laws by fiat in smart constructors.
// scala-check: skip
def flipH[A](t: Tile[A]): Tile[A] = t match
case FlipH(inner) => inner
case _ => FlipH(t)
def cw[A](t: Tile[A]): Tile[A] = t match
case Cw(Cw(Cw(inner))) => inner
case _ => Cw(t)
Three Cw in the pattern, not four — the fourth is the call itself. Easy to get wrong, and
Scala 3’s nested enum patterns make the intent about as
legible as it can be.
You could keep going, hand-writing a case for every equation. For twenty laws that’s unmanageable. So instead, use the laws to shrink the problem: pick a minimal set of primitives and derive everything else.
Maguire lands on five:
enum Tile[A]:
case Pure(a: A)
case Cw(t: Tile[A])
case FlipH(t: Tile[A])
case Above(t: Tile[A], b: Tile[A])
case Ap[X, B](f: Tile[X => B], x: Tile[X]) extends Tile[B]
Ten constructors gone. Where did the five come from? Trial and error — the book says so, and so do I. There’s little downside to guessing badly: a non-minimal set means more work, and an insufficient one means you get stuck inside five minutes and back up.
🔗Deriving the rest
Every other constructor now comes from a derivation we already did in part 2. This is the step where the algebra stops being documentation and starts being code generation you perform by hand.
// scala-check: skip
def ccw[A](t: Tile[A]): Tile[A] = cw(cw(cw(t)))
That’s the part-2 derivation, transcribed:
ccw(t) = ccw(cw(cw(cw(cw(t))))) -- cw⁴ = id
= cw(cw(cw(t))) -- ccw ∘ cw = id
Likewise:
// scala-check: skip
def flipV[A](t: Tile[A]): Tile[A] = ccw(flipH(cw(t)))
def beside[A](l: Tile[A], r: Tile[A]): Tile[A] = ccw(above(cw(l), cw(r)))
def quad[A](a: Tile[A], b: Tile[A], c: Tile[A], d: Tile[A]): Tile[A] =
above(beside(a, b), beside(c, d))
def swirl[A](t: Tile[A]): Tile[A] = quad(t, cw(t), ccw(t), cw(cw(t)))
def above[A](t: Tile[A], b: Tile[A]): Tile[A] = Above(t, b)
One rule matters enormously here: derive using the smart constructors, never the raw data
constructors. ccw is cw(cw(cw(t))), not Cw(Cw(Cw(t))). Use the raw ones and
ccw(ccw(t)) builds six nested Cw nodes — a state cw guarantees is impossible, which
means every downstream pattern match is now working from a false premise. Corollary: each
primitive should be used by exactly one smart constructor, if you can manage it.
Functor and Applicative fall straight out of part 3:
// scala-check: skip
given Applicative[Tile] with
def pure[A](a: A): Tile[A] = Pure(a)
def ap[A, B](ff: Tile[A => B])(fa: Tile[A]): Tile[B] = Ap(ff, fa)
override def map[A, B](fa: Tile[A])(f: A => B): Tile[B] = ap(pure(f))(fa)
map defined as ap(pure(f)) is the applicative law fmap f x = pure f <*> x, used as an
implementation. It’s correct for every applicative, so cats gives it to you as a default —
but writing it out makes the point that we’re not inventing anything.
And the monoid pair from part 3:
// scala-check: skip
def emptyTile[A](using A: Monoid[A]): Tile[A] = pure(A.empty)
def behind[A: Monoid](back: Tile[A], front: Tile[A]): Tile[A] = (front, back).mapN(_ |+| _)🔗The interpreter
Now rasterize, pattern-matching over five cases:
// scala-check: skip
def rasterize[A](w: Int, h: Int, t: Tile[A]): Vector[Vector[A]] = t match
case Pure(a) =>
Vector.fill(h)(Vector.fill(w)(a))
case FlipH(inner) =>
rasterize(w, h, inner).map(_.reverse)
case Cw(inner) =>
rasterize(h, w, inner).transpose.map(_.reverse)
case Above(top, bottom) =>
rasterize(w, h / 2, top) ++ rasterize(w, h - h / 2, bottom)
case Ap(f, x) =>
val fs = rasterize(w, h, f)
val xs = rasterize(w, h, x)
fs.zip(xs).map { (fRow, xRow) => fRow.zip(xRow).map(_(_)) }
Five cases, each a transcription of a law from part 2. Cw swaps w and h in the
recursive call because rotating swaps the dimensions. Above gives the odd pixel to the
bottom half, exactly as the law says. Ap zips pointwise — the zippy applicative from
part 3, now concrete.
This allocates a nested Vector at every node of the tree and it is slow. Deliberately.
It is also, line for line, the specification.
A Scala 3 wrinkle. The
Apcase relies on GADT refinement: matchingAp(f, x)has to teach the compiler thatf: Tile[X => A]andx: Tile[X]for some existentialX, and that the zip therefore producesA. Scala 3’s GADT reasoning is real but thinner than GHC’s, and depending on your version you may need to annotate the intermediate values or introduce the existential via a helper method with its own type parameter. If it fights you, that’s the known limitation and not your mistake.
🔗Generating tiles
For property tests we need random tiles. The pattern is fixed and worth memorising, because every recursive generator you ever write has this shape.
// scala-check: skip
import org.scalacheck.{Arbitrary, Gen}
import Arbitrary.arbitrary
def genTile[A: Arbitrary]: Gen[Tile[A]] =
Gen.sized { size =>
if size <= 1 then arbitrary[A].map(pure)
else
def smaller(k: Int): Gen[Tile[A]] = Gen.resize(size / k, genTile[A])
Gen.frequency(
3 -> arbitrary[A].map(pure),
9 -> Gen.zip(smaller(2), smaller(2)).map(beside),
9 -> Gen.zip(smaller(2), smaller(2)).map(above),
2 -> smaller(1).map(cw),
2 -> smaller(1).map(ccw),
4 -> smaller(1).map(flipH),
4 -> smaller(1).map(flipV),
6 -> smaller(4).map(swirl),
3 -> Gen.zip(smaller(4), smaller(4), smaller(4), smaller(4)).map(quad)
)
}
given [A: Arbitrary]: Arbitrary[Tile[A]] = Arbitrary(genTile[A])
Three things doing real work:
The size check terminates the recursion. Without it, genTile recurses forever. Every
recursive generator needs a base case keyed off Gen.sized.
smaller splits the complexity budget. A beside gets two sub-tiles of half the size,
so total complexity stays bounded. swirl and quad divide by four because they use their
argument four times. Skip this and generation blows up exponentially.
The weights are not decoration. beside and above are weighted 9 against pure’s 3
because they’re what makes a tile structurally interesting. An unweighted generator
produces a lot of nearly-trivial tiles and tests almost nothing.
And the rule from earlier, which matters most here: the generator calls the smart
constructors. smaller(1).map(cw), never .map(Cw.apply). Generating raw data
constructors would produce trees that violate our by-fiat laws and you’d spend an evening
debugging a “failure” that’s really an invalid input.
🔗Which type to instantiate at
Subtle, and almost never discussed. Tile[A] is polymorphic; tests need a concrete A.
Maguire’s argument, which transfers directly: you want a type that’s small enough that any
particular value gets hit (so edge cases can’t hide) but large enough that collisions are
rare (so you don’t see false equalities). He uses Word8. In Scala that’s Byte — 256
values.
To exercise empty and behind, you additionally need a Monoid. Cheapest is
List[Byte].
The failure mode is worth stating out loud: instantiate at Unit and every test passes,
because all values of Unit are equal. Your suite is green and it is testing nothing.
🔗Observational equality, made executable
Now the payoff from part 2. Two tiles are equal iff they rasterize identically at every size. In a test, approximate “every” with “a random sample”:
// scala-check: skip
import org.scalacheck.Prop.forAll
def sameTile[A](t1: Tile[A], t2: Tile[A]): Prop =
forAll(Gen.choose(1, 8), Gen.choose(1, 8)) { (w, h) =>
rasterize(w, h, t1) == rasterize(w, h, t2)
}
Sizes start at 1, never 0 — two zero-sized rasters agree trivially, the degenerate solution we flagged in part 2.
And the same idea gives us the Eq instance cats needs:
// scala-check: skip
given [A: Eq]: Eq[Tile[A]] with
def eqv(t1: Tile[A], t2: Tile[A]): Boolean =
(1 to 5).forall { w =>
(1 to 5).forall { h => rasterize(w, h, t1) == rasterize(w, h, t2) }
}
Not equality in any structural sense. Equality in the only sense the algebra ever defined.
🔗The suite
Now transcribe the laws.
// scala-check: skip
class TileSpec extends munit.ScalaCheckSuite:
property("cw⁴ = id") {
forAll { (t: Tile[Byte]) => sameTile(cw(cw(cw(cw(t)))), t) }
}
property("ccw ∘ cw = id") {
forAll { (t: Tile[Byte]) => sameTile(ccw(cw(t)), t) }
}
property("flipH ∘ flipH = id") {
forAll { (t: Tile[Byte]) => sameTile(flipH(flipH(t)), t) }
}
property("x-symmetry: flipH ∘ cw = ccw ∘ flipH") {
forAll { (t: Tile[Byte]) => sameTile(flipH(cw(t)), ccw(flipH(t))) }
}
property("flipV ∘ flipH = cw ∘ cw") {
forAll { (t: Tile[Byte]) => sameTile(flipV(flipH(t)), cw(cw(t))) }
}
property("flipH distributes over beside, swapping arguments") {
forAll { (a: Tile[Byte], b: Tile[Byte]) =>
sameTile(flipH(beside(a, b)), beside(flipH(b), flipH(a)))
}
}
property("above of besides = beside of aboves") {
forAll { (a: Tile[Byte], b: Tile[Byte], c: Tile[Byte], d: Tile[Byte]) =>
sameTile(above(beside(a, b), beside(c, d)), beside(above(a, c), above(b, d)))
}
}
Plus the typeclass laws, for free:
// scala-check: skip
checkAll("Tile.applicative", ApplicativeTests[Tile].applicative[Byte, Byte, Byte])
checkAll("Tile.monoid", MonoidTests[Tile[List[Byte]]].monoid)
At ScalaCheck’s default of 100 cases per property, that’s several thousand generated unit tests from about sixty lines. Which is the argument Maguire quotes from John Hughes: writing 3–4 tests per feature is linear work and misses everything that only shows up when two features interact; writing tests for every pair of features is quadratic and nobody does it. Property tests get the quadratic coverage at linear cost.
For a release build, crank minSuccessful to five figures and go home. You’ll have tens of
thousands of unit tests by morning.
🔗What this bought
An implementation that is slow, allocation-heavy, and — as far as several thousand randomised checks can establish — correct.
More importantly, a frozen specification. The suite doesn’t test Tile’s internals. It
tests observable behaviour, which means it constrains any implementation that claims to be
this algebra.
So: any other implementation that passes this suite is also correct.
Which is the licence we need to throw all of it away.
Next: Part 5 — Finding the right observation, and rendering fractals,
in which the ugliness of rasterize finally gets diagnosed, the implementation becomes a
one-line type alias, and we render some pictures.
🔗Sources
- Maguire, Sandy. Algebra-Driven Design. 2020. Chapter 5.1 (The Initial Encoding) and 5.2
(Generating Tests). https://leanpub.com/algebra-driven-design — the initial-encoding
method, the minimal-primitive-set technique, the by-fiat law enforcement, the generator
shape with its complexity budget, and the argument for choosing
Word8are all his. - Claessen, Koen & John Hughes. QuickCheck: a lightweight tool for random testing of Haskell programs. ICFP 2000 — the origin of property-based testing, and of ScalaCheck.
- Hughes, John. Experiences with QuickCheck: Testing the Hard Stuff and Staying Sane. 2016 — the linear-vs-quadratic coverage argument, quoted by Maguire.
- Henderson, Peter. Functional Geometry. 1982 — the tile algebra.
- ScalaCheck (Rickard Nilsson), cats-laws and discipline (Typelevel), MUnit (Scala Center).
The Scala 3 translation and the note on GADT refinement in the Ap case are mine.