The Message Interface and Higher-Order Programs

one runtime, many algorithms · sample and observe as messages · closures · recursion · dynamic traces

Javier Burroni

2026-06-26

Part 1: One Runtime, Many Algorithms

Three algorithms, three evaluators

Likelihood weighting, Metropolis-Hastings, and sequential Monte Carlo were each written as their own evaluator. Compare what they do at each expression form:

expression LW MH SMC
let, if, application, … identical identical identical
sample draw from prior reuse or propose by address draw per particle
observe add \(\log p_d(y)\) add to trace score weight, resample, fork

Only sample and observe differ. Everything else is evaluated the same way, every time.

A program is mostly inference-agnostic

If only two expression forms are algorithm-specific, the evaluator should not bake in an algorithm. It should run the deterministic computation and hand control to a controller at each sample and observe.

The message interface

resume(m) runs the machine until the next probabilistic effect, then returns a message:

return ('sample',  address, distribution, m)         # reached (sample d)
return ('observe', address, distribution, value, m)  # reached (observe d v)
return ('done',    value, m)                          # program finished

The controller replies by pushing a value, and resumes:

def send(m, value):
    m.V.append(value)
resume(m)

The control stack C and value stack V inside m are everything left to do: the machine is the continuation. Pausing is returning from resume; resuming is calling it again; forking is copying the stacks.

Same control state, two spellings

The book writes the continuation in continuation-passing style, as a function k. Our evaluator holds the same control state as explicit stacks.

Book / Chapter 6 Notebook implementation
continuation function k stack-machine state (C, V, env, rng, addr)
resume by calling k(x) push x onto V, call resume(m)
pause at sample return ('sample', address, distribution, m)
pause at observe return ('observe', address, distribution, observed_value, m)
fork a continuation copy / fork the machine stacks
inference handles effects controller handles messages
model written once one runtime, used by LW, SMC, and MH controllers

The new idea is not how to evaluate let or if; it is where inference lives: outside the evaluator, as a controller over messages.

Not a new mechanism

CPS is the book’s representation; the stack machine is the course implementation. Both name the rest of the computation.

Question 1

LW, MH, and SMC were three separate evaluators; now one runtime serves all three. What makes that possible?

A) The evaluator handles ordinary computation; the controller handles only sample and observe.

B) All three algorithms use the prior at sample, and differ only in how they weight traces.

C) Each algorithm installs a different handler for every expression form.

D) Since all target the same posterior, their executions must have the same structure.

Question 2

Single-site MH and SMC can both be implemented by re-running the program from the start at each step, conditioning on stored values. What does pausing and forking a live execution mainly buy over that?

A) It changes the stationary distribution from the prior to the posterior.

B) It keeps the residual computation alive, so the controller does not replay the prefix before the next effect.

C) It removes the need for stable addresses, because the stack already stores all random choices.

D) It makes mutation safe, because forked executions share the same heap.

Why pause and fork, not re-run

A controller could instead re-run the program from the start at each step, conditioning on stored values. That is what made earlier implementations expensive:

single-site MH : re-run the whole program to change one site
SMC            : re-run from the top at every observe  ->  quadratic in observations

Pausing keeps the partial execution; forking copies it and continues both halves. Re-execution of the deterministic prefix disappears, and SMC becomes linear.

Forking is cheap here for a reason

Outside sample and observe the language has no side effects, so a fork never has to copy mutable state. Immutability is what makes copying the stacks enough.

Activity 1: implement the SSMH controller

Single-site MH is a third controller over the same runtime. It keeps an address-keyed trace, resamples one site per step, and reuses the rest by address. The dimension-corrected ratio is given as mh_log_alpha(...): the point is not to rederive it, but to see that the controller has enough information to compute it from the two address-keyed traces.

def run(program, rng, x0, cache):
    m = initial_machine(program, rng); X, S, O = {}, {}, {}
    while True:
        msg = resume(m); tag = msg[0]
        if tag == 'sample':
            _, a, d, m = msg
            x = ...                          # resample the selected/new site, else reuse cache[a]
        elif tag == 'observe':
            _, a, d, y, m = msg
            O[a] = log_prob(d, y); m.V.append(y)
        elif tag == 'done':
            _, value, m = msg; return value, X, S, O

In your notebook, write the sample reply policy and the accept/reject loop.

x = d.sample(rng) if (a == x0 or a not in cache) else cache[a]         # reuse by address

value, X, S, O = run(program, rng, None, {})                           # initial trace
for _ in range(steps):
    a0 = list(X)[rng.integers(len(X))]                                 # pick one site to change
    v2, X2, S2, O2 = run(program, rng, a0, X)                          # propose; reuse the rest
    if np.log(rng.random()) < mh_log_alpha(X, X2, S, S2, O, O2, a0):
        value, X, S, O = v2, X2, S2, O2                                # accept

The address is what lines up the current and proposed executions: a site keeps its name across runs, so reuse and proposal target the same random choice.

Part 2: Closures and Recursion

A model the first-order language cannot express

(defn geom []
  (if (sample (bernoulli 0.3))
      0
      (+ 1 (geom))))
(geom)

Each call draws one coin; a failure recurses and draws again. The number of sample calls is decided by the run itself, and it differs from run to run.

So the model has no fixed list of random variables: it denotes an unbounded number of them. The program can no longer be compiled to a graph, because the graph would need infinitely many nodes, and eager evaluation of the if would never terminate.

Why the message runtime still works

Graph compilation fails, but evaluation does not. The reason is simple:

Every terminating run is finite

However many times geom might recurse, any run that finishes has made finitely many sample calls. Evaluation selects that finite subset of variables as it goes, and resume loops until done no matter how many messages arrive.

Likelihood weighting needs only complete runs, so it transfers unchanged: draw at each sample reached, score at each observe, read the return value. Each terminating run generates its own finite trace. The SMC controller uses the same interface, but it assumes particles reach a compatible sequence of observe breakpoints.

A function value is a closure

Recursion needs defn to refer to itself; first-class functions go further, letting a function be returned and used elsewhere:

(let [make-shift (fn [mu] (fn [x] (+ x mu)))
      f          (make-shift 10)]
  (f 3))            ; => 13

The returned f still needs mu = 10 after make-shift has returned.

Code plus environment

A function value is code plus the lexical environment where it was created: a closure. Free variables resolve where the function was written, not where it is called.

Addressing a moving target

A controller matches choices across runs by address. With dynamic control flow, a flat counter breaks: a different branch renumbers every site after it.

The runtime instead names each effect by the path the machine took to reach it: which binding, which branch, which argument, which call. That structural address keeps a site’s name stable across runs even when the trace changes shape, which is exactly what single-site MH needs to reuse and propose.

It also exposes a hazard: if a proposal makes the run take a different branch, a site that the current trace addressed may not be reached at all in the proposed run.

Question 4

The geom program can call sample an unbounded number of times, so the model denotes infinitely many possible random variables and cannot be compiled to a finite graph. Yet likelihood weighting runs on it unchanged. Why?

A) LW caps the recursion at a fixed depth, so only finitely many sample sites are ever reached.

B) LW integrates out the unreached random variables analytically before scoring the weight.

C) Each terminating run makes only finitely many sample calls, and LW needs only one complete run at a time: draw at each site reached, score at each observe.

D) All possible recursive sample calls share one address, so LW collapses them into a single draw.

Question 5

(let [make-shift (fn [mu] (fn [x] (+ x mu)))
      f          (make-shift 10)]
  (f 3))          ; => 13, though mu is not in scope at the call site

(f 3) returns 13 even though mu is not bound where f is called. This works because:

A) mu is promoted to a global binding when make-shift returns.

B) Free variables are resolved in the caller’s environment at the moment of application.

C) The evaluator rewrites the returned function body by replacing mu with 10.

D) The returned function carries the environment where it was defined.

Question 6

When applying a closure, which environment should be extended with the argument bindings?

A) The caller’s current environment.

B) The global environment.

C) The closure’s captured environment.

D) A fresh empty environment.

Activity 2: implement closure application

The closure-application case of the evaluator is incomplete. new_env starts from the closure’s captured environment; the step that binds the formal parameters to the actual arguments is missing.

elif t == 'callk':
    _, n, addr = instr
    args = [V.pop() for _ in range(n)][::-1]; f = V.pop()
    if isinstance(f, Closure):
        ...                                 # <- your code
    else:
        V.append(f(*args))

In your notebook, write the binding step.

for p, arg in zip(f.params, args): new_env[p] = arg

The new environment extends the closure’s captured env, not the caller’s. That is why (fn [x] (+ x mu)) still sees mu when called from a context where mu is unbound: the closure brought it along.

Next class

Reading

  • Chapter 7.1: higher-order programs with static support; the unnormalized density as a side effect
  • Chapter 7.2: Hamiltonian Monte Carlo, why gradients help
  • Chapter 7.3: automatic differentiation, reverse-mode intuition

Focus while reading

  • what static support buys, and what it rules out
  • how a program comes to compute its own log density
  • why gradients of that density make better proposals
  • what automatic differentiation does to a deterministic computation

Summary

One story in two parts

The message interface separates model execution from inference: resume emits sample and observe messages, and LW, MH, and SMC are three reply policies over one runtime. Closures and recursion let a program decide its own execution structure at run time. The interface handles them without change, because every terminating run is finite and resume loops until done, however many effects arrive.

Answer key

Solutions

  • Q1: A. Ordinary computation is algorithm-independent; controllers differ only in their replies to sample and observe. B coincides only partly (single-site MH may propose off the prior), C negates the abstraction, D confuses a shared target with a shared execution structure.
  • Q2: B. Pausing keeps the residual computation alive and avoids replaying the prefix; in SMC this removes the quadratic replay. A confuses efficiency with correctness, C is false (MH still needs stable addresses), D is backwards (forking is cheap because the language is pure outside the effects).
  • Activity 1: x = d.sample(rng) if (a == x0 or a not in cache) else cache[a]; the driver picks one address, re-runs reusing the rest, and accepts on mh_log_alpha.
  • Q4: C. A terminating run makes finitely many sample calls, and LW needs only complete runs. A invents a depth cap, B integrates out nothing (unreached variables simply do not occur), D is addressing nonsense.
  • Q5: D. The returned function carries its defining environment, so mu resolves there. A invents a global, B is dynamic scope, C is source rewriting (the evaluator does not rewrite bodies).
  • Q6: C. Closure application extends the closure’s captured environment with the argument bindings. A (caller’s) is dynamic scope, B (global) loses local lexical bindings, D (fresh) loses free variables.
  • Activity 2: for p, arg in zip(f.params, args): new_env[p] = arg, extending the closure’s captured env, not the caller’s.