example implementation

The example every factor-graph library opens with — GTSAM’s OdometryExample — written against this library. A robot drives along a line, odometry links consecutive poses, one GPS reading lands on the last pose, and the posterior over all poses comes back exact.

It is the smallest problem in which this library does something a Lux.jl pipeline cannot: the same factor is run in both directions and information travels backwards along the chain.

Sources: code: messages.jl, runtests.jl

Theory (CT-ML wiki): Bayesian Inversion · Gaussian Relations · Statistical Game · Bayesian Lens · Variational Free Energy · Lens

1. The model

with clamped to the observed reading . Everything is linear and everything is Gaussian, so the posterior is Gaussian and known in closed form — which is the point: this is an oracle, not a demo.

¼x1odo1x2odo2x3gpszz0

Circles are variables (wires), squares are factors. There is no cycle, so this is a istree(g) == true graph and belief propagation is exact — see Factor Graphs for the two distinct acyclicity notions and why it is istree and not isdag that matters here.

2. Writing it

The whole model is built from one factor type used three times.

using Lenticulum, LenticulumCore, Mycelium
 
μ₀, Σ₀ = [0.0], fill(1.0, 1, 1)             # prior on x₁
u      = ([2.0], [2.0])                     # odometry measurements
Q      = (fill(0.25, 1, 1), fill(0.25, 1, 1))
R      = fill(0.5, 1, 1)                    # GPS noise
z₀     = [4.5]                              # the GPS reading
Id     = fill(1.0, 1, 1)
 
b = GraphBuilder()
for v in (:x1, :x2, :x3, :z); variable!(b, v, 1); end
 
factor!(b, :prior, GaussianPrior(:x1, μ₀, Σ₀))
factor!(b, :odo1, GaussianFactor(1 => 1; noise = Q[1], channels = (:x, :y)))
factor!(b, :odo2, GaussianFactor(1 => 1; noise = Q[2], channels = (:x, :y)))
factor!(b, :gps,  GaussianFactor(1 => 1; noise = R,    channels = (:x, :y)))
factor!(b, :data, DataFactor(:z, z₀))
 
connect!(b, :prior, :x1, :x1; direction = Bidirectional())
connect!(b, :odo1, :x, :x1; direction = Bidirectional())
connect!(b, :odo1, :y, :x2; direction = Bidirectional())
connect!(b, :odo2, :x, :x2; direction = Bidirectional())
connect!(b, :odo2, :y, :x3; direction = Bidirectional())
connect!(b, :gps,  :x, :x3; direction = Bidirectional())
connect!(b, :gps,  :y, :z;  direction = Bidirectional())
connect!(b, :data, :z, :z;  direction = Emitting())
 
g = validate(build(b))
 
ps = (prior = NamedTuple(), odo1 = (A = Id, b = u[1]), odo2 = (A = Id, b = u[2]),
      gps = (A = Id, b = [0.0]), data = NamedTuple())
st = (prior = NamedTuple(), odo1 = NamedTuple(), odo2 = NamedTuple(),
      gps = NamedTuple(), data = NamedTuple())
 
marg, report, st, store = infer!(g, tree_schedule(g), ps, st)

and that is the whole program. marg.x1, marg.x2, marg.x3 are GaussianBeliefs; marg.z is the DiracBelief the clamp put there.

The odometry factor is the Gaussian factor with

is GaussianFactor(1 => 1; noise = Q[k]) with and . GTSAM calls this a between factor and gives it its own class; here it is a parameter choice, and sits in ps where a neural network’s weights would. Nothing marks it as a measurement rather than a learnable offset — see §6.

The unary measurement factor is that factor plus a clamp

GTSAM’s GPS factor is unary: it attaches to alone and carries inside itself. This library refuses that, on the grounds of Everything is a Factor: a variable is a wire and has no content of its own, whereas data is evidence, and evidence is a factor. So the measurement becomes two nodes and an auxiliary variable,

with DataFactor(:z, z₀) supplying the hard clamp of Channels and Polarity. This costs one variable and buys three things:

  1. the noise model and the datum are separately addressable — swap the clamp for a different reading and nothing else changes;
  2. unclamping turns the measurement into a prediction with no code change (see §5);
  3. the clamp edge is Emitting(), so schedule pruning never even tries to compute a message back into it.

3. What comes out, and against what

meanvariance

The oracle is what GTSAM actually computes. Stack the poses into ; each factor contributes a quadratic , so the joint is one information form

with , and . That is the sparse Hessian a nonlinear factor-graph library builds by linearisation, and its tridiagonality is the chain structure written down — a loop closure is exactly what would put entries in the corners. Here it is dense and unapologetic, because being obviously right matters more than being fast in an oracle.

The test asserts equality, not closeness

belief_cov(marg[v])[1] ≈ Σ[i,i] holds to floating-point rounding, for every pose and at every chain length with inhomogeneous . On a tree, BP is not an approximation to elimination; it is elimination, reorganised.

These are smoothed marginals

, half its prior . Nothing in the model tells about the GPS reading directly — the information travelled

which is the backward sweep of tree_schedule, and requires each odo factor to be run with observed and unobserved — the opposite of the direction it was written in. A filter would report ; a smoother reports . Getting the smoother for free from the schedule, rather than as a second algorithm, is the payoff of Messages are Inversions.

This is why factors are relations, not functions

A Lux.jl layer has one direction and its reverse pass carries a gradient. odo1 here has two directions and both carry posteriors. Running it backwards is not differentiating it — it is assemble-ing a different Inversions and Bayesian Lenses out of the same factor, and it is exact.

4. The evidence falls out too

scalar_free_energy on the converged store returns

for the numbers above. The graph is never told this. It sums local energies and local entropies and applies the counting correction, and the marginal likelihood of the GPS reading is what is left. This is AutoBayes Remark 24 (see Factors are Parameterized Statistical Games) surviving a generalisation the paper does not make: from a two-factor composite to an -factor graph, via Bethe Free Energy.

The counting correction is doing real work here, and the chain is the cleanest place to see why. Every variable has degree exactly , so

Each of the two factors touching a variable charges in its own negentropy summand, so the shared entropy is counted twice; the correction adds one copy back. Drop it and is wrong by — on a chain, wrong by an amount that grows with , which is what makes a chain a better test than the two-factor model of gaussian.md §4, where the error is a single term and easy to mistake for a constant.

5. Turning the crank the other way

Unclamp the measurement and the same graph predicts instead of estimating. Delete the DataFactor and marg.z becomes the predictive distribution — the pushforward of the prior along the whole chain, noise accumulated at every step. Clamp instead of and it becomes dead reckoning. Clamp both ends and it is a bridge.

None of these are separate code paths. They are the same graph with the clamps moved, and Polarity Resolution works out which direction each factor must be run in from which messages have arrived — a fact about the graph at that moment, not a fact about the model. This is the property the README calls learning relations rather than functions, and the chain is the smallest example where it is visible.

6. What this is not

This is a linear factor graph, not a SLAM system

GTSAM’s real value is in the layer above this one, and none of it is here.

  • No nonlinearity. A Pose2 chain has in the residual, and a real library linearises at the current estimate and iterates (Gauss–Newton, Levenberg–Marquardt, Dogleg). This library has no relinearisation loop; GaussianFactor is linear by construction. Iterating BP on a linearised graph and iterating the linearisation point are different loops, and only the first exists here.
  • No manifolds. Poses live on /; beliefs would need to be Gaussians in a tangent space with a retraction, and combine would need to agree about which tangent space. GaussianBelief is flat.
  • No loops. A chain is a tree. Close the loop — the actual reason SLAM is hard — and istree(g) goes false, tree_schedule is no longer available, and everything in §3 becomes approximate. See Loopy Message Passing.
  • No ordering. GTSAM’s performance is variable ordering (COLAMD, nested dissection) and incremental updates (iSAM2). Mycelium has message schedules, which are the same information organised differently, and no elimination-order heuristics at all.
  • The odometry measurements are parameters. sits in ps and islearnable(::GaussianFactor) is true, so a gradient step would happily edit the odometry readings to fit the GPS better. Nothing in the type system distinguishes “measured input” from “learnable weight”. That is the honest state of the library, not a design position, and it is the gap Everything is a Factor is pointing at when it says data should be factors: ought to be a clamped variable on a third channel, not an entry in ps.

7. What this example caught

Two things, both of which passed on the two-factor model in runtests.jl:

  1. combine was ambiguous on (TrivialBelief, GaussianBelief). messages.jl had combine(::TrivialBelief, b) (untyped second argument) alongside the catch-all combine(a::AbstractBelief, b::AbstractBelief) that throws. Neither is more specific than the other, so Julia reported a MethodError: ... is ambiguous from inside excluded_marginal — i.e. the unit law for message pooling was unreachable for the one belief type that can actually be pooled. Fixed by restating the unit law at the AbstractBelief level. The failure was total rather than subtle only by luck: had the catch-all returned something instead of throwing, the ambiguity would have resolved silently in whichever order the methods were defined.

  2. The chain is where a missing counting correction stops looking like a constant. See §4.

What the chain’s depth buys

Expressively, nothing. A chain of linear-Gaussian factors eliminates to a single linear-Gaussian relation, so this graph represents exactly the joint Gaussian a flat model would — depth adds no model class. What it adds is sparsity: the block-tridiagonal precision of §3, which is why inference is rather than .

That is the linear-Gaussian case of a general question, and the general answer is different — see Depth in Implicit Learning.

The continuous version

Everything above discretises time by hand: one variable per pose, one factor per interval. The continuous alternative — a variable that carries a trajectory, with a Gauss–Markov prior — is designed in Time as a Base, and the connection is exact rather than analogical:

A Gauss–Markov process sampled at has a block-tridiagonal joint precision, which is precisely the precision of this chain.

So this graph is not merely like a continuous-time trajectory estimate; it is what one collapses to at a fixed set of query times. The operation the continuous version adds is interpolation — reading at a time no factor mentions — which is exactly what §6’s “no ordering” and the asynchronous-measurement problem need.