implementation

Sources: code: passing.jl

Theory (CT-ML wiki): Bayesian Inversion · Lens

Implements: executing a schedule. step! → sweep! → propagate! → infer!. Theory: Messages are Inversions, Loopy Message Passing.

step! is where the identity lives

For a :to_variable task:

chans  = available_channels(s, g, fid; skip = t.edge)   # ← factor-side exclusion
pol    = resolve_polarity(g, fid, e.channel, chans)
check_legal(g, fid, pol)
prior  = excluded_marginal(s, g, e.variable, t.edge)    # ← variable-side exclusion
belief = factor_message(factor, e.channel, pol, inputs, prior, ps, st)

Five lines, and every one of them is a named thing from the paper: the polarity is the input/output split, the prior is the of , the inputs are the , and factor_message is the inversion.

factor_message’s generic implementation is assemble then invert — the composite of LenticulumCore’s two interface functions. Structural factors override it directly, because for them the lens is trivial and building one would be ceremony.

sweep! has two methods, and the difference is semantic

  • Generic (sequential, tree, forward-backward): executes in place. Later tasks see earlier results within the same sweep, so information crosses a whole path per sweep.
  • FloodingSchedule: double buffered. Every task is computed with commit = false against the previous sweep’s messages, and all results are written afterwards. Information crosses one edge per sweep.

The trade is not just speed. On a loopy graph the sequential order is a modelling choice made without noticing; flooding is order-independent.

Implementation difficulties

1. _inputs is per channel, so step! is

_inputs does a findfirst over the factor’s edges for each requested channel. For a factor of degree that is per message and per factor per sweep. Irrelevant for degree 2–5, wrong for a factor with a hundred channels. A precomputed channel → edge map per factor in FactorGraph would fix it and is the obvious next optimisation.

2. ps/st threading is by name and is not validated

_subtree(ps, name) returns NamedTuple() when the key is absent, so a typo in a factor’s name silently yields empty parameters rather than an error. That is the single most likely way to get a confusingly wrong answer from this file: a factor trains on nothing and nobody says so.

_setsubtree also merges into the state tree on every message, allocating a fresh NamedTuple each time. Correct (states must not be mutated in place if the caller holds the old one) but wasteful in the inner loop.

A validate_parameters(g, ps) pre-flight check — every learnable factor has an entry, no entries without a factor — is missing and would be cheap.

3. Damping is applied after the message is computed, and only to :to_variable

step! damps the outgoing belief against the previous one on that edge. Variable → factor messages are never damped, which is the usual convention (they are deterministic pools of already-damped inputs) but is a convention, not a theorem.

More importantly, damping is a silent no-op for every belief type except numeric-payload Diracs (messages §4). propagate! does not check can_damp before promising damping, so a caller can pass damping = 0.3 and get none.

4. propagate! special-cases TreeSchedule by type

if sched isa TreeSchedule
    ... return ConvergenceReport(true, 1, 0.0, "tree schedule: one sweep is exact")

The claim “one sweep is exact” is true only if the graph really is a tree — which tree_schedule enforced at construction. So the invariant is carried by the type, which is sound but implicit: a hand-constructed TreeSchedule(inward, outward) over a loopy graph would get a false “exact” report. A TreeSchedule carrying the graph it was built for, or a construction-only inner constructor, would close that hole.

5. infer! returns four things

(marginals, report, st, store). The store is returned because [[free_energy|bethe_free_energy]] needs it, but a four-tuple is a poor interface. An InferenceResult struct with named fields is the obvious improvement and was skipped only to avoid inventing a type before knowing what else belongs in it.

6. Cotangents are not here, deliberately

Gradients with respect to parameters are not messages. They are accumulated per factor, governed by each edge’s AbstractGradientCoupling, on a different schedule — once per training step, not once per inference sweep.

optimiser_step is a separate function from step! for exactly this reason. Conflating the two schedules (updating parameters inside a belief sweep) is a classic source of silent bugs, and the separation is the main structural decision in this file.

The gradient machinery itself does not exist yet: no AD backend is a dependency, and AbstractGradientCoupling is still inert (statistical_game §6).

Related: Messages are Inversions, schedules, free_energy, Loopy Message Passing