Skip to content

Core

Probabilistic-program transformations and automatic program inversion. See Program inversion for the concepts.

Program transformations

probjax.core.trace

trace(fun, traced_vars=None, *, sites=False, kind_labels='probabilistic')
Source code in probjax/core/transformation.py
def trace(
    fun: Callable,
    traced_vars=None,
    *,
    sites: bool = False,
    kind_labels: str = "probabilistic",
):
    base_fun = _get_base_fun(fun)
    get_jaxpr = _cached_jaxpr_getter(base_fun)
    interventions, observations, replay = _collect_stochastic_maps(fun)

    @wraps(fun)
    def wrapped(*args, **kwargs):
        processing_rule = TraceProcessingRule(
            traced_vars=traced_vars,
            sites=sites,
            interventions=interventions,
            observations=observations,
            replay=replay,
            kind_labels=kind_labels,
        )
        with enable_rv_tracing():
            jaxpr = get_jaxpr(*args, **kwargs)
        trace_result = cast(
            tuple[list, dict],
            interpret(
                jaxpr.jaxpr,
                jaxpr.consts,
                jaxpr.jaxpr.invars,
                _flatten_call_inputs(args, kwargs),
                jaxpr.jaxpr.outvars,
                process_eqn=processing_rule,
                reducer=trace_state_reducer,
                initial_state={},
                return_state=True,
            ),
        )
        traced_samples = trace_result[1]

        return traced_samples

    return wrapped

probjax.core.joint_sample

joint_sample(fun, rvs=None)

Samples all random variables called in the probabilistic function. If rvs is given, it only samples the random variables in rvs.

Parameters:

Name Type Description Default
fun Callable

Probabilistic function

required
rvs Optional[Iterable]

Subset of random variables in the probabilistic program. Defaults to None.

None

Returns:

Name Type Description
Callable Callable

Sampling function

Source code in probjax/core/transformation.py
def joint_sample(fun: Callable, rvs: Optional[Iterable] = None) -> Callable:
    """Samples all random variables called in the probabilistic function. If rvs is
    given, it only samples the random variables in rvs.

    Args:
        fun (Callable): Probabilistic function
        rvs (Optional[Iterable], optional): Subset of random variables in the
            probabilistic program. Defaults to None.

    Returns:
        Callable: Sampling function
    """
    base_fun = _get_base_fun(fun)
    get_jaxpr = _cached_jaxpr_getter(base_fun)
    interventions, observations, replay = _collect_stochastic_maps(fun)
    fixed_values = {}
    fixed_values.update(replay)
    fixed_values.update(observations)
    fixed_values.update(interventions)
    legacy_fixed = getattr(fun, "_probjax_interventions", None)

    def wrapped(*args, **kwargs):
        processing_rule = JointSampleProcessingRule(
            rvs=rvs,
            fixed_values=fixed_values,
            fixed_names=legacy_fixed,
        )
        with enable_rv_tracing():
            jaxpr = get_jaxpr(*args, **kwargs)
        joint_result = cast(
            tuple[list, dict],
            interpret(
                jaxpr.jaxpr,
                jaxpr.consts,
                jaxpr.jaxpr.invars,
                _flatten_call_inputs(args, kwargs),
                jaxpr.jaxpr.outvars,
                process_eqn=processing_rule,
                reducer=joint_sample_state_reducer,
                initial_state={},
                return_state=True,
            ),
        )
        joint_samples = joint_result[1]

        return joint_samples

    return wrapped

probjax.core.log_joint_fn

log_joint_fn(fun, *args, strict=True, allow_partial=False, **kwargs)

Compute the model log-joint (up to a constant).

Source code in probjax/core/transformation.py
def log_joint_fn(
    fun: Callable,
    *args,
    strict: bool = True,
    allow_partial: bool = False,
    **kwargs,
):
    """Compute the model log-joint (up to a constant)."""
    return log_potential_fn(
        fun,
        *args,
        strict=strict,
        allow_partial=allow_partial,
        **kwargs,
    )

probjax.core.log_potential_fn

log_potential_fn(fun, *args, strict=True, allow_partial=False, **kwargs)

Compute the unnormalized log density of a probabilistic function.

This is the legacy name for :func:log_joint_fn.

This does not include the normalizing constant.

Parameters:

Name Type Description Default
fun Callable

Probabilistic function

required

Returns:

Name Type Description
Callable

Log potential function

Source code in probjax/core/transformation.py
def log_potential_fn(
    fun: Callable,
    *args,
    strict: bool = True,
    allow_partial: bool = False,
    **kwargs,
):
    """Compute the unnormalized log density of a probabilistic function.

    This is the legacy name for :func:`log_joint_fn`.

    This does not include the normalizing constant.

    Args:
        fun (Callable): Probabilistic function

    Returns:
        Callable: Log potential function
    """
    base_fun = _get_base_fun(fun)
    interventions, observations, replay = _collect_stochastic_maps(fun)
    legacy_interventions = getattr(fun, "_probjax_interventions", None)

    with enable_rv_tracing():
        jaxpr = jax.make_jaxpr(base_fun)(jax.random.PRNGKey(0), *args, **kwargs)
    model_inputs = _flatten_call_inputs((jax.random.PRNGKey(0),) + args, kwargs)

    def log_potential(**joint_samples):
        intervention_config = interventions if interventions else legacy_interventions
        processing_rule = LogPotentialProcessingRule(
            joint_samples=joint_samples,
            interventions=intervention_config,
            observations=observations,
            replay=replay,
            strict=strict,
            allow_partial=allow_partial,
        )

        log_potential_result = cast(
            tuple[list, jax.Array],
            interpret(
                jaxpr.jaxpr,
                jaxpr.consts,
                jaxpr.jaxpr.invars,
                model_inputs,
                jaxpr.jaxpr.outvars,
                process_eqn=processing_rule,
                reducer=log_potential_state_reducer,
                initial_state=jnp.asarray(0.0),
                return_state=True,
            ),
        )
        log_prob = log_potential_result[1]

        return jnp.nan_to_num(log_prob, nan=-jnp.inf, posinf=jnp.inf, neginf=-jnp.inf)

    return log_potential

probjax.core.condition

condition(fun, observations)

Condition a probabilistic program on observed site values.

The preferred probabilistic name is :func:observe.

Source code in probjax/core/transformation.py
def condition(fun: Callable, observations: Mapping[str, Array]) -> Callable:
    """Condition a probabilistic program on observed site values.

    The preferred probabilistic name is :func:`observe`.
    """
    return _apply_stochastic_substitution(fun, update_observations=observations)

probjax.core.observe

observe(fun, observations)

Observe stochastic sites in a probabilistic program.

This is a probabilistic alias for :func:condition.

Source code in probjax/core/transformation.py
def observe(fun: Callable, observations: Mapping[str, Array]) -> Callable:
    """Observe stochastic sites in a probabilistic program.

    This is a probabilistic alias for :func:`condition`.
    """
    return condition(fun, observations)

probjax.core.intervene

intervene(fun, rvs)

Fix stochastic sites via intervention values.

This is equivalent to a causal do operation. The name do is the probabilistic alias for this function.

This does not sample the random variables, but fixes them to the given values.

The wrapped function uses interpreter-level overrides for the selected random variables while leaving all other equations unchanged.

Parameters:

Name Type Description Default
fun Callable

A function to transform.

required
rvs dict[str, Array]

A dictionary of random variable names and values to intervene.

required

Returns:

Name Type Description
Callable Callable

Wrapped probabilistic function with interventions.

Source code in probjax/core/transformation.py
def intervene(fun: Callable, rvs: Mapping[str, Array]) -> Callable:
    """Fix stochastic sites via intervention values.

    This is equivalent to a causal ``do`` operation. The name ``do`` is the
    probabilistic alias for this function.

    This does not sample the random variables, but fixes them to the given values.

    The wrapped function uses interpreter-level overrides for the selected
    random variables while leaving all other equations unchanged.

    Args:
        fun (Callable): A function to transform.
        rvs (dict[str, Array]): A dictionary of random variable names and values to
            intervene.

    Returns:
        Callable: Wrapped probabilistic function with interventions.
    """
    return _apply_stochastic_substitution(fun, update_interventions=rvs)

probjax.core.do

do(fun, interventions)

Apply a causal do intervention to stochastic sites.

This is a probabilistic alias for :func:intervene.

Source code in probjax/core/transformation.py
def do(fun: Callable, interventions: Mapping[str, Array]) -> Callable:
    """Apply a causal ``do`` intervention to stochastic sites.

    This is a probabilistic alias for :func:`intervene`.
    """
    return intervene(fun, interventions)

probjax.core.substitute

substitute(fun, values, mode='condition')

Substitute stochastic sites with fixed values.

Parameters:

Name Type Description Default
fun Callable

Probabilistic function.

required
values Mapping[str, Array]

Site-value mapping.

required
mode str

"condition" (or legacy "replay") includes substituted sites in log-probability; "do" (or legacy "intervene") applies intervention semantics and drops substituted-site log terms.

'condition'
Source code in probjax/core/transformation.py
def substitute(
    fun: Callable, values: Mapping[str, Array], mode: str = "condition"
) -> Callable:
    """Substitute stochastic sites with fixed values.

    Args:
        fun: Probabilistic function.
        values: Site-value mapping.
        mode: `"condition"` (or legacy `"replay"`) includes substituted sites
            in log-probability; `"do"` (or legacy `"intervene"`) applies
            intervention semantics and drops substituted-site log terms.
    """
    normalized_mode = _normalize_substitute_mode(mode)

    if normalized_mode == "condition":
        return _apply_stochastic_substitution(fun, update_replay=values)

    if normalized_mode == "do":
        return intervene(fun, values)

    raise AssertionError("unreachable")

probjax.core.scope

scope(name)

Create a naming scope for stochastic sites.

Source code in probjax/core/transformation.py
def scope(name: str):
    """Create a naming scope for stochastic sites."""
    return name_stack.scope(name)

Inversion

probjax.core.inverse

inverse(fun, static_argnums=(), invertible_arg=None, input_template=None)

Return a function computing the inverse of fun.

Traces fun to a jaxpr and walks it backwards, replacing each primitive with its registered inverse rule, so the result is ordinary JAX code with no interpreter left at runtime:

inverse(lambda x: 2 * jnp.exp(x))(jnp.asarray(4.0)) Array(0.6931472, dtype=float32)

Parameters:

Name Type Description Default
fun Callable

The function to invert. May itself be a custom_inverse, in which case its registered inverse is used directly.

required
static_argnums

Positional arguments held fixed rather than inverted.

()
invertible_arg

Which positional argument to solve for (default 0). A tuple of indices solves several arguments jointly -- e.g. invertible_arg=(0, 1) inverts (x, y) -> (x + y, x - y) back to (x, y) -- and returns them as a tuple in order.

None
input_template

Optional tracing example. It must preserve boundary shapes; templates cannot enable expansion or other shape-changing inverses.

None

Returns:

Type Description

A callable mapping outputs back to the invertible argument.

Raises:

Type Description
ValueError

if traced input/output shapes or pytree structures differ. Automatic inversion uses the supplied output as the presumed input signature; it requires a shape-preserving function. Some violations (such as a broadcast that becomes an identity at that signature) cannot be detected without the original input specification.

Note

Detectable boundary shape/tree mismatches raise ValueError. Elementwise affine sections use symbolic coefficients, without Jacobians. Affine sections compose with nonlinear inverse rules; analysis and schedules are cached, including normalized nested jit programs.

An unresolved inversion returns NaN. The interpreter works one equation at a time, so it inverts a tree of operations; a value used twice stalls it, because the bivariate rules need exactly one unknown operand.

On a stall, structurally proven affine sections are recovered by linear solves and propagation resumes. This supports exp(3*x-x) and 3*exp(x)-exp(x), including sequential compositions and nested jit. Pointwise maps invert elementwise in O(n); small coupled maps build the matrix with one vmapped sweep; large coupled maps solve matrix-free. When a stall cannot be sectioned, a whole-program affine solve replaces NaN with a value. Analysis and scheduling decisions are cached; generated inverses are ordinary JAX computations and can be jitted, vmapped, and differentiated.

These remain silently unsupported and produce NaN:

  • a value used more than once nonlinearly -- x * x, or a residual x + f(x). A residual is invertible by fixed-point iteration when its branch is a contraction, but a jaxpr carries no Lipschitz bound, so register it with :class:custom_inverse instead.
  • lax.fori_loop, and any lax.scan carrying something that is not itself invertible (a counter, a running sum). Plain scan and lax.cond do work.
  • inverse(inverse(f)).

lax.while_loop raises rather than returning NaN. Checking jnp.isfinite on the result is the reliable way to detect the rest.

Source code in probjax/core/transformation.py
def inverse(fun: Callable, static_argnums=(), invertible_arg=None, input_template=None):
    """Return a function computing the inverse of ``fun``.

    Traces ``fun`` to a jaxpr and walks it backwards, replacing each primitive
    with its registered inverse rule, so the result is ordinary JAX code with no
    interpreter left at runtime:

    >>> inverse(lambda x: 2 * jnp.exp(x))(jnp.asarray(4.0))
    Array(0.6931472, dtype=float32)

    Args:
        fun: The function to invert. May itself be a ``custom_inverse``, in
            which case its registered inverse is used directly.
        static_argnums: Positional arguments held fixed rather than inverted.
        invertible_arg: Which positional argument to solve for (default 0).
            A tuple of indices solves several arguments jointly -- e.g.
            ``invertible_arg=(0, 1)`` inverts ``(x, y) -> (x + y, x - y)``
            back to ``(x, y)`` -- and returns them as a tuple in order.
        input_template: Optional tracing example. It must preserve boundary shapes;
            templates cannot enable expansion or other shape-changing inverses.

    Returns:
        A callable mapping outputs back to the invertible argument.

    Raises:
        ValueError: if traced input/output shapes or pytree structures differ.
            Automatic inversion uses the supplied output as the presumed input
            signature; it requires a shape-preserving function. Some violations
            (such as a broadcast that becomes an identity at that signature)
            cannot be detected without the original input specification.

    Note:
        Detectable boundary shape/tree mismatches raise ValueError.
        Elementwise affine sections use symbolic coefficients, without Jacobians.
        Affine sections compose with nonlinear inverse rules; analysis and
        schedules are cached, including normalized nested jit programs.

        **An unresolved inversion returns NaN.** The interpreter works
        one equation at a time, so it inverts a *tree* of operations; a value
        used twice stalls it, because the bivariate rules need exactly one
        unknown operand.

        On a stall, structurally proven affine sections are recovered by linear
        solves and propagation resumes. This supports ``exp(3*x-x)`` and
        ``3*exp(x)-exp(x)``, including sequential compositions and nested jit.
        Pointwise maps invert elementwise in O(n); small coupled maps build
        the matrix with one vmapped sweep; large coupled maps solve
        matrix-free. When a stall cannot be sectioned, a whole-program affine
        solve replaces NaN with a value. Analysis and scheduling decisions
        are cached; generated inverses are ordinary JAX computations and can
        be jitted, vmapped, and differentiated.


        These remain silently unsupported and produce NaN:

        * a value used more than once **nonlinearly** -- ``x * x``, or a
          residual ``x + f(x)``. A residual is invertible by fixed-point
          iteration when its branch is a contraction, but a jaxpr carries no
          Lipschitz bound, so register it with :class:`custom_inverse` instead.
        * ``lax.fori_loop``, and any ``lax.scan`` carrying something that is not
          itself invertible (a counter, a running sum). Plain ``scan`` and
          ``lax.cond`` do work.
        * ``inverse(inverse(f))``.

        ``lax.while_loop`` raises rather than returning NaN. Checking
        ``jnp.isfinite`` on the result is the reliable way to detect the rest.
    """
    if input_template is not None and isinstance(fun, custom_inverse):
        raise ValueError("input_template has no effect for custom_inverse inputs")
    maybe_custom = maybe_inverse_custom_inverse(
        fun,
        static_argnums=static_argnums,
        invertible_arg=invertible_arg,
    )
    if maybe_custom is not None:
        return maybe_custom

    get_jaxpr = _cached_jaxpr_getter(
        fun, static_argnums=static_argnums, return_shape=True
    )
    recovery_cache = {}
    schedule_cache = {}

    @wraps(fun)
    def wrapped(*args, **kwargs):
        processing_rule = InverseProcessingRule()
        (
            jaxpr,
            known_invars,
            target_invars,
            args_for_propagate,
            target_trees,
        ) = _trace_inverse_program(
            get_jaxpr, args, kwargs, invertible_arg, static_argnums, input_template
        )
        graph = cached_inverse_graph(jaxpr, target_invars, recovery_cache)
        if input_template is not None and not _outputs_match_program(
            args_for_propagate[len(known_invars) :], jaxpr.jaxpr.outvars
        ):
            return _unflatten_targets(
                target_trees, _unresolvable_targets(target_invars)
            )
        out, env = cast(
            tuple[list, Any],
            propagate(
                graph.jaxpr,
                graph.consts,
                known_invars + graph.jaxpr.outvars,
                args_for_propagate,
                target_invars,
                process_eqn=processing_rule,
                schedule_cache=schedule_cache,
                stall_recovery=make_affine_recovery(
                    graph,
                    known_invars,
                    args_for_propagate,
                    target_invars,
                    processing_rule,
                    recovery_cache,
                    schedule_cache=schedule_cache,
                    with_logdet=isinstance(
                        processing_rule, InverseAndLogAbsDetProcessingRule
                    ),
                ),
                cost_fn=inverse_cost_fn,
                process_all_eqns=True,
                return_env=True,
            ),
        )
        out, complete = _materialize_inverse_targets(out, target_invars, env)
        if not complete:
            solved = _affine_fallback(
                jaxpr,
                known_invars,
                args_for_propagate,
                target_invars,
                need_logdet=False,
            )
            if solved is not None:
                out = solved[0]

        return _unflatten_targets(target_trees, out)

    return wrapped

probjax.core.inverse_and_logabsdet

inverse_and_logabsdet(fun, static_argnums=(), invertible_arg=None, input_template=None)

Return a function computing the inverse of fun and its log-det.

The log-determinant is that of the inverse map -- log|d(inv)/dy|, summed over the event -- which is the term a change of variables needs:

inverse_and_logabsdet(lambda x: 2 * jnp.exp(x))(jnp.asarray(4.0)) (Array(0.6931472, dtype=float32), Array(-1.3862944, dtype=float32))

Parameters:

Name Type Description Default
fun Callable

The function to invert, possibly a custom_inverse.

required
static_argnums

Positional arguments held fixed rather than inverted.

()
invertible_arg

Which positional argument to solve for (default 0). A tuple of indices solves several arguments jointly and returns them as a tuple in order.

None
input_template

Optional tracing example. It must preserve boundary shapes; templates cannot enable expansion or other shape-changing inverses.

None

Returns:

Type Description

(inverse_value, log_abs_det). Both are NaN if the inversion could

not be completed.

Raises:

Type Description
ValueError

if traced boundary shapes or pytree structures differ, with the same input-signature limitation as :func:inverse.

NotImplementedError

if a primitive on the inverse path has no log-determinant rule and is not elementwise. Guessing one by differentiating the inverse elementwise -- the old behaviour -- silently returned a number that was not a log-determinant.

Note

Everything :func:inverse cannot do applies here too, and the log-det additionally requires a rule for every primitive involved. A primitive that inverts fine may still have no log-det: dynamic_slice recovers only its window, leaving the input partially known and no square Jacobian to take a determinant of.

Note

When the program is structurally volume-preserving in the target -- rearrangements, translations, neg, ±1 scalings -- the log-det is proven zero from the jaxpr and no accumulation is staged at all, so the compiled inverse matches a hand-written one equation for equation.

Source code in probjax/core/transformation.py
def inverse_and_logabsdet(
    fun: Callable, static_argnums=(), invertible_arg=None, input_template=None
):
    """Return a function computing the inverse of ``fun`` and its log-det.

    The log-determinant is that of the **inverse** map -- ``log|d(inv)/dy|``,
    summed over the event -- which is the term a change of variables needs:

    >>> inverse_and_logabsdet(lambda x: 2 * jnp.exp(x))(jnp.asarray(4.0))
    (Array(0.6931472, dtype=float32), Array(-1.3862944, dtype=float32))

    Args:
        fun: The function to invert, possibly a ``custom_inverse``.
        static_argnums: Positional arguments held fixed rather than inverted.
        invertible_arg: Which positional argument to solve for (default 0).
            A tuple of indices solves several arguments jointly and returns
            them as a tuple in order.
        input_template: Optional tracing example. It must preserve boundary shapes;
            templates cannot enable expansion or other shape-changing inverses.

    Returns:
        ``(inverse_value, log_abs_det)``. Both are NaN if the inversion could
        not be completed.

    Raises:
        ValueError: if traced boundary shapes or pytree structures differ,
            with the same input-signature limitation as :func:`inverse`.
        NotImplementedError: if a primitive on the inverse path has no
            log-determinant rule and is not elementwise. Guessing one by
            differentiating the inverse elementwise -- the old behaviour --
            silently returned a number that was not a log-determinant.

    Note:
        Everything :func:`inverse` cannot do applies here too, and the log-det
        additionally requires a rule for every primitive involved. A primitive
        that inverts fine may still have no log-det: ``dynamic_slice`` recovers
        only its window, leaving the input partially known and no square
        Jacobian to take a determinant of.

    Note:
        When the program is structurally volume-preserving in the target --
        rearrangements, translations, ``neg``, ``±1`` scalings -- the log-det
        is proven zero from the jaxpr and no accumulation is staged at all, so
        the compiled inverse matches a hand-written one equation for equation.
    """
    if input_template is not None and isinstance(fun, custom_inverse):
        raise ValueError("input_template has no effect for custom_inverse inputs")
    maybe_custom = maybe_inverse_custom_inverse(
        fun,
        static_argnums=static_argnums,
        invertible_arg=invertible_arg,
    )
    if maybe_custom is not None:

        @wraps(fun)
        def custom_wrapped(*args, **kwargs):
            value, logdet = fun.inv_and_logdet(*args, **kwargs)
            total_logdet = sum(
                (jnp.sum(leaf) for leaf in jax.tree_util.tree_leaves(logdet)),
                jnp.asarray(0.0),
            )
            return value, total_logdet

        return custom_wrapped

    get_jaxpr = _cached_jaxpr_getter(
        fun, static_argnums=static_argnums, return_shape=True
    )
    recovery_cache = {}
    schedule_cache = {}

    @wraps(fun)
    def wrapped(*args, **kwargs):
        processing_rule = InverseAndLogAbsDetProcessingRule(
            state_namespace=INVERSE_AND_LOGABSDET_STATE_NAMESPACE
        )
        (
            jaxpr,
            known_invars,
            target_invars,
            args_for_propagate,
            target_trees,
        ) = _trace_inverse_program(
            get_jaxpr, args, kwargs, invertible_arg, static_argnums, input_template
        )
        graph = cached_inverse_graph(jaxpr, target_invars, recovery_cache)
        invars = known_invars + graph.jaxpr.outvars
        outvars = target_invars

        if input_template is not None and not _outputs_match_program(
            args_for_propagate[len(known_invars) :], jaxpr.jaxpr.outvars
        ):
            return _unflatten_targets(
                target_trees, _unresolvable_targets(target_invars)
            ), jnp.asarray(jnp.nan)

        if is_volume_preserving(jaxpr.jaxpr, target_invars):
            # Proven |det J| = 1 from the jaxpr structure, so the log-det is
            # exactly zero and the accumulation machinery below would only
            # stage per-equation `+ 0.0`s. Run the plain inverse propagation
            # instead; on incompleteness fall through to the full path so
            # failure semantics (NaN, affine fallback) are unchanged.
            out, env = cast(
                tuple[list, Any],
                propagate(
                    jaxpr.jaxpr,
                    jaxpr.consts,
                    known_invars + jaxpr.jaxpr.outvars,
                    args_for_propagate,
                    target_invars,
                    process_eqn=InverseProcessingRule(),
                    cost_fn=inverse_cost_fn,
                    process_all_eqns=True,
                    return_env=True,
                ),
            )
            out, complete = _materialize_inverse_targets(out, target_invars, env)
            if complete:
                return _unflatten_targets(target_trees, out), jnp.asarray(0.0)

        inverse_result = cast(
            tuple[list, dict, Any],
            propagate(
                graph.jaxpr,
                graph.consts,
                invars,
                args_for_propagate,
                outvars,
                process_eqn=processing_rule,
                schedule_cache=schedule_cache,
                stall_recovery=make_affine_recovery(
                    graph,
                    known_invars,
                    args_for_propagate,
                    target_invars,
                    processing_rule,
                    recovery_cache,
                    schedule_cache=schedule_cache,
                    with_logdet=isinstance(
                        processing_rule, InverseAndLogAbsDetProcessingRule
                    ),
                ),
                cost_fn=inverse_cost_fn,
                process_all_eqns=True,
                reducer=inverse_and_logabsdet_state_reducer,
                initial_state={},
                return_state=True,
                return_env=True,
                state_namespace=INVERSE_AND_LOGABSDET_STATE_NAMESPACE,
            ),
        )
        out, log_dets, env = inverse_result
        out, complete = _materialize_inverse_targets(out, outvars, env)

        log_det = _sum_log_dets_for_vars(log_dets, outvars)
        if not complete:
            solved = _affine_fallback(jaxpr, known_invars, args_for_propagate, outvars)
            if solved is not None:
                out, log_det = solved
            else:
                log_det = jnp.asarray(jnp.nan)
        return _unflatten_targets(target_trees, out), log_det

    return wrapped

probjax.core.custom_inverse

Attach a custom inverse (and optional log-det) to a function via a primitive.

  • Plain (non-traced) calls: direct call to fun.
  • Under JAX transforms: emits custom_inverse_call_p with
    • forward_jaxpr: closed JAXPR of forward
    • inverse_jaxpr_thunk: lazy constructor for inverse JAXPR The thunk is only ever called by your inverse interpreter.
Source code in probjax/core/custom_primitives/custom_inverse.py
class custom_inverse:
    """
    Attach a custom inverse (and optional log-det) to a function via a primitive.

    - Plain (non-traced) calls: direct call to `fun`.
    - Under JAX transforms:
        emits `custom_inverse_call_p` with
          * `forward_jaxpr`: closed JAXPR of forward
          * `inverse_jaxpr_thunk`: lazy constructor for inverse JAXPR
        The thunk is only ever called by your inverse interpreter.
    """

    def __init__(self, fun: Callable, inv_argnum=0, static_argnums=None) -> None:
        update_wrapper(self, fun)
        self.fun = fun
        self.inv_argnum = inv_argnum
        self.static_argnums = (
            None if static_argnums is None else tuple(sorted(static_argnums))
        )

        self.inv_fun = None
        self.inv_fun_and_log_det = None
        self._logdet_registered = False
        self.value_and_logdet_fun = None

        # Per-instance cached builder.
        @lru_cache(maxsize=2048)
        def _trace(
            dyn_idxs: Tuple[int, ...],
            static_idxs: Tuple[int, ...],
            in_tree,
            in_avals: Tuple[Any, ...],
            static_args_key: Tuple[Any, ...],
            params_key: Tuple[Tuple[str, Any], ...],
        ):
            static_args = static_args_key
            params = dict(params_key)
            return self._build_jaxprs_for_signature(
                dyn_idxs, static_idxs, in_tree, in_avals, static_args, params
            )

        self._trace = _trace

    # ----- registration API -----

    def _clear_cache(self):
        self._trace.cache_clear()

    def _warn_if_replacing(self, what: str, already: bool) -> None:
        """Warn only on a genuine double registration.

        Calling ``definv`` and then ``definv_and_logdet`` is the intended way to
        supply both, so that pair must stay silent. Calling the *same* one twice
        discards the first, which is almost always a module imported twice or a
        decorator applied in a loop.
        """
        if not already:
            return
        name = getattr(self.fun, "__name__", str(self.fun))
        warnings.warn(
            f"{what} was called twice for {name}; the earlier inverse is "
            "discarded.",
            RuntimeWarning,
            stacklevel=3,
        )

    def definv(self, inv_fun: Callable) -> Callable:
        """Define inverse; log-det defaults to NaN."""
        self._warn_if_replacing("definv", self.inv_fun is not None)

        def inv_and_ld(*a, **k):
            return inv_fun(*a, **k), jnp.nan

        self.inv_fun = inv_fun
        self.inv_fun_and_log_det = inv_and_ld
        self._clear_cache()
        return inv_and_ld

    def definv_and_logdet(self, inv_fun_and_log_det: Callable) -> Callable:
        """Define inverse that returns (x, logdet)."""
        self._warn_if_replacing(
            "definv_and_logdet", getattr(self, "_logdet_registered", False)
        )
        self._logdet_registered = True
        self.inv_fun_and_log_det = inv_fun_and_log_det
        if self.inv_fun is None:
            self.inv_fun = lambda *a, **k: inv_fun_and_log_det(*a, **k)[0]
        self._clear_cache()
        return inv_fun_and_log_det

    def defvalue_and_logdet(self, value_and_logdet_fun: Callable) -> Callable:
        """Optionally expose forward value_and_logdet."""
        self.value_and_logdet_fun = value_and_logdet_fun
        return value_and_logdet_fun

    # ----- Python-level helpers -----

    def inv(self, *a, **k):
        if self.inv_fun is None:
            raise AttributeError("Inverse not defined. Use definv/definv_and_logdet.")
        return self.inv_fun(*a, **k)

    def inv_and_logdet(self, *a, **k):
        if self.inv_fun_and_log_det is None:
            raise AttributeError("Inverse+logdet not defined.")
        return self.inv_fun_and_log_det(*a, **k)

    def value_and_logdet(self, *a, **k):
        if self.value_and_logdet_fun is None:
            raise AttributeError("value_and_logdet not defined.")
        return self.value_and_logdet_fun(*a, **k)

    # ----- core tracing helper -----

    def _build_jaxprs_for_signature(
        self,
        dyn_idxs: Tuple[int, ...],
        static_idxs: Tuple[int, ...],
        in_tree,
        in_avals: Tuple[Any, ...],
        static_args: Tuple[Any, ...],
        params: dict,
    ):
        """Given a call signature (no concrete values), build lazy forward jaxpr + inverse thunk.

        Both forward and inverse jaxprs are now lazy - they are only traced when
        actually needed (during impl/abstract_eval for forward, or during inverse
        interpretation for inverse).
        """
        dyn_idxs = tuple(dyn_idxs)
        static_idxs = tuple(static_idxs)
        static_args = tuple(static_args)

        n_args = len(dyn_idxs) + len(static_idxs)
        if len(static_args) != len(static_idxs):
            raise ValueError("Mismatch between static_argnums and static_args.")
        if set(dyn_idxs) | set(static_idxs) != set(range(n_args)):
            raise ValueError("dyn_idxs/static_argnums must partition positional args.")

        static_pos_to_val = {idx: static_args[i] for i, idx in enumerate(static_idxs)}

        def assemble_args(dyn_args_tuple):
            # dyn_args_tuple has len == len(dyn_idxs), in that order.
            full = [None] * n_args
            # place statics
            for idx, val in static_pos_to_val.items():
                full[idx] = val
            # place dynamics
            for j, v in enumerate(dyn_args_tuple):
                full[dyn_idxs[j]] = v
            return tuple(full)

        # Inverted arg must be dynamic. Negative indices count from the end,
        # as they do for `inverse(..., invertible_arg=-1)`.
        inv_argnum = self.inv_argnum
        if inv_argnum < 0:
            inv_argnum += n_args
        if not 0 <= inv_argnum < n_args:
            raise ValueError(
                f"inv_argnum={self.inv_argnum} is out of range for a call with "
                f"{n_args} positional arguments."
            )
        if inv_argnum not in dyn_idxs:
            raise ValueError(
                f"inv_argnum={self.inv_argnum} refers to argument {inv_argnum}, "
                f"which is listed in static_argnums ({static_idxs}). The "
                "argument being inverted has to be dynamic."
            )
        inv_argnum_dyn_index = dyn_idxs.index(inv_argnum)
        leaf_indices = tree_unflatten(in_tree, tuple(range(len(in_avals))))
        target_in_indices = tuple(tree_leaves(leaf_indices[inv_argnum_dyn_index]))
        target_tree = tree_structure(leaf_indices[inv_argnum_dyn_index])
        target_avals = tuple(in_avals[i] for i in target_in_indices)
        fun_label = getattr(self.fun, "__name__", str(self.fun))

        # ---------- lazy forward jaxpr ----------
        def forward_jaxpr_thunk():
            def f_dyn(*dyn_args_tuple):
                return self.fun(*assemble_args(dyn_args_tuple), **params)

            fun_name = getattr(self.fun, "__name__", str(self.fun))
            forward_jaxpr, out_avals, out_tree = trace_to_closed_jaxpr(
                f_dyn,
                in_tree=in_tree,
                in_avals=in_avals,
                debug_name="custom_inverse forward",
                const_context=f"custom_inverse forward ({fun_name})",
            )

            if not out_avals:
                raise ValueError("custom_inverse expects at least one output.")

            return forward_jaxpr, out_avals, out_tree

        lazy_forward = Lazy(forward_jaxpr_thunk)

        # ---------- lazy inverse jaxpr thunk ----------
        def inverse_jaxpr_thunk():
            if self.inv_fun_and_log_det is None:
                raise ValueError(
                    "Inverse JAXPR requested, but no inverse was registered via "
                    "definv/definv_and_logdet."
                )

            _, out_avals, out_tree = lazy_forward.get()

            structured_in_avals = list(tree_unflatten(in_tree, in_avals))
            structured_in_avals[inv_argnum_dyn_index] = tree_unflatten(
                out_tree, out_avals
            )
            inverse_in_tree = tree_structure(tuple(structured_in_avals))
            inverse_in_avals = tuple(tree_leaves(tuple(structured_in_avals)))

            def inv_dyn(*dyn_args_tuple):
                full = assemble_args(dyn_args_tuple)
                result, logdet = self.inv_fun_and_log_det(*full, **params)
                result_leaves, result_tree = tree_flatten(result)
                if result_tree != target_tree:
                    raise ValueError(
                        f"custom_inverse {fun_label}: the registered inverse "
                        f"returned {result_tree}, but the invertible argument "
                        f"is {target_tree}. They must match."
                    )
                # Shape and dtype too, not just structure. A tree-compatible
                # result of the wrong shape used to be accepted, and the engine
                # went on to hand back an inverse of the wrong size.
                _check_inverse_avals(result_leaves, target_avals, fun_label)

                logdet_leaves = tree_leaves(logdet)
                if not logdet_leaves:
                    raise ValueError("custom_inverse logdet must contain a value")
                total_logdet = sum(
                    (jnp.sum(jnp.asarray(value)) for value in logdet_leaves),
                    jnp.asarray(0.0),
                )
                # Preserve additive vmap semantics even when the registered
                # logdet is numerically independent of mapped inputs: the term
                # is always zero, but referencing every dynamic argument makes
                # vmap batch the log-det along with them.
                #
                # The obvious spelling, `0.0 * value`, is NaN whenever the
                # argument is inf or NaN -- so a non-finite value in an argument
                # the log-det provably does not use would poison it, far from
                # wherever the NaN came from. Two alternatives do not work:
                # `zeros_like` is folded to a literal while tracing, which drops
                # the edge and un-batches the log-det, and a select with two
                # zero branches crashes the XLA compiler on larger programs.
                # Clamping the value finite first keeps a real edge and makes
                # the multiply exact.
                dependency = sum(
                    (
                        jnp.asarray(0.0) * jnp.nan_to_num(jnp.sum(jnp.asarray(value)))
                        for value in tree_leaves(dyn_args_tuple)
                    ),
                    jnp.asarray(0.0),
                )
                return tree_unflatten(target_tree, result_leaves), (
                    total_logdet + dependency
                )

            inv_name = getattr(
                self.inv_fun_and_log_det, "__name__", "custom_inverse inverse"
            )
            inverse_jaxpr, _, _ = trace_to_closed_jaxpr(
                inv_dyn,
                in_tree=inverse_in_tree,
                in_avals=inverse_in_avals,
                debug_name="custom_inverse inverse",
                const_context=f"custom_inverse inverse ({inv_name})",
            )
            return inverse_jaxpr

        lazy_inverse = LazyClosedJaxpr(inverse_jaxpr_thunk)

        return lazy_forward, inv_argnum_dyn_index, target_in_indices, lazy_inverse

    # ----- transformed call -----

    def __call__(self, *args, **kwargs) -> Any:
        name = getattr(self.fun, "__name__", str(self.fun))
        if self.inv_fun_and_log_det is None:
            raise AttributeError(
                f"No inverse defined for custom_inverse function {name}; "
                f"use definv or definv_and_logdet first."
            )

        # Fast path: outside any trace, with no tracers -> plain Python.
        # Both conditions matter; see ``must_emit_primitive``. An explicit
        # opt-out takes the same path even inside a trace, so no
        # ``custom_inverse_call_p`` is ever emitted while disabled.
        if not custom_inverse_enabled() or not must_emit_primitive((args, kwargs)):
            return self.fun(*args, **kwargs)

        # Enforce hashable kwargs (by assumption)
        params_items = tuple(
            sorted((k, ensure_hashable(v, f"kwargs['{k}']")) for k, v in kwargs.items())
        )

        n_args = len(args)
        static_idxs = tuple(
            sorted({
                n_args + index if index < 0 else index
                for index in (self.static_argnums or ())
            })
        )
        if any(index < 0 or index >= n_args for index in static_idxs):
            raise IndexError("static_argnums contains an out-of-range argument index")
        dyn_idxs = tuple(i for i in range(n_args) if i not in static_idxs)

        static_args = tuple(
            ensure_hashable(args[i], f"static arg {i}") for i in static_idxs
        )
        dyn_args = tuple(args[i] for i in dyn_idxs)

        # Flatten dynamic args & abstract
        args_flat, in_tree = tree_flatten(dyn_args)
        in_avals = tuple(
            _abstractify_dynamic_arg(leaf, index, name, self.static_argnums)
            for index, leaf in enumerate(args_flat)
        )

        # Lookup / build lazy jaxprs
        lazy_forward, inv_argnum_dyn_index, target_in_indices, lazy_inverse = (
            self._trace(
                dyn_idxs,
                static_idxs,
                in_tree,
                in_avals,
                static_args,
                params_items,
            )
        )

        # Resolve the output tree before binding so the inverse interpreter can
        # reconstruct all forward output leaves as one logical argument.
        _, _, out_tree = lazy_forward.get()
        out_flat = custom_inverse_call_p.bind(
            *args_flat,
            lazy_forward=lazy_forward,
            inverse_jaxpr_thunk=lazy_inverse,
            in_tree=in_tree,
            out_tree=out_tree,
            inv_argnum=inv_argnum_dyn_index,
            target_in_indices=target_in_indices,
        )

        return tree_unflatten(out_tree, out_flat)

definv

definv(inv_fun)

Define inverse; log-det defaults to NaN.

Source code in probjax/core/custom_primitives/custom_inverse.py
def definv(self, inv_fun: Callable) -> Callable:
    """Define inverse; log-det defaults to NaN."""
    self._warn_if_replacing("definv", self.inv_fun is not None)

    def inv_and_ld(*a, **k):
        return inv_fun(*a, **k), jnp.nan

    self.inv_fun = inv_fun
    self.inv_fun_and_log_det = inv_and_ld
    self._clear_cache()
    return inv_and_ld

definv_and_logdet

definv_and_logdet(inv_fun_and_log_det)

Define inverse that returns (x, logdet).

Source code in probjax/core/custom_primitives/custom_inverse.py
def definv_and_logdet(self, inv_fun_and_log_det: Callable) -> Callable:
    """Define inverse that returns (x, logdet)."""
    self._warn_if_replacing(
        "definv_and_logdet", getattr(self, "_logdet_registered", False)
    )
    self._logdet_registered = True
    self.inv_fun_and_log_det = inv_fun_and_log_det
    if self.inv_fun is None:
        self.inv_fun = lambda *a, **k: inv_fun_and_log_det(*a, **k)[0]
    self._clear_cache()
    return inv_fun_and_log_det

defvalue_and_logdet

defvalue_and_logdet(value_and_logdet_fun)

Optionally expose forward value_and_logdet.

Source code in probjax/core/custom_primitives/custom_inverse.py
def defvalue_and_logdet(self, value_and_logdet_fun: Callable) -> Callable:
    """Optionally expose forward value_and_logdet."""
    self.value_and_logdet_fun = value_and_logdet_fun
    return value_and_logdet_fun

probjax.core.registry.inverse_checks

inverse_checks(enabled=True)

Make inverse guards raise through checkify instead of only NaN-ing.

Off by default, and it must be: checkify.check cannot be staged out by a plain jit -- it raises "Cannot abstractly evaluate a checkify.check which was not functionalized" -- and an active checkify trace is not detectable from inside a rule. So the default is a NaN, which is always safe, and this switch adds the error channel for callers who are wrapping in checkify.checkify anyway:

with inverse_checks(): ... err, out = checkify.checkify(inverse(f))(y)

Source code in probjax/core/registry.py
@contextmanager
def inverse_checks(enabled: bool = True):
    """Make inverse guards raise through ``checkify`` instead of only NaN-ing.

    Off by default, and it must be: ``checkify.check`` cannot be staged out by a
    plain ``jit`` -- it raises "Cannot abstractly evaluate a checkify.check which
    was not functionalized" -- and an active checkify trace is not detectable
    from inside a rule. So the default is a NaN, which is always safe, and this
    switch adds the error channel for callers who are wrapping in
    ``checkify.checkify`` anyway:

    >>> with inverse_checks():
    ...     err, out = checkify.checkify(inverse(f))(y)
    """
    global _INVERSE_CHECKS
    previous = _INVERSE_CHECKS
    _INVERSE_CHECKS = enabled
    try:
        yield
    finally:
        _INVERSE_CHECKS = previous

Inspection

probjax.core.JaxprGraph

Source code in probjax/core/jaxpr_propagation/graph.py
class JaxprGraph:
    def __init__(
        self,
        jaxpr: Jaxpr,
        maxlevel: int = jnp.inf,
        graph: nx.DiGraph | None = None,
    ) -> None:
        self._jaxpr = jaxpr
        if graph is None:
            self._graph = to_networkx(
                jaxpr, var_name_fn, lambda x: str(x.primitive.name), maxlevel=maxlevel
            )

    @property
    def eqns(self):
        eqn_names = [f"f{i}" for i in range(len(self._jaxpr.eqns))]
        return dict(zip(eqn_names, self._jaxpr.eqns, strict=False))

    @property
    def vars(self):
        var_names = [n for n in self._graph.nodes if not re.match(r"f\d+", n)]
        vars = [self._graph.nodes[n] for n in var_names]
        return dict(zip(var_names, vars, strict=False))

    def __repr__(self):
        AGraph = nx.nx_agraph.to_agraph(self._graph)
        # Node styles by tag
        nodes = AGraph.nodes()
        max_level = 0
        for n in nodes:
            attributes = dict(n.attr)
            level = attributes.get("level", "0")
            if int(level) > max_level:
                max_level = int(level)
            n.attr.update(
                COMPUTE_GRAPH_NODE_STYLES[attributes.get("tag", "intermediate")]
            )
        # Cluster by "level"
        # TODO : Name clusters by pjit name
        for i in range(max_level + 1):
            AGraph.add_subgraph(
                [n for n in nodes if n.attr["level"] == str(i)],
                name=f"cluster_{i}",
            )

        # Left to right in topological order
        AGraph.graph_attr["rankdir"] = "LR"
        AGraph.layout("dot")

        # Render for jupyter
        svg = io.BytesIO()
        AGraph.draw(svg, format="svg")
        svg.seek(0)
        display(SVG(svg.read()))
        return ""