Source code for waxmorph.jax.warp_autograd

"""JAX/Warp custom-VJP bridge for frozen-pair physics on CUDA.

Backward-enabled Warp FFI differentiates dedicated per-pair kernels; JAX owns endpoint
gathers, masking, scatter aggregation, and state updates. ``pair_i`` and ``pair_j`` are
constructed outside autodiff, so gradients treat topology as fixed. PyTorch records its
emulator kernels on a :class:`warp.Tape`; JAX uses per-pair FFI kernel objects.

Top-level JAX APIs are preferred; a lazy legacy fallback supports the declared Warp 1.10
minimum.
"""

from functools import cache

import jax
import jax.numpy as jnp
import warp as wp

from waxmorph.constants import EPS_DIST, EPS_NORM

# Separate JAX FFI kernels duplicate these emulator coefficients; keep them aligned.
K_REP = 2.0
K_ATT = 0.5


@cache
def _load_jax_kernel():
    jax_kernel = getattr(wp, "jax_kernel", None)
    if jax_kernel is None:
        from warp.jax_experimental.ffi import jax_kernel
    return jax_kernel


def _device_requires_cuda(device: str | wp.Device | None) -> str:
    """Require available CUDA-capable Warp and JAX backends.

    ``None`` selects ``"cuda"``. The bridge requires initialized CUDA backends and raises
    ``RuntimeError`` for other device configurations.
    """
    device_name = "cuda" if device is None else str(device)
    if not device_name.startswith("cuda"):
        raise RuntimeError(
            "Differentiable JAX/Warp physics requires a CUDA device. "
            f"Received device={device_name!r}."
        )

    try:
        wp_device = wp.get_device(device_name)
    except Exception as exc:  # pragma: no cover - depends on local CUDA setup.
        raise RuntimeError(f"Warp CUDA device {device_name!r} is not available.") from exc

    if not wp_device.is_cuda:
        raise RuntimeError(
            "Differentiable JAX/Warp physics requires a CUDA Warp device. "
            f"Resolved device={wp_device!r}."
        )

    try:
        gpu_devices = jax.devices("gpu")
    except Exception as exc:  # pragma: no cover - depends on local CUDA setup.
        raise RuntimeError(
            "Differentiable JAX/Warp physics requires a JAX GPU backend, "
            "but JAX could not initialize one."
        ) from exc

    if not gpu_devices:
        raise RuntimeError("Differentiable JAX/Warp physics requires a JAX GPU backend.")

    return device_name


def _sticky_pair_forces_kernel_impl(
    X_i: wp.array(dtype=wp.vec3f),
    X_j: wp.array(dtype=wp.vec3f),
    R_i: wp.array(dtype=wp.float32),
    R_j: wp.array(dtype=wp.float32),
    F_i: wp.array(dtype=wp.vec3f),
    F_j: wp.array(dtype=wp.vec3f),
):
    e = wp.tid()
    d = X_i[e] - X_j[e]
    dist = wp.length(d) + EPS_NORM
    u = d / dist

    rs = R_i[e] + R_j[e]
    drep = rs - EPS_DIST
    datr = rs + EPS_DIST

    f_rep = K_REP * wp.max(drep - dist, wp.float32(0.0))
    f_att = K_ATT * wp.max(datr - dist, wp.float32(0.0)) * wp.float32(dist > drep)

    f_ij = (f_rep - f_att) * u
    F_i[e] = f_ij
    F_j[e] = -f_ij


def _molecule_pair_flux_kernel_impl(
    c_i: wp.array2d(dtype=wp.float32),
    c_j: wp.array2d(dtype=wp.float32),
    flux_i: wp.array2d(dtype=wp.float32),
    flux_j: wp.array2d(dtype=wp.float32),
):
    e, g = wp.tid()
    flux = c_j[e, g] - c_i[e, g]
    flux_i[e, g] = flux
    flux_j[e, g] = -flux


@cache
def _sticky_pair_forces_jax():
    return _load_jax_kernel()(
        wp.kernel(_sticky_pair_forces_kernel_impl),
        num_outputs=2,
        enable_backward=True,
    )


@cache
def _molecule_pair_flux_jax():
    return _load_jax_kernel()(
        wp.kernel(_molecule_pair_flux_kernel_impl),
        num_outputs=2,
        enable_backward=True,
    )


[docs] def warp_mech_step( X: jax.Array, R: jax.Array, pair_i: jax.Array, pair_j: jax.Array, dt: float, *, num_pairs: jax.Array | int | None = None, device: str | wp.Device | None = "cuda", ) -> jax.Array: r"""Apply one differentiable sticky-sphere mechanics step. For a frozen pair :math:`(i,j)`, define raw displacement :math:`\mathbf d=\mathbf x_i-\mathbf x_j`, stabilized distance :math:`r=\lVert\mathbf d\rVert+\epsilon_n`, regularized direction :math:`\mathbf u=\mathbf d/r`, and :math:`s=R_i+R_j`: .. math:: f_{rep} &= k_{rep}\,\max(s - \epsilon_d - r,\; 0) \\ f_{att} &= k_{att}\,\max(s + \epsilon_d - r,\; 0) \cdot \mathbb{1}[r > s - \epsilon_d] \\ \mathbf{F}_{i} &= (f_{rep} - f_{att})\,\mathbf{u}, \quad \mathbf{F}_{j} = -\mathbf{F}_{i} Here :math:`k_{rep}=2`, :math:`k_{att}=0.5`, :math:`\epsilon_d` is ``EPS_DIST``, and :math:`\epsilon_n` is ``EPS_NORM``. JAX scatter-adds endpoint forces and applies :math:`\mathbf x\leftarrow\mathbf x+\mathrm{dt}\,\mathbf F`. The custom VJP differentiates ``X`` and ``R`` through per-pair forces while pair selection remains a fixed forward input. ``max`` has zero derivative on inactive branches; the strict adhesion indicator is constant in backward. Values at their nonsmooth boundaries follow Warp's branch convention. ``pair_i`` and ``pair_j`` are unordered frozen endpoints. For a padded pair buffer, ``num_pairs`` masks indices beyond the real count; omitted means all pairs are real. An empty buffer returns ``X`` unchanged. Raises: RuntimeError: If CUDA-backed Warp/JAX device validation fails. """ _device_requires_cuda(device) pair_count = int(pair_i.shape[0]) if pair_count == 0: return X if num_pairs is None: num_pairs = jnp.asarray(pair_count, dtype=jnp.int32) else: num_pairs = jnp.asarray(num_pairs, dtype=jnp.int32) pair_i = jnp.asarray(pair_i, dtype=jnp.int32) pair_j = jnp.asarray(pair_j, dtype=jnp.int32) force_i, force_j = _sticky_pair_forces_jax()( X[pair_i], X[pair_j], R[pair_i], R[pair_j], ) real_pair_mask = jnp.arange(pair_count, dtype=jnp.int32) < num_pairs force_i = jnp.where(real_pair_mask[:, None], force_i, 0.0) force_j = jnp.where(real_pair_mask[:, None], force_j, 0.0) f_net = jnp.zeros_like(X) f_net = f_net.at[pair_i].add(force_i) f_net = f_net.at[pair_j].add(force_j) return X + jnp.asarray(dt, dtype=X.dtype) * f_net
[docs] def warp_diffusion_step( c: jax.Array, pair_i: jax.Array, pair_j: jax.Array, D_emu: float, dt: float, *, num_pairs: jax.Array | int | None = None, device: str | wp.Device | None = "cuda", ) -> jax.Array: r"""Apply one differentiable graph-Laplacian signaling-molecule diffusion step. Each frozen pair contributes antisymmetric flux :math:`\phi_{ij,g}=c_{j,g}-c_{i,g}`. JAX scatter-adds these values into :math:`(Lc)_{i,g}=\sum_{j\in\mathcal N(i)}(c_{j,g}-c_{i,g})`, then applies .. math:: c_{i,g} \leftarrow \max\!\big(c_{i,g} + \mathrm{dt}\,D\,(L c)_{i,g},\; 0\big) where :math:`D` is ``D_emu``. The custom VJP differentiates concentrations through the per-pair flux, while pair selection remains fixed. At the JAX output clamp, the derivative with respect to the pre-clamp value is zero below zero, one above zero, and one half at exactly zero. ``num_pairs`` masks padded indices beyond the real frozen-pair count; omitted means every pair is real. An empty pair buffer returns ``c`` unchanged. Raises: RuntimeError: If CUDA-backed Warp/JAX device validation fails. """ _device_requires_cuda(device) pair_count = int(pair_i.shape[0]) if pair_count == 0: return c if num_pairs is None: num_pairs = jnp.asarray(pair_count, dtype=jnp.int32) else: num_pairs = jnp.asarray(num_pairs, dtype=jnp.int32) pair_i = jnp.asarray(pair_i, dtype=jnp.int32) pair_j = jnp.asarray(pair_j, dtype=jnp.int32) flux_i, flux_j = _molecule_pair_flux_jax()(c[pair_i], c[pair_j]) real_pair_mask = jnp.arange(pair_count, dtype=jnp.int32) < num_pairs flux_i = jnp.where(real_pair_mask[:, None], flux_i, 0.0) flux_j = jnp.where(real_pair_mask[:, None], flux_j, 0.0) lap = jnp.zeros_like(c) lap = lap.at[pair_i].add(flux_i) lap = lap.at[pair_j].add(flux_j) scale = jnp.asarray(dt * D_emu, dtype=c.dtype) return jnp.maximum(c + scale * lap, jnp.asarray(0.0, dtype=c.dtype))
__all__ = ["warp_diffusion_step", "warp_mech_step"]