Factor graph models¶
A CouplingGraph declares a rooted tree of Gaussian-coupled variables. It holds the shape a chain cannot: a node with three or more neighbours, inferred through the ones around it. Coupling edges carry the within-slice drive child = W·parent + noise, and the factor types below supply each node's dynamics and its observation. Run one through a CouplingGraphBackend.
CouplingGraph ¶
CouplingGraph(
root: int,
dims: Sequence[int],
couplings: Sequence[Coupling],
observations: Mapping[int, ObservationFactor],
)
A rooted tree of Gaussian-coupled variables.
The N nodes are indexed 0..N-1 with dimensions dims. couplings are
the tree edges, directed away from root, and observations maps a node index
to the observation factor attached to it (fixed
GaussianObservation or state-dependent
CallableGaussianObservation). Construction
validates the wiring and raises if it is malformed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
root
|
int
|
index of the node the tree is rooted at. |
required |
dims
|
Sequence[int]
|
|
required |
couplings
|
Sequence[Coupling]
|
the tree edges — one per non-root node, each that node's only parent. |
required |
observations
|
Mapping[int, ObservationFactor]
|
maps a node index to its |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
Source code in src/cpomdp/ffg/graph.py
infer ¶
Compute the marginal belief at the root from a prior and per-node readings.
Each reading becomes a message about its node; those messages are passed up the tree through the couplings and combined at the root with the prior, giving the root's posterior over every reading. Only the root is converted to and from moment form — once to lift the prior in, once to read the result out — while every message in between stays in canonical form.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prior
|
Belief
|
the belief on the root node, taken as its prior. |
required |
readings
|
Mapping[int, ArrayLike]
|
maps a node index to that node's observation; each such node must
carry a fixed |
required |
Returns:
| Type | Description |
|---|---|
Belief
|
The marginal belief at the root. |
Source code in src/cpomdp/ffg/graph.py
infer_all ¶
Every node's exact marginal by two-pass belief propagation over the tree.
Where infer collects to the root and returns only that one marginal, this
adds a downward distribute pass so every node's marginal comes back — the
cheap, structure-exploiting alternative to a dense joint solve. Each seed is a
node's already-formed canonical message (its local prior + evidence, combined by
the caller); marginalisation stays in canonical form, so no node is inverted on
this path.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
seeds
|
Mapping[int, CanonicalGaussian]
|
a canonical message per node — the node's local information. Unlike
|
required |
Returns:
| Type | Description |
|---|---|
dict[int, CanonicalGaussian]
|
A |
dict[int, CanonicalGaussian]
|
any one for its |
Source code in src/cpomdp/ffg/graph.py
Coupling
dataclass
¶
Coupling(
parent: int,
child: int,
factor: GaussianCoupling,
tau: float,
efe_relevant: bool = False,
)
A directed edge from a parent node to a child node: child = W·parent + noise.
parent and child are node indices, oriented so the parent is the endpoint
nearer the tree's root. factor is the
GaussianCoupling holding this edge's W (shape
(dim[child], dim[parent])) and its noise covariance. tau is a
time-constant carried alongside the edge; it is metadata and does not affect the
factor.
efe_relevant is a modeller's declaration that this edge carries information
the instrumental epistemic depends on — a physics call, not a structural one (in
chemotaxis the gradient rides receptor->CheA->CheY, while CheA->CheB methylation is
observed and coupled yet gradient-blind). A carry partition (ADR-016) that severs a
flagged edge drops the cross-temporal covariance it holds, breaking the integration
of that information about a slow latent; the EFE selector refuses such a partition
(ADR-018). Default False; it does not affect the factor or the filter.
GaussianCoupling
dataclass
¶
Tier-1 structural coupling factor N(child; W·parent, Q) — a graph edge.
Where GaussianTransition couples a state to its
successor in time, this couples two variables joined by an edge of the factor
graph (e.g. the shared
CheA node to a branch latent). The maths is identical — a linear-Gaussian
coupling — but a coupling carries no time semantics and W need not be square.
coupling— W, shape(c, p): maps the p-D parent's mean to the c-D child.coupling_noise— Q, shape(c, c), positive-definite (it is inverted).
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
message_to_parent ¶
Summarise what a child's belief says about the parent: eliminate the child.
The coupling is the joint Gaussian over z = [parent, child]::
Λ_J = [[ WᵀQ⁻¹W, −WᵀQ⁻¹ ], h_J = 0 (a pure coupling has no bias)
[ −Q⁻¹W, Q⁻¹ ]]
The upward message:
- Folds
child_messageinto the child block — its precision into the bottom-rightc×cofΛ_J, its potential into the trailingcofh_J(a block add during construction, not__add__). - Marginalizes the child out, leaving the message on the p-D parent.
This is the mirror of GaussianTransition.predict (which folds into the
parent block and eliminates the parent, emitting downward onto the child);
here we fold into the child block and eliminate the child, emitting upward.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
child_message
|
CanonicalGaussian
|
the incoming belief on the c-D child, as a
|
required |
Returns:
| Type | Description |
|---|---|
CanonicalGaussian
|
A |
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
message_to_child ¶
Push a parent's belief down the edge onto the child: eliminate the parent.
The distribute-pass mirror of message_to_parent. Over the same joint on
z = [parent, child], but here the parent is known, so its message folds
into the parent block of Λ_J and the parent is marginalized out, leaving
the message on the c-D child.
Structurally this is GaussianTransition.predict (fold the incoming belief
into the source block, eliminate the source, emit onto the target) with a
non-square W and no control shift — a pure coupling carries no bias. So on
its own the downward message is a full child belief, landing in moment form on
mean = W·μ_parent and cov = W·Σ_parent·Wᵀ + Q.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
parent_message
|
CanonicalGaussian
|
the incoming belief on the p-D parent, as a
|
required |
Returns:
| Type | Description |
|---|---|
CanonicalGaussian
|
A |
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
tree_flatten ¶
Leaves for JAX: (coupling, coupling_noise), no static aux data.
tree_unflatten
classmethod
¶
tree_unflatten(
aux_data: None,
children: tuple[
Float64[Array, "c p"], Float64[Array, "c c"]
],
) -> GaussianCoupling
Rebuild from leaves without validating — the leaves may be tracers.
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
GaussianTransition
dataclass
¶
Tier-1 dynamics factor N(x'; Ax + b, Q) — emits the forward predict.
Holds the fixed transition and process noise; predict(message, b) pushes a
belief on x through the dynamics to a belief on x'.
dynamics_matrix— A, shape(n, n).dynamics_noise— Q, shape(n, n), positive-definite (it is inverted).
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
from_ou
classmethod
¶
Build a 1-D transition from Ornstein–Uhlenbeck (OU) parameters.
An Ornstein–Uhlenbeck process is a scalar state that relaxes toward zero on a
timescale tau while random noise keeps it wobbling with a stationary
variance stationary_var (Σ_stat). Exactly discretising it over a step dt
gives the linear-Gaussian transition x' = A·x + noise(Q) (ADR-017):
A = exp(−dt / tau) — the fraction of the state surviving a step
Q = stationary_var · (1 − A²) — the kick that holds the stationary variance
Scalar (1-D) only: the vector OU would need a matrix exponential and a Lyapunov solve, which no cpomdp node needs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tau
|
float
|
the relaxation timescale τ (same time unit as |
required |
stationary_var
|
float
|
the steady-state variance Σ_stat the node settles to; A (dynamics) and Q (dynamics_noise) are set so it holds this spread. |
required |
dt
|
float
|
the discretisation step. |
required |
Returns:
| Type | Description |
|---|---|
GaussianTransition
|
A |
GaussianTransition
|
|
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
predict ¶
Push an incoming belief on x through the dynamics to a belief on x'.
The transition is the joint Gaussian over z = [x, x']::
Λ_J = [[ AᵀQ⁻¹A, −AᵀQ⁻¹ ], h_J = [ −AᵀQ⁻¹b ,
[ −Q⁻¹A, Q⁻¹ ]] Q⁻¹b ]
with b = control_term (the Bu shift; None → zero). The predict:
- Folds the incoming message into the x block — its precision into the
top-left
n×nofΛ_J, its potential into the topnofh_J(a block add during construction, not__add__). - Marginalizes x out, leaving the predicted message on x'.
In moment form this lands exactly on cov_pred = AΣAᵀ + Q and
mean_pred = Aμ + b.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
CanonicalGaussian
|
the incoming belief on x, as a |
required |
control_term
|
ArrayLike | None
|
b = Bu, shape |
None
|
Returns:
| Type | Description |
|---|---|
CanonicalGaussian
|
A |
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
tree_flatten ¶
Leaves for JAX: (dynamics, dynamics_noise), no static aux data.
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
tree_unflatten
classmethod
¶
tree_unflatten(
aux_data: None,
children: tuple[
Float64[Array, "n n"], Float64[Array, "n n"]
],
) -> GaussianTransition
Rebuild from leaves without validating — the leaves may be tracers.
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
GaussianObservation
dataclass
¶
Tier-1 likelihood factor N(y; Cx, R) — emits a message into the state.
Holds the fixed sensor map and noise; message(y) turns a reading into its
canonical-form contribution to the belief on x.
observation_matrix— C, shape(m, n).observation_noise— R, shape(m, m), positive-definite (it is inverted).
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
message ¶
The likelihood's message into x: Λ = CᵀR⁻¹C, h = CᵀR⁻¹y.
The information form of the reading — the evidence the observation injects
about the state. The measurement update is then prior_message + this
(CanonicalGaussian.__add__). A solve against R avoids forming R⁻¹;
the result is valid by construction, so it builds via the no-validate seam.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
observation
|
ArrayLike
|
the reading y, shape |
required |
state
|
ArrayLike | None
|
ignored — a fixed sensor's noise does not depend on the state. It is
accepted so the fixed and state-dependent factors share one |
None
|
Returns:
| Type | Description |
|---|---|
CanonicalGaussian
|
A |
CanonicalGaussian
|
potential |
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
linearize ¶
Local (C, R) — both constant; state is ignored (fixed sensor).
The shared seam with CallableGaussianObservation.linearize, so a caller can
read (C, R) off either factor without a type branch.
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
tree_flatten ¶
Leaves for JAX: (observation_matrix, observation_noise); no aux.
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
tree_unflatten
classmethod
¶
tree_unflatten(
aux_data: None,
children: tuple[
Float64[Array, "m n"], Float64[Array, "m m"]
],
) -> GaussianObservation
Rebuild from leaves without validating — the leaves may be tracers.
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
CallableGaussianObservation
dataclass
¶
CallableGaussianObservation(
observation_matrix: ArrayLike,
noise_fn: Callable[
[Float64[Array, n], PyTree], Float64[Array, "m m"]
],
noise_params: PyTree,
)
Likelihood factor with state-dependent noise N(y; Cx, R(x)) (issue #27).
The state-dependent sibling of GaussianObservation:
the observation map stays linear (constant C), but the noise covariance varies
with the state through
noise_fn(x, params) -> R(x). Evaluated at the predicted mean μ⁺ — which the
action moves — R is no longer action-invariant, so the FFG epistemic term stops
collapsing to LQR (ADR-003) and the chosen action can seek states where the sensor
is sharper (the dual effect, ADR-014 finding #1). message(y, state) emits the
same information-form message as the fixed factor, at the plugged-in R(state).
observation_matrix— C, shape(m, n)(constant); a traced pytree leaf.noise_fn—(x, params) -> R(x), a positive-definite(m, m)covariance; static aux (a callable cannot be a traced leaf, and keeping it static letsjitcache on it). Pass a module-level function, not a closure.noise_params— the sensor's tunables; a traced leaf, so the EFE is grad-able w.r.t. them (sensor learning). Keep every tunable here, not in a closure overnoise_fn, orjitcaching breaks.
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
message ¶
The likelihood's message into x, with R evaluated at the plug-in state.
Identical to GaussianObservation.message (Λ = CᵀR⁻¹C, h = CᵀR⁻¹y)
but for the one thing that makes the sensor state-dependent: R is taken at
state — the predicted mean μ⁺ — rather than fixed. A solve against
R(state) avoids forming its inverse; the result is valid by construction, so
it builds via the no-validate seam. A constant noise_fn reproduces the fixed
factor's message exactly (the reduction gate).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
observation
|
ArrayLike
|
the reading y, shape |
required |
state
|
ArrayLike | None
|
the state R is evaluated at (the predicted mean μ⁺), shape |
None
|
Returns:
| Type | Description |
|---|---|
CanonicalGaussian
|
A |
CanonicalGaussian
|
potential |
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
linearize ¶
Local (C, R(state)) — constant C, noise evaluated at the plug-in state.
The seam the FFG backend and EFE selector read R(μ⁺) from, per candidate
action, without reconstructing a message (mirrors CallableSensor.linearize).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state
|
ArrayLike
|
the state R is evaluated at (the predicted mean μ⁺), shape |
required |
Returns:
| Type | Description |
|---|---|
Float64[Array, 'm n']
|
|
Float64[Array, 'm m']
|
|
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
tree_flatten ¶
Leaves (traced): (observation_matrix, noise_params); aux noise_fn.
The callable cannot be a traced leaf, so it rides as aux (and staying static
lets jit cache on it); the sensor map and the tunable params are leaves, the
params grad-able for sensor learning.
Source code in src/cpomdp/ffg/factors/linear_gaussian.py
tree_unflatten
classmethod
¶
tree_unflatten(
aux_data: Callable[
[Float64[Array, n], PyTree], Float64[Array, "m m"]
],
children: tuple[Float64[Array, "m n"], PyTree],
) -> CallableGaussianObservation
Rebuild from leaves without validating — the leaves may be tracers.
Under jit/grad/vmap the leaves arrive as tracers, so the
construction-time PD probe (which needs a concrete R) is skipped here; it
already ran once when the factor was first built.