Sources: code:
passing.jlTheory (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 withcommit = falseagainst 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