Lowent Manual←↑→

44 Proofs about effects — writing “what it can do” into the type

What to know first

chapter 15, Effects · effects spread along calls, and purity permits reordering, remembering and deleting
chapter 16, Capabilities · effects write what kind of work, capabilities write who permitted it
chapter 39, The mathematical toolkit · lattices and join

Looking back

In chapter 15, what separated fn from proc, and what did the promise of purity permit the processor to do?

A. fn leaves no trace outside and proc leaves as much as its written effects. A pure op gives the same answer for the same arguments, so the processor may reorder calls, remember results, or delete unused calls. This chapter covers the proof that the promise really holds — and why that proof is not circular.

The need for this chapter, and its context

chapters 40 and 43 were about “how memory is touched”. This chapter is about “what it can do”. effects none is a strong promise — it reads no globals, allocates nothing, writes nothing to the screen, waits for nothing. If the optimiser trusts that promise and deletes a call that actually wrote a file, observable behaviour disappears. The effect system does not give memory safety. Instead it gives a different kind of safety — legitimate optimisation, auditability, rejection on small machines. That safety comes from trusting declarations, so a theorem that declarations cover reality is needed.

By the end of this chapter

You will learn that effects are sets of atoms forming a lattice under subset order, and that propagation and gating are two operations of one lattice. You will confirm with examples the four rules the processor enforces (propagation · purity · a set of atoms · stopping is not an effect), and pick up attenuation and local audit, which capabilities give as the partner of effects. You will also see why the effect soundness theorem is not circular, and the proof, with counterexamples, that four optimisations relying on effects are legitimate.

The questions this chapter answers

  1. Why does using a reserved word as a parameter name give a name diagnostic rather than an effect diagnostic?

44.1 Effects are sets of atoms#

An effect is a set of atoms. It can hold several atoms, as in effects io alloc .. Then there is a natural order — ε₁ ⊑ ε₂ is ε₁ ⊆ ε₂, “ε₁ does less than ε₂”. The subset relation forms a lattice, the join is union, and none (the empty set) is the bottom.

WhatLattice operationMeaning
Propagation⊔ (union)Gathering the effects of everything I call gives my effect
Gating⊆ (subset)Doing less than declared is fine

Table 44.1 — Two jobs done by one lattice

It is the same shape as chapter 40′s type lattice. The same mathematics answers a different problem. And the judgement on small machines becomes one ⊆. Code that allocates must not translate for a machine without a heap (chapter 30), and with effect declarations it is enough to ask “is this op’s effect within the set this tier supports”.

            {io, alloc}
             ▲       ▲
            {io}     {alloc}
             ▲       ▲
              none

 propagation  if f calls g (effects io) and h (effects alloc),
              effects(f) ⊇ {io} ⊔ {alloc} = {io, alloc}      short of it: E-EFFECT
 gating       if the set a heapless board supports has no alloc,
              {io, alloc} ⊄ that set                         it does not translate for that board

A hierarchical lattice splitting io into {read, write} is possible too. This language uses a flat one for now. The precision a hierarchy gives is already available from capabilities, and a flat lattice keeps the ⊆ test simple so the cost is visible. Express precision in two places and the two begin to diverge.

44.2 Four rules the processor enforces#

Rule 1 — propagation. If f calls g, then effects(f) ⊇ effects(g). Otherwise it is E-EFFECT (E-EFFECT-CALC for a fn). It stops an op marked effects none from secretly writing a file (chapter 15).

Rule 2 — purity. A fn writing to the caller’s buffer is E-EFFECT-PURITY. fn computes values and proc does work. If the criterion is the shape of a word, holes appear. At one time an actor’s handlers were counted as pure because they were not proc, so a handler mutating actor state could declare effects none and nobody objected. The same offence was caught on one side and not the other.

Rule 3 — an effects clause is a set of atoms.

examples/ch44/effect_dup.low

module effect_dup .
rem expect: E-EFFECT-DUP

proc shout input out cap io . output u64 . effects io io . do
  return write_out out 1 "hi\n" .
end

Output

$ lowentc --check effect_dup.low
effect_dup.low:4:0 E-EFFECT-DUP: an effect atom appears more than once in the `effects` clause — the row is a SET, not a list. A repeat says nothing the first one didn't, and the reader has to decide it's noise (RFC-0057 E3)

examples/ch44/effect_typo.low

module effect_typo .
rem expect: E-EFFECT-UNDEF

proc ping input out cap io . output u64 . effects netwrok . do
  return 0 .
end

Output

$ lowentc --check effect_typo.low
effect_typo.low:4:0 E-EFFECT-UNDEF: unknown effect (the vocabulary is closed: none/alloc/heap/io/wait/concurrent/lock/atomic/unsafe/device/page_fault/blocking/cancel/detach/panic/state) — a typo here silently declares the op PURE

Writing the same atom twice is rejected. A duplicate in a set means nothing, and the reader has to decide whether it is noise. A nonexistent atom is caught as a typo — as the diagnostic says, a typo passing silently would declare the op pure. The rule looks obvious, but there was a time the implementation read only the first word of the clause. Then effects io alloc registered only io, and atoms from the second on were as good as absent. A defect in the code reading declarations disables the whole declaration system.

Rule 4 — stopping is not an effect. This is the most thought-provoking part of the chapter.

examples/ch44/trap_not_effect.low

module trap_not_effect .
rem run: pick [1,2,3] 1
rem trap: pick [1,2,3] 7

rem it stops when out of range, but this op is still a pure fn
fn pick input s slice u8 . input i u64 . output u8 . do
  return index s i .
end

Output

$ lowentc --run pick trap_not_effect.low [1,2,3] 1
pick([1,2,3], 1) = 2
  arg0 (written) = [1,2,3]
$ lowentc --run pick trap_not_effect.low [1,2,3] 7
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: slice index out of bounds (panic)

index s i stops when out of range. So should pick carry the panic effect? No. panic counts only an explicit panic "…". The possibility of stopping is what contracts speak of, not effects. The danger of index can be removed with requires lt i (len s), and the processor really does remove that check (chapter 41). An effect mark is never removed. Marking something removable as an effect makes the mark permanent, nearly every op ends up with panic, and the mark loses meaning.

 what ends the op              where it is written         can it disappear
 panic "…"  (intended)         effects panic               no — it always stays
 index out of range (breach)   requires lt i (len s)       the check goes once proven

A common misconception. An op that can stop is not pure

Effects say “what this op does” and contracts say “when it is safe”. Because the two were not mixed, the panic mark still carries information. An op marked panic can end the program on purpose. An out-of-range index is not intent but a contract violation, and contracts handle that place.

44.3 Capabilities — the partner of effects#

If effects say “what kind of work”, capabilities say “where the right to do it came from”. Writing to the screen requires receiving cap io as an argument; it cannot be pulled out globally from anywhere. This object-capability model gives two things.

What it givesHow
Legitimate optimisationWith effects none, reordering, common subexpression elimination and deletion do not change observable behaviour
AuditabilityCode doing io can be found from declarations
Tier and profile gatingOps allocating on small machines are stopped as translation errors
No surprisesWhat is written as pure really is pure

Table 44.2 — Safety the effect system gives

44.4 Effect soundness — not circular#

examples/ch44/recursive.low

module recursive .
rem run: main

proc stars input out cap io . input n u64 . output u64 . effects io .
  requires le n 10 .
do
  if eq n 0 . do
    return write_out out 1 "\n" .
  end
  let w u64 be write_out out 1 "*" .
  return add w (stars out (sub n 1)) .
end

proc main input out cap io . output u8 . effects io .
do
  return narrow u8 (stars out 5) .
end

Output

$ lowentc --run main recursive.low
*****
main() = 6

stars is recursive, calling itself, and declares effects io. At a call site the processor uses the callee’s declaration rather than walking its body again. At first glance this looks circular — a declaration trusts a declaration.

The mathematics. Effect soundness (effect_sound in LowentEffect.v)

prog_ok P = true -> lookup P f = Some (d, b) -> performs P (ECall f) a -> In a d. In a program passing the check, when op f is called, every effect that actually happens is within what f declared. Call depth does not matter. It is not circular because the induction is over executions, not programs. When an effect happens, it happens at some finite call depth, and induction on that depth closes. So recursion, mutual recursion and non-terminating recursion need no separate treatment. The proof file includes an example where a self-calling op passes the check, io actually happens, and alloc cannot happen.

44.5 Optimisations relying on effects are legitimate#

The legitimacy of optimisations stands on effect soundness. At the effect layer, pure pieces may be reordered (pure_calls_commute) or deleted (dropping_pure_is_legal) with the same effects happening. The proof raised to the value layer is LowentOpt.v. In a model with values, state and an output stream, reordering, common subexpression elimination, memoisation and dead code elimination were all four proven legitimate.

The key is one line — a value depends only on the places it reads (value_depends_only_on_reads). And counterexamples showed the premises are not decoration. Reordering effectful pieces changes a value from 14 to 7, and deleting a piece that prints shrinks the output. One theorem, one counterexample — without the counterexample, the theorem would be true even if the condition were overly strict.

Q. Why does using a reserved word as a parameter name give a name diagnostic rather than an effect diagnostic?

A. At one time it was the other way round. Naming a parameter raw, the raw-pointer word, silently contaminated effect inference and produced “declared pure but performs an effect” (E-EFFECT-CALC). The real reason was “a reserved word was used as a name”, but the diagnostic pointed at purity. A diagnostic that sends the programmer to the wrong place is the worst kind. So those words were handed to E-NAME-BUILTIN, leaving one diagnostic for one offence. Proofs decide what is rejected, but people meet why — if the latter is wrong, the former loses half its value.

44.6 What is not proven#

Recap

Effects are sets of atoms forming a lattice under subset order; propagation is union and gating is a subset test. The processor enforces four rules — propagation · purity · a set of atoms (rejecting duplicates and typos) · stopping is not an effect — and capabilities, as the partner of effects, give attenuation and local audit. Effect soundness is induction over executions, so it is not circular even with recursion, and on top of it reordering, common subexpression elimination, memoisation and dead code elimination were proven legitimate along with counterexamples.