Skip to content

Model structure

An optional, static declaration of a model's factorisation — factors, Markov-blanket roles, and observation channels — that you can inspect and validate() against the matrices. Declared but not yet exploited by the engine (ADR-010): the data and inspection surface is stable; validate() is experimental.

ModelStructure dataclass

ModelStructure(
    factors: _Pairs = (),
    roles: _Pairs = (),
    channels: _Pairs = (),
)

A static, hashable declaration of a model's factor / blanket / channel structure.

Three index groupings over a model with state dimension n and observation dimension m:

  • factors -- name -> state indices: which indices form which cause/block.
  • roles -- role -> state indices: the Markov-blanket typing of the state. The intended (epistemic, not metaphysical — RFC-003 section 7) vocabulary is "external" / "internal" / "active"; names are free-form labels, only partition-checked in v0.3 (the blanket independence test is v0.4).
  • channels -- name -> observation-row indices: the sensory typing of outputs.

Construct with the tuple form directly, or :meth:from_dicts for the dict form. Every field normalises to (("name", (idx, ...)), ...) so the whole object is hashable — it must be, it rides in the model's pytree aux_data. There is no construction-time _validate: every real check needs the model, so it lives in the opt-in :meth:validate.

Source code in src/cpomdp/structure.py
def __init__(
    self,
    factors: _Pairs = (),
    roles: _Pairs = (),
    channels: _Pairs = (),
) -> None:
    object.__setattr__(self, "factors", _freeze(factors))
    object.__setattr__(self, "roles", _freeze(roles))
    object.__setattr__(self, "channels", _freeze(channels))

factor_names property

factor_names: tuple[str, ...]

The declared factor names, in declaration order.

from_dicts classmethod

from_dicts(
    *,
    factors: Mapping[str, Sequence[int]] | None = None,
    roles: Mapping[str, Sequence[int]] | None = None,
    channels: Mapping[str, Sequence[int]] | None = None,
) -> ModelStructure

Build from the natural dict form, e.g. factors={"pos": [0, 1]}.

Source code in src/cpomdp/structure.py
@classmethod
def from_dicts(
    cls,
    *,
    factors: Mapping[str, Sequence[int]] | None = None,
    roles: Mapping[str, Sequence[int]] | None = None,
    channels: Mapping[str, Sequence[int]] | None = None,
) -> "ModelStructure":
    """Build from the natural dict form, e.g. ``factors={"pos": [0, 1]}``."""
    return cls(
        factors=() if factors is None else factors.items(),
        roles=() if roles is None else roles.items(),
        channels=() if channels is None else channels.items(),
    )

factor

factor(name: str) -> tuple[int, ...]

State indices of the named factor (raises KeyError if undeclared).

Source code in src/cpomdp/structure.py
def factor(self, name: str) -> tuple[int, ...]:
    """State indices of the named factor (raises ``KeyError`` if undeclared)."""
    return self._lookup(self.factors, name, "factor")

channel

channel(name: str) -> tuple[int, ...]

Observation-row indices of the named channel (raises KeyError).

Source code in src/cpomdp/structure.py
def channel(self, name: str) -> tuple[int, ...]:
    """Observation-row indices of the named channel (raises ``KeyError``)."""
    return self._lookup(self.channels, name, "channel")

role_of

role_of(index: int) -> str | None

The role typing state index, or None if no role contains it.

Source code in src/cpomdp/structure.py
def role_of(self, index: int) -> str | None:
    """The role typing state ``index``, or ``None`` if no role contains it."""
    for role, idx in self.roles:
        if index in idx:
            return role
    return None

summary

summary() -> str

A readable multi-line dump of the declared structure (does not print).

Source code in src/cpomdp/structure.py
def summary(self) -> str:
    """A readable multi-line dump of the declared structure (does not print)."""

    def block(title: str, groups: _Groups, unit: str) -> list[str]:
        if not groups:
            return [f"  {title}: (none)"]
        rows = [f"    {name} -> {unit} {idx}" for name, idx in groups]
        return [f"  {title}:", *rows]

    return "\n".join(
        [
            "ModelStructure(",
            *block("factors", self.factors, "states"),
            *block("roles", self.roles, "states"),
            *block("channels", self.channels, "rows"),
            ")",
        ]
    )

validate

validate(
    model: LinearGaussianModel, *, atol: float = 1e-09
) -> None

Raise if this declaration contradicts model (opt-in; EXPERIMENTAL).

Partition well-formedness (pure index arithmetic, a stable contract): declared factors and roles each partition the n-state space — every index in [0, n), pairwise disjoint, covering all of it; channels index valid, distinct observation rows in [0, m) but need not cover them. The full-coverage requirement for factors/roles is a strict but reversible choice (ADR-010), to relax if it proves a faff.

Conditional independence (EXPERIMENTAL): factors declared independent must have ≈0 cross-blocks in the dynamics A (and the fixed process noise Q) to atol, and a sensory channel must read within a single factor. A state-dependent Q(x) has no single matrix to check, so it is skipped. This criterion checks one-step blocks now and tightens to the rigorous precision-based (Σ⁻¹ block-diagonal) test in v0.4.

Not run at construction to remain lean; opt in via model.structure.validate(model).

Source code in src/cpomdp/structure.py
def validate(self, model: "LinearGaussianModel", *, atol: float = 1e-9) -> None:
    """Raise if this declaration contradicts ``model`` (opt-in; EXPERIMENTAL).

    Partition well-formedness (pure index arithmetic, a stable contract): declared
    factors and roles each partition the ``n``-state space — every index in
    ``[0, n)``, pairwise disjoint, covering all of it; channels index valid,
    distinct observation rows in ``[0, m)`` but need not cover them. The
    full-coverage requirement for factors/roles is a strict but reversible choice
    (ADR-010), to relax if it proves a faff.

    Conditional independence (EXPERIMENTAL): factors declared independent must have
    ≈0 cross-blocks in the dynamics ``A`` (and the fixed process noise ``Q``) to
    ``atol``, and a sensory channel must read within a single factor. A
    state-dependent ``Q(x)`` has no single matrix to check, so it is skipped. This
    criterion checks one-step blocks now and tightens to the rigorous
    precision-based (``Σ⁻¹`` block-diagonal) test in v0.4.

    Not run at construction to remain lean; opt in via
    ``model.structure.validate(model)``.
    """
    n_states = model.n_states
    n_observations = model.n_observations

    self._validate_partition(self.factors, n_states, "factor", require_cover=True)
    self._validate_partition(self.roles, n_states, "role", require_cover=True)
    self._validate_partition(
        self.channels, n_observations, "channel", require_cover=False
    )
    a_mat = np.asarray(model.dynamics)
    c_mat = np.asarray(model.sensor_model)
    # fixed Q only — a state-dependent Q(x) has no single matrix to check (skip it).
    q_mat = (
        None
        if model.process_noise is not None
        else np.asarray(model.dynamics_noise)
    )
    for name_i, idx_i in self.factors:
        for name_j, idx_j in self.factors:
            if name_i == name_j:
                continue
            self._assert_zero_block(a_mat, idx_i, idx_j, atol, "A", name_i, name_j)
            if q_mat is not None:
                self._assert_zero_block(
                    q_mat, idx_i, idx_j, atol, "dynamics_noise", name_i, name_j
                )
    if self.channels and self.factors:
        for ch_name, rows in self.channels:
            self._assert_channel_clean(c_mat, rows, self.factors, atol, ch_name)