📌 Coq CoFixpoint Tricks and Tips
Started by FixpointFeline
Posted 2025-11-03 14:22 UTC
Replies: 19
Views: 1,847
Tags: coq coinduction cofix type-theory paco
#1

Hey everyone, I keep seeing the same CoFixpoint pain points come up in various threads so I figured I'd write up a megathread with all the tricks I've accumulated over about two years of fighting Coq's coinduction checker. Consider this my battle diary 🐱⚔️

§1 — The Guardedness Condition: What It Actually Means

The fundamental rule: every corecursive call must be guarded by at least one constructor of the coinductive type. CoFixpoint is lazy — Coq will only reduce it when it appears as the scrutinee of a match. This is intentional:

(* This is FINE — the corecursive call is under the Cons constructor *) CoFixpoint ones : Stream nat := Cons 1 ones. (* This is REJECTED — naked corecursion, no guarding constructor *) Fail CoFixpoint bad : Stream False := bad. (* Error: Recursive definition of bad is ill-formed. *)

Key insight: productivity is undecidable, so Coq uses a conservative syntactic criterion. This means many perfectly productive definitions get rejected. Don't rage-quit — there are workarounds.

§2 — The Unfold Trick

The single most useful trick: when Coq can't see through a CoFixpoint, force it with an eta-expand identity function (often called frob or unfold_stream):

Definition force {A} (s : Stream A) : Stream A := match s with | Cons h t => Cons h t end. Lemma force_eq : forall {A} (s : Stream A), force s = s. Proof. intros. destruct s; reflexivity. Qed. (* Now rewrite with force_eq to help the checker unfold *)

This works because Coq reduces a cofixpoint only when it is the scrutinee of a match statement. The force trick manufactures that match.

§3 — Defining CoInductive Predicates Correctly

For coinductive propositions (as opposed to data), you use CoInductive with a proposition-valued type. The bisimilarity predicate is the classic example:

CoInductive stream_bisim {A} : Stream A -> Stream A -> Prop := | bisim_cons : forall h t1 t2, stream_bisim t1 t2 -> stream_bisim (Cons h t1) (Cons h t2). (* Proofs use 'cofix' tactic, but beware: *) Lemma bisim_refl : forall {A} (s : Stream A), stream_bisim s s. Proof. cofix CIH. intros. destruct s. apply bisim_cons. apply CIH. (* guarded — CIH is under bisim_cons *) Qed.

More tomorrow! This post is already getting long ^^;

-- FixpointFeline | "a cat can haz greatest fixed points" | she/her
#2

Great thread already! One thing that trips me up constantly: why does Qed fail after a proof that seemed to work?

The painful thing about cofix is that the guardedness check is not run during tactic mode — it only runs at Qed. So you can happily build a "proof" and then watch it explode at the end:

(* Seemingly ok in tactic mode, but... *) Proof. cofix CIH. intros. (* Doing something that breaks guardedness... *) exact CIH. (* ← naked use of CIH! *) Qed. (* Error: Recursive definition of ... is ill-formed. In environment ... unguarded recursive call in ... *)

The lesson: always double-check that every use of the coinduction hypothesis is under a constructor. If you use CIH "bare" (without first applying a constructor), Coq's kernel will reject it.

-- AbstractKitty | λ-calculus enjoyer
#3

Yes exactly! And the reason is subtle: Coq reduces a cofixpoint only when it is the scrutinee of a match statement. So if you have a helper lemma that's opaque (Qed-closed), the guardedness checker can't peek inside it and will fail:

(* Classic pitfall: rewrite through an opaque lemma *) Lemma stream_unfold_eq : forall {A} (s : Stream A), s = match s with | Cons h t => Cons h t end. Proof. destruct s; reflexivity. Qed. (* If you do: rewrite stream_unfold_eq; ...apply CIH *) (* The proof term involves this Qed-opaque lemma, *) (* so guardedness checking cannot inspect its proof *) (* and FAILS at Qed time. Use Defined instead: *) Lemma stream_unfold_eq' : forall {A} (s : Stream A), s = match s with | Cons h t => Cons h t end. Proof. destruct s; reflexivity. Defined. (* ← transparent! *)

Rule of thumb: any auxiliary lemma used inside a cofix proof should be Defined not Qed, or it needs to be definitionally transparent. Otherwise, the guard checker hits an opaque wall and rejects the whole proof.

Alternatively, avoid the rewrite entirely and use destruct or case to force unfolding directly.

-- FixpointFeline | "a cat can haz greatest fixed points" | she/her
#4

Bookmarking this thread so hard. Can you cover negative vs positive coinductive types? I read in the Coq manual that as of 8.9 they recommend negative coinductives but I'm not clear on why.

#5

Great question! §4 — Positive vs Negative Coinductive Types

Positive coinductives are the traditional constructor-based style you see in most tutorials. But they have a dirty secret: using them can break subject reduction because of issues with dependent pattern-matching and η-equality.

Negative coinductives are defined as CoInductive record types using primitive projections. The Coq manual recommends these since 8.9. The pattern looks like:

(* POSITIVE style (old, can break subject reduction): *) CoInductive Stream (A : Type) : Type := | Cons : A -> Stream A -> Stream A. (* NEGATIVE style (recommended since Coq 8.9): *) Set Primitive Projections. CoInductive Stream (A : Type) : Type := Seq { hd : A; tl : Stream A }. (* Access via projections: s.(hd), s.(tl) *)

The negative style has copattern-based syntax similar to Agda, and preserves subject reduction. You define values by specifying all projections, rather than applying a constructor. The main downside is that some standard tactics work slightly differently.

A key technical issue with positive coinductives: you can derive a term of type evalN infS = infS, but Coq can't reduce it to eq_refl because it can only reduce cofixpoints under a match, not in arbitrary type positions. This inconsistency is why negative coinductives exist.

-- FixpointFeline | "a cat can haz greatest fixed points" | she/her
#6

Speaking of automation: the guardedness check and Coq's automation do not play well together. If you try to use auto or eauto inside a cofix proof, it'll likely break guardedness because these tactics don't know or care about the guard condition. This is a real bottleneck for large coinductive proofs.

For instance: if you use rewrite and it introduces an application of a Setoid library lemma (which is Qed-opaque), the guardedness checker can't inspect that lemma's proof and the whole thing fails.

(* The 'pattern ... at 1' trick — use this instead of bare rewrite when in a cofix proof to avoid opaque lemma issues: *) Proof. cofix CIH. intros. pattern (enumerate n) at 1; rewrite sunf_eq; simpl. constructor. rewrite (sunf_eq (enumerate n)); simpl. rewrite (sunf_eq (map _ _)); simpl. apply CIH. Qed.

Notice the careful use of pattern ... at 1 to control exactly which occurrence gets rewritten. This avoids introducing extra proof terms that confuse the guard checker.

-- AbstractKitty | λ-calculus enjoyer
#7

OK so this brings me to a question I've been avoiding asking: is there a way to do coinductive proofs that doesn't involve fighting the guard checker at every step? Something more ergonomic?

I've heard people mention paco but I don't really understand how it works.

#8

Oh yes, this is the big one! §5 — Parameterized Coinduction and Paco

The core problem with cofix is that it uses a syntactic guardedness criterion. This means automation tactics have no way to reason about whether a proof is guarded. The result: you can't use eauto, omega, or most other Coq automation inside cofix proofs without breaking things.

Enter parameterized coinduction, introduced by Hur, Neis, Dreyer, and Vafeiadis (POPL 2013). The key idea:

📄 Hur et al., "The Power of Parameterization in Coinductive Proof" — POPL 2013
Parameterize the greatest fixed point over the accumulated knowledge of "the proof so far". Instead of the cofix hypothesis giving you P, you get P parameterized by a relation r representing what you've already proven — and crucially, you can accumulate more facts into r as the proof progresses.

The Paco library implements this for Coq. Instead of writing:

(* Traditional cofix-based bisimilarity proof *) CoInductive bisim : proc -> proc -> Prop := ... Lemma my_bisim : bisim P Q. Proof. cofix CIH. (* must be very careful about guardedness here *)

You define a monotone functor for your predicate, and use pcofix:

(* Paco style: define the functor separately *) Inductive bisim_gen (bisim : proc -> proc -> Prop) : proc -> proc -> Prop := ... (* The coinductive predicate is the greatest fixed point *) Definition bisim := paco2 bisim_gen bot2. Lemma my_bisim : bisim P Q. Proof. pcofix CIH. (* Now CIH : r P Q where r is the coinduction relation *) (* Can freely use eauto, omega, etc. — no guard checker! *) pfold. constructor. ... Qed.

The magic: pcofix replaces the syntactic guard check with a semantic one, baked into the library's types. You can't "accidentally" use CIH in an unguarded position because the type system prevents it. All of Coq's automation works normally inside pcofix proofs!

-- FixpointFeline | "a cat can haz greatest fixed points" | she/her
#9

Can you explain the accumulation principle in Paco a bit more? I get that there's a relation parameter r, but I'm not clear on when/how you "add things to r".

#10

§6 — The Accumulation Principle

The technical foundation (due to Hur et al.) is this: for a monotone function f on a complete lattice, define:

(* Parameterized greatest fixed point — Hur et al. POPL 2013 *) (* G_f(r) = ν(λy. f(r ⊔ y)) *) (* = greatest fixed point of (λy. f(r ∪ y)) *) (* The accumulation lemma: *) (* y ⊑ G_f(r) ⟺ y ⊑ G_f(r ⊔ y) *) (* You can always add y itself to the relation parameter *)

In practice, this means: if you're coinductively proving P and during the proof you discover that some auxiliary fact Q is also true by coinduction, you can accumulate Q into your coinduction hypothesis and use it freely thereafter. In contrast, with raw cofix, you'd need separate nested cofix proofs (which are extremely fragile).

(* With pcofix + accumulation (gpaco / pcpaco): *) Lemma bisim_mutual : bisim P1 Q1 /\ bisim P2 Q2. Proof. split. - pcofix CIH. (* Prove bisim P1 Q1, accumulate bisim P2 Q2 into CIH *) guclo bisim_gen_clo. (* up-to closure tactic *) ... - (* The second part follows from the accumulated knowledge *) Qed.

The generalized version (gpaco) from Zakowski, He, Hur, Zdancewic (CPP 2020) extends this further with "up-to-tau" reasoning and more powerful closure operators, which is essential for reasoning about interaction trees in compiler verification work.

-- FixpointFeline | "a cat can haz greatest fixed points" | she/her
#11

This is SO much clearer than the paper tbh. One more thing — the lifting issue? I've seen warnings about "lift the continuation" in some blog posts about CoFixpoint and interaction trees. What's going on there?

#12

§7 — The Parameter Lifting Trick

This is a subtle but important one! When defining a CoFixpoint that takes a function parameter (like a continuation k), the guardedness checker can't infer that k is constant across corecursive calls. So this fails:

(* BROKEN: k is a parameter to the cofixpoint *) CoFixpoint map_tree (k : A -> B) (t : Tree A) : Tree B := match t with | Leaf a => Leaf (k a) | Branch l r => Branch (map_tree k l) (map_tree k r) end. (* Error: unguarded recursive call — k obscures the check *)

The fix is to lift the parameter outside the cofixpoint using a wrapper, so the corecursive part has no extra arguments:

(* FIX: lift k outside the corecursion *) Section map_tree_def. Variable k : A -> B. CoFixpoint map_tree_inner (t : Tree A) : Tree B := match t with | Leaf a => Leaf (k a) | Branch l r => Branch (map_tree_inner l) (map_tree_inner r) end. (* k is a Section variable, not a parameter — OK! *) End map_tree_def. Definition map_tree (k : A -> B) := map_tree_inner k.

The intuition: inside a Section, k is a global constant from Coq's perspective, not a varying argument. The guard checker is happy because it can see the corecursive call is on a structurally "smaller" part with the same fixed k.

-- FixpointFeline | "a cat can haz greatest fixed points" | she/her
#13

Adding to the Section trick: this is also why the interaction trees coq library uses Section blocks liberally when defining monadic bind over potentially infinite computations. The continuation in bind has to be lifted outside the cofixpoint or the whole thing explodes.

Here's the "bind for lazy computations" issue in minimal form:

CoInductive Lazy (A : Type) : Type := | Now : A -> Lazy A | Later : Lazy A -> Lazy A. (* bind: sequence two lazy computations *) (* The continuation k MUST be lifted outside! *) Section Bind. Variable A B : Type. Variable k : A -> Lazy B. CoFixpoint bind_inner (x : Lazy A) : Lazy B := match x with | Now a => k a | Later x' => Later (bind_inner x') end. End Bind. Definition bind {A B} (x : Lazy A) (k : A -> Lazy B) : Lazy B := bind_inner A B k x.
-- AbstractKitty | λ-calculus enjoyer
#14

Wait, does Paco install cleanly with current Coq versions? Last time I tried I got some opam dependency hell 😿

#15

Paco installs fine via opam from the coq-released repo:

opam repo add coq-released https://coq.inria.fr/opam/released opam install coq-paco

It's compatible with Coq 8.13–8.15 as of v4.1.2. For newer Coq (8.16+) you may want to check the GitHub repo for the latest. The coq-community maintained version by Damien Pous (coq-coinduction) is an alternative that uses the companion approach ("Coinduction All The Way Up", LICS 2016) instead — it's even more powerful but has a steeper learning curve.

§8 — The Companion Approach (brief)

Pous's companion theory says: for a monotone f, the "companion" t is the greatest compatible function below f. Any compatible function is subsumed by the companion, and the companion is idempotent (tt = t). This lets you plug in "up-to" techniques compositionally:

(* coq-coinduction library (Pous): up-to techniques *) (* The companion 't' unifies all sound up-to functions *) (* If b is your bisimulation functor: *) (* - up-to symmetry *) (* - up-to transitivity *) (* - up-to context (for CCS/process algebra) *) (* are all instances of compatible functions ≤ t *) Require Import Coinduction. Lemma my_bisim : bisim P Q. Proof. coinduction R CIH. (* 'coinduction' tactic from the library *) ... Qed.
-- FixpointFeline | "a cat can haz greatest fixed points" | she/her
#16

One pattern I use constantly and would add to this list: bisimulation up-to (without a library). When doing a manual cofix bisimulation proof, you often want to "close up to" some equivalence. The trick is to maintain an explicit relation that you carry around:

(* Manual bisimulation candidate technique *) (* Rather than proving bisim P Q directly, *) (* guess a relation R and prove R is a bisimulation *) (* then show (P, Q) ∈ R *) Definition is_bisimulation (R : proc -> proc -> Prop) := forall P Q, R P Q -> (forall a P', step P a P' -> exists Q', step Q a Q' /\ R P' Q') /\ (forall a Q', step Q a Q' -> exists P', step P a P' /\ R P' Q'). Lemma bisimulation_sound : forall R P Q, is_bisimulation R -> R P Q -> bisim P Q. Proof. intros R P Q HR HRpq. revert P Q HRpq. cofix CIH. intros. constructor. apply HR in HRpq as [Hfwd Hbwd]. (* ... each step is guarded by constructor *) Qed.

This is the "bisimulation as a greatest fixed point" argument and it's much cleaner than trying to do coinduction on the goal directly.

-- AbstractKitty | λ-calculus enjoyer
#17

Amazing thread. Should we make a wiki page for this? I'd love a summary table of all the techniques with pros/cons. Maybe something like:

  • Raw cofix — builtin, no deps, terrible for automation
  • cofix + Defined lemmas — solves opacity issues, still manual
  • Paco (snu-sf) — semantic guardedness, great for predicates, POPL 2013
  • gpaco (CPP 2020) — generalized Paco, handles weak bisimulation
  • coq-coinduction (Pous) — companion-based, compositional up-to, LICS 2016
  • Negative coinductives — use for data, avoids subject reduction issues

FixpointFeline you should write this up as a proper CGPA wiki article honestly uwu

#18

Great idea Cleo! One more tip before I write the wiki page: mutual CoFixpoints. You can define them with with, same as with Fixpoint:

(* Mutual coinduction — alternating streams *) CoFixpoint evens : Stream nat := Cons 0 odds with odds : Stream nat := Cons 1 evens. (* Each definition is guarded — the corecursive call to 'odds' in 'evens' is under 'Cons', and vice versa *) (* The 'for' clause selects which definition to use: *) Definition my_stream := cofix f := Cons 0 (cofix g := Cons 1 f).

Mutual coinduction in proof mode is trickier — you typically need to formulate it as a single predicate about a pair, or use Paco's pcofix2 / pcofix3 for multivariate relations. The guardedness checker for mutual coinduction checks that each corecursive definition is guarded w.r.t. all others in the mutual block.

Wiki article is in progress, will post link when done! 🐱

-- FixpointFeline | "a cat can haz greatest fixed points" | she/her
#19

tl;dr resources for anyone finding this thread later:

  • Adam Chlipala's CPDT, Chapter "Coinductive" — free online, best intro
  • Hur et al., POPL 2013 — "The Power of Parameterization in Coinductive Proof"
  • Paco library — github.com/snu-sf/paco
  • coq-coinduction — github.com/damien-pous/coinduction
  • Breitner's blog — "Coinduction in Coq and Isabelle" (great for comparison)
  • The Coq reference manual section on coinductive types (Coq 8.9+ for negative coinductives)

This thread deserves a sticky. /cc mods 👀

-- AbstractKitty | λ-calculus enjoyer
#20

One thing I'll add: if you're coming from Agda, be warned that Agda's coinduction framework differs significantly from Coq's. Agda uses copatterns and the type constructor, which sidestep a lot of these issues by design. Coq's negative coinductives (with primitive projections) get closer to Agda's approach but it's still not as smooth.

Also: Agda has a productivity checker that goes beyond syntactic guardedness in some cases. Don't assume your Agda intuitions transfer directly to Coq! 🐾

[ Post a Reply ]

You must be logged in to post. | Forum Rules | Use [code]...[/code] for Coq snippets.