Specifying error behavior¶
Remember the warning back in Algebraic specification, that our stack specification was
incomplete? Here is exactly how. What is top(A, empty(A)) — the top of an
empty stack? The two axioms never say. A programmer implementing that spec would
have to decide something on the spot — perhaps throw an exception — for a case the
specification simply left open. We usually want to avoid runtime errors, or at
the very least pin down in the specification where they may occur.
So this is a real gap. Algae has no partial functions — top is total, so
top(A, empty(A)) is some element, we just never said which. A caller who pops one
element too many deserves a defined answer, not a shrug.
Algae’s motto is failure is modeled with sum types. The standard library gives
this shape a name: Result(A, E) — the type of “an A on success, or an
E on failure,” built from two constructors,
ok : forall (A E : Sort) st A → Result(A, E); # success, carrying an A
err : forall (A E : Sort) st E → Result(A, E); # failure, carrying an E
So we give pop and top a second possible outcome — an Error — by
returning a Result and specifying the empty case outright:
import result(ok, err, result_cases);
sort Error : Sort;
op underflow : → Error; # the one way our stack operations can fail
op pop : forall (A : Sort) st Stack(A) → Result(Stack(A), Error);
op top : forall (A : Sort) st Stack(A) → Result(A, Error);
axiom pop_empty(A : Sort)
⊢ pop(A, empty(A)) = err(Stack(A), Error, underflow);
axiom top_empty(A : Sort)
⊢ top(A, empty(A)) = err(A, Error, underflow);
The return types are now sum types: pop yields “a stack or an error,”
written Result(Stack(A), Error). Unlike an untyped model, a plain value does
not silently slip into the sum — you say which outcome it is with ok or
err:
err(Stack(A), Error, underflow)tags theErrorvalueunderflowas the failure branch, giving it typeResult(Stack(A), Error).On success you use
ok: a real results : Stack(A)becomesok(Stack(A), Error, s) : Result(Stack(A), Error). The old success laws survive, reworded to say “on success, you getokof your value back.”
(The import result(ok, err, result_cases); is what brings the Result type,
its ok / err constructors, and the result_cases eliminator into scope.)
The explicit ok / err are the price of honesty: the type now records
which outcome happened, and every use site has to say so.
Here’s the whole thing, error-aware, still verifying:
import result(ok, err, result_cases, ok_inj);
import core(forward, implication_intro);
sort Stack : Sort → Sort;
sort Error : Sort;
op empty : forall (A : Sort) st → Stack(A);
op push : forall (A : Sort) st A * Stack(A) → Stack(A);
op underflow : → Error;
op pop : forall (A : Sort) st Stack(A) → Result(Stack(A), Error);
op top : forall (A : Sort) st Stack(A) → Result(A, Error);
axiom top_push(A : Sort, x : A, s : Stack(A))
⊢ top(A, push(A, x, s)) = ok(A, Error, x);
axiom pop_push(A : Sort, x : A, s : Stack(A))
⊢ pop(A, push(A, x, s)) = ok(Stack(A), Error, s);
axiom pop_empty(A : Sort)
⊢ pop(A, empty(A)) = err(Stack(A), Error, underflow);
axiom top_empty(A : Sort)
⊢ top(A, empty(A)) = err(A, Error, underflow);
# empty is now fully specified: peeking it is an error, not a mystery.
lemma peek_empty(A : Sort)
⊢ top(A, empty(A)) = err(A, Error, underflow);
proof
by top_empty(A);
qed;
# the success law: peeking a push returns ok of the pushed value.
lemma top_of_push(A : Sort, x : A, s : Stack(A))
⊢ top(A, push(A, x, s)) = ok(A, Error, x);
proof
by top_push(A, x, s);
qed;
# pop after push returns the same stack — a *successful* ok of s.
lemma pop_of_push(A : Sort, a : A, s : Stack(A))
⊢ pop(A, push(A, a, s)) = ok(Stack(A), Error, s);
proof
by pop_push(A, a, s);
qed;
# A more evolved proof
lemma push_of_pop_weak(A : Sort, a : A, s0 s1 : Stack(A))
⊢ pop(A, push(A, a, s0)) = ok(Stack(A), Error, s1) ⇒ s0 = s1;
proof
by forward(
Result(Stack(A), Error),
pop(A, push(A, a, s0)),
ok(Stack(A), Error, s0),
pop_push(A, a, s0),
(_ = ok(Stack(A), Error, s1) ⇒ s0 = s1))
then ⊢ (ok(Stack(A), Error, s0) = ok(Stack(A), Error, s1)) ⇒ (s0 = s1);
by implication_intro(ok(Stack(A), Error, s0) = ok(Stack(A), Error, s1), s0 = s1)
then
P := ok(Stack(A), Error, s0) = ok(Stack(A), Error, s1)
⊢ s0 = s1;
by ok_inj(Stack(A), Error, s0, s1)
then
P := ok(Stack(A), Error, s0) = ok(Stack(A), Error, s1)
⊢ ok(Stack(A), Error, s0) = ok(Stack(A), Error, s1);
by P;
qed;
``peek_empty`` is the point of the whole exercise:
top(A, empty(A))now has an answer,err(…, underflow), provable in one step. The gap is closed.``top_of_push`` confirms nothing regressed — the success law is exactly as before, now stated as an explicit
okresult.``pop_of_push`` is the round-trip law: for any stack
s, pushingathen popping returnsok(Stack(A), Error, s)— a tagged success carrying the same stacksyou started with.
The tradeoff¶
Specifying errors this way buys you totality and honesty: there are no
undefined corners left, pop(A, empty(A)) genuinely equals err(…, underflow),
and any caller can tell success from failure by looking at which constructor they
got.
But you pay for it, and the types now make you pay up front. Once pop returns
Result(Stack(A), Error), you can no longer write top(A, pop(A, s)) — top
wants a Stack(A), not a Result(Stack(A), Error), so the composition simply
does not type-check. To chain another operation you must case-split the result
first (with result_cases): if it is ok of a stack, carry on; if it is
err of an error, propagate. The tidy equations sprout side conditions, and
pop(A, pop(A, s)) stops being something you can write without first asking “did
the first pop fail?”
That’s the perennial bargain. Leave failure unspecified and the algebra stays clean but the spec lies by omission. Track it in the types and the spec tells the whole truth, but the plumbing multiplies. For a stack — where popping too far is an everyday mistake — paying for honesty is usually worth it. For an operation you can prove never fails, the sum type is just noise you’ll spend proofs peeling back off. Choosing where to draw that line is a real part of writing a specification.