Expand description
§program module
Requires the program cargo feature:
fugue-ppl = { version = "0.2", features = ["program"] }.
§Overview
A fugue model is ordinarily Rust closures, so it exists only inside the
binary that compiled it: it cannot be stored, shipped, diffed, or loaded at
runtime. This module makes a model data. A Program is a small
prob!-subset language with a serializable AST, two front ends — the text
syntax and JSON — and an interpreter that folds it into fugue’s own
combinators (sample, observe, factor, pure, bind) at load time.
Only model construction is interpreted: the handlers, traces, MH/HMC/SMC
kernels and diagnostics that run the result are the real crate.
text ── Program::parse ─────┐
JSON ── Program::from_json ─┴──> Program (plain data: Clone, PartialEq, serde)
── compile(&Registry, &Data) ──> CompiledProgram
── build() ──> Model<Value> (for any handler or inference driver)A host extends the language through the Registry: its own distributions
(any Distribution<T>, including wrapper types that carry site metadata)
and pure functions, callable by name from programs.
§Quick start
use fugue::program::{Data, Program, Registry};
use fugue::*;
use rand::{rngs::StdRng, SeedableRng};
let program = Program::parse(r#"
let p <- sample(addr!("p"), Beta(2.0, 2.0));
for i in 0..flips.len() {
observe(addr!("flip", i), Bernoulli(p), flips[i]);
}
pure(p)
"#).unwrap();
// The same program as JSON, and back: structurally equal.
let json = program.to_json().unwrap();
assert_eq!(Program::from_json(&json).unwrap(), program);
let data = Data::from_json(r#"{"flips": [1, 0, 1, 1, 0, 1, 1, 0, 1, 1]}"#).unwrap();
let compiled = program.compile(&Registry::new(), &data).unwrap();
// A real Model, for any handler or inference driver.
let mut rng = StdRng::seed_from_u64(11);
let draws = adaptive_mcmc_chain(&mut rng, || compiled.build(), 2000, 500);
let mean = draws.iter().filter_map(|(_, t)| t.get_f64(&addr!("p"))).sum::<f64>()
/ draws.len() as f64;
assert!((mean - 9.0 / 14.0).abs() < 0.05); // Beta(2 + 7, 2 + 3)§The text syntax
Statements end with ;; a program ends with pure(expr).
program := stmt* "pure" "(" expr ")" ";"?
stmt := "let" "mut"? NAME "<-" "sample" "(" addr "," dist ")" ";" draw a site
| "let" "mut"? NAME "=" expr ";" bind a variable
| NAME "=" expr ";" reassign one
| "observe" "(" addr "," dist "," expr ")" ";" condition on data
| "factor" "(" expr ")" ";" add a log-weight
| "for" NAME "in" expr ".." expr block loop
| "if" expr block ("else" (block | if-statement))? branch
| "break" ";" leave the loop
block := "{" stmt* "}"
addr := "addr" "!" "(" STRING ("," expr)? ")"
dist := NAME ("::" "new")? "(" (expr ("," expr)*)? ")" ("." "unwrap" "(" ")")?
expr := expr BINOP expr | ("-" | "!") expr | postfix
postfix := primary ("[" expr "]" | "." "len" "(" ")")*
primary := NUMBER | "true" | "false" | NAME | NAME "(" exprs ")"
| "[" exprs "]" | "(" expr ")"Operators bind as in Rust, tightest first: prefix - and !; * /;
+ -; the comparisons == != < <= > >= (which do not chain:
a < b < c is a syntax error); &&; ||. Binary operators associate to
the left, and &&/|| short-circuit.
Lexically: // starts a comment; 2 is an integer literal and 2.0 or
1e-3 a float; strings (address names) take Rust’s escapes (\\ \" \' \n \r \t \0). Rust spellings paste in unchanged — Normal::new(0.0, 1.0).unwrap()
reads as Normal(0.0, 1.0) and let mut x as let x (every variable is
assignable). let, mut, observe, factor, for, in, if, else,
break, pure, true and false are reserved; every other name is an
ASCII identifier.
§Semantics
Scoping is Rust’s. A let (or let .. <- sample) binds a new variable
from that point to the end of the enclosing block, shadowing any outer
variable of the same name until then; a loop variable is scoped to the loop
body. An assignment name = expr; updates the binding name currently
refers to, so an assignment to an outer variable persists across loop
iterations and after the loop, while a let inside a body never leaks out.
Assigning a name that nothing binds is a compile error. Data names are bound
in an outer scope, like variables declared before the program.
// Sample until the first success; carry a counter out of the loop.
let compiled = Program::parse(r#"
let tries = 0;
for i in 0..10 {
let hit <- sample(addr!("hit", i), Bernoulli(0.3));
tries = tries + 1;
if hit { break; }
}
pure(tries)
"#).unwrap().compile(&Registry::new(), &Data::new()).unwrap();
let (tries, trace) = runtime::handler::run(
PriorHandler { rng: &mut StdRng::seed_from_u64(4), trace: Trace::default() },
compiled.build(),
);
// Only the iterations that ran have sites.
assert_eq!(Some(trace.choices.len()), tries.as_usize());Loops. for i in a..b evaluates both bounds once, on entry; i is an
integer; break leaves the innermost loop.
Values. Every value is a Value: F64, Int (i64), U64, Usize,
Bool, or Arr (an array of numbers, from data or a literal [a, b, c]).
Sample sites keep their natural types and bind them: Bernoulli binds a
Bool, Poisson/Binomial a U64, Categorical a Usize,
DiscreteUniform an Int, continuous distributions an F64. The trace
records the same types (trace.get_bool, get_u64, get_usize, …).
| construct | rule |
|---|---|
+ - * | exact on integers (overflow is an error, not a wrap); float if either side is a float |
/ | always float division (7 / 2 is 3.5) |
| comparisons | numbers compare numerically (exactly when both are integers), bools with bools (false < true); NaN is unordered |
&& || !, if | take bools, or numbers as “nonzero”; NaN has no truth value |
| numeric positions | a bool reads as 0/1, as in the playground (2.0 * flag) |
observe | converts the value to the site’s type: Bernoulli takes a bool or a number (nonzero is true), counts and indices must be non-negative integers |
a[i], a.len() | i an integral number in bounds; .len() is an Int |
A compiled program builds a Model<Value>. For a typed result, map it with
the accessors (Value::as_bool, as_u64,
as_usize, …) or use
build_f64, which is the playground’s
Model<f64>.
§The JSON form
A program serializes to one JSON object: the format version, the statements,
and the return expression. Statements are objects tagged by "stmt",
expressions objects tagged by "op":
"stmt" | fields |
|---|---|
"sample" | var, addr, dist |
"let", "assign" | var, value |
"observe" | addr, dist, value |
"factor" | logw |
"for" | var, start, end, body |
"if" | cond, then, and else (omitted when empty) |
"break" | — |
"op" | fields |
|---|---|
"f64", "int", "bool" | value |
"var" | name |
"array" | items |
"index" | array, index |
"len" | array |
"neg", "not" | arg |
"add" "sub" "mul" "div" "eq" "ne" "lt" "le" "gt" "ge" "and" "or" | lhs, rhs |
"call" | func, args |
An address is {"name": .., "index": expr} (index optional), a
distribution call {"name": .., "args": [..]}. The coin-flip program above,
written as JSON:
let json = r#"{
"fugue_program": 1,
"body": [
{"stmt": "sample", "var": "p", "addr": {"name": "p"},
"dist": {"name": "Beta", "args": [{"op": "f64", "value": 2.0}, {"op": "f64", "value": 2.0}]}},
{"stmt": "for", "var": "i",
"start": {"op": "int", "value": 0},
"end": {"op": "len", "array": {"op": "var", "name": "flips"}},
"body": [
{"stmt": "observe",
"addr": {"name": "flip", "index": {"op": "var", "name": "i"}},
"dist": {"name": "Bernoulli", "args": [{"op": "var", "name": "p"}]},
"value": {"op": "index", "array": {"op": "var", "name": "flips"},
"index": {"op": "var", "name": "i"}}}
]}
],
"ret": {"op": "var", "name": "p"}
}"#;
let text = r#"
let p <- sample(addr!("p"), Beta(2.0, 2.0));
for i in 0..flips.len() {
observe(addr!("flip", i), Bernoulli(p), flips[i]);
}
pure(p)
"#;
assert_eq!(Program::from_json(json).unwrap(), Program::parse(text).unwrap());The two front ends are equally expressive: every program either reads can be
written by the other (Display on a Program prints its canonical text).
Reading is strict:
- Versioned.
fugue_programis the format version (FORMAT_VERSION, currently 1). It is checked before anything else is decoded, so a program from a newer format fails withProgramError::UnsupportedVersionrather than with whatever node its newer syntax trips over first. - No unknown fields. A misspelt key is an error, not a silently different program.
- Exact numbers. Float literals round-trip bit for bit (and must be
finite: JSON has no
NaN). Names are identifiers, as in the text syntax; address names are arbitrary strings. - Bounded nesting. A program may nest at most
MAX_NESTINGlevels, counted as the depth of its JSON (one per expression node, two per block). Both front ends andProgram::compileenforce the same limit — the text parser as it reads, so a too-deep program is a syntax error with a line — so anything one front end accepts the other does too (serde_json refuses deeper documents), and hostile input cannot exhaust the parser’s or the interpreter’s stack. Long sums belong in a loop:for i in 0..n { s = s + x[i]; }.
§The registry
Registry::new holds the built-in distributions — Normal, Uniform,
LogNormal, Exponential, Beta, Gamma, InverseGamma, StudentT,
Cauchy, Laplace, Weibull, ChiSquared, Bernoulli, Binomial,
Poisson, Categorical, DiscreteUniform, with fugue’s constructors and
parameterizations — and the functions exp, ln/log, sqrt, abs,
floor, sin, cos, tanh, pow, min, max (on f64).
Categorical takes its probabilities as arguments or as one array of any
length: Categorical(0.2, 0.8), Categorical(probs),
Categorical([p, 1.0 - p]).
A host registers its own. A distribution constructor receives the argument
Values and returns a HostDist — any boxed Distribution<T> at one of
the five site types, so it can be a wrapper that carries whatever the host’s
handler needs to know about the site. A function receives values and returns
one.
use fugue::program::{Arity, Data, HostDist, Program, Registry, SiteType, Value};
use fugue::*;
use rand::{rngs::StdRng, RngCore, SeedableRng};
/// A host's decision site: the habit's predictive at this context, plus the
/// context itself, for a harness that resolves sites.
#[derive(Clone)]
struct Habit {
predictive: Categorical,
last_tool: usize,
failed: bool,
}
impl Distribution<usize> for Habit {
fn sample(&self, rng: &mut dyn RngCore) -> usize {
self.predictive.sample(rng)
}
fn log_prob(&self, x: &usize) -> f64 {
self.predictive.log_prob(x)
}
fn clone_box(&self) -> Box<dyn Distribution<usize>> {
Box::new(self.clone())
}
}
let mut registry = Registry::new();
registry
.register_distribution("Habit", SiteType::Usize, Arity::Exact(2), |args| {
let last_tool = args[0].as_usize().ok_or("Habit: the last tool is an index")?;
let failed = args[1].as_bool().ok_or("Habit: `failed` is a bool")?;
let probs = if failed { vec![0.7, 0.2, 0.1] } else { vec![0.1, 0.3, 0.6] };
let predictive = Categorical::new(probs).map_err(|e| e.to_string())?;
Ok(HostDist::Usize(Box::new(Habit { predictive, last_tool, failed })))
})
.register_function("is_finish", Arity::Exact(1), |args| {
Ok(Value::Bool(args[0].as_usize() == Some(2)))
});
let flow = Program::parse(r#"
let last = 0;
let failed = false;
for step in 0..10 {
let tool <- sample(addr!("decide", step), Habit(last, failed));
if is_finish(tool) { break; }
let ok <- sample(addr!("outcome", step), Bernoulli(0.9));
last = tool;
failed = !ok;
}
pure(last)
"#)
.unwrap()
.compile(®istry, &Data::new())
.unwrap();
let (last, trace) = runtime::handler::run(
PriorHandler { rng: &mut StdRng::seed_from_u64(1), trace: Trace::default() },
flow.build(),
);
assert!(last.as_usize().is_some());
assert!(trace.get_usize(&addr!("decide", 0)).is_some());Names resolve at compile time: an unknown distribution or function is a
ProgramError::Check, and so is a wrong argument count (Arity). The
declared SiteType gives every site a fixed type even when its
distribution cannot be built (see below). Registered code must be
deterministic in its arguments — replay and scoring rebuild the same site
many times — and must not panic.
§Error policy
Static errors are ProgramErrors, returned before any model exists:
syntax errors (with a line number), JSON decoding errors, an unsupported
version, and check errors — unknown variables, distributions or functions,
assignment to an unbound name, wrong arities, break outside a loop,
non-finite literals, non-identifier names, nesting beyond the cap — and
malformed data.
Runtime errors are soft. Model-building code runs inside bind
continuations, where an error cannot propagate as a Result (and wasm has
no unwinding), so evaluation is total: an index out of bounds, a type error,
integer overflow, invalid distribution parameters, a registered
constructor’s or function’s Err, an observed value outside its site’s type
— each adds factor(-inf) (“this region of parameter space is impossible”),
records a warning (CompiledProgram::take_warnings), and lets the
execution continue:
- a failed
letor assignment bindsNaN; a failedifcondition orforrange skips the statement; a failedobserveorfactorcontributes only the-inf; a failedpurereturnsNaN; - a
samplewhose distribution cannot be built still records its site, at its address and with its declared type — a standard-normal draw forf64sites,false/0for the discrete types — so traces keep a stable shape for replay and scoring. (A sample whose address cannot be built records nothing.)
So a proposal into an invalid region is simply rejected by MH, weighted out by SMC, and never crashes the host. As for any fugue model, a program that samples the same address twice in one execution is a modeling error that the fast handlers report by panicking.
§Addresses
addr!("name") builds its address with fugue’s make_name, and
addr!("name", i) with make_indexed, formatting the index with the
Display of its natural type — so every address is byte-identical to what
addr! builds in compiled Rust, escaping included, and a trace from a
program lines up with one from the equivalent Rust model:
let compiled = Program::parse(r#"
let k <- sample(addr!("k"), Poisson(2.0));
let a <- sample(addr!("x", k), Normal(0.0, 1.0));
let b <- sample(addr!("y", 0.5), Normal(0.0, 1.0));
let c <- sample(addr!("a#1"), Normal(0.0, 1.0));
let d <- sample(addr!("layer::w", 3), Normal(0.0, 1.0));
pure(0)
"#).unwrap().compile(&Registry::new(), &Data::new()).unwrap();
let (_, t) = runtime::handler::run(
PriorHandler { rng: &mut StdRng::seed_from_u64(0), trace: Trace::default() },
compiled.build(),
);
let k = t.get_u64(&addr!("k")).unwrap();
for a in [addr!("x", k), addr!("y", 0.5), addr!("a#1"), scoped_addr!("layer", "w", "{}", 3)] {
assert!(t.choices.contains_key(&a), "{a}");
}A scope is part of the name (addr!("layer::w", 3) is
scoped_addr!("layer", "w", "{}", 3) for a scope without # or \).
§Data
Data binds named values as variables: Data::from_json reads a JSON
object of arrays and scalars (or a bare array, bound to data, as the
playground sends it); Data::new().with(name, value) builds one in
Rust.
§Where it is used
The docs site’s playground and WASM-backed explorables (crates/fugue-wasm)
compile their editable models with this module.
Structs§
- Addr
- A site address:
addr!("name")oraddr!("name", index), built with the same encoding as fugue’saddr!macro. - Compiled
Program - A checked program bound to its data, ready to build models.
- Data
- Named values bound as variables when a program is compiled: the observed data, and any scalar settings a host wants to pass in.
- Dist
Call - A distribution constructor call: a registered distribution’s name and its
argument expressions (
Normal(mu, 1.0)). - Program
- A program: statements and a return expression, plus the format version.
- Registry
- The distributions and pure functions programs can call, by name.
Enums§
- Arity
- How many arguments a registered distribution or function takes. Checked when a program is compiled.
- BinOp
- The binary operators, for building and inspecting
Exprgenerically. - Expr
- An expression. The JSON form is an object tagged by
"op". - Host
Dist - A distribution built by a registered constructor, boxed at its site type.
- Program
Error - Why a program could not be read, written or compiled.
- Site
Type - The value type of a sample site: which
Modelsample variant (and whichChoiceValue) the site uses. - Stmt
- A statement. The JSON form is an object tagged by
"stmt". - Value
- A value computed by a
Program.
Constants§
- FORMAT_
VERSION - The program format version this build reads and writes (the
fugue_programfield of every serializedProgram). - MAX_
NESTING - How deeply a program may nest, counted as the nesting depth of its JSON form (every expression node is one level, a block is two).