Skip to content

Diagnostics

Whether a state-dependent R(x) earns its keep is a question about the states an action can actually reach. A noise that varies only where no policy goes is, for every purpose the filter and the objective have, a constant. probe_model samples the reachable set and reports back a SensorReport: whether R is positive definite at every sample, whether it moves at all, and whether the epistemic value moves with it. The set is sampled, not exhausted, so a negative is evidence rather than proof.

probe_model

probe_model(
    model: LinearGaussianModel | ProbeBackend,
    belief: Belief,
    actions: Sequence[ArrayLike],
    *,
    tol: float = 1e-12,
) -> SensorReport

Probe a model's sensor over the predicted means a set of actions reaches.

Each action is pushed through the prediction step to get (μ⁻, Σ⁻); the sensor is linearized at each μ⁻ and the four conditions are evaluated over the resulting sample. This is the reachable set the conditions are about — vary actions to widen it.

Parameters:

Name Type Description Default
model LinearGaussianModel | ProbeBackend

a LinearGaussianModel, or a backend exposing predicted_belief / observation_noise_at / observation_model (CouplingGraphBackend does).

required
belief Belief

the belief to predict from — the shared prior every action starts at.

required
actions Sequence[ArrayLike]

the candidate actions to sample.

required
tol float

below this, two noise covariances or two epistemic values count as equal.

1e-12

Returns:

Type Description
SensorReport

Raises:

Type Description
ValueError

If actions is empty.

Source code in src/cpomdp/diagnostics.py
def probe_model(
    model: "LinearGaussianModel | ProbeBackend",
    belief: "Belief",
    actions: Sequence[ArrayLike],
    *,
    tol: float = 1e-12,
) -> SensorReport:
    """Probe a model's sensor over the predicted means a set of actions reaches.

    Each action is pushed through the prediction step to get ``(μ⁻, Σ⁻)``; the sensor is
    linearized at each ``μ⁻`` and the four conditions are evaluated over the resulting
    sample. This is the reachable set the conditions are about — vary ``actions`` to
    widen it.

    Args:
        model: a [`LinearGaussianModel`][cpomdp.LinearGaussianModel], or a backend
            exposing ``predicted_belief`` / ``observation_noise_at`` /
            ``observation_model`` ([`CouplingGraphBackend`][cpomdp.CouplingGraphBackend]
            does).
        belief: the belief to predict from — the shared prior every action starts at.
        actions: the candidate actions to sample.
        tol: below this, two noise covariances or two epistemic values count as equal.

    Returns:
        A [`SensorReport`][cpomdp.SensorReport].

    Raises:
        ValueError: If ``actions`` is empty.
    """
    if len(actions) == 0:
        raise ValueError("probe_model needs at least one action to predict under.")

    if hasattr(model, "predicted_belief"):  # a graph backend
        backend = cast("ProbeBackend", model)
        observation_matrix = np.asarray(backend.observation_model[0], dtype=float)
        predicted = [backend.predicted_belief(belief, np.asarray(a)) for a in actions]
        means = np.asarray([np.asarray(p.mean, dtype=float) for p in predicted])
        covs = [np.asarray(p.cov, dtype=float) for p in predicted]
        noises = [
            np.asarray(backend.observation_noise_at(mu), dtype=float) for mu in means
        ]
    else:  # a flat LinearGaussianModel
        observation_matrix = np.asarray(model.observation_matrix, dtype=float)
        dynamics_matrix = np.asarray(model.dynamics_matrix, dtype=float)
        prior_mean = np.asarray(belief.mean, dtype=float)
        prior_cov = np.asarray(belief.cov, dtype=float)
        # A control-free model goes nowhere under any action, which is itself an answer:
        # every sample lands on the same predicted mean and the noise cannot move.
        if model.control_matrix is None:
            means = np.asarray([dynamics_matrix @ prior_mean for _ in actions])
        else:
            control_matrix = np.asarray(model.control_matrix, dtype=float)
            means = np.asarray(
                [
                    dynamics_matrix @ prior_mean
                    + control_matrix @ np.asarray(a, dtype=float).ravel()
                    for a in actions
                ]
            )
        cov = dynamics_matrix @ prior_cov @ dynamics_matrix.T + np.asarray(
            model.dynamics_noise, dtype=float
        )
        covs = [cov] * len(actions)
        fixed = np.asarray(model.observation_noise, dtype=float)
        noises = _linearizations(
            model.observation_model, observation_matrix, means
        ) or [fixed] * len(actions)

    rank = int(np.linalg.matrix_rank(observation_matrix))
    n_obs = int(observation_matrix.shape[0])

    indefinite = tuple(
        tuple(float(v) for v in mu)
        for mu, r in zip(means, noises, strict=True)
        if not is_positive_definite(r)
    )

    spread = 0.0
    for i in range(len(noises)):
        for j in range(i + 1, len(noises)):
            spread = max(spread, float(np.max(np.abs(noises[i] - noises[j]))))

    epistemics = [
        epistemic_value(cov, observation_matrix, r)
        for cov, r in zip(covs, noises, strict=True)
    ]
    finite = [e for e in epistemics if np.isfinite(e)]
    lo, hi = (min(finite), max(finite)) if finite else (float("nan"), float("nan"))

    # The extreme pair is the one most likely to be ordered; a strictly ordered pair is
    # enough on its own, so there is no need to walk every combination.
    comparable = False
    if finite and len(noises) > 1:
        i_lo = int(np.argmin([e if np.isfinite(e) else np.inf for e in epistemics]))
        i_hi = int(np.argmax([e if np.isfinite(e) else -np.inf for e in epistemics]))
        comparable = loewner_order(noises[i_lo], noises[i_hi]) in ("a<b", "b<a")

    return SensorReport(
        n_samples=len(actions),
        rank=rank,
        n_observations=n_obs,
        full_row_rank=rank == n_obs,
        definite=not indefinite,
        indefinite_at=indefinite,
        non_constant=spread > tol,
        noise_spread=spread,
        epistemic_varies=bool(finite) and (hi - lo) > tol,
        epistemic_range=(lo, hi),
        loewner_comparable=comparable,
    )

SensorReport dataclass

SensorReport(
    n_samples: int,
    rank: int,
    n_observations: int,
    full_row_rank: bool,
    definite: bool,
    indefinite_at: tuple[tuple[float, ...], ...],
    non_constant: bool,
    noise_spread: float,
    epistemic_varies: bool,
    epistemic_range: tuple[float, float],
    loewner_comparable: bool,
)

What the sampled reachable set says about a model's sensor.

Attributes:

Name Type Description
n_samples int

how many predicted means were probed.

rank int

the rank of C.

n_observations int

the number of observation channels, C's row count.

full_row_rank bool

whether C has no redundant channels.

definite bool

whether R was positive definite at every sampled mean.

indefinite_at tuple[tuple[float, ...], ...]

the sampled means where it was not.

non_constant bool

whether R took more than one value across the samples.

noise_spread float

the largest pairwise distance between the sampled R.

epistemic_varies bool

whether the epistemic value differed across the samples.

epistemic_range tuple[float, float]

its smallest and largest sampled values, in nats.

loewner_comparable bool

whether the extreme pair of sampled R is strictly ordered — the cheap sufficient condition for the epistemic value to move.

flattens property

flattens: bool

Whether the sampled evidence says the sensor is a constant in disguise.

True when R never moved across the sampled means: the covariance recursion then never consults the action, so a fixed noise schedule reproduces the agent and the epistemic term cannot tell two policies apart.

summary

summary() -> str

A few lines a human can read, one per condition.

Source code in src/cpomdp/diagnostics.py
def summary(self) -> str:
    """A few lines a human can read, one per condition."""
    lo, hi = self.epistemic_range
    mark = {True: "yes", False: "NO "}
    return "\n".join(
        [
            f"sampled {self.n_samples} predicted mean(s)",
            f"  full row rank C   {mark[self.full_row_rank]}  "
            f"(rank {self.rank} of {self.n_observations})",
            f"  R positive def.   {mark[self.definite]}  "
            f"({len(self.indefinite_at)} failing sample(s))",
            f"  R non-constant    {mark[self.non_constant]}  "
            f"(spread {self.noise_spread:.3e})",
            f"  epistemic varies  {mark[self.epistemic_varies]}  "
            f"({lo:.6f} .. {hi:.6f} nats)",
            f"  Loewner-ordered   {mark[self.loewner_comparable]}",
        ]
    )

__str__

__str__() -> str

The same text summary returns.

Source code in src/cpomdp/diagnostics.py
def __str__(self) -> str:
    """The same text ``summary`` returns."""
    return self.summary()

A flat LinearGaussianModel and a graph backend reach their predicted means by different routes, so probe_model takes either. ProbeBackend is the three members it needs from the second.

ProbeBackend

Bases: Protocol

The three members probe_model reads off a graph backend.

Structural rather than a name, because three members is what the function requires. Any backend growing them works, and the diagnostic keeps its one-way dependency on the backends package. CouplingGraphBackend satisfies it.

observation_model property

observation_model: tuple[Array, Array]

(C, R) over the joint state.

predicted_belief

predicted_belief(
    prior: Belief, action: ArrayLike | None = None
) -> Belief

The belief one prediction step ahead under action.

Source code in src/cpomdp/diagnostics.py
def predicted_belief(
    self, prior: "Belief", action: ArrayLike | None = None
) -> "Belief":
    """The belief one prediction step ahead under ``action``."""
    ...

observation_noise_at

observation_noise_at(
    mean: ArrayLike,
) -> Float64[jax.Array, "m m"]

The stacked R linearized at a predicted mean.

Source code in src/cpomdp/diagnostics.py
def observation_noise_at(self, mean: ArrayLike) -> Float64[jax.Array, "m m"]:
    """The stacked ``R`` linearized at a predicted mean."""
    ...