Source code for brainstate.transform._eval_shape

# Copyright 2025 BrainX Ecosystem Limited. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================

import functools
from typing import Any, Callable, TypeVar

from brainstate._state import catch_new_states
from brainstate.graph import graph_to_tree, tree_to_graph
from ._make_jaxpr import StatefulFunction

__all__ = [
    'eval_shape',
]

A = TypeVar('A')


[docs] def eval_shape( f: Callable[..., A], *args: Any, return_state_shapes: bool = False, **kwargs: Any, ) -> A: """Compute the abstract output shape of ``f`` without executing it. This is the brainstate-aware analogue of :func:`jax.eval_shape`. It traces ``f`` abstractly (no real computation, no array allocation, no mutation of any :class:`~brainstate.State` value) and returns the shape/dtype structure of the output. It is built on :class:`~brainstate.transform.StatefulFunction`, exactly like :func:`~brainstate.transform.jit`, :func:`~brainstate.transform.grad`, and :func:`~brainstate.transform.vmap`, so it handles existing global states the same way every other transform does. Three behaviors are supported by a single abstract trace: 1. **Plain outputs.** If ``f`` returns arrays/pytrees, the result is a pytree with :class:`jax.ShapeDtypeStruct` leaves. 2. **Existing states.** If ``f`` reads or writes pre-existing global :class:`~brainstate.State` objects, they are traced transparently (no error). Their concrete values are left unchanged after the call. 3. **New nodes.** If ``f`` constructs and returns a brainstate :class:`~brainstate.graph.Node` (e.g. ``lambda: brainstate.nn.LSTMCell(3, 4)``), a node of the same type is reconstructed with abstract :class:`jax.ShapeDtypeStruct` leaves (lazy/abstract model construction, no memory allocated). The returned node is a first-class input to subsequent brainstate transformations. Parameters ---------- f : Callable The function to abstractly evaluate. It is never executed for real. *args : Any Example positional arguments. May contain arrays, pytrees, or brainstate graph nodes. return_state_shapes : bool, optional If ``True``, return ``(state_shapes, out_shapes)`` where ``state_shapes`` is a ``dict`` mapping each touched :class:`~brainstate.State` to the :class:`jax.ShapeDtypeStruct` of its value. Default is ``False`` (return only the output shapes). **kwargs : Any Example keyword arguments. Returns ------- out_shapes : Any The abstract output of ``f`` (pytree of :class:`jax.ShapeDtypeStruct`, or a reconstructed abstract :class:`~brainstate.graph.Node`). When ``return_state_shapes=True`` this is the second element of the returned ``(state_shapes, out_shapes)`` tuple. state_shapes : dict Only when ``return_state_shapes=True``: ``dict`` of ``State -> jax.ShapeDtypeStruct`` for every state touched by ``f``. Returned as the first element of the ``(state_shapes, out_shapes)`` tuple. Examples -------- .. code-block:: python >>> import brainstate >>> import jax.numpy as jnp >>> out = brainstate.transform.eval_shape(lambda x: x * 2.0, jnp.ones(3)) >>> out.shape, out.dtype ((3,), dtype('float32')) >>> model = brainstate.transform.eval_shape(lambda: brainstate.nn.LSTMCell(3, 4)) >>> isinstance(model, brainstate.nn.LSTMCell) True """ # Convert any graph nodes inside the example inputs into pure pytrees so the # StatefulFunction trace can consume them. (g_args, g_kwargs), _ = graph_to_tree((args, kwargs)) # Captured states created INSIDE f (the lambda: LSTMCell(...) case) are stored # here so we can clean up their trace level after the abstract trace ends. caught_box: dict = {} @functools.wraps(f) def _wrapped(*inner_args, **inner_kwargs): # Rebuild graph nodes from the pure-pytree inputs before calling f. inner_args, inner_kwargs = tree_to_graph((inner_args, inner_kwargs)) with catch_new_states() as catcher: try: out = f(*inner_args, **inner_kwargs) finally: # record the created states even when f fails, so the cleanup # below can unwind them caught_box['states'] = catcher.get_states() caught_box['values'] = catcher.get_state_values() # Represent a returned Node as a pure pytree so StatefulFunction can derive # its output shapes. out_tree, _ = graph_to_tree(out) return out_tree # One abstract trace via the canonical stateful wrapper. # ``return_only_write=False`` makes ``get_out_shapes`` carry a proper # ``jax.ShapeDtypeStruct`` for EVERY state the function touches (read or # write), aligned 1:1 with ``state_trace.states``. With the default # (write-only) the read-only slots come back as ``None``, which would map # read-only states to ``None`` in ``state_shape_map`` and break the # documented ``State -> ShapeDtypeStruct`` contract. stateful_fn = StatefulFunction(_wrapped, name='eval_shape', return_only_write=False) try: stateful_fn.make_jaxpr(*g_args, **g_kwargs) out_shapes, state_shapes = stateful_fn.get_out_shapes(*g_args, **g_kwargs) # Reconstruct a Node (if any) from the abstract output pytree. ``tree_to_graph`` # builds fresh States bound to the current (top-level) trace, so the reconstructed # Node is already a first-class input to subsequent brainstate transformations # (nested eval_shape, jit/grad/vmap, init_all_states). For plain-array outputs # ``tree_to_graph`` is a no-op passthrough. out = tree_to_graph(out_shapes) # Build the State -> ShapeDtypeStruct mapping for the optional return. The # state_shapes tuple is aligned with state_trace.states. state_trace = stateful_fn.get_state_trace(*g_args, **g_kwargs) state_shape_map = {st: sh for st, sh in zip(state_trace.states, state_shapes)} finally: # States created INSIDE f (the ``lambda: LSTMCell(...)`` case) were elevated # to the abstract trace's stack level. Restore their original values and drop # the stack level so the trace does not leak -- on failure too. Mirrors # vmap_new_states. new_states = caught_box.get('states', []) new_values = caught_box.get('values', []) for st, val in zip(new_states, new_values): st.restore_value(val) st.decrease_stack_level() if return_state_shapes: return state_shape_map, out return out