Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Model Is a Score

A Model in fugue is not a running program. It is a score — pure notation that describes what could happen, note by note, but makes no sound on its own. A handler is the performer that reads the score and decides what each note means: improvise a fresh value, or replay one from a recording.

Below is a real inference problem. Five data points say where the world landed; a prior says what you believed about the mean μ beforehand; the posterior is the answer — updated live. Grab a yellow dot and drag it. The green curve follows in real time. The strip below the picture is the machinery fugue actually steps through to get there.

This run uses seed 11. A seeded run is a replayable trace — the same seed always draws the same μ.

Things to try

  1. Drag any yellow dot to the right. The green posterior slides after it and the posterior mean readout climbs — the data pulled your belief.
  2. Press Step five times. Watch the SampleF64 chip fire first (it sets μ), then each ObserveF64 chip light up and its data dot pulse in the picture — chip and dot are the same event, seen twice.
  3. Press Perform ×200 with the PriorHandler active: 200 fresh μ draws rain down as a blue cloud that traces the prior curve. The handler is improvising.
  4. Flip on the Replay handler and press Perform ×200 again: all 200 draws stack on one value — a single coral spike. The handler is performing a fixed recording, not drawing.
  5. Scrub the seed in prior mode — the coral μ tick jumps to a new draw. Scrub it in replay mode — it does not move. Same score, different performer.

What you just saw

The score is a tiny probabilistic program: sample a mean, then observe five data points drawn from it.

Because both the prior and the likelihood are Gaussian, the posterior over μ is Gaussian too, in closed form — this is the exact green curve you were dragging:

With the five default points (∑yᵢ = 6.0), that is Normal(1.143, 0.436²) — the numbers in the readout. The score never computes this. The handler does, one node at a time, accumulating a log-weight:

The key idea: effects are interpreted, not performed. The score names a sample site; it does not draw. Whether a value is improvised (PriorHandler) or replayed (ReplayHandler) is the handler's decision, made later. That is what the Perform ×200 button shows: the same score becomes a cloud of guesses or a single fixed spike, depending only on who performs it. This separation is the machinery every inference algorithm in fugue is built from — and fugue's MCMC reaches that same green curve without knowing the conjugate formula, by walking this chain over and over.

Monads, demystified in one paragraph

A Model is built from two operations. bind is "and then": run this node, then feed its value to a function that produces the next node. pure is "done": wrap a plain value as a finished model. That is the entire monad — and then and done. Each node stores its continuation k, the rest of the score. The score is a linked list of "and then"s ending in a "done", and it does not play itself.

The fugue code

Here is the exact score from the widget. The sample line is the SampleF64 chip; each observe in the fold is an ObserveF64 chip lighting its yellow dot; .map(move |_| mu) is the trailing Pure(μ).

use fugue::*;
use rand::rngs::StdRng;
use rand::SeedableRng;

// The score: sample the mean, then observe five data points given it.
fn model(data: Vec<f64>) -> Model<f64> {
    sample(addr!("mu"), Normal::new(0.0, 2.0).unwrap()).bind(move |mu| {
        // Fold the observations into the chain: each one is an ObserveF64 node.
        let mut m = pure(mu);
        for (i, y) in data.into_iter().enumerate() {
            m = m.bind(move |mu| {
                observe(addr!("y", i), Normal::new(mu, 1.0).unwrap(), y)
                    .map(move |_| mu)
            });
        }
        m // ends in Pure(mu): the score terminates, returning mu
    })
}

fn main() {
    let data = vec![1.3, 0.7, 2.1, 0.4, 1.5];

    // PriorHandler improvises: it draws a fresh mu from the RNG.
    let mut rng = StdRng::seed_from_u64(11);
    let (mu, recording) = runtime::handler::run(
        PriorHandler { rng: &mut rng, trace: Trace::default() },
        model(data.clone()),
    );
    println!("prior draw: mu = {mu:.3}, total log-weight = {:.3}",
             recording.total_log_weight());

    // ReplayHandler performs that recording: the same mu, no fresh draw.
    let (mu_again, _) = runtime::handler::run(
        ReplayHandler { rng: &mut rng, base: recording, trace: Trace::default() },
        model(data),
    );
    assert_eq!(mu, mu_again); // same score, replayed exactly
}

Under the hood, the score is the Model enum. Every effect variant carries its address, its distribution, and a boxed continuation k — the rest of the score (abridged from src/core/model.rs):

pub enum Model<A> {
    Pure(A),
    SampleF64 {
        addr: Address,
        dist: Box<dyn Distribution<f64>>,
        k: Box<dyn FnOnce(f64) -> Model<A> + Send + 'static>, // "and then"
    },
    ObserveF64 {
        addr: Address,
        dist: Box<dyn Distribution<f64>>,
        value: f64,
        k: Box<dyn FnOnce(()) -> Model<A> + Send + 'static>,
    },
    Factor { logw: f64, k: Box<dyn FnOnce(()) -> Model<A> + Send + 'static> },
    // ... plus SampleBool/U64/Usize/I64 and their Observe twins, one per
    // return type — this is fugue's type-safety story: a Bernoulli site yields
    // a bool, a Poisson site a u64, never an untyped float.
}

The handler you toggled is any type that answers those effects. run is the interpreter — a flat trampoline, not recursion (abridged from src/runtime/handler.rs):

pub fn run<A>(mut h: impl Handler, m: Model<A>) -> (A, Trace) {
    let mut m = m;
    let a = loop {
        m = match m {
            Model::Pure(a) => break a,                    // "done": return the value
            Model::SampleF64 { addr, dist, k } => {
                let x = h.on_sample_f64(&addr, &*dist);    // ask the handler
                k(x)                                       // advance to the rest
            }
            Model::ObserveF64 { addr, dist, value, k } => {
                h.on_observe_f64(&addr, &*dist, value);    // score the data
                k(())
            }
            Model::Factor { logw, k } => { h.on_factor(logw); k(()) }
            // ... one arm per variant ...
        };
    };
    (a, h.finish())
}

Each node hands back its continuation k(value) and the loop goes around again. Nothing recurses, so the interpreter runs in constant stack depth — a model with 100 000 sample sites is interpreted without overflowing the stack.

Go deeper


Next: Random Walks in Posterior Space