Skip to content

kups.potential.mliap.torch.interface

Universal PyTorch MLFF interface.

Mirrors the JAX tojax interface for PyTorch models. Each MLFF backend only needs to provide a torch.nn.Module whose forward consumes the universal AtomGraphInput and returns a dict with "energy", "position_gradients", and optionally "cell_gradients". All graph extraction, padding, and kUPS Potential wiring is handled here.

Example
from kups.application.potential.filter import POSITIONS_AND_CELL
from kups.application.potential.mliap.torch import make_torch_mliap_from_state
from kups.potential.mliap.torch.interface import TorchMliap

# A backend provides a Module with the universal forward contract:
model = TorchMliap.from_module(my_module, cutoff=6.0, compute_cell_gradients=True)

# Wire into a kUPS Potential:
potential = make_torch_mliap_from_state(state_lens, gradient=POSITIONS_AND_CELL)

Requires the torch_dev dependency group: uv sync --group torch_dev.

AtomGraphInput

Bases: TypedDict

Universal input schema shared by all torch MLFF backends.

Mirrors the JAX AtomGraphInput. Shapes use N atoms, B systems, and E edges (each padded by one extra atom/system to work around backends that cannot handle empty graphs).

Source code in src/kups/potential/mliap/torch/interface.py
class AtomGraphInput(TypedDict):
    """Universal input schema shared by all torch MLFF backends.

    Mirrors the JAX [AtomGraphInput][kups.potential.mliap.tojax.AtomGraphInput].
    Shapes use ``N`` atoms, ``B`` systems, and ``E`` edges (each padded by one
    extra atom/system to work around backends that cannot handle empty graphs).
    """

    pos: Array  # (N, 3)
    atomic_numbers: Array  # (N,)
    cell: Array  # (B, 3, 3)
    pbc: Array  # (B, 3)
    edge_index: Array  # (2, E)
    cell_offsets: Array  # (E, 3) integer multiples of cell vectors
    batch: Array  # (N,)
    charge: Array  # (B,)
    spin: Array  # (B,)

IsTorchMliapParticles

Bases: IsRadiusGraphPoints, HasAtomicNumbers, Protocol

Particle protocol for torch MLFF models.

Source code in src/kups/potential/mliap/torch/interface.py
class IsTorchMliapParticles(IsRadiusGraphPoints, HasAtomicNumbers, Protocol):
    """Particle protocol for torch MLFF models."""

    ...

TorchMliap

Container for a torch MLFF wired into JAX.

Attributes:

Name Type Description
cutoff Table[SystemId, Array]

Per-system cutoff radius [Å].

wrapper TorchModuleWrapper

TorchModuleWrapper over the MLFF module.

compute_cell_gradients bool

Whether the module returns "cell_gradients".

Source code in src/kups/potential/mliap/torch/interface.py
@dataclass
class TorchMliap:
    """Container for a torch MLFF wired into JAX.

    Attributes:
        cutoff: Per-system cutoff radius [Å].
        wrapper: ``TorchModuleWrapper`` over the MLFF module.
        compute_cell_gradients: Whether the module returns ``"cell_gradients"``.
    """

    cutoff: Table[SystemId, Array]
    wrapper: TorchModuleWrapper = field(static=True)
    compute_cell_gradients: bool = field(static=True, default=False)

    @staticmethod
    def from_module(
        module: torch.nn.Module,
        cutoff: float,
        compute_cell_gradients: bool = False,
    ) -> "TorchMliap":
        """Wrap a torch.nn.Module that returns energy and gradients.

        Args:
            module: torch ``nn.Module`` satisfying ``TorchMliapForward``.
            cutoff: Interaction cutoff radius [Å].
            compute_cell_gradients: Whether the module returns
                ``"cell_gradients"`` for stress computation.

        Returns:
            Configured ``TorchMliap`` ready for use with the kUPS interface.
        """
        wrapper = TorchModuleWrapper(module, requires_grad=True)
        return TorchMliap(
            cutoff=Table((SystemId(0),), jnp.array([cutoff], float)),
            wrapper=wrapper,
            compute_cell_gradients=compute_cell_gradients,
        )

    def call(self, input: AtomGraphInput) -> dict[str, Array]:
        """Call the wrapped module on a prepared ``AtomGraphInput``."""
        return self.wrapper(input)

call(input)

Call the wrapped module on a prepared AtomGraphInput.

Source code in src/kups/potential/mliap/torch/interface.py
def call(self, input: AtomGraphInput) -> dict[str, Array]:
    """Call the wrapped module on a prepared ``AtomGraphInput``."""
    return self.wrapper(input)

from_module(module, cutoff, compute_cell_gradients=False) staticmethod

Wrap a torch.nn.Module that returns energy and gradients.

Parameters:

Name Type Description Default
module Module

torch nn.Module satisfying TorchMliapForward.

required
cutoff float

Interaction cutoff radius [Å].

required
compute_cell_gradients bool

Whether the module returns "cell_gradients" for stress computation.

False

Returns:

Type Description
'TorchMliap'

Configured TorchMliap ready for use with the kUPS interface.

Source code in src/kups/potential/mliap/torch/interface.py
@staticmethod
def from_module(
    module: torch.nn.Module,
    cutoff: float,
    compute_cell_gradients: bool = False,
) -> "TorchMliap":
    """Wrap a torch.nn.Module that returns energy and gradients.

    Args:
        module: torch ``nn.Module`` satisfying ``TorchMliapForward``.
        cutoff: Interaction cutoff radius [Å].
        compute_cell_gradients: Whether the module returns
            ``"cell_gradients"`` for stress computation.

    Returns:
        Configured ``TorchMliap`` ready for use with the kUPS interface.
    """
    wrapper = TorchModuleWrapper(module, requires_grad=True)
    return TorchMliap(
        cutoff=Table((SystemId(0),), jnp.array([cutoff], float)),
        wrapper=wrapper,
        compute_cell_gradients=compute_cell_gradients,
    )

TorchMliapForward

Bases: Protocol

Forward contract for a torch MLFF module.

The module must accept an AtomGraphInput dict and return a dict with:

  • "energy": (B,) per-system total energies.
  • "position_gradients": (N, 3) :math:\partial E / \partial r.
  • "cell_gradients": (B, 3, 3) :math:\partial E / \partial h, required only when compute_cell_gradients=True.

Outputs are gradients (not forces); adapters around models that natively produce forces/virials negate appropriately inside the module.

Source code in src/kups/potential/mliap/torch/interface.py
class TorchMliapForward(Protocol):
    """Forward contract for a torch MLFF module.

    The module must accept an ``AtomGraphInput`` dict and return a dict with:

    - ``"energy"``: ``(B,)`` per-system total energies.
    - ``"position_gradients"``: ``(N, 3)`` :math:`\\partial E / \\partial r`.
    - ``"cell_gradients"``: ``(B, 3, 3)`` :math:`\\partial E / \\partial h`,
      required only when ``compute_cell_gradients=True``.

    Outputs are gradients (not forces); adapters around models that natively
    produce forces/virials negate appropriately inside the module.
    """

    def __call__(self, input: AtomGraphInput) -> dict[str, Array]: ...

lattice_gradient_from_virial(forces, positions, batch, cell, virial)

Recover ∂E/∂h from a symmetric-strain virial.

Many torch MLFF backends (MACE, UMA, …) return a virial or stress quantity that encodes the gradient of energy under a symmetric infinitesimal strain applied jointly to positions and cell. In kUPS's row convention (r = frac @ h; lattice vectors are the rows of h) that virial is

virial = pos_virial + cell_virial        (exactly symmetric)

where

pos_virial[s, j, k]  = Σ_{b∈s} (∂E/∂r_b)_j · (r_b)_k
cell_virial          = (∂E/∂h)^T @ h

Rotational invariance makes the total symmetric, but cell_virial on its own is not. Its antisymmetric part is pinned by pos_virial, known exactly from forces and positions, so the raw lattice gradient (antisymmetric part included) is recovered by

∂E/∂h = h^-T @ (virial - pos_virial^T).

Parameters:

Name Type Description Default
forces 'torch.Tensor'

(N, 3) = -∂E/∂r.

required
positions 'torch.Tensor'

(N, 3).

required
batch 'torch.Tensor'

(N,) int system index per atom.

required
cell 'torch.Tensor'

(B, 3, 3).

required
virial 'torch.Tensor'

(B, 3, 3) symmetric strain virial as defined above.

required

Returns:

Type Description
'torch.Tensor'

(B, 3, 3) ∂E/∂h at fixed positions.

Source code in src/kups/potential/mliap/torch/interface.py
def lattice_gradient_from_virial(
    forces: "torch.Tensor",
    positions: "torch.Tensor",
    batch: "torch.Tensor",
    cell: "torch.Tensor",
    virial: "torch.Tensor",
) -> "torch.Tensor":
    """Recover ``∂E/∂h`` from a symmetric-strain virial.

    Many torch MLFF backends (MACE, UMA, …) return a virial or stress quantity
    that encodes the gradient of energy under a *symmetric infinitesimal strain*
    applied jointly to positions and cell. In kUPS's row convention
    (``r = frac @ h``; lattice vectors are the rows of ``h``) that virial is

        virial = pos_virial + cell_virial        (exactly symmetric)

    where

        pos_virial[s, j, k]  = Σ_{b∈s} (∂E/∂r_b)_j · (r_b)_k
        cell_virial          = (∂E/∂h)^T @ h

    Rotational invariance makes the *total* symmetric, but ``cell_virial`` on its
    own is not. Its antisymmetric part is pinned by ``pos_virial``, known exactly
    from forces and positions, so the raw lattice gradient (antisymmetric part
    included) is recovered by

        ∂E/∂h = h^-T @ (virial - pos_virial^T).

    Args:
        forces: ``(N, 3)`` ``= -∂E/∂r``.
        positions: ``(N, 3)``.
        batch: ``(N,)`` int system index per atom.
        cell: ``(B, 3, 3)``.
        virial: ``(B, 3, 3)`` symmetric strain virial as defined above.

    Returns:
        ``(B, 3, 3)`` ``∂E/∂h`` at fixed positions.
    """
    # Backends may emit ``forces``/``virial`` at a different precision than
    # ``cell``/``positions`` (e.g. UMA's predict-unit casts to its inference
    # dtype but normalizers/denorm steps can bump back). Unify on the highest
    # precision present so ``torch.linalg.solve`` doesn't reject a Float/Double
    # mix at the end.
    dtypes = (forces.dtype, positions.dtype, cell.dtype, virial.dtype)
    common_dtype = torch.float64 if torch.float64 in dtypes else torch.float32
    forces = forces.to(common_dtype)
    positions = positions.to(common_dtype)
    cell = cell.to(common_dtype)
    virial = virial.to(common_dtype)

    n_sys = cell.shape[0]
    g_r = -forces  # ∂E/∂r
    pos_virial_per_atom = g_r.unsqueeze(2) * positions.unsqueeze(1)  # (N, 3, 3)
    pos_virial = positions.new_zeros(n_sys, 3, 3)
    pos_virial = pos_virial.index_add(0, batch, pos_virial_per_atom)
    # cell_virial = virial - pos_virial^T; ∂E/∂h = h^-T @ cell_virial (= solve(h^T, ·)).
    cell_virial = virial - pos_virial.transpose(-1, -2)
    # Substitute identity for singular ``cell^T`` so ``torch.linalg.solve``
    # never raises on the all-zero mock tensors that ``TorchModuleWrapper``
    # uses for output-shape inference (CUDA's lstsq drivers also require full
    # rank, so we can't rely on them). The output values for singular cells
    # are meaningless and discarded by the wrapper's mock pass.
    cell_T = cell.transpose(-1, -2)
    det = torch.linalg.det(cell_T)
    eye = cell.new_zeros(3, 3)
    eye.fill_diagonal_(1.0)
    eye = eye.expand_as(cell_T)
    is_singular = (det.abs() < 1e-12).view(-1, 1, 1).expand_as(cell_T)
    safe_cell_T = cell_T.where(~is_singular, eye)
    return torch.linalg.solve(safe_cell_T, cell_virial)

make_torch_mliap_potential(particles_view, systems_view, neighborlist_view, model, patch_idx_view=None, out_cache_lens=None, gradient=None)

make_torch_mliap_potential(
    particles_view: View[State, Table[ParticleId, P]],
    systems_view: View[State, Table[SystemId, S]],
    neighborlist_view: View[State, NNList],
    model: View[State, TorchMliap] | TorchMliap,
    patch_idx_view: View[
        State, PotentialOut[PositionsAndCell, EmptyType]
    ]
    | None = None,
    out_cache_lens: Lens[
        State,
        KahanSummand[
            PotentialOut[PositionsAndCell, EmptyType]
        ],
    ]
    | None = None,
) -> Potential[
    State, PositionsAndCell, EmptyType, Patch[State]
]
make_torch_mliap_potential(
    particles_view: View[State, Table[ParticleId, P]],
    systems_view: View[State, Table[SystemId, S]],
    neighborlist_view: View[State, NNList],
    model: View[State, TorchMliap] | TorchMliap,
    patch_idx_view: View[
        State, PotentialOut[PositionsAndCell, EmptyType]
    ]
    | None = None,
    out_cache_lens: Lens[
        State,
        KahanSummand[
            PotentialOut[PositionsAndCell, EmptyType]
        ],
    ]
    | None = None,
    *,
    gradient: Lens[Geometry, PositionsAndCell],
) -> Potential[
    State, PositionsAndCell, EmptyType, Patch[State]
]

Create a kUPS Potential from a TorchMliap.

Forces and stress are computed inside the torch module; the kUPS side just routes the precomputed PositionsAndCell gradients through DirectPotential. Without a gradient the raw PositionsAndCell gradients pass through; with one they are pulled back through gradient.set into ∂E/∂u — the pullback is hooked here, where the gradients are concretely PositionsAndCell.

Parameters:

Name Type Description Default
particles_view Any

Extracts particle data from state.

required
systems_view Any

Extracts system data (cell) from state.

required
neighborlist_view Any

Extracts a cutoff-bound neighbor list from state.

required
model Any

TorchMliap instance or view to model in state.

required
patch_idx_view Any | None

Cached output index structure (optional).

None
out_cache_lens Any | None

Cache location lens (optional).

None
gradient Lens[Geometry, PositionsAndCell] | None

Relaxation filter Lens[Geometry, PositionsAndCell] selecting the optimizer DOFs.

None

Returns:

Type Description
Any

Configured Potential backed by the torch MLFF.

Source code in src/kups/potential/mliap/torch/interface.py
def make_torch_mliap_potential(
    particles_view: Any,
    systems_view: Any,
    neighborlist_view: Any,
    model: Any,
    patch_idx_view: Any | None = None,
    out_cache_lens: Any | None = None,
    gradient: Lens[Geometry, PositionsAndCell] | None = None,
) -> Any:
    """Create a kUPS ``Potential`` from a ``TorchMliap``.

    Forces and stress are computed inside the torch module; the kUPS side just
    routes the precomputed ``PositionsAndCell`` gradients through
    ``DirectPotential``. Without a ``gradient`` the raw ``PositionsAndCell``
    gradients pass through; with one they are pulled back through ``gradient.set``
    into ``∂E/∂u`` — the pullback is hooked here, where the gradients are concretely
    ``PositionsAndCell``.

    Args:
        particles_view: Extracts particle data from state.
        systems_view: Extracts system data (cell) from state.
        neighborlist_view: Extracts a cutoff-bound neighbor list from state.
        model: ``TorchMliap`` instance or view to model in state.
        patch_idx_view: Cached output index structure (optional).
        out_cache_lens: Cache location lens (optional).
        gradient: Relaxation filter ``Lens[Geometry, PositionsAndCell]``
            selecting the optimizer DOFs.

    Returns:
        Configured ``Potential`` backed by the torch MLFF.
    """
    model_view = constant(model) if isinstance(model, TorchMliap) else model
    model_fn: Any
    if gradient is None:
        model_fn = torch_mliap_model_fn
    else:

        def model_fn[P: IsTorchMliapParticles, S: HasCell[AnyPeriodicity]](
            inp: TorchMliapInput[P, S],
        ) -> WithPatch[PotentialOut[PositionsAndCell, EmptyType], IdPatch[Any]]:
            result = torch_mliap_model_fn(inp)
            data = result.data
            geometry = GRAPH_GEOMETRY.get(inp)
            dof_gradient = filter_pullback(geometry, data.gradients, gradient)
            return WithPatch(
                PotentialOut(data.total_energies, dof_gradient, data.hessians),
                result.patch,
            )

    return make_direct_mliap_potential(
        model_fn=model_fn,
        particles_view=particles_view,
        systems_view=systems_view,
        neighborlist_view=neighborlist_view,
        model_view=model_view,
        patch_idx_view=patch_idx_view,
        out_cache_lens=out_cache_lens,
    )

torch_mliap_model_fn(inp)

Run a TorchMliap on a graph input and package the result.

Always packages "cell_gradients" into a PositionsAndCell gradients structure (the module must produce them); downstream consumers that only need forces let XLA prune the unused cell-gradient ops.

Parameters:

Name Type Description Default
inp TorchMliapInput[P, S]

Graph potential input bundling the model and graph.

required

Returns:

Type Description
WithPatch[PotentialOut[PositionsAndCell, EmptyType], IdPatch[Any]]

WithPatch containing PotentialOut with energy, PositionsAndCell

WithPatch[PotentialOut[PositionsAndCell, EmptyType], IdPatch[Any]]

gradients, and an identity patch.

Source code in src/kups/potential/mliap/torch/interface.py
def torch_mliap_model_fn[
    P: IsTorchMliapParticles,
    S: HasCell[AnyPeriodicity],
](
    inp: TorchMliapInput[P, S],
) -> WithPatch[PotentialOut[PositionsAndCell, EmptyType], IdPatch[Any]]:
    """Run a ``TorchMliap`` on a graph input and package the result.

    Always packages ``"cell_gradients"`` into a ``PositionsAndCell`` gradients
    structure (the module must produce them); downstream consumers that only
    need forces let XLA prune the unused cell-gradient ops.

    Args:
        inp: Graph potential input bundling the model and graph.

    Returns:
        ``WithPatch`` containing ``PotentialOut`` with energy, ``PositionsAndCell``
        gradients, and an identity patch.
    """
    graph, sort_order = inp.graph.sorted_by_system(
        sort_edges=True, return_sort_order=True
    )
    # Invert the permutation via scatter rather than a second argsort: XLA's
    # permutation_sort_simplifier miscompiles argsort-of-a-permutation with
    # int64 indices (x64 mode) on GPU.
    n = sort_order.shape[0]
    unsort_order = (
        jnp.zeros(n, dtype=sort_order.dtype)
        .at[sort_order]
        .set(jnp.arange(n, dtype=sort_order.dtype))
    )

    input_dict = _prepare_torch_inputs(graph)
    result = inp.parameters.call(input_dict)

    # Torch backends may run at a different (typically lower) precision than
    # the JAX side (e.g. UMA's predict-unit casts to float32 internally;
    # MACE may be loaded as float32 while JAX runs in x64). Pin every output
    # to the JAX input ``pos`` dtype here so adapters don't need to think
    # about precision and downstream ``lax.scan``/optax pipelines see
    # consistent types.
    out_dtype = input_dict["pos"].dtype
    energy = result["energy"].astype(out_dtype)
    pos_grad = result["position_gradients"][unsort_order].astype(out_dtype)
    # Zero padded-particle force rows: their system index is the OOB sentinel,
    # which the downstream ``cell[system]`` gather (e.g. in the filter pullback)
    # silently clamps to a real system, contaminating its virial.
    valid = inp.graph.particles.data.system.valid_mask
    pos_grad = jnp.where(valid[:, None], pos_grad, 0.0)
    energy_table = Table.arange(energy, label=SystemId)

    cell_grad = result["cell_gradients"].astype(out_dtype)
    # Project the raw ∂E/∂h onto the input frame's parameter space,
    # preserving its type for downstream stress/relaxation consumers.
    new_cell = _project_grad_onto_frame(inp.graph.systems.data.cell, cell_grad)
    gradients = PositionsAndCell(
        positions=Table(inp.graph.particles.keys, pos_grad),
        cell=Table(inp.graph.systems.keys, new_cell),
    )
    return WithPatch(
        PotentialOut(energy_table, gradients, EMPTY),
        IdPatch[Any](),
    )