I want to organize some type-centric thoughts about small functional programs that maintain caches of
expensive-to-compute data, and the reasons for their correctness.
Let's say a "cache span" is a span of types:
BβGγCB is the "base type". This is the actual data we care about, before we start caching anything.
C is the "cache type". This probably looks like an extension of B with more record fields,
which are the cache. G is the set of "good cache states", ones where the cached derived data in C
is consistent with B. There may be multiple cache states in C that correspond to any particular
value in B: maybe we have some dirty-bit bookkeeping which might be in various different legitimate states.
So in general G is a relation between B and C.
We write B for the cache span (B,β,G,γ,C).
A computationf from type A to type D, over state B, is a triple of arrows
(fB,fC,fG) that form a commutative diagram
A×BfB↓⏐D×BA×βD×βA×G↓⏐fGD×GA×γD×γA×C↓⏐fCD×C
Each vertical arrow can be thought of as being a map A→D but in a state monad (for store type B,G, or C).
We can also with slightly more generality define a computation from type A to type D, over the state transition from B to B′,
as a commutative diagram
A×BfB↓⏐D×B′A×βD×β′A×G↓⏐fGD×G′A×γD×γ′A×C↓⏐fCD×C′
In this case we write f:A×B→D×B′. Composition of computations is composition of commutative diagrams.
Let 1 be the trivial cache span 1←1→1.
Lemma
Suppose we have cache spans B0,…,Bn with B0 and Bn both being assumed to be the trivial cache span.
Suppose we have types A0,…An. Suppose we have computations
f0:A0×B0→A1×B1,…,fn−1:An−1×Bn−1→An×Bn
Then
f(n−1)B∘⋯∘f0B=f(n−1)C∘⋯∘f0C Proof
A straightforward diagram chase.
A0f0B↓⏐A1×B1f1B↓⏐A2×B2f2B↓⏐⋮f(n−2)B↓⏐An−1×Bn−1f(n−1)B↓⏐AnA1×β1A2×β2An−1×βn−1A0↓⏐f0GA1×G1↓⏐f1GA2×G2↓⏐f2G⋮↓⏐f(n−2)GAn−1×Gn−1↓⏐f(n−1)GAnA1×γ1A2×γ2An−1×γn−1A0↓⏐f0CA1×C1↓⏐f1CA2×C2↓⏐f2C⋮↓⏐f(n−2)CAn−1×Cn−1↓⏐f(n−1)CAn
We aim to interpret this lemma as saying that the "naive specification" f(n−1)B∘⋯∘f0B is the same as
the "optimized implementation" f(n−1)C∘⋯∘f0C. Nothing in the above definitions really breaks the symmetry between
B and C. So far it's just a matter of convention that we regard one arm of the relation G as being "specification" and
the other as "implementation".
Examples
Let's assume our base state type is B, and that we have some type D of data derived from B and a presumed expensive
function f:B→D that computes the derived data.
Naive Ref Cell with Push
We can define a cache span Bpush to be
BBγpushB×D
where
γpush(b)=⟨b,f(b)⟩
We can define some computations for this cache span.
An initialization computation init:B×1→1×Bpush:
B∥∥BB∥∥BγpushB↓⏐γpushB×D
This takes a value of type B and a trivial store to a store with one ref cell of type B,
whose cache is already warmed up by computing f.
Given any u:B→B, an update computation
upd:1×Bpush→1×Bpush:
Bu↓⏐BB↓⏐uBγpushγpushB×D↓⏐updCB×D
where
updC⟨b,d⟩=letb′=u(b)in⟨b′,f(b′)⟩
If we had decidable equality on B, we could have checked whether the new value of the store
was equal to the previous one, and decided to forgo the computation of f in that case.
For simplicity I won't write out the details here.
A read computation to read out the value of type D:
read:1×Bpush→D×Bpush:
BreadB↓⏐D×BB↓⏐readBD×BγpushD×γpushB×D↓⏐readCD×(B×D)
where
readB⟨b,d⟩=⟨f(b),b⟩readC⟨b,d⟩=⟨d,⟨b,d⟩⟩
Notice two things:
The "implementation" readC is cheap (because it doesn't use f) while
the "specification" readB is expensive (because it does use f)
this computation is advertised as being allowed to update state during read, even though it doesn't
use this ability. However we will use this ability for the "pull" protocol below.
Ref Cell with Pull and Dirty Bit
Let's take what is essentially the option type MaybeD as a "dirty bit status"
S(D)=clean(D)+dirty
We can define a cache span Bpull to be
BβpullB×S(1)γpullB×S(D)
where
βpull(b,s)=bγpull(b,clean(⋆))=⟨b,clean(f(b))⟩γpull(b,dirty)=⟨b,dirty⟩
We can define some computations for this cache span.
An initialization computation init:B×1→1×Bpull:
B∥∥BβpullB↓⏐initGB×S(1)γpullB↓⏐initCB×S(D)
where
initG(b)=⟨b,dirty⟩initC(b)=⟨b,dirty⟩
Given any u:B→B, we can define an update computation
upd:1×Bpull→1×Bpull:
Bu↓⏐BβpullβpullB×S(1)↓⏐updGB×S(1)γpullγpullB×S(D)↓⏐updCB×S(D)
where
updG(b,s)=⟨u(b),dirty⟩updC(b,s)=⟨u(b),dirty⟩
A read computation to read out the value of type D:
read:1×Bpull→D×Bpull:
BreadB↓⏐D×BβpullD×βpullB×S(1)↓⏐readGD×(B×S(1))γpullD×γpullB×S(D)↓⏐readCD×(B×S(D))
where
readB(b)=⟨f(b),b⟩readG⟨b,dirty⟩=⟨f(b),⟨b,clean(⋆)⟩⟩readG⟨b,clean(⋆)⟩=⟨f(b),⟨b,clean(⋆)⟩⟩readC⟨b,dirty⟩=letd=f(b)in⟨d,⟨b,clean(d)⟩⟩readC⟨b,clean(d)⟩=⟨d,⟨b,clean(d)⟩⟩
Notice:
The "implementation" readC is sometimes expensive, when the dirty bit says dirty
Update is always cheap, i.e. never mentions f.
State is sometimes changed during read, when we compute f and clear the dirty bit.