Backends¶
The inference engine is swappable behind the InferenceBackend protocol. KalmanBackend is the default fast path; RxInferBackend — imported from cpomdp.backends.rxinfer and gated behind the optional rxinfer extra — re-derives the same answers through Julia and exists as an independent correctness oracle [^bagaev2023rxinfer].
InferenceBackend ¶
Bases: Protocol
A swappable inference engine for a LinearGaussianModel.
A backend is built from a model: any expensive, data-independent work
(front-loading — see DECISIONS.md ADR-002) happens at construction, so the
per-step infer_states stays cheap. Each call advances the belief one
recursive filter step: the current belief goes in as the prior and the
updated belief comes back as the posterior.
The Protocol is structural: any class with a matching infer_states (and the
model it was built from) is a backend, with no shared base class. This is the
abstraction wall — the native Kalman fast path and the RxInfer oracle are
interchangeable behind it, and neither's implementation (JAX, juliacall, …) leaks
into this signature.
infer_states ¶
Advance the belief by one filter step: prior in, posterior out.
Given the current belief (prior) and a new observation (plus the
action just taken, if the model has a control matrix), return the
updated belief.
Source code in src/cpomdp/backends/base.py
KalmanBackend ¶
KalmanBackend(
model: LinearGaussianModel,
*,
steady_state: bool = False,
tol: float = 1e-12,
max_iter: int = 1000,
)
Exact Kalman-filter inference for a LinearGaussianModel.
Implements the InferenceBackend protocol: constructed from a model,
then advances a belief one step at a time (prior in, posterior out) via
the standard predict/update recursion.
Two modes:
- Per-step (default): recomputes the Kalman gain and covariance every step from the incoming belief. Correct for any linear-Gaussian model, including transient (pre-convergence) behaviour. This is the analytic oracle the rest of the toolbox is validated against.
- Steady-state (
steady_state=True): solves the covariance recursion once at construction to a fixed point, then reuses the frozen gain and covariance every step. Cheap (no per-step covariance maths), but only valid for time-invariant models with regular complete observations. RaisesRuntimeErrorif the recursion does not converge withinmax_iter(i.e. the model is not stabilisable/detectable).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
LinearGaussianModel
|
The linear-Gaussian generative model to filter under. |
required |
steady_state
|
bool
|
If True, precompute and freeze the steady-state gain. |
False
|
tol
|
float
|
Convergence tolerance for the steady-state fixed point (absolute, on successive covariances). |
1e-12
|
max_iter
|
int
|
Cap on steady-state iterations before giving up. |
1000
|
Source code in src/cpomdp/backends/kalman.py
infer_states ¶
Advance the belief by one filter step.
Runs one predict/update cycle: step the prior through the dynamics
(applying action if the model has a control matrix), then correct the
prediction toward observation using the Kalman gain. In steady-state
mode the gain and covariance are the frozen fixed-point values; otherwise
they are recomputed from prior.cov on this step.
The numeric work is delegated to the jit-compiled module kernels
(_gain_and_posterior_cov, _posterior_mean); this method stays the
eager orchestrator that validates inputs and wraps the result in a
Belief.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
observation
|
ArrayLike
|
The latest sensor reading, shape |
required |
prior
|
Belief
|
The current belief, treated as this step's previous posterior. Never mutated. |
required |
action
|
ArrayLike | None
|
The action just taken, shape |
None
|
Returns:
| Type | Description |
|---|---|
Belief
|
The posterior belief — a new |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/cpomdp/backends/kalman.py
138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 | |
RxInferBackend ¶
Linear-Gaussian filtering via RxInfer.jl — the oracle backend.
Satisfies the InferenceBackend protocol: built from a model, advances a belief one step at a time. No steady-state mode — that belongs to the native fast path; this backend exists for correctness, not speed. The first instance built in a process loads the Julia runtime; later ones reuse it.
Source code in src/cpomdp/backends/rxinfer.py
infer_states ¶
Advance the belief one filter step: prior in, posterior out.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
observation
|
ArrayLike
|
Latest sensor reading, shape |
required |
prior
|
Belief
|
Current belief; never mutated. |
required |
action
|
ArrayLike | None
|
Action just taken, shape |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
On a shape/None mismatch (see |
Source code in src/cpomdp/backends/rxinfer.py
CouplingGraphBackend is the branching peer: message passing on a CouplingGraph rather than a chain. A state-dependent R(x) on a coupled node cannot be flattened to a fixed linear-Gaussian model, and asking it to raises IncompatibleLinearizationError.
CouplingGraphBackend ¶
CouplingGraphBackend(
graph: CouplingGraph,
transitions: Sequence[GaussianTransition],
*,
control_matrix: ArrayLike | None = None,
readout_node: int | None = None,
partition: Sequence[Sequence[int]] | None = None,
)
FFG message-passing inference on a branching linear-Gaussian tree.
Implements the InferenceBackend protocol for a
CouplingGraph whose nodes each carry their own temporal
dynamics. Constructed once from the graph and the per-node transitions, then
advances a joint belief over every node one step at a time (prior in, posterior
out). Read a single node back with marginal / readout.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
graph
|
CouplingGraph
|
the rooted tree — the structural couplings (the within-slice drive
|
required |
transitions
|
Sequence[GaussianTransition]
|
one |
required |
control_matrix
|
ArrayLike | None
|
B, shape |
None
|
readout_node
|
int | None
|
the node |
None
|
An observation passed to infer_states is the readings of the observed nodes
stacked in ascending node-index order, each node contributing its sensor's rows.
Validate the wiring and front-load the data-independent work (ADR-002).
Everything here depends only on the graph and the transitions, never on a
per-step observation / prior / action, so it is built once and reused across
every infer_states call.
Source code in src/cpomdp/backends/coupling.py
model
property
¶
The flat LinearGaussianModel of the real interface (the backend model).
Block-diagonal dynamics (F, Q), the real sensors (C, R), and the control — the
joint state as one linear-Gaussian model, carrying the metadata an
Agent reads (n_observations, control_matrix,
n_controls). The Agent derives
its own model from this, so passing the backend is enough (issue #26).
Distinct from to_flat_model(), which augments this with the structural
couplings as pseudo-observations for the oracle cross-check.
observation_model
property
¶
The real sensor (C, R) embedded in the joint state — what the EFE reads.
C (shape (n_observations, n_total)) reads the observed nodes out of the
joint state, R their noise. The real sensors, not the structural pseudo-
observations to_flat_model adds; the EFE pragmatic and epistemic terms
both read them.
observation_noise_at ¶
The stacked observation noise R at a predicted mean.
Each observed node's R is linearized at mean[node_block] (fixed sensors
ignore the mean and return their constant R); the block-diagonal stack is the
action-dependent R(μ⁺) the EFE selector folds per candidate (issue #27).
Source code in src/cpomdp/backends/coupling.py
infer_states ¶
Advance the joint belief by one filter step over the tree.
Validate the inputs, form the control shift b = control @ action, then run
the driven-relaxation pipeline: lift the joint prior into canonical form,
predict through the block-diagonal per-node dynamics (the temporal edges),
add the structural coupling precision and the per-node observation messages (the
within-slice update), and to_moment the joint posterior back.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
observation
|
ArrayLike
|
the observed nodes' readings, stacked in ascending node-index
order, shape |
required |
prior
|
Belief
|
the current joint belief over all nodes. Never mutated. |
required |
action
|
ArrayLike | None
|
the action just taken, shape |
None
|
Returns:
| Type | Description |
|---|---|
Belief
|
The posterior joint belief over all nodes; slice one node out with |
Belief
|
|
Source code in src/cpomdp/backends/coupling.py
partition_error ¶
The severed mass a step under this partition drops (ADR-016 diagnostic).
The norm of the between-cluster covariance blocks the carry zeros — how much
cross-cluster correlation the partition drops at the time boundary, the
approximation cost of the cut. A covariance magnitude, not bits and not a rate.
0.0 for the full-joint [[all]] partition (exact), growing as the cut
severs more correlation. The per-node marginals this slice are unaffected — the
carry only drops what is carried forward (ADR-017).
This is the eager convenience surface: it forces a host float and so is not
itself jit-able. For a per-run profile with no host syncs, stack the
_carry scalar inside a traced rollout instead.
Source code in src/cpomdp/backends/coupling.py
rollout ¶
rollout(
prior: Belief,
observations: ArrayLike,
actions: ArrayLike | None = None,
) -> tuple[Belief, jax.Array]
Filter a whole sequence, profiling the severed mass at each step (ADR-016).
One traced lax.scan pass with no per-step host syncs: the joint belief is
the scan carry, and each step emits its posterior and the severed mass its carry
drops. This is the per-run diagnostic a mutable per-step field could not give —
the profile is produced inside the traced rollout, not read off self.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prior
|
Belief
|
the joint belief the run starts from. |
required |
observations
|
ArrayLike
|
the stacked readings per step, shape |
required |
actions
|
ArrayLike | None
|
the per-step actions, shape |
None
|
Returns:
| Type | Description |
|---|---|
Belief
|
|
Array
|
|
tuple[Belief, Array]
|
|
tuple[Belief, Array]
|
severed-mass profile. The full-joint |
tuple[Belief, Array]
|
all-zero. |
Source code in src/cpomdp/backends/coupling.py
marginal ¶
The marginal belief at a single node — a pure slice of the joint belief.
The joint carried by infer_states makes every node exact, so a chosen node's
belief is a slice, no re-inference (issue #25: the target latent need not be the
root).
Source code in src/cpomdp/backends/coupling.py
readout ¶
block ¶
The state indices node node occupies in the joint (a public _block).
Lets a caller (e.g. the EFE selector) turn a node index into the joint-state
block the epistemic term targets — info_node node → block (issue #26).
Source code in src/cpomdp/backends/coupling.py
severed_efe_edges ¶
The EFE-relevant edges this carry partition cuts (ADR-018 diagnostic).
Each efe_relevant coupling whose parent and child fall in different clusters
— so the carry drops the cross-temporal covariance it holds, breaking the
integration of that information about the targeted latent. Non-empty means the
EFE selector must refuse this partition; empty for the exact [[all]] carry
and for any cut that only severs unflagged edges (e.g. the methylation cut). A
structural read, not a filter step — no covariance is formed here.
Source code in src/cpomdp/backends/coupling.py
predicted_belief ¶
The joint belief after dynamics + structural couplings, before observing.
The predict side of one step (Σ⁺, μ⁺): push the joint prior through the
per-node dynamics under action and fold in the structural couplings, but add
no observation. This is the covariance the EFE reads — a candidate action's
epistemic value is the info gain from this Σ⁺ to the post-observation covariance
(issue #26). Pure (no host-side validation), so it rides jit / vmap over
a grid of candidate actions.
Source code in src/cpomdp/backends/coupling.py
to_flat_model ¶
The tree flattened into one dense LinearGaussianModel (the oracle route).
The temporal edges become the block-diagonal transition (F, Q); the real
observations and the structural couplings stack into one sensor, each coupling
an always-zero pseudo-observation child − W·parent ~ N(0, Q_struct). Running
KalmanBackend or RxInferBackend on this
reproduces this backend's filter exactly — the independent cross-check, and what
the Phase-3 demo contrasts the native FFG against. Pad a step's readings with
flat_observation first; the returned prior is an unused placeholder (pass
the real prior per step).
With couplings present a state-dependent R(x) is a category error for this
route (IncompatibleLinearizationError — the flat Kalman linearizes at μ⁻,
the FFG at μ⁺). Coupling-free, μ⁻ = μ⁺, so the emitted model carries the graph's
own state-dependent sensor and stays faithful; it is a state-dependent flat
model, not a fixed one, and no fixed one exists.
The refusal reads the sensor's declared state-dependence. Whether R really
varies over the means a policy can reach is a property of the noise function and
the reachable set, which this cannot decide — cpomdp.diagnostics.probe_model
samples it.
Source code in src/cpomdp/backends/coupling.py
flat_observation ¶
Pad a step's readings with the structural pseudo-observations' zeros.
to_flat_model stacks the real observations then the structural couplings, so
a flat backend consumes [readings, zeros(n_structural)].
Source code in src/cpomdp/backends/coupling.py
IncompatibleLinearizationError ¶
Bases: ValueError
The flattened-Kalman route can't reproduce this backend's linearization.
to_flat_model encodes couplings as pseudo-observations, so the flat Kalman
linearizes R at the pre-coupling predicted mean μ⁻, while the FFG linearizes at
the coupling-resolved μ⁺ (issue #27, ADR-019). With a state-dependent R(x) and a
mean-shifting coupling, μ⁻ ≠ μ⁺ and no fixed flat model reproduces the filter. The
oracle is the standalone NumPy R(μ⁺) filter (backend tests) plus the single-node
CallableSensor cross-check.
[^bagaev2023rxinfer]: Dmitry Bagaev, Albert Podusenko, and Bert de Vries. RxInfer: a Julia package for reactive real-time Bayesian inference. Journal of Open Source Software, 8(84):5161, 2023. URL: https://doi.org/10.21105/joss.05161, doi:10.21105/joss.05161.