๐Ÿฑ Agda Sized Types: Deep Dive

Posted by CatamorphismCleo ยท Forum: Type Theory ยท 15 replies ยท 1,847 views ยท Tags: agda sized-types coinduction productivity copatterns

Alright kittens, I've been meaning to write this up for a while. Let's do a proper deep dive into sized types in Agda โ€” specifically how they help us control coinductive productivity in a way that's actually compositional and modular, unlike Agda's older syntactic guardedness checker.

The core motivation: coinductive types in Agda denote greatest fixpoints. If you want to define infinite structures (streams, potentially infinite trees, etc.), you need corecursion. But Agda is a total language โ€” every program must terminate or be productive. The guardedness checker is the traditional answer, but it has sharp edges.

Let's start with the classic example. A stream without sized types:

Agda โ€” Traditional Streams
record Stream (A : Set) : Set where
  coinductive
  field
    head : A
    tail : Stream A

-- This works fine
repeat : โˆ€ {A} โ†’ A โ†’ Stream A
head (repeat x) = x
tail (repeat x) = repeat x

-- This might be REJECTED by the guardedness checker!
zipWith : โˆ€ {A B C} โ†’ (A โ†’ B โ†’ C) โ†’ Stream A โ†’ Stream B โ†’ Stream C
head (zipWith f xs ys) = f (head xs) (head ys)
tail (zipWith f xs ys) = zipWith f (tail xs) (tail ys)

The guardedness checker works syntactically โ€” it needs corecursive calls to be directly under a constructor. Once you pass a corecursive result through a helper function, the checker may lose track of the guard. This is where sized types come in.

Sized types track the "depth" of a coinductive structure via an ordinal-like index. The key primitives in Agda are:

Agda โ€” Size Primitives
-- Enable with: {-# OPTIONS --sized-types #-}
open import Size

-- Size : Set              (the type of sizes)
-- โˆž    : Size             (the "infinite" size / greatest element)
-- โ†‘_   : Size โ†’ Size      (successor of a size)
-- Size< i : Set           (sizes strictly less than i)

-- A sized stream:
record Stream (A : Set) (i : Size) : Set where
  coinductive
  field
    head : A
    tail : โˆ€ {j : Size< i} โ†’ Stream A j

Now Stream A i is a stream defined to depth at least i. A value of type Stream A โˆž is a fully productive infinite stream. The sized version of zipWith typechecks beautifully:

Agda โ€” Sized zipWith
zipWith : โˆ€ {A B C} {i : Size}
         โ†’ (A โ†’ B โ†’ C)
         โ†’ Stream A i โ†’ Stream B i โ†’ Stream C i
head (zipWith f xs ys)         = f (head xs) (head ys)
tail (zipWith f xs ys) {j = j} = zipWith f (tail xs) (tail ys)
-- tail xs : Stream A j, with j : Size< i  โœ“ structurally decreasing!

The size index acts as a certificate: tail xs has type Stream A j for j < i, so we're definitely making progress. The type system handles what the syntactic checker couldn't.

I'll follow up with the Abel-Pientka paper discussion. Who's read it?

โ€” CatamorphismCleo | "A fold by any other name is still a catamorphism" ๐Ÿˆ

Oh hell yes, this is exactly the thread I needed. I've been trying to connect sized types to the coinductive program analysis stuff we discuss here. The key thing for me is that sized types make productivity compositional โ€” you can black-box functions as long as they're "size-preserving".

This is huge for program analysis! If you have a coinductive abstract domain (think: abstract interpretation over infinite traces), you want to be able to compose analysis transformers without reexposing all their internals to the guardedness checker every time.

The analogy I use: sized types are to productivity what structural recursion is to termination. They're the semantic, compositional version of a syntactic check. Instead of asking "is the corecursive call syntactically under a constructor?", you ask "does the type guarantee the structure decreases in size?"

On the Abel-Pientka paper โ€” I've read the JFP version ("Well-founded recursion with copatterns and sized types"). The combination of copatterns + sized types is genuinely elegant. Copatterns let you define coinductive types by their observations (what you can destructure out), and sized types give you the compositionality.

โ€” AbstractKitty | ฮฝ(X. F X) enjoyer ๐Ÿพ

Lurker here, finally posting. Can you say more about what Size< i means exactly? I get that it's "sizes strictly less than i" but what is the underlying model? Are we talking actual ordinals?

MonadicMochi wrote:
Can you say more about what Size< i means exactly? Are we talking actual ordinals?

Great question! Semantically, yes โ€” sizes correspond to ordinal numbers. The idea is that a coinductive type Stream A i represents streams unfolded up to ordinal depth i. Here's the intuition spelled out:

Agda โ€” Size semantics intuition
-- Sizes are essentially ordinals, with an extra โˆž element.
-- You have access to:
--   โˆž    : Size         -- "fully infinite", the top element
--   โ†‘ i  : Size         -- successor of i
--   Size< i             -- all sizes j such that j < i

-- Key: โˆž is *not* the largest size โ€” โ†‘ โˆž would be larger!
-- Actually โˆž behaves as if โˆž : Size< โˆž, which is a known quirk.

-- For coinduction: Stream A โˆž is the "real" coinductive type.
-- Stream A (โ†‘ i) means: head is defined, and tail has depth i.
-- Stream A i (for i : Size< j) means: depth < j.

-- Sized corecursion pattern:
myStream : โˆ€ {i : Size} โ†’ Stream โ„• i
head myStream     = 42
tail myStream {j} = myStream {j}
-- j : Size< i, so the recursive call is at a SMALLER size โœ“

The paper by Abel and Pientka phrases it precisely: sizes are an abstraction over well-founded ordinal-like orders. You don't need the full power of classical ordinals โ€” you just need a well-founded order with a successor operation. The full power of classical ordinals is not needed to justify the recursion schemes; we only need a well-founded order that is "long enough" and has a successor operation.

โ€” CatamorphismCleo | "A fold by any other name is still a catamorphism" ๐Ÿˆ

I want to bring in the comparison with Coq's guardedness checker since people always ask "why not just use Coq?"

Coq uses a syntactic guardedness check for corecursive definitions (the CoFixpoint mechanism). The rule is roughly: a CoFixpoint definition is productive if every corecursive call appears directly under at least one constructor of the coinductive type.

Coq โ€” Guardedness (for contrast)
(* This is ACCEPTED by Coq's guard checker *)
CoFixpoint zeros : Stream nat :=
  Cons 0 zeros.   (* corecursion under Cons โœ“ *)

(* This may be REJECTED โ€” call not directly under constructor *)
CoFixpoint bad (f : Stream nat โ†’ Stream nat)
               (s : Stream nat) : Stream nat :=
  f (Cons (hd s) (bad f (tl s))).  (* bad: f is opaque *)

The problem is that Coq can't look inside f to verify productivity. Even if f is clearly size-preserving, the syntactic checker has no way to know. You end up having to inline helper functions, use dirty tricks with Delay monads, or restructure your program significantly.

Agda's sized types solve this by encoding the productivity guarantee in the type. A function of type Stream A i โ†’ Stream A i is provably size-preserving, and the checker can trust it without looking inside.

Let me now go through the Abel-Pientka paper more carefully. The key paper is:

Andreas Abel & Brigitte Pientka (2016). "Well-founded recursion with copatterns and sized types." Journal of Functional Programming, 26, e2.

The main insight is combining two ideas:

  • Copatterns โ€” define coinductive data by their observations (eliminations), not just constructors
  • Sized types โ€” track structural depth of (co)inductive data via ordinal-like size indices

Here's a coinductive type defined with copatterns + sized types:

Agda โ€” Copatterns + Sized Types (Abel-Pientka style)
open import Size

-- Sized Stream: parameterized by size i
record Stream (A : Set) (i : Size) : Set where
  coinductive
  field
    head : A
    tail : {j : Size< i} โ†’ Stream A j
open Stream

-- map defined using copatterns โ€” each observation defined separately
map : โˆ€ {A B} {i : Size} โ†’ (A โ†’ B) โ†’ Stream A i โ†’ Stream B i
head (map f s)     = f (head s)
tail (map f s) {j} = map f (tail s {j})
-- Recursive call: map f (tail s {j}) : Stream B j, j < i โœ“

-- Fibonacci stream (mix of induction + coinduction!)
fib : โˆ€ {i : Size} โ†’ โ„• โ†’ โ„• โ†’ Stream โ„• i
head (fib m n)     = m
tail (fib m n) {j} = fib n (m + n)
-- Result: 0, 1, 1, 2, 3, 5, 8, ...

What's remarkable here: fib uses copatterns (defining the stream by what you observe from it) and sized types (the j < i ensures productivity). The termination checker sees the recursive call produces a stream of strictly smaller size.

โ€” CatamorphismCleo | "A fold by any other name is still a catamorphism" ๐Ÿˆ

This connects really beautifully to coinductive program analysis. Let me sketch the connection.

In abstract interpretation of potentially infinite/reactive programs (think: process calculi, dataflow systems), your abstract domain often needs to be a final coalgebra. Your analysis functions are then natural transformations between coalgebras โ€” and their compositionality is critical.

With sized types, an abstract analysis function over infinite traces can be typed like:

Agda โ€” Coinductive Analysis (Abstract)
-- An abstract trace is a coinductive stream of abstract states
record AbsTrace (S : Set) (i : Size) : Set where
  coinductive
  field
    now  : S
    next : {j : Size< i} โ†’ AbsTrace S j

-- A size-preserving analysis transformer
-- (compositional by construction!)
Transformer : Set โ†’ Set โ†’ Set
Transformer S T = โˆ€ {i : Size} โ†’ AbsTrace S i โ†’ AbsTrace T i

-- Compose two transformers โ€” productivity guaranteed by types!
_โˆ˜แต€_ : โˆ€ {S T U} โ†’ Transformer T U โ†’ Transformer S T
        โ†’ Transformer S U
(g โˆ˜แต€ f) trace = g (f trace)

The composition g โˆ˜แต€ f is automatically accepted because each function's type declares it as size-preserving. No manual productivity proofs needed! This is the compositional payoff.

โ€” AbstractKitty | ฮฝ(X. F X) enjoyer ๐Ÿพ

What about the known issues with Agda's sized types implementation? I heard there are consistency problems?

FixpointFelicia wrote:
What about the known issues with Agda's sized types implementation?

Yeah, this is important to address. The current Agda implementation of sized types is flagged as experimental and has known soundness issues. The most famous one is that โˆž : Size< โˆž is treated as true in the implementation, which leads to paradoxes.

Agda โ€” The โˆž < โˆž problem
-- In Agda's current implementation:
-- โˆž : Size< โˆž  is ACCEPTED (it shouldn't be!)
-- This means you can "unpack" โˆž as if it's smaller than itself.
-- This can lead to non-termination / False proofs in edge cases.

-- Example of what goes wrong conceptually:
-- If โˆž < โˆž, then the well-founded recursion on Size is broken
-- because there's no "base case" โ€” โˆž can always go deeper.

-- Agda has an open issue for this (github: infinity-less-than-infinity)
-- A new design is being discussed to fix this properly.

Danielsson's paper "Up-to Techniques using Sized Types" (POPL 2018) explicitly notes this โ€” the current Agda implementation contains bugs related to sized types, specifically that โˆž : Size< โˆž has led to implementation problems.

The practical advice: use {-# OPTIONS --sized-types #-} and be careful. For truly safety-critical proofs, consider the --safe flag (which disables sized types). For most coinductive programming tasks, sized types work fine in practice and the unsoundness is hard to trigger accidentally.

โ€” CatamorphismCleo | "A fold by any other name is still a catamorphism" ๐Ÿˆ

The guardedness vs. sized types debate also extends to the question of which is easier to use. Let me give a concrete example where sized types shine: the Delay monad.

Agda โ€” Sized Delay Monad
open import Size

-- The Delay monad: a computation that may take finitely many steps
data Delay (A : Set) (i : Size) : Set where
  now   : A โ†’ Delay A i
  later : {j : Size< i} โ†’ Delay A j โ†’ Delay A i

-- bind: sequence two delayed computations
_>>=_ : โˆ€ {A B} {i : Size}
      โ†’ Delay A i
      โ†’ (A โ†’ Delay B i)
      โ†’ Delay B i
now x     >>= f = f x
later d   >>= f = later (d >>= f)
-- d : Delay A j, j < i, so recursive call typechecks! โœ“

-- never: a computation that never terminates
never : โˆ€ {A} {i : Size} โ†’ Delay A i
never = later never

Without sized types, getting _>>=_ to pass the guardedness checker requires awkward encoding. With sized types, it's structurally obvious. This is what Abel and Chapman demonstrated in their "Normalization by Evaluation in the Delay Monad" paper.

This is gold. What's the comparison with guarded recursion (Nakano modality, Atkey-McBride, etc.)? I've seen a paper called "Guarded Recursion in Agda via Sized Types" that claims to simulate guarded recursion using sized types?

PurpleKernelKat wrote:
What's the comparison with guarded recursion?

Yes! That's the Veltri & van der Weide paper from FSCD 2019. The key insight is that sized types can simulate the โ–ถ (later) modality of guarded type theory. Here's the sketch:

Agda โ€” Simulating โ–ถ (Later) with Sized Types
open import Size

-- In guarded recursion: โ–ถ A is "A available one step later"
-- We simulate it with a Thunk:

record Thunk (F : Size โ†’ Set) (i : Size) : Set where
  coinductive
  field
    force : {j : Size< i} โ†’ F j
open Thunk

-- Thunk F i is "F, available at any size j < i"
-- This corresponds to โ–ถ F in guarded recursion!

-- Example: sized Conat using Thunk
data Conat (i : Size) : Set where
  zero : Conat i
  suc  : Thunk Conat i โ†’ Conat i

-- infinity: the conatural number ฯ‰
infinity : โˆ€ {i} โ†’ Conat i
infinity = suc ฮป where .force โ†’ infinity

This is exactly how the Agda standard library defines Conat in Codata.Sized.Conat! The Thunk type is the key bridge โ€” it wraps a computation that must be forced at a smaller size.

The difference from proper guarded type theory: in guarded recursion (Clocks & Causality style), the modality โ–ถ is a first-class type constructor with its own introduction/elimination forms and clock quantifiers. Sized types are more like a structural approximation โ€” expressive enough for most uses but without the full clock quantification machinery.

โ€” AbstractKitty | ฮฝ(X. F X) enjoyer ๐Ÿพ

Okay I'm sold. One last question: what's a good "minimal example" that shows why you'd pick sized types over just using the standard library's record ... coinductive without sizing?

Perfect timing. Here's the canonical example โ€” a coinductive parser / language recognizer. This is actually what the Agda docs use to explain sized types, and it's very clean:

Agda โ€” Coinductive Language (sized types shine!)
open import Size
open import Data.Bool

-- A language over alphabet A is an infinite decision tree
record Lang (i : Size) (A : Set) : Set where
  coinductive
  field
    ฮฝ : Bool                              -- does ฮต belong?
    ฮด : {j : Size< i} โ†’ A โ†’ Lang j A    -- derivative by character
open Lang

-- Union of two languages (sized โ†’ compositional!)
_โˆช_ : โˆ€ {i A} โ†’ Lang i A โ†’ Lang i A โ†’ Lang i A
ฮฝ   (a โˆช b)     = ฮฝ a โˆจ ฮฝ b
ฮด   (a โˆช b) {j} c = ฮด a {j} c โˆช ฮด b {j} c

-- Concatenation (here sized types are ESSENTIAL)
_ยท_ : โˆ€ {i A} โ†’ Lang i A โ†’ Lang i A โ†’ Lang i A
ฮฝ (a ยท b)     = ฮฝ a โˆง ฮฝ b
ฮด (a ยท b) {j} c =
  if ฮฝ a
  then (ฮด a {j} c ยท b) โˆช ฮด b {j} c   -- ฮฝ a true: both derivatives
  else  ฮด a {j} c ยท b               -- ฮฝ a false: only left
-- All recursive calls are at size j < i. Without sized types,
-- this FAILS the guardedness check (b appears inside if_then_else)!

The _ยท_ (concatenation) case is the killer example. The corecursive call passes through if_then_else, which is opaque to the guardedness checker. Sized types handle this transparently because all we care about is that the resulting languages have a smaller depth index.

โ€” CatamorphismCleo | "A fold by any other name is still a catamorphism" ๐Ÿˆ

Amazing thread. For people wanting to dive deeper, the reading order I'd suggest:

  1. Agda documentation on sized types โ€” start here
  2. Abel (2010) โ€” MiniAgda: Integrating sized and dependent types
  3. Abel & Pientka (2016) โ€” Well-founded recursion with copatterns and sized types (JFP)
  4. Danielsson (2018) โ€” Up-to Techniques using Sized Types (POPL)
  5. Veltri & van der Weide (2019) โ€” Guarded Recursion in Agda via Sized Types (FSCD)

Start with the Agda docs, then MiniAgda for the theory, then JFP for the full copatterns story, then the latter two for more advanced applications.

Great discussion everyone! Let me summarize the key takeaways:

  • ๐Ÿฑ Sized types encode productivity guarantees in the type, making coinductive programming compositional
  • ๐Ÿ”ข Size indices are ordinal-like; Stream A i = stream defined to depth i; Stream A โˆž = fully infinite
  • ๐Ÿ“ The Abel-Pientka approach combines copatterns + sized types for a unified treatment of termination and productivity
  • โš ๏ธ Agda's current implementation has known soundness bugs (โˆž < โˆž); use --safe for critical proofs
  • ๐Ÿ†š vs. Coq guardedness: syntactic vs. semantic; Agda sized types win on compositionality, but Coq's checker is sound
  • ๐Ÿ”— Thunk bridges sized types and guarded recursion's โ–ถ modality

I'll write up a more complete resource page at /resources/sized-types-guide.html. Also see the related thread on coinductive program analysis for how AbstractKitty's framework applies this to real analysis tasks.

โ€” CatamorphismCleo | "A fold by any other name is still a catamorphism" ๐Ÿˆ

โœ๏ธ Quick Reply

Full Reply Editor