Composing Contracts, Part 4: Lattices, Laziness, and the Sharing Problem
The last of four, following “Composing Contracts” by Peyton Jones, Eber and Seward (ICFP 2000). Parts 1, 2 and 3 built a contract language and gave it a compositional valuation semantics in terms of abstract value processes.
Now we make a computer compute one. That means picking a financial model, picking a numerical method, and representing a process as a data structure — and it turns out that the last of those is where laziness stops being a stylistic preference and becomes load-bearing.
The paper ends on an unsolved problem. So does this post, though Scala 3 gets closer to a solution than Haskell did, for a reason worth the whole series.
🔗Choosing a model
A value process is a mathematical object. To compute with one you need to represent uncertainty about the future explicitly, and there are three families of numerical method in industrial use: partial differential equations, Monte Carlo, and lattice methods.
The paper picks the Ho–Lee interest rate model with a lattice, for technical simplicity and historical importance — and stresses something worth repeating, because it’s the payoff of the two-layer design from part 3: changing the numerical method would entail bigger changes, but nothing in the language or its semantics would be affected. You can even use different methods for different parts of one contract.
A lattice is a recombining tree. Time runs left to right in discrete steps. Today’s interest rate is known, so the first column has one entry. By the next step the rate has moved up or down, so the second column has two. In the third, up-then-down and down-then-up land on the same node — that’s the recombination — so there are three, not four.
That recombination is the whole trick. The tree grows linearly rather than exponentially,
which is what makes the scheme computable at all. Column t has exactly t + 1 nodes.
final case class Lattice(rates: Vector[Vector[Double]], dt: Double):
def rate(step: Int, node: Int): Double = rates(step)(node)
def steps: Int = rates.length
Column t is rates(t), with t + 1 entries, indexed from the bottom.
🔗Representing a value process
A value process has the same lattice shape as the rate model, with a value at each node instead of a rate. So:
type RV[A] = Vector[A] // a random variable: one column
type PR[A] = LazyList[RV[A]] // a value process: columns, one per time step
Column i has i + 1 entries. A LazyList rather than a List because a process may be
defined for all time — and, as we’ll see, because it must not be computed eagerly.
The generic operations from part 3 fall out immediately:
def K[A](x: A): PR[A] =
LazyList.from(0).map(i => Vector.fill(i + 1)(x))
def lift[A, B](f: A => B, p: PR[A]): PR[B] =
p.map(_.map(f))
def lift2[A, B, C](f: (A, B) => C, p: PR[A], q: PR[B]): PR[C] =
p.zip(q).map((sp, sq) => sp.zip(sq).map(f))
K(x) is infinite and costs nothing until observed. And lift2 gets its domain semantics for
free: part 3 said the result of lift2 is defined only where both arguments are, and
LazyList.zip truncates to the shorter of the two. The type did the bookkeeping.
andneeds more care. Its rule (part 3) keeps whichever process survives past the other’s horizon, so plainzip— which takes the intersection — is wrong there. You need a zip-the-longer variant. It’s the one place where the horizon bookkeeping doesn’t fall out of the standard combinators, and it’s worth knowing before you discover it in a test.
🔗Discounting: the one equation that does the work
disc maps a payout at a future date into a process giving its value at earlier dates. On a
lattice, that’s a backwards recurrence:
V(t, i) = ( V(t+1, i) + V(t+1, i+1) ) / ( 2 · (1 + R(t, i) · Δt) )
Take the two successor values, average them (each branch is equally likely), then discount that average back one step at the interest rate sitting at this node.
def disc(model: Lattice, horizon: Int, payout: RV[Double]): PR[Double] =
def back(step: Int, next: RV[Double], acc: List[RV[Double]]): List[RV[Double]] =
if step < 0 then acc
else
val prev = Vector.tabulate(step + 1) { i =>
(next(i) + next(i + 1)) / (2 * (1 + model.rate(step, i) * model.dt))
}
back(step - 1, prev, prev :: acc)
LazyList.from(back(horizon - 1, payout, List(payout)))
Work an example by hand once and the whole scheme clicks. Take a bond paying £10 at step 3.
At step 3 every node is worth exactly 10 — the payout is certain by then, which is axiom
disc ᵗₖ(v)(t) = v from part 3 showing up as a base case. Step 2 averages pairs of 10s and
discounts, giving something a little under 10 at each node. Repeat back to step 0 and the
single value there is what the bond is worth today — call it £8.64 at plausible rates.
snell, for Anytime, is the same shape with one extra step: discount the next column back,
then take the pointwise maximum against the original process at this column, then repeat.
That max is “exercise now versus hold”, evaluated at every node.
def snell(model: Lattice, horizon: Int, p: PR[Double]): PR[Double] =
val cols = p.take(horizon + 1).toVector
def back(step: Int, next: RV[Double], acc: List[RV[Double]]): List[RV[Double]] =
if step < 0 then acc
else
val held = Vector.tabulate(step + 1) { i =>
(next(i) + next(i + 1)) / (2 * (1 + model.rate(step, i) * model.dt))
}
val best = held.zip(cols(step)).map(_ max _)
back(step - 1, best, best :: acc)
LazyList.from(back(horizon - 1, cols(horizon), List(cols(horizon))))🔗Why laziness is not optional
The paper is emphatic that lazy evaluation plays a crucial role, and gives two reasons. Both survive the trip to Scala; one comes with a warning.
Process trees are quadratic. A tree over n time steps has about n²/2 nodes. A complex
contract combines many sub-trees, and fully evaluating each before combining them would be —
in the paper’s words — Very Bad. Laziness pipelines the computation: only the current column
of each tree needs to exist at any moment.
Only part of a tree may be needed. Take the bond from part 1:
Get(scaleK(10, Truncate(t, One(GBP))))
The inner process is defined at every time, with value 10 everywhere, all the way back to step
zero. But Get samples it only at its horizon. Computing the earlier columns is pure
waste, and with a lazy list you don’t — not because you wrote an optimisation, but because
nobody asked.
That second one is the better argument for laziness generally: it isn’t that lazy code is
faster, it’s that lazy code doesn’t need you to know in advance which parts will be needed.
The get combinator and the process representation were written independently and compose
without waste.
LazyListmemoises, and that cuts against reason one. Each cell caches its value once forced, so aLazyListyou hold a reference to retains every column you’ve walked — turning the pipelining win back into the quadratic memory you were avoiding. The fix is to not hold the head: consume the process in a fold and let the prefix be collected. Haskell has the same hazard, but Scala makes it easier to trip over, because binding a process to avalin an enclosing scope is the natural thing to write and the compiler says nothing. If your valuation engine’s memory grows with the square of the time steps, this is why.
🔗The problem the paper ends on
Recall the American option from part 2:
def american(t1: Date, t2: Date, u: Contract): Contract =
val opt = Anytime(perhaps(t2, u))
Get(Truncate(t1, opt)) thereafter opt
opt appears twice. In the source it’s obviously one thing — it’s a val. But by the
time eval walks the tree, it sees two branches of a Thereafter and has no way to know
they’re the same contract. It values opt twice. Nest a few of these and the duplicated work
compounds exponentially.
The paper is candid: this arises in almost every embedded domain-specific language. They cite hitting it in Fran’s reactive animations and in extracting netlists from Hawk circuit descriptions. What makes it especially galling, they say, is that the sharing is absolutely apparent in the source program — and invisible to the interpreter.
The obvious response is to memoise eval. They reject it, and the reasoning is worth
absorbing: losing sharing can cost an unbounded amount of duplicated work, so it’s unpleasant
to relegate something that important to an operational mechanism that might be defeated by an
unevaluated argument or an automatically purged table. They leave it as an open problem,
citing Claessen and Sands as the only work addressing it head-on.
🔗Scala 3 can do better here, at a price
This is the one place in the series where the port has a genuine advantage, and it’s instructive because the advantage isn’t a language feature — it’s a consequence of a design choice we’re free to make differently.
A memo table needs to answer “have I seen this contract before?”, which needs structural
equality and hashing on Contract. Scala 3 enum cases are case classes, so they get
equals and hashCode derived for free. So this should work:
val memo = collection.mutable.HashMap.empty[Contract, PR[Double]]
def eval(c: Contract): PR[Double] = memo.getOrElseUpdate(c, evalImpl(c))
Except it doesn’t, and the reason is Obs. Look back at part 2:
case Lift[A, B](f: A => B, o: Obs[A]) extends Obs[B]
Lift holds a Scala function. Functions have reference equality, not structural. Two
observables built the same way are unequal, so any Contract containing a Scale is
unhashable in practice. Obs being higher-order poisons structural equality for the entire
Contract type.
The fix is to make observables first-order — symbolic operations instead of embedded functions:
enum Obs[A]:
case Konst(a: A)
case TimeObs(t: Date) extends Obs[Days]
case Plus(o1: Obs[Double], o2: Obs[Double]) extends Obs[Double]
case Times(o1: Obs[Double], o2: Obs[Double]) extends Obs[Double]
case Negate(o: Obs[Double]) extends Obs[Double]
case Libor(k: Currency, m1: Days, m2: Days) extends Obs[Double]
Now Contract is a first-order algebraic data type all the way down. Structural equality
works, hashing works, and the memo table is two lines. Better still, you can now inspect
observables — simplify them, print them, compile them, serialise a contract to a database and
read it back.
The cost is real and worth stating plainly: you can no longer embed arbitrary Scala
functions in an observable. lift(sqrt, o) is gone unless Sqrt is a case. That’s the
classic deep-versus-shallow embedding trade — a shallow embedding borrows the host language’s
functions and is easy to extend but opaque to analysis; a deep embedding is a closed
vocabulary you can take apart.
So I don’t think this solves the paper’s problem so much as dissolve it for the first-order
fragment. The general problem — observing sharing in a higher-order embedded DSL — is still
open, and Scala’s reference equality (eq, or an IdentityHashMap) is available as an
impure escape hatch with all the fragility the authors objected to.
What I’d take away: if you want your DSL to be analysable, keep it first-order. The temptation to let users drop in a host-language lambda is enormous and it costs you every whole-program transformation you might later want. That’s a rule I’d apply well outside finance.
🔗Looking back at the series
Some numbers from the paper. Their Haskell implementation was about 900 lines for a working, limited valuation engine with a COM interface into a commercial risk system. Not as fast as the production C++, but not unbearable: roughly 20 seconds for a contract with 15 sub-contracts over 500 time steps, on a desktop of the year 2000.
And the detail I keep coming back to: despite lacking much functionality, the compositional approach meant it could already value contracts — options over options — that the production system could not. Not because C++ can’t express them, but because the production system was programmed case by case, and the complicated cases were daunting.
That’s the argument for the whole approach, in one observation. Ten primitives and a compositional semantics gave a research prototype capabilities that a mature commercial system, built as a catalogue, didn’t have.
Twenty-five years on, LexiFi is still trading on it.
And the through-line from the algebra series holds here
too. This paper is algebra-driven design, done in 2000, in the least likely domain imaginable:
find the primitives, refuse to make the convenient thing primitive, give a compositional
semantics, prove your identities, and only then pick a numerical method. The Scala 3
translation is mostly frictionless — opaque types for Date, an enum for the contract
algebra, LazyList for the processes. The two places it isn’t frictionless were both
informative: Numeric couldn’t hold an observable, and LazyList’s memoisation is a trap
where Haskell’s laziness is merely a hazard.
Neither is a reason to write Haskell. Both are reasons to know exactly which of your Scala habits are choices and which are consequences.
🔗Sources
- Peyton Jones, Simon, Jean-Marc Eber & Julian Seward. Composing Contracts: An Adventure in
Financial Engineering. ICFP 2000. The lattice representation, the discount recurrence, the
slice-list representation of a value process, both arguments for why laziness matters, the
sketch of
snell, the memoisation problem and the reasons for rejecting a memo function, and the implementation statistics are all theirs. - Ho, Thomas & Sang-Bin Lee. Term structure movements and pricing interest rate contingent claims. Journal of Finance, 1986 — the interest rate model.
- Claessen, Koen & David Sands. Observable sharing for functional circuit description. ASIAN 1999 — the work the paper cites as the only one tackling sharing head-on.
- Elliott, Conal & Paul Hudak. Functional Reactive Animation. ICFP 1997 — Fran, one of the DSLs where the authors report hitting the same sharing problem.
The Scala 3 translation, the LazyList memoisation warning, and the analysis of how making
Obs first-order dissolves the sharing problem for a price are mine.