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",
):
    if kind_labels == "legacy":
        warnings.warn(
            "probjax.core.trace(kind_labels='legacy') is deprecated; "
            "use kind_labels='probabilistic' instead.",
            DeprecationWarning,
            stacklevel=2,
        )

    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).

This is the canonical name. It computes the model log-joint up to a constant (the normalizing constant is not included).

Parameters:

Name Type Description Default
fun Callable

Probabilistic function

required

Returns:

Name Type Description
Callable

Log joint function

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).

    This is the canonical name. It computes the model log-joint up to a
    constant (the normalizing constant is not included).

    Args:
        fun (Callable): Probabilistic function

    Returns:
        Callable: Log joint 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_joint(**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_joint

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.

.. deprecated:: Use :func:log_joint_fn instead; log_potential_fn is the legacy spelling.

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.

    .. deprecated::
        Use :func:`log_joint_fn` instead; ``log_potential_fn`` is the legacy
        spelling.

    This does not include the normalizing constant.

    Args:
        fun (Callable): Probabilistic function

    Returns:
        Callable: Log potential function
    """
    _warn_deprecated("log_potential_fn")
    return log_joint_fn(
        fun,
        *args,
        strict=strict,
        allow_partial=allow_partial,
        **kwargs,
    )

probjax.core.condition

condition(fun, observations)

Condition a probabilistic program on observed site values.

This is the canonical name. Observed sites are fixed to the given values and contribute their log-density at those values.

Parameters:

Name Type Description Default
fun Callable

A function to transform.

required
observations dict[str, Array]

A dictionary of random variable names and observed values.

required

Returns:

Name Type Description
Callable Callable

Wrapped probabilistic function with observations.

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

    This is the canonical name. Observed sites are fixed to the given values
    and contribute their log-density at those values.

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

    Returns:
        Callable: Wrapped probabilistic function with observations.
    """
    return _apply_stochastic_substitution(fun, update_observations=observations)

probjax.core.observe

observe(fun, observations)

Observe stochastic sites in a probabilistic program.

.. deprecated:: Use :func:condition instead; observe is the legacy spelling.

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

    .. deprecated::
        Use :func:`condition` instead; ``observe`` is the legacy spelling.
    """
    _warn_deprecated("observe")
    return condition(fun, observations)

probjax.core.intervene

intervene(fun, rvs)

Fix stochastic sites via intervention values.

.. deprecated:: Use :func:do instead; intervene is the legacy spelling.

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.

    .. deprecated::
        Use :func:`do` instead; ``intervene`` is the legacy spelling.

    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.
    """
    _warn_deprecated("intervene")
    return do(fun, rvs)

probjax.core.do

do(fun, interventions)

Apply a causal do intervention to stochastic sites.

This is the canonical name. It fixes stochastic sites to the given values without sampling them, using 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
interventions dict[str, Array]

A dictionary of random variable names and values to intervene on.

required

Returns:

Name Type Description
Callable Callable

Wrapped probabilistic function with interventions.

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 the canonical name. It fixes stochastic sites to the given values
    without sampling them, using interpreter-level overrides for the selected
    random variables while leaving all other equations unchanged.

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

    Returns:
        Callable: Wrapped probabilistic function with interventions.
    """
    return _apply_stochastic_substitution(fun, update_interventions=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 do(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.scan carrying a non-invertible accumulation (a running sum of the target). Counter lanes are fine, and so is lax.fori_loop with an invertible body. 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.scan`` carrying a non-invertible accumulation (a running sum
          of the target). Counter lanes are fine, and so is ``lax.fori_loop``
          with an invertible body. 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")

                # Preserve additive vmap semantics even when the registered
                # logdet is numerically independent of mapped inputs: the term
                # is always zero, but referencing a value makes vmap batch
                # the log-det along with it.
                #
                # 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.
                def _dependency(value):
                    return jnp.asarray(0.0) * jnp.nan_to_num(
                        jnp.sum(jnp.asarray(value))
                    )

                if tree_structure(logdet) == result_tree:
                    # Per-event logdets aligned with the result leaves: drag
                    # each leaf by its own event, so vmap batches mapped
                    # leaves and leaves unmapped ones alone. A single combined
                    # scalar would replicate unmapped parts once per slice.
                    out_logdets = tuple(
                        jnp.sum(jnp.asarray(leaf)) + _dependency(res)
                        for leaf, res in zip(logdet_leaves, result_leaves)
                    )
                else:
                    total_logdet = sum(
                        (jnp.sum(jnp.asarray(value)) for value in logdet_leaves),
                        jnp.asarray(0.0),
                    )
                    dependency = sum(
                        (_dependency(value) for value in tree_leaves(dyn_args_tuple)),
                        jnp.asarray(0.0),
                    )
                    out_logdets = (total_logdet + dependency,)
                return tree_unflatten(target_tree, result_leaves), out_logdets

            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 ""