bayesmithDocumentation0.10.0 source

bayesmith.marginal.factorize

Source-derived signatures and contracts. See the reference index for coverage and conventions.

Module contract
Which latents survive an epoch, and which are integrated away inside it.

rheplicant declares this: a user tags each latent ``scope="global"``,
``"per_epoch"`` or ``"linked"``, and a ``Factorization`` partitions the space
by those tags. The migration spec's argument for rewriting B11 rather than
transplanting it is that bayesmith's graph already carries plates, so the
partition can be **derived** -- "which kills the whole error class
`factorize.py` exists for (the same space declared twice)".

**That argument is two-thirds right, and this module is written to the
measured version of it.** ``docs/probes/probe_10_b11_scope_derivation.py``:

* ``global`` and ``per_epoch`` really are a plate-membership question --
  ``node.plate`` is empty for one and names the epoch plate for the other.
* ``linked`` is expressible, and reaches the exact path in its NON-CENTRED
  form (iid innovations through a deterministic linear recursion). But in
  that form the innovations are per-epoch and the chain lives in a
  ``Deterministic`` node, so **``node.plate`` cannot tell a linked latent
  from an ordinary per-epoch one.**

Plate membership is therefore a hypothesis, not the answer, and this module
tests it rather than trusting it. :func:`epoch_leakage` asks the question the
fold actually depends on -- *does this latent touch any epoch but its own* --
and that is the same question for a linked latent, a mis-plated one, and a
model whose author simply did not think about it.

**What a leak can actually look like, measured.** Three facts about plates
narrow it, and each was found by building a fixture that did not work:

* a plated ``Deterministic``'s function sees a SCALAR for a plated parent, so
  a cross-epoch map cannot be written as a plated node at all;
* a plated node whose parents are ALL unplated is refused by the graph itself
  ("nothing to map over");
* so a leak needs a plated consumer with BOTH a plated parent and an unplated
  ``(E,)`` map. Then it broadcasts, and every epoch's observation depends on
  every epoch's latent.

The other reachable shape is an observation that is not plate-tagged at all:
its axis is an epoch axis only by the author's intention, and nothing in the
graph says so, which is exactly why it cannot be sliced. Both are refused,
and ``tests/marginal/test_factorize.py`` carries one fixture for each -- the
guard has two reachable branches and neither is decoration.

**Why it matters, measured.** A per-epoch nuisance is integrated exactly once,
inside its own epoch. One that also touches its neighbours is integrated E
times instead, and the result is a finite, plausible, WRONG evidence: on a
four-epoch fixture, folding a latent that leaks into the next epoch moves the
answer by 0.15 nats, and at sixteen and thirty-two epochs the gap runs to
several and then tens of nats with **no consistent sign**. At zero leakage
the fold is exact to the bit. Nothing downstream tests for it, which is why
this is a refusal rather than a diagnostic.

**A second refusal, added 2026-08-31, and found the same way.** The fold's
design is one jacobian of the observed predictions, taken at zeros. That is
the whole design ONLY when the predictions are affine in the survivors and
per-epoch latents jointly; on a map that is not, the jacobian describes the
point and not the model. Measured on `mu = g * n`: the jacobian at zeros is
identically zero, so the leakage probe above read 0.0, the folded term was a
finite constant independent of the data, and `log_prob({"g": x})` was bitwise
identical for x = -2.0, 0.0 and +2.0. The module's own docstrings had always
named `check_linearity` as the thing that says a non-affine map "cannot be
folded at all" -- nothing called it. :func:`factorize` does now, before the
leakage probe, because a leakage score is meaningless on a map whose jacobian
does not describe it.

LEAK_RTOL

Defined in bayesmith.marginal.factorize, line 83.

LEAK_RTOL: float = 1e-10

Factorization

Defined in bayesmith.marginal.factorize, line 86.

class Factorization(eqx.Module)
epoch_plate: str = eqx.field(static=True)
survivors: tuple[str, ...] = eqx.field(static=True)
per_epoch: tuple[str, ...] = eqx.field(static=True)
A campaign's latents, split by the extent of data they are constant over.

Attributes:
    epoch_plate: the plate whose axis indexes epochs.
    survivors: latents outside that plate -- constant over the whole
        campaign, so they are what a streamed term is over.
    per_epoch: latents inside it -- integrated away within their own
        epoch, and therefore never accumulated.

epoch_leakage

Defined in bayesmith.marginal.factorize, line 102.

epoch_leakage(graph: Graph, name: str, epoch_plate: str, at: dict[str, Any]) -> float
How far outside its own epoch a per-epoch latent reaches, relatively.

``0.0`` means the latent touches only its own epoch's observations, which
is what makes integrating it once per epoch the same as integrating it
once. Anything else means it does not.

The measurement is one forward-mode jacobian of the observed predictions
with respect to the latent -- the same map :func:`compress` will build a
design from -- so it asks about the model the fold will actually see,
not about a declaration.

Returns:
    ``max |off-epoch| / max |any|`` over every observed node, or ``0.0``
    if the latent moves nothing at all. Relative to the latent's OWN
    largest sensitivity, so a latent the model barely uses is judged on
    the same scale as one it leans on -- an absolute floor would wave
    through a leak in a weakly-used nuisance and refuse a well-used one
    for roundoff.

factorize

Defined in bayesmith.marginal.factorize, line 184.

factorize(graph: Graph, epoch_plate: str, *, at: dict[str, Any] | None=None, rtol: float=LEAK_RTOL) -> Factorization
Derive the campaign's factorization from the graph, and check it.

Plate membership gives the partition; :func:`epoch_leakage` and
:func:`~bayesmith.exact.linearity.check_linearity` decide whether it is
TRUE. Both are refusals rather than diagnostics, for measured reasons:

* a per-epoch latent that reaches its neighbours' data would be integrated
  once per epoch instead of once, and the wrong answer is finite and
  plausible;
* a map that is not affine in the survivors and per-epoch latents JOINTLY
  makes the fold's design -- one jacobian, taken at zeros -- describe the
  point rather than the model. Measured on `mu = g * n`: the jacobian
  at zeros is identically zero, so the leakage probe read 0.0 and the
  folded term was a finite constant independent of the data.

Affinity is checked BEFORE leakage, because the leakage score is one of
those jacobians at one point -- on a non-affine map it is meaningless.

Args:
    graph: the model.
    epoch_plate: the plate whose axis indexes epochs.
    at: values for the latents this check holds fixed. Defaults to zeros,
        which is enough because the leakage of an affine model does not
        depend on the point -- and a model that is not affine in its
        per-epoch latents cannot be folded at all.
    rtol: relative leakage above which a latent is refused.

Raises:
    StructureError: if the plate is not in the graph; if no latent
        survives the epoch, which would leave nothing to accumulate; if
        the observed predictions are not jointly affine in the survivors
        and per-epoch latents; or if a per-epoch latent is not
        epoch-local.

Type a concept or API name.