Thread started by NbENewbie
Replies: 44
Views: 2,871
Last post: CubicalCatgirl — 2 hours ago
Tags: #NbE #normalization #lambda-calculus #type-checker
📌 Normalization by Evaluation — please explain like I have paws
Forum:Type Theory & Formal Verification
Replies:44
Status:● Open
📚 Marked as Learning Resource — This thread has been highlighted by moderators as a useful introduction to NbE.
🐾
NbENewbie
Kitten Coder
Newbie
Joined: 3 months ago
Posts: 12

Hey everyone!! I keep seeing "Normalization by Evaluation" (NbE) mentioned in threads about type checkers, Agda internals, bidirectional typing, etc. I even saw someone say it's at the heart of how Agda checks definitional equality. But every time I try to read about it, my tiny kitten brain short-circuits after about two paragraphs of dense PL theory 😿

Can someone please explain:

  1. What problem does NbE solve?
  2. What are reflect and reify?
  3. Why is it better than just repeatedly beta-reducing?
  4. What does "presheaf model" mean and do I actually need to know that?
  5. Bonus paw points: show me some code! Haskell or Agda preferred 🐱

I am a simple cat who understands Haskell at a beginner level and has read the first half of PLFA. That's my entire prior. Please be gentle with my paws 🐾

🐾 13 ❤️ 8 🧠 21
🐈
SemanticsSiamese
Senior Theorem Prover
Expert
Joined: 4 years ago
Posts: 3,847

Great question, and don't worry — this is one of those things that's intimidating from the name but actually quite elegant once you see the core idea.

What problem does NbE solve?

You have terms in a lambda calculus. Two terms are definitionally equal if they reduce to the same normal form. Your type checker needs to check this constantly. The naive way: apply beta-reduction rules over and over until you can't anymore. That works, but it's slow and fragile for real type theories.

The NbE insight: instead of grinding through reductions syntactically, you evaluate your term into a richer semantic domain (like Haskell functions for arrow types), and then read it back into syntax. The round-trip reify ∘ eval produces the normal form.

As one nice summary puts it: "NbE does not work by finding redexes and reducing them. Instead it works by evaluating terms in a suitable model and then reifying them back into the syntax — the result is a normal form of the original term."

More precisely, in the most general formulation: to perform NbE for a set X of objects, we introduce another set M of meanings with two functions: eval: X → M and reify: M → X. The normal form of x is defined to be reify(eval(x)).

— "Types are just vibes until you normalize them." — SemanticsSiamese, probably
🧠 34 🐾 19 ⭐ 27
🐈
SemanticsSiamese
Senior Theorem Prover
Expert
Joined: 4 years ago
Posts: 3,847

Now for reflect and reify — the heart of it.

There are a pair of type-indexed functions that move back and forth between the syntactic and semantic layers:

  • reflect (written ↑τ) — takes a syntactic term and "lifts" it into the semantic domain. This is used for free variables whose values are unknown.
  • reify (written ↓τ) — takes a semantic value and "reads it back" as a syntactic normal form.

They are mutually recursive, defined by induction on the type τ. Here's the classic SML-style formulation (slightly simplified):

SML / pseudocode
(* reflect : ty -> tm -> sem *) fun reflect (Arrow (a, b)) t = LAM (fn S => reflect b (app (t, reify a S))) | reflect (Basic _) t = SYN t (* reify : ty -> sem -> tm *) and reify (Arrow (a, b)) (LAM S) = let val x = fresh_var () in lam (x, reify b (S (reflect a (var x)))) end | reify (Basic _) (SYN t) = t

See how they chase each other? reflect at arrow type wraps a term into a semantic function — but to apply it, it'll have to reify the argument first. And reify at arrow type generates a fresh variable, reflects it, applies the semantic function, then reifys the result. This mutual dance is what produces η-long β-normal forms.

The type of reify then makes it clear that the result is normalized — and if the datatype of normal forms is typed, reify makes it clear that normalization is type-preserving.

🧠 41 🐾 22 ⭐ 30
🐾
NbENewbie
Kitten Coder
Newbie
Joined: 3 months ago
Posts: 12

Ooh okay that makes intuitive sense! The semantic domain for arrow types is actual Haskell functions (or SML functions, or whatever metalanguage you're using), so beta reduction is just... function application in the host language. And then when you need the syntax back, you do the fresh-variable trick to read the function back out!

But wait — what does "η-long" mean? I see it everywhere and I feel like it's important 👀

🐾 5 ❓ 3
⚙️
InferenceEngine9
Type Inference Specialist
Veteran
Joined: 2 years ago
Posts: 1,204
NbENewbie wrote:
But wait — what does "η-long" mean?

Good question! So you know β-reduction: (λx. f x) a reduces to f a. The η-rule says the reverse: f is the same as λx. f x (at function type). A β-normal form has no more β-redexes. An η-long form has been fully expanded with η so that every function-typed subterm is syntactically a lambda.

Example: id at type A → A beta-normalizes to just id. But the η-long β-normal form is λx. id x, which further reduces to λx. x. NbE naturally produces η-long forms because reify always generates a fresh lambda when it sees an arrow type, regardless of whether the semantic value was already a lambda.

This is actually crucial for type-checking! η-equality is the reason you can replace f with λx. f x freely. Without η-long normal forms, you might fail to see that two definitionally equal terms are actually equal.

Typed NbE gives you η-long β-normal forms basically for free. That's one of the big wins over naive beta-reduction, which doesn't naturally η-expand anything.

🧠 28 🐾 11
🐈
SemanticsSiamese
Senior Theorem Prover
Expert
Joined: 4 years ago
Posts: 3,847

Let me drop some actual Haskell pseudocode for a tiny simply-typed lambda calculus. This won't compile as-is but captures the full structure:

Haskell (pseudocode)-- Types data Ty = Base | Arr Ty Ty -- Syntax data Tm = Var String | Lam String Tm | App Tm Tm -- Semantic domain (values) -- At base type: stuck/neutral terms -- At arrow type: actual Haskell functions! data Val = VNe Ne -- neutral (stuck) term | VLam (Val -> Val) -- a real Haskell function -- Neutral terms = things we can't evaluate further data Ne = NVar String | NApp Ne Nf -- Normal forms (output of reify) data Nf = NfNe Ne -- neutral at base type | NfLam String Nf -- lambda in normal form -- Evaluate into semantic domain eval :: Env -> Tm -> Val eval env (Var x) = lookupEnv x env eval env (Lam x t) = VLam (\v -> eval (extend x v env) t) eval env (App f a) = doApp (eval env f) (eval env a) doApp :: Val -> Val -> Val doApp (VLam f) v = f v doApp (VNe n) v = VNe (NApp n (reify Base v)) -- ^ stuck application: reify the argument -- Counter for fresh variable names supply :: [String] supply = ["x"++show n | n <- [0..]] -- Reify: semantic value -> normal form reify :: Ty -> Val -> Nf reify Base (VNe n) = NfNe n reify (Arr a b) (VLam f) = let x = freshVar () -- fresh name arg = reflect a (NVar x) -- reflect fresh var res = reify b (f arg) -- apply, then reify result in NfLam x res -- Reflect: neutral term -> semantic value reflect :: Ty -> Ne -> Val reflect Base n = VNe n reflect (Arr a b) n = VLam $ \v -> reflect b (NApp n (reify a v)) -- The full NbE pipeline nbe :: Ty -> Tm -> Nf nbe ty tm = reify ty (eval emptyEnv tm)

The magic is in doApp: when you have a real lambda (VLam f), you just apply the Haskell function — that's your beta-reduction done by the host language. When you have a neutral (VNe n), you record the application. The result of nbe is guaranteed to be in normal form purely from the types of reify — you can't construct a Nf that still has redexes.

🧠 52 ⭐ 45 🐾 31
🌀
CubicalCatgirl
HoTT Heretic
Expert
Joined: 3 years ago
Posts: 2,193

Great post above! I want to add the Agda angle since OP asked. Here's a sketch of typed NbE for STLC with unit type — this is closer to how it's actually formalized:

Agda (sketch)module NbE where data Ty : Set where base : Ty _⇒_ : Ty → Ty → Ty -- Contexts as lists of types data Ctx : Set where ε : Ctx _,_ : Ctx → Ty → Ctx -- Neutral and normal forms (intrinsically typed) mutual data Nf (Γ : Ctx) : Ty → Set where lam : Nf (Γ , A) B → Nf Γ (A ⇒ B) ne : Ne Γ base → Nf Γ base data Ne (Γ : Ctx) : Ty → Set where var : A ∈ Γ → Ne Γ A app : Ne Γ (A ⇒ B) → Nf Γ A → Ne Γ B -- Semantic domain: indexed by type Sem : Ctx → Ty → Set Sem Γ base = Nf Γ base -- base type = normal forms Sem Γ (A ⇒ B) = -- arrow type = functions ∀ {Δ} → Γ ⊆ Δ → Sem Δ A → Sem Δ B -- reflect and reify are mutually recursive on types mutual reflect : (A : Ty) → Ne Γ A → Sem Γ A reflect base n = ne n -- neutral at base: already Nf reflect (A ⇒ B) n = λ ρ a → -- neutral at arrow: make a function reflect B (app (wkNe ρ n) (reify A a)) reify : (A : Ty) → Sem Γ A → Nf Γ A reify base nf = nf -- base: already a normal form! reify (A ⇒ B) f = -- arrow: apply to a fresh variable lam (reify B (f wk (reflect A (var here))))

Notice that Sem Γ (A ⇒ B) is a function that takes a weakening Γ ⊆ Δ — this is needed so you can move into extended contexts when generating fresh variables. This is where the presheaf model sneaks in! The semantic domain is actually a presheaf over the category of contexts and weakenings (order-preserving embeddings, or OPEs).

You don't need to know the full category theory to use NbE in practice, but the presheaf structure is what makes it work correctly with variable binding. A presheaf is just a functor from contexts (with OPEs as morphisms) to Set — meaning you can always move semantic values into larger contexts, which is exactly what we need for the fresh-variable trick in reify.

🧠 38 🌀 17 🐾 14
🐾
NbENewbie
Kitten Coder
Newbie
Joined: 3 months ago
Posts: 12

Wait okay the Sem Γ (A ⇒ B) definition is really cool. So at base type, the "meaning" of a term is just its normal form — we don't need to go anywhere. But at arrow type, the meaning is a real function from meanings to meanings, parametrized over a weakening. That's why you can apply it to fresh variables in reify without capturing anything!

And you're saying the whole semantic domain is a presheaf over a category of contexts? So OPEs are the morphisms?

I think I'm starting to see it. Like, the functor law says: if you have a semantic value at context Γ and a weakening Γ ⊆ Δ, you can always get a semantic value at Δ. That's the wk in the Agda code above. And that's what makes the fresh variable trick valid 🤔

My paws are hurting a little but in a good way 🐾

🐾 7 🧠 9
⚙️
InferenceEngine9
Type Inference Specialist
Veteran
Joined: 2 years ago
Posts: 1,204

Why is NbE better than naive beta-reduction? Let me count the ways:

  1. Speed. Beta-reduction may require lots of substitutions, each of which traverses the term. NbE uses closures (environments) so substitution is deferred and often never needs to happen for subterms that aren't scrutinized.
  2. η-expansion is free. As explained above, typed NbE produces η-long forms automatically from the structure of reify.
  3. Correctness by construction. The types of Nf and Ne enforce that the result is normal — you literally cannot represent a redex in those types. With raw beta-reduction you have to prove separately that you didn't miss any redexes.
  4. Scales to dependent types. In Agda/Coq-style dependent types, the type of a normal form can depend on values. NbE handles this naturally because the semantic domain can contain actual computed values. Naive head-reduction strategies get much more complicated here.
  5. Works for proof checking. Proof assistants use NbE (or variants) to decide definitional equality between types during unification. It's at the heart of Agda's kernel, for example.

The tradeoff: NbE is harder to implement correctly, especially once you add things like sum types, universes, or recursive definitions. Sum types in particular require delimited continuations or a different semantic domain. But the payoff for a production type checker is very much worth it.

🧠 44 ⭐ 22 🐾 18
🌀
CubicalCatgirl
HoTT Heretic
Expert
Joined: 3 years ago
Posts: 2,193
NbENewbie wrote:
So OPEs are the morphisms? … And that's what makes the fresh variable trick valid 🤔

Exactly! Let me draw a little diagram for the presheaf structure:

Category 𝒞 of Contexts: Objects: Γ, Δ, Θ, … (typing contexts like [x:A, y:B]) Morphisms: ρ : Γ → Δ (OPEs, aka context weakenings) Presheaf P : 𝒞ᵒᵖ → Set means: • For each Γ, we have a set P(Γ) • For each ρ : Γ → Δ, we have P(ρ) : P(Δ) → P(Γ) (note: contravariant! bigger context → smaller) • Laws: P(id) = id, P(ρ∘σ) = P(σ) ∘ P(ρ) The semantic domain Sem(A) is a presheaf: Sem(A)(Γ) = "meanings of type A in context Γ" Sem(A)(ρ) = "weaken a meaning along ρ" = wk ρ

In typed NbE, the correctness of the algorithm is captured by a Kripke logical relation — essentially a presheaf-valued logical relation that tracks how meanings relate to syntax across weakenings. This is what makes the soundness and completeness proofs go through.

Do you need to know this to use NbE? No, not really. You can absolutely implement it from the code patterns SemanticsSiamese showed without understanding the categorical semantics. But knowing the presheaf model helps you when you're trying to extend NbE to new type formers and you're not sure whether your semantic domain is "right" — the presheaf condition (functoriality) is your sanity check.

For dependent types, the situation is more subtle: in the adaption of NbE to dependent types, special attention is paid to reflection and reification, which are now directed by type-values rather than syntactic types. This is where things get spicy 🌶️

🧠 56 🌀 29 ⭐ 33 🐾 21

✏️ Post a Reply