Mycelium

Factor graphs and message passing — how factors are wired and in what order they talk. This is the layer with no counterpart in Lux, because in Lux the wiring is a DAG and the order is implied by it.

Building a graph

b = GraphBuilder()
variable!(b, :x, 1)
variable!(b, :y, 1)
factor!(b, :f, some_factor)
connect!(b, :f, :in, :x; direction = Bidirectional())
connect!(b, :f, :out, :y; direction = Bidirectional())
g = validate(build(b))

validate checks the structure: every edge names a channel its factor actually declares, nothing is isolated, and dangling outputs are reported.

Edges are directed, and the direction restricts which messages are legal:

directionthe factor can
Bidirectional()both send and receive
Emitting()only send (a data clamp)
Absorbing()only receive (a loss)

Two acyclicity notions

Do not confuse them:

predicateaboutdecides
istree(g)the undirected graphwhether message passing is exact
isdag(g)the directed multigraphwhether the graph is Lux-compatible

They are independent.

Running inference

marg, report, st, store = infer!(g, tree_schedule(g), ps, st)

marg is a NamedTuple of marginal beliefs keyed by variable name; report says whether messages converged; store holds the messages, and is what the free-energy functions read.

Schedules: tree_schedule (exact on trees), flooding_schedule (general, iterative), forward_backward_schedule (chains — the Kalman/RTS smoother), SequentialSchedule.

Structural factors

Data, priors, losses and optimisers are all factors here rather than special machinery:

DataFactor, PriorFactor, LossFactor, RelayFactor, OptimiserFactor, and the update rules GradientDescent, Momentum, Nesterov.

Free energy

scalar_free_energy, bethe_free_energy, factor_free_energies and variable_corrections compute the graph's objective — a sum of per-factor energies plus a per-variable entropy correction. For a linear-Gaussian graph this equals the exact negative log marginal likelihood; see the worked example.

Known gaps

  • combine works for Gaussians and Diracs and throws for anything else — pooling general beliefs needs densities.
  • No parameter sharing between factor nodes, so weight tying is not expressible.
  • Loopy message passing has no convergence guarantees, and report.converged means the messages stopped moving, not that the answer is right.

API

Graphs

Mycelium.Absorbing — Type
Absorbing()

Variable → factor only. The factor consumes on this channel and never writes it, so the channel is never Unobserved(). A loss and the input side of an ordinary layer are absorbing.

source
Mycelium.Bidirectional — Type
Bidirectional()

Messages flow both ways. The attached channel may be Observed() or Unobserved() depending on the message being computed — this is what makes a factor implicit.

source
Mycelium.Edge — Type
Edge(factor, channel, variable, direction, coupling)

Attaches channel channel of factor node factor to variable node variable.

coupling is the AbstractGradientCoupling for this edge: which of the terms AutoBayes' Definition 29 drops are restored here. Per-edge rather than global, because a conjugate edge and a discrete-latent edge genuinely want different answers.

source
Mycelium.EdgeDirection — Type
abstract type EdgeDirection

Which way messages may travel along an edge. The graph is directed; bidirectional edges are the special (and, for implicit factors, usual) case.

source
Mycelium.Emitting — Type
Emitting()

Factor → variable only. The factor produces on this channel and never reads it, so the channel is never Observed(). A data source, a prior, and the output side of an ordinary Lux-style layer are all emitting.

source
Mycelium.FactorGraph — Type
FactorGraph

A bipartite graph of variables and factors with named, directed channel attachments.

Build one with GraphBuilder; do not construct directly, since the adjacency indices must stay consistent with the edge list.

source
Mycelium.FactorNode — Type
FactorNode(id, name, factor)

A node holding an AbstractLenticulumFactor — a parameterized statistical game.

source
Mycelium.VariableNode — Type
VariableNode(id, name, space)

A wire. Carries a belief; has no behaviour of its own.

Per AutoBayes' Remark 24 (and the design note [[Everything is a Factor]]), data is not a variable — data is a DataFactor of arity one attached to a variable. Variables are purely structural.

source
Mycelium.build — Method
build(b::GraphBuilder) -> FactorGraph

Freeze the builder into an immutable graph with adjacency indices.

source
Mycelium.channels_of — Method
channels_of(g, fid) -> Tuple{Vararg{Symbol}}

The channel names of factor fid that are actually connected in this graph. A factor may declare more channels than the graph uses; the unused ones are Latent() in every message.

source
Mycelium.connect! — Method
connect!(b, factor::Symbol, channel::Symbol, variable::Symbol;
         direction = Bidirectional(), coupling = DiagonalCoupling())

Attach one channel of a factor to a variable.

Each (factor, channel) pair may be connected at most once: a channel is a single port, not a bus. Fanning a variable out to several factors is done by connecting those factors, which is the graph-level form of the copier of Copiers Cups and Caps.md.

source
Mycelium.euler_characteristic — Method
euler_characteristic(g) -> Int

$|F| + |V| - |E|$.

Equal to 1 exactly when the graph is a connected tree, and 1 - L when it has L independent loops. It is also the sum of the Bethe counting numbers $\sum_f 1 + \sum_v (1 - d_v)$, since $\sum_v d_v = |E|$ — so this single integer says how badly the free-energy bookkeeping over-counts. See free_energy.md.

source
Mycelium.factor! — Method
factor!(b::GraphBuilder, name::Symbol, factor) -> Int

Add a factor node wrapping an AbstractLenticulumFactor; returns its id.

source
Mycelium.isdag — Method
isdag(g) -> Bool

Whether the directed multigraph induced by the edge directions has no directed cycle.

An Emitting edge contributes the arc factor → variable, an Absorbing edge the arc variable → factor, and a Bidirectional edge contributes both — so a single bidirectional edge is already a 2-cycle and makes this false.

That is the correct behaviour, not an artefact: a bidirectional edge is exactly what "implicit" means, and isdag(g) is precisely the predicate "this graph could have been written in Lux".

source
Mycelium.istree — Method
istree(g) -> Bool

Whether the undirected bipartite graph is a tree: connected and loop-free.

This is the property that decides whether message passing is exact. On a tree, two sweeps (inward then outward) give the exact marginals and the exact free energy. Off a tree you are running loopy BP and everything is an approximation — see Loopy Message Passing.md.

Do not confuse with isdag, which is about edge directions and decides whether the graph is Lux-compatible.

source
Mycelium.learnable_factors — Method
learnable_factors(g) -> Vector{Int}

Factor node ids that contribute trainable parameters, per LenticulumCore.islearnable. A graph of non-learnable factors is an inference graph, not a training graph.

source
Mycelium.leaves — Method
leaves(g) -> Vector{Tuple{Symbol,Int}}

Nodes of undirected degree 1 — where a tree sweep starts.

source
Mycelium.topological_order — Method
topological_order(g) -> Vector{Tuple{Symbol,Int}}

Nodes in topological order as (:variable, id) / (:factor, id) pairs. Errors unless isdag.

This is the order in which an ordinary forward pass runs, and it exists only for the Lux-compatible case. Cyclic graphs get a FloodingSchedule or a ResidualSchedule instead.

source
Mycelium.variable! — Function
variable!(b::GraphBuilder, name::Symbol, space = nothing) -> Int

Add a variable node; returns its id.

source
Mycelium.variable_degree — Method
degree(g, ::Val{:variable}, vid) -> Int

The number of edges incident to a variable. This is the $d_v$ of the Bethe counting number $1 - d_v$; see free_energy.jl.

source

Polarity resolution

Mycelium.PolarityError — Type
PolarityError(factor, channel, reason)

Raised when a requested message is not legal: the edge direction forbids it, or the factor does not support the resulting polarity.

source
Mycelium.can_emit — Method
can_emit(g, edge_index) -> Bool
can_absorb(g, edge_index) -> Bool

Whether the edge's direction permits a factor → variable (resp. variable → factor) message.

source
Mycelium.check_legal — Method
check_legal(g, fid, polarity)

Verify that a polarity is compatible with the edge directions of this graph, and with the factor's own supports_polarity. Throws a PolarityError naming the offending channel.

The two checks are genuinely different:

  • edge direction is a property of the wiring: an Emitting edge may not be Observed(), an Absorbing edge may not be Unobserved();
  • supports_polarity is a property of the factor: can it be run this way at all?

Together they are the replacement for Lux's acyclicity restriction. A Lux Chain is well-formed iff the wiring is a DAG; a Mycelium graph is well-formed iff every scheduled message passes both checks.

source
Mycelium.legal_targets — Method
legal_targets(g, fid) -> Vector{Symbol}

The channels of factor fid on which it could ever emit, given the edge directions alone. A factor with exactly one legal target is unidirectional in this graph, which is a weaker and more useful notion than LenticulumCore.isunidirectional (a property of the factor).

source
Mycelium.resolve_polarity — Method
resolve_polarity(g, fid, target_channel, available_channels) -> Polarity

Build the Polarity for the message that infers target_channel from the channels in available_channels.

available_channels is the set of channels for which an incoming (variable → factor) message exists and whose edge absorbs. Every other declared channel of the factor becomes Latent().

Precisely one channel is Unobserved(). That is a deliberate restriction for v0: a message addresses one variable. Joint messages over several channels at once are the correct generalisation (and the only way to keep correlations between them — cf. the mean-field laxness of Composition of Bayesian Lenses.md Remark 16), and are noted as future work in polarity_resolution.md.

source
Mycelium.validate — Method
validate(g::FactorGraph)

Structural checks that must hold before any message can be scheduled. Throws on failure, returns g on success.

Checks:

  1. every edge's channel is declared by its factor (LenticulumCore.channels);
  2. every factor has at least one edge (an isolated factor contributes energy nobody reads);
  3. every variable has at least one edge;
  4. no variable is written by two Emitting edges and read by none — a variable that is only ever produced is a dangling output, which is legal but almost always a wiring bug, so it is reported as a warning rather than an error.
source

Messages and beliefs

Mycelium.Message — Type
Message(belief, iteration)

A belief in transit, tagged with the sweep that produced it. The tag is what lets the scheduler tell a stale message from a fresh one without comparing beliefs.

source
Mycelium.MessageStore — Type
MessageStore(g)

Per-edge storage for both message directions. nothing means "not yet computed", which is distinct from "computed and uninformative" (the latter is TrivialBelief()).

source
Mycelium.belief_distance — Method
belief_distance(a, b) -> Real

A non-negative measure of how much a message changed, used as the convergence criterion in propagate!.

Implemented for the cases that can be answered without densities; otherwise returns Inf, which makes the scheduler run to maxiters rather than silently declaring convergence it cannot verify. Erring towards Inf is deliberate: a false "converged" is much worse than a wasted sweep.

source
Mycelium.belief_logdensity — Function
belief_logdensity(b::AbstractBelief, x) -> Real

$\log p_b(x)$. Not implemented by any LenticulumCore belief type yet; it is the missing piece that would make a generic combine possible. Declared here so that downstream belief representations have a name to extend.

source
Mycelium.can_damp — Method
can_damp(new, old) -> Bool

Whether a convex combination of these two beliefs is defined. false by default, so damp is a reported no-op rather than a silent one.

source
Mycelium.combine — Function
combine(a::AbstractBelief, b::AbstractBelief) -> AbstractBelief

Pool two beliefs about the same variable — the product of densities, in sum-product terms.

This is the hardest operation in the package and is deliberately partial. What is implemented is what can be done honestly for the belief types LenticulumCore currently provides:

caseresultwhy
TrivialBelief with anythingthe other oneit is the unit
two agreeing DiracBeliefsthat Diracidempotent
two disagreeing DiracBeliefsthrowstwo hard clamps in contradiction is a modelling error, not a numerical one
DiracBelief with anythingthe Diraca hard clamp dominates (ρ_in = Inf, per Channels and Polarity.md)

Everything else throws an informative error. In particular two SampleBeliefs cannot be combined without densities — that needs importance reweighting, which needs belief_logdensity, which no belief type currently implements. See messages.md §"Implementation difficulties".

source
Mycelium.damp — Method
damp(new, old, α) -> belief

$\alpha \cdot \text{new} + (1-\alpha)\cdot\text{old}$, the standard remedy for oscillating loopy BP.

Defined only where a convex combination of beliefs makes sense. For DiracBelief with numeric payloads this is interpolation of the values; for anything else, damping is a no-op returning new, and that is reported rather than silently ignored — see can_damp.

source
Mycelium.excluded_marginal — Method
excluded_marginal(store, g, vid, skip_edge) -> AbstractBelief

The belief at a variable excluding the message that arrived along skip_edge — the variable → factor message of belief propagation.

This exclusion is not an optimisation; it is what makes BP correct. Without it a factor's own previous message is fed back to it as if it were independent evidence, and the belief becomes exponentially over-confident with each sweep. On a tree the exclusion is exactly what makes two sweeps give the true marginals.

Note that we recompute the product over the other edges rather than dividing the full marginal by the skipped message. Division requires densities and is numerically fragile; recomputation costs O(degree) per message and is always defined.

source
Mycelium.marginal — Method
marginal(store, g, vid) -> AbstractBelief

The belief at a variable: the combination of all incoming factor → variable messages.

This is what you read out at the end of inference.

source
Mycelium.reset! — Method
reset!(store)

Clear all messages. Used between independent inference problems on the same graph.

source

Schedules

Mycelium.AbstractSchedule — Type
abstract type AbstractSchedule

A plan for message passing. Subtypes differ in whether they are static (a fixed task list computed from the graph) or dynamic (recomputed from message residuals each step).

source
Mycelium.FloodingSchedule — Type
FloodingSchedule(tasks)

All tasks computed from the previous sweep's messages and committed together (double-buffered). The classical parallel BP update.

Slower to propagate information than a sequential sweep (one edge per sweep rather than a whole path), but order-independent, so the result does not depend on an arbitrary choice — which matters when the graph has no natural order at all.

source
Mycelium.ForwardBackwardSchedule — Type
ForwardBackwardSchedule(forward, backward)

For a graph that is a DAG under its edge directions: a topological belief sweep, then a reverse sweep along whatever edges are Bidirectional.

[!note] The backward list is empty for a strictly unidirectional DAG — and that is correct A Lux-style graph (all edges Emitting/Absorbing) has no backward belief flow. Its backward pass carries cotangents, not beliefs, and cotangents are handled by the AbstractGradientCoupling on each edge together with the free-energy accumulation — not by this scheduler. Seeing isempty(sched.backward) is therefore a useful diagnostic: it says "this graph is explicit; there is nothing to infer".

source
Mycelium.ResidualSchedule — Type
ResidualSchedule(; maxtasks)

Dynamic: repeatedly send whichever pending message would change the most (Elidan, McGraw & Koller's residual belief propagation).

Converges on many loopy graphs where flooding oscillates, because it prioritises the parts of the graph that have not settled. Requires a usable belief_distance; with the current belief types most pairs return Inf, so this degrades to "some order", which is recorded honestly rather than hidden.

source
Mycelium.SequentialSchedule — Type
SequentialSchedule(tasks)

A fixed list, executed in order, in place: each task sees the results of the ones before it in the same sweep. Faster-propagating than flooding, and the basis of tree and forward schedules.

source
Mycelium.TreeSchedule — Type
TreeSchedule(inward, outward, pruned)

The two-sweep schedule for a tree: every edge carries one message towards the root, then one away from it.

On a tree with only Bidirectional edges this is exact. After the outward sweep, marginal(store, g, v) is the true marginal for every v. This is the only schedule here with a correctness guarantee.

pruned counts the messages the edge directions forbade. A Emitting edge cannot carry a variable → factor message and an Absorbing edge cannot carry a factor → variable one, so a graph containing unidirectional edges has fewer than 2|E| legal messages and the sweeps are correspondingly shorter.

[!warning] Pruning weakens the exactness guarantee, and deliberately so A LossFactor on Absorbing edges never tells its input variable anything: in belief terms a sink is genuinely uninformative about what it consumes. What a loss "tells" its input is a cotangent, not a belief, and cotangents travel by the AbstractGradientCoupling machinery, not by this scheduler. So pruned > 0 does not mean the schedule is broken — it means part of the graph is explicit rather than implicit. It is surfaced as a field rather than swallowed so that a genuinely mis-wired graph (an implicit factor accidentally given a unidirectional edge) is visible.

source
Mycelium.all_tasks — Method
all_tasks(g) -> Vector{MessageTask}

Every message the edge directions permit: one :to_factor per absorbing edge, one :to_variable per emitting edge. A bidirectional edge yields both.

source
Mycelium.forward_backward_schedule — Method
forward_backward_schedule(g) -> ForwardBackwardSchedule

Topological belief sweep, then the reverse sweep over bidirectional edges only.

Errors unless isdag — which, note, excludes any graph containing a Bidirectional edge. So in practice this schedule is for the explicit subset of graphs, and its backward list is always empty. It is provided because that degenerate case is exactly Lux.jl, and having it nameable makes the boundary between the two libraries explicit.

source
Mycelium.islegal — Method
islegal(g, task) -> Bool

Whether the edge's direction permits this message. A :to_factor task needs an absorbing edge; a :to_variable task needs an emitting one.

Schedules filter on this rather than throwing, because a unidirectional edge is a legitimate modelling choice, not an error — see TreeSchedule.

source
Mycelium.tree_schedule — Method
tree_schedule(g; root = nothing) -> TreeSchedule

Root the tree (at root, or at the first leaf found) and produce the inward/outward sweeps.

Errors unless istree. The inward sweep is in reverse breadth-first order (children strictly before parents) and the outward sweep in breadth-first order, which is what makes each message computable exactly once from already-final inputs.

source

Running inference

Mycelium.ConvergenceReport — Type
ConvergenceReport(converged, sweeps, residual, reason)

What propagate! reports. converged == false is not an error — on a loopy graph it is the expected outcome, and callers must decide whether to trust the marginals.

source
Mycelium.available_channels — Method
available_channels(store, g, fid; skip = 0) -> Tuple{Vararg{Symbol}}

Channels of factor fid that currently have an incoming (variable → factor) message, optionally excluding one edge. These become Observed() in the resolved polarity.

source
Mycelium.factor_message — Method
factor_message(factor, target::Symbol, polarity, inputs::NamedTuple, prior, ps, st)
    -> (belief, st)

Compute the outgoing belief on channel target.

The generic implementation is assemble(factor, polarity, ps, st) followed by invert(lens, prior, inputs, ps, st) — i.e. the composite of LenticulumCore's two interface functions. Structural factors (DataFactor, LossFactor, …) implement this directly instead, because for them the "lens" is trivial and building one would be ceremony.

inputs is a NamedTuple of the incoming beliefs keyed by channel, and prior is the excluded marginal at the target variable — the $\pi$ of $c'_\pi$.

source
Mycelium.infer! — Method
infer!(g, sched, ps, st; kwargs...) -> (marginals::NamedTuple, report, st)

Convenience: fresh store, propagate, read out every variable's marginal.

source
Mycelium.propagate! — Method
propagate!(store, g, sched, ps, st; maxsweeps = 100, tol = 1e-8, damping = 0.0)
    -> (ConvergenceReport, st)

Run the schedule to convergence, or to maxsweeps.

For a TreeSchedule one sweep is enough and is exact; the loop stops after one and says so. For loopy graphs there is no guarantee: see Loopy Message Passing.md.

damping ∈ [0,1) mixes each new message with the previous one (0 = none, higher = more inertia), the standard remedy for oscillation. Damping only applies where can_damp is true; elsewhere it is silently a no-op, which is recorded in the report's reason.

source
Mycelium.step! — Method
step!(store, g, task, ps, st; damping = 0.0, commit = true) -> (belief, residual, st)

Execute one MessageTask.

ps and st are the graph-wide parameter and state trees, keyed by factor name — the same nested NamedTuple shape LuxCore.setup produces.

Returns the new belief, the belief_distance to the message it replaces (the residual, used for convergence and for ResidualSchedule), and the updated state. With commit = false the message is computed but not stored, which is what FloodingSchedule needs for double buffering.

source
Mycelium.sweep! — Function
sweep!(store, g, sched, ps, st; damping = 0.0) -> (maxresidual, st)

One pass of the schedule.

SequentialSchedule, TreeSchedule and ForwardBackwardSchedule execute in place, so later tasks see earlier results in the same sweep. FloodingSchedule is double buffered: every task is computed against the previous sweep's messages and all results are committed together.

source

Free energy

LenticulumCore.scalar_free_energy — Method
LenticulumCore.scalar_free_energy(store, g, ps, st) -> (Real, st)

The scalarised Bethe free energy: what an optimiser minimises.

This extends LenticulumCore.scalar_free_energy rather than defining a new function of the same name: "the scalar free energy" of a graph and of a factor are the same concept at two scales, and having two exported bindings with that name would force every downstream user to disambiguate. (The test suite caught exactly that.)

Each factor's own scalarisation is applied to its own summand, per GradedScalarisation — so a residual factor can use a squared norm while a likelihood factor uses the identity, in the same graph. See Scalar and Multivariate Energy.md §5 for when that is strict and when it is lax.

source
Mycelium.bethe_free_energy — Method
bethe_free_energy(store, g, ps, st) -> (GradedEnergy, st)

The graph free energy, graded into (factors = …, variables = …):

\[\mathbf{F}_{\text{Bethe}} = \bigoplus_f \mathbf{F}^f \ \oplus\ \bigoplus_v (1-d_v)\,\mathbf{H}^v\]

Exact on a tree. On a loopy graph it is the Bethe approximation, whose stationary points are exactly the fixed points of loopy belief propagation (Yedidia–Freeman–Weiss) — so running BP and minimising this quantity are the same activity, which is a genuinely useful thing to know when the two appear to disagree.

Contrast chain_free_energy, which follows AutoBayes Theorem 23 literally and is schedule-dependent.

source
Mycelium.chain_free_energy — Method
chain_free_energy(Fs) -> GradedEnergy

AutoBayes Theorem 23 applied along a linear order: $\mathbf{F}^{dc} = (\mathbb{E}[\mathbf{F}^c],\ \mathbf{F}^d)$ folded right to left.

Fs is the ordered vector of per-factor vector free energies. Uses LenticulumCore.compose_free_energy, so the nesting matches what Composition of Statistical Games.md describes.

[!warning] This is schedule-dependent and Bethe is not Theorem 23's recursion refers to "downstream", which on a graph is a property of the message order, not of the wiring. Two different schedules give two different decompositions of the same total. Bethe's form has no order in it at all.

They agree when every belief is a DiracBelief (deterministic inference: all expectations are evaluations and all variable entropies vanish), which is exactly the regime the test suite checks. Off it, prefer Bethe for reporting and the chain form for reasoning about a specific message path.

source
Mycelium.counting_number — Method
counting_number(g, vid) -> Int

The Bethe counting number $1 - d_v$ of a variable, where $d_v$ is its degree.

A variable of degree 1 counts 0 (nothing to correct: only one factor mentions it). A variable of degree 2 counts -1 (its entropy was counted twice, so subtract one copy). And so on.

source
Mycelium.factor_free_energies — Method
factor_free_energies(store, g, ps, st) -> (GradedEnergy, st)

Each factor's vector free energy $\mathbf{F}^f$, graded by factor name.

This is $E_G = \bigoplus_f E_f$ from Scalar and Multivariate Energy.md, realised: the composite energy of a graph is the per-factor breakdown, not a number that a logger later decomposes.

source
Mycelium.messages_into — Method
messages_into(store, g, fid) -> NamedTuple

The incoming messages $\mu_{i \to a}$ of each variable this factor touches, keyed by channel. nothing for a channel whose edge cannot carry one.

[!warning] These are messages, not marginals — and the difference is the whole formula The Bethe factor belief is $b_a \propto f_a \prod_{i \in a}\mu_{i\to a}$: the factor's own potential times the messages into it. Using the variable marginals instead would multiply in the factor's own outgoing message as well, counting its evidence twice.

An earlier version of this file did exactly that. It was invisible until a factor with a non-trivial entropy existed to expose it — see free_energy.md §7.

source
Mycelium.total_counting_number — Method
total_counting_number(g) -> Int

$\sum_f 1 + \sum_v (1 - d_v)$.

This equals the Euler characteristic $|F| + |V| - |E|$, because $\sum_v d_v = |E|$. So it is 1 exactly when the graph is a connected tree, and 1 - L when it has L independent loops.

That single integer measures how badly the Bethe bookkeeping can be wrong: on a tree the counting is exact, and the deficit from 1 is the number of loops the approximation has to pretend are not there.

Asserted against euler_characteristic in the test suite, since it is the cheapest possible check that the graph and the free-energy accounting agree.

source
Mycelium.variable_corrections — Method
variable_corrections(store, g) -> GradedEnergy

The per-variable counting terms $c_v F_v = (1 - d_v)\,(-H(b_v))$, graded by variable name.

source
Mycelium.variable_entropy — Method
variable_entropy(belief) -> Real

The entropy $H(b_v)$ of a variable's marginal, needed for the counting correction.

Implemented only where it is unambiguous: a DiracBelief has entropy 0 (differential entropy of a point mass is $-\infty$, but the correction term it contributes is zero because a clamped variable carries no free bits — this is the convention BP uses for observed nodes). Anything else throws rather than guess.

source
Mycelium.variable_free_energy — Method
variable_free_energy(belief) -> Real

$F_v = -H(b_v)$. A variable has no energy of its own — it is a wire, not a node with a potential — so its free energy is minus its entropy.

[!warning] The sign here is the whole correction, and it was wrong The Bethe free energy is $F_\beta = \sum_\alpha c_\alpha F_\alpha$ over factors and variables, with $c_f = 1$ and $c_v = 1 - d_v$. Because $F_v = -H_v$, the variable term is $\sum_v (1-d_v)(-H_v) = +\sum_v (d_v - 1)H_v$ — it adds back the entropy that the factor terms subtracted $d_v$ times.

An earlier version applied $c_v$ to $+H_v$, flipping the sign. Every test passed, because every belief in them was a DiracBelief with $H_v = 0$. See free_energy.md §6.

source

Structural factors and optimisers

Mycelium.AbstractUpdateRule — Type
abstract type AbstractUpdateRule

A stateful parameter update: Cruttwell et al.'s Definition 3.14, a lens $U : (S\times P,\ S\times P) \to (P, P')$.

Implement rule_get (the lens's get, $U$) and rule_put (its put, $U^*$).

source
Mycelium.DataFactor — Type
DataFactor(channel, value)

A clamped observation: arity one, emitting only, non-learnable.

This is the categorical cup of Copiers Cups and Caps.md and the $\rho_{in} = \infty$ hard clamp of Channels and Polarity.md, and it is why README.md's "training data are variables" is refined here to training data are factors attached to variables: a variable is a wire and has no content of its own, whereas data is evidence, and evidence is a factor.

Energy is 0 and entropy is 0: a Dirac prior contributes $-\log p_\pi$ which vanishes at its own point.

source
Mycelium.GradientDescent — Type
GradientDescent(η)

get: p. put: (s, p - η·p̄).

Cruttwell's Definition 3.11 writes G*(p,p') = p + p', with the sign and step size supplied by the learning-rate cap α*(l) = -ε. Folding η in here is the same map with the cap absorbed, which is what every real optimiser API does.

source
Mycelium.LossFactor — Type
LossFactor(channels, loss)

A sink: absorbs on every channel, emits nothing, is not learnable, and contributes loss(values...) as its energy.

loss may return a scalar or a vector — the latter being the multivariate energy of Scalar and Multivariate Energy.md. Pair it with a non-identity scalarisation to control how the vector collapses.

Note that Cruttwell et al.'s Definition 3.3 makes the label the loss map's parameter; here the label is simply another absorbed channel, which is the same statement with the graph doing the bookkeeping.

source
Mycelium.Momentum — Type
Momentum(η, γ)

get: p. put: s' = -γ s + (-η p̄), then p + s'. Recovers gradient descent at γ = 0.

source
Mycelium.OptimiserFactor — Type
OptimiserFactor(channel, rule)

An optimiser, as a factor attached to an exposed parameter variable.

Cruttwell et al. present optimisers as reparametrisations — boxes sitting above the parameter wire. In a factor graph a "wire" is a variable, so the box above it is a factor, and the reparametrisation is an ordinary node. This is only possible because a factor may expose its parameters as channels rather than hiding them in ps, which is exactly the move AutoBayes Example 3 (VBEM) makes when $\Theta$ "migrates from the parameter space into the wire".

Bidirectional by nature: it emits the current parameter (rule_get) and absorbs the update (rule_put). Not learnable — it holds state, it does not have parameters.

source
Mycelium.PriorFactor — Type
PriorFactor(channel, belief, nlogp)

A prior over one variable: emits belief, and charges energy nlogp(x) at the variable's current point.

This is AutoBayes Remark 24 exactly: give the prior a trivial inversion, set $l^\pi(x) = -\log p_\pi(x)$ and $H^\pi \equiv 0$, and the composite with a downstream factor has the true variational free energy as its loss. Without a prior factor you get an "open free energy", missing the $-\log p_\pi(x)$ term.

Learnable if nlogp carries parameters — set learnable = true and implement initialparameters.

source
Mycelium.RelayFactor — Type
RelayFactor(a, b)

A bidirectional pass-through: whatever arrives on one channel leaves on the other.

This is the identity game of AutoBayes Remark 24 — the identity lens with constantly zero energy and entropy — and it is the sanity check that inserting a node into a graph changes nothing. It is also the smallest factor that genuinely has two polarities, so it is what the polarity machinery is tested against.

source
Mycelium.issink — Method
issink(factor) -> Bool

A factor with no supported polarity: it contributes energy but never sends a message.

Losses, monitors and pure observations are sinks. This is a third case beyond LenticulumCore.isunidirectional (exactly one polarity) and bidirectional (two or more), and it is the one that corresponds to Cruttwell et al.'s learning-rate cap — the lens (L,L') → (1,1) that terminates a wire.

source
Mycelium.local_free_energy — Function
local_free_energy(factor, beliefs::NamedTuple, ps, st) -> (𝐅, st)

The factor's vector free energy, evaluated at the current beliefs of its neighbouring variables (keyed by channel).

This is the graph-level entry point corresponding to LenticulumCore.free_energy(factor, π, y, ps, st); it differs in taking all the factor's channels at once rather than a prior/observation split, because in a graph there is no distinguished split until a polarity is chosen — and the free energy is a property of the factor, not of any one message.

source
Mycelium.optimiser_step — Method
optimiser_step(f::OptimiserFactor, state, p, p̄) -> (state', p')

Apply the rule. Separated from message passing because a parameter update is not a belief message: it happens once per training step, whereas messages happen once per inference sweep, and conflating the two schedules is a classic source of silent bugs.

source
Mycelium.point — Method
point(belief)

The value of a DiracBelief. Energy functions are pointwise (Statistical Game.md, Definition 20), so they need a point, and only a Dirac provides one unambiguously. Anything else throws rather than silently taking a mean.

source
Mycelium.rule_get — Function
rule_get(rule, state, p)

$U(s,p)$ — the parameter value handed down to the factor.

For plain gradient descent and momentum this is just p, which makes it look like dead weight. Nesterov is the counterexample that justifies the whole formulation: its get is p + γs, the look-ahead point. "Evaluate the gradient at the look-ahead point" is "the forward part of the optimiser lens is not the identity".

source
Mycelium.rule_put — Function
rule_put(rule, state, p, p̄) -> (state', p')

$U^*(s,p,p')$ — the new state and the new parameter.

source

Everything else

Mycelium.Mycelium — Module
Mycelium

Factor graphs and message passing for Lenticulum.jl.

LenticulumCore says what a factor is (a parameterized statistical game); Mycelium says how factors are wired and in what order they talk. It is the layer that has no counterpart in Lux, because in Lux the wiring is a DAG and the order is implied by it.

The design in five sentences

  1. The graph is bipartite: variables are wires, factors are nodes. Everything with content is a factor — data, priors, losses and optimisers included, following AutoBayes Remark 24. See factors.jl.
  2. Edges are directed, and carry one of Emitting, Absorbing, Bidirectional. A bidirectional edge is exactly what "implicit" means.
  3. A factor → variable message is an inversion $c'_\pi$: the target channel becomes Unobserved(), the channels with incoming messages become Observed(), the rest Latent(), and assemble + invert do the rest. See polarity_resolution.jl.
  4. Scheduling is the whole problem. On a tree, two sweeps are exact; off a tree you are running loopy BP and nothing is guaranteed. See schedules.jl.
  5. The free energy of a graph is the Bethe form: energies add, entropies get a counting correction $(1-d_v)$. That is the same energy/entropy asymmetry AutoBayes identifies, one level up. See free_energy.jl.

Two acyclicity notions, not one

predicateaboutdecides
istree(g)the undirected bipartite graphwhether message passing is exact
isdag(g)the directed multigraphwhether the graph is Lux-compatible

They are independent, and confusing them is the commonest way to be wrong about a factor graph.

Concept notes are in vault/Factor Graphs/; per-file implementation notes sit next to each source file.

source