Source code for nvalchemiops.interactions.electrostatics._factory_common

# SPDX-FileCopyrightText: Copyright (c) 2025 - 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# 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.

"""Shared factory infrastructure for electrostatics Warp kernels.

This module owns the **component-agnostic** pieces of the kernel factory contract:
the :class:`_DerivState` axis enum, the per-dtype Warp vec/mat bundle
(:class:`_WarpDtypes` / :data:`_DTYPE_INFO`), the dtype guard, and the shared
axis literal types. Every electrostatics kernel specialization is identified
conceptually by the axes owned by its component factory, for example::

    (wp_dtype, component, batched, neighbor_input, deriv_state, cell_grad, order)

This tuple is the *conceptual* key; the concrete cache is the ``@lru_cache`` on
each per-component factory (e.g. :func:`make_ewald_real_kernel`), keyed on the
axes that factory accepts.

Per-component kernel code lives in sibling factory modules
(``ewald_real_factory.py`` for ``ewald_real``; sibling factories add
``ewald_recip`` / ``pme``). Those modules import the shared infra below and
define their own ``@lru_cache``d ``make_*`` builders. Keep this module's shared
private infra stable for sibling factories -- adding a new component must not
require editing it.

Factory boundary
----------------
The factory owns **Warp-owned work only**. Torch-native spline / FFT / grid
orchestration stays outside, on Torch autograd.
"""

import enum
import math
import os
from collections.abc import Iterable, Mapping, Sequence
from functools import lru_cache
from typing import Any, Literal, NamedTuple

import warp as wp

from nvalchemiops.math import wp_erfc_input_precision

__all__ = ["get_backward_scale_kernel"]


# === Factory axis value types ===


class _DerivState(enum.Enum):
    """Derivative-state axis for electrostatics kernel specializations.

    * :attr:`E` -- energy only.
    * :attr:`E_F` -- energy + forces (``-dE/dR``).
    * :attr:`E_F_dQ` -- energy + forces + charge gradient (``dE/dq``).
    * :attr:`E_dQ` -- energy + charge gradient only. This is a forward-only
      direct-output specialization; backward and double-backward construction
      must use :attr:`E_F_dQ`.
    """

    E = 0
    E_F = 1
    E_F_dQ = 2
    E_dQ = 3


# Neighbor-input / order axis literal types (documentation + validation).
_NeighborInput = Literal["list", "matrix", "none"]
_Order = Literal["forward", "backward", "double_backward"]
_CellCacheMode = Literal["none", "system", "atom"]


class _WarpDtypes(NamedTuple):
    """Per-Python-float-dtype Warp vec/mat dtype bundle."""

    vec: type
    mat: type


# === Dtype maps ===


# Electrostatics inputs are float32 or float64 only (accumulators are always
# float64 internally). Unlike the NL factory there is no float16 path.
_DTYPE_INFO: dict[type, _WarpDtypes] = {
    wp.float32: _WarpDtypes(wp.vec3f, wp.mat33f),
    wp.float64: _WarpDtypes(wp.vec3d, wp.mat33d),
}


def _require_supported_dtype(wp_dtype: type) -> None:
    """Validate that ``wp_dtype`` is a supported electrostatics scalar type."""
    if wp_dtype not in _DTYPE_INFO:
        raise ValueError(f"wp_dtype must be wp.float32 or wp.float64; got {wp_dtype!r}")


# Complex-as-vec2 Warp type per scalar dtype (rfftn output is complex64 for f32,
# complex128 for f64; passed to Warp via torch.view_as_real). Used by the PME
# convolve sentinels / slot specs.
_VEC2_INFO: dict[type, type] = {
    wp.float32: wp.vec2f,
    wp.float64: wp.vec2d,
}


# === Shared numeric constants ===

# 2/sqrt(pi); matches ewald_kernels.TWO_OVER_SQRT_PI.
_TWO_OVER_SQRT_PI = 2.0 / math.sqrt(math.pi)

# Per-pair distance guard for the real-space sum: pairs closer than this (i.e. an
# atom with its own periodic image at the origin) are skipped.
_DISTANCE_EPSILON = 1e-8

# Reciprocal-space ``k_sq`` guard: k-points with ``|k|^2`` below this (the k=0 term)
# are skipped in the recip / PME convolve kernels.
_K_SQUARED_EPSILON = 1e-10


@lru_cache(maxsize=1)
def _read_legacy_fp32_env() -> bool:
    """Read ``NVALCHEMIOPS_ELECTROSTATICS_LEGACY_FP32`` once and cache it.

    ``lru_cache`` on a zero-argument function is a memoized read: the
    environment is consulted on the first call in a process and never
    again, so behavior cannot change mid-process if something sets or
    mutates the variable later (a test fixture, code running after import
    time, etc). Use :func:`electrostatics_uses_legacy_fp32` rather than
    calling this directly.

    Raises
    ------
    ValueError
        If the environment variable is set to a value that is neither a
        recognized truthy nor a recognized falsy spelling.
    """
    value = os.environ.get("NVALCHEMIOPS_ELECTROSTATICS_LEGACY_FP32")
    if value is None or value == "":
        return False
    normalized = value.strip().lower()
    if normalized in {"1", "true", "on", "y", "yes", "t"}:
        return True
    if normalized in {"0", "false", "off", "n", "no", "f"}:
        return False
    raise ValueError(
        "NVALCHEMIOPS_ELECTROSTATICS_LEGACY_FP32 must be "
        "one of: 1, true, on, yes, t, y, 0, false, off, no, f, n"
    )


[docs] def electrostatics_uses_legacy_fp32() -> bool: """Whether electrostatics kernels are forced onto the legacy float64-core path. Reads ``NVALCHEMIOPS_ELECTROSTATICS_LEGACY_FP32`` once per process (see :func:`_read_legacy_fp32_env`). Unset or falsy (the default) means the fast float32-core path is used for float32 CUDA inputs; set truthy to force every float32 input back onto the same float64-evaluated cores float64 inputs always use. Governs both halves of the monopole split -- the real-space per-pair cores and the reciprocal no-store structure factors -- each of which is additionally gated to CUDA only; float32 CPU execution is unaffected by this flag either way. Real space is shared by Ewald and PME, so this affects both. Returns ------- bool ``True`` if the legacy (always float64 core) path is forced, ``False`` if the fast float32-core path may be used on CUDA. """ return _read_legacy_fp32_env()
def _use_fp32_electrostatics() -> bool: """Device-agnostic fast/legacy decision (no CUDA gate). Equivalent to ``not electrostatics_uses_legacy_fp32()``. Kept as the fallback for call sites that have not been updated to gate on device explicitly and pass a resolved ``core_scalar`` instead (currently the JAX bindings, whose kernels are specialized per dtype before the device is known) -- for those, this makes the fast path available on CPU too, unlike the torch chain and direct Warp launcher call sites, which pass an explicit, CUDA-gated ``core_scalar`` (see :func:`_resolve_core_scalar`) and never reach this function. """ return not electrostatics_uses_legacy_fp32() def _resolve_core_scalar(wp_dtype: type, device: Any) -> type: """Device-aware precision for the real-space per-pair scalar cores. float64 callers always evaluate in float64. float32 callers on CUDA do too only when :func:`electrostatics_uses_legacy_fp32` forces the legacy float64 core; otherwise they evaluate in float32. float32 callers on any other device always get float64, so CPU execution is unchanged. This mirrors the Torch layer's CUDA gate for callers that know their Warp device. Parameters ---------- wp_dtype : type Warp scalar dtype of the caller's inputs. device : str or warp.context.Device Warp device the kernel will launch on. Returns ------- type ``wp.float64`` or ``wp_dtype``. """ if wp_dtype is wp.float64: return wp.float64 if not wp.get_device(device).is_cuda: return wp.float64 return wp.float64 if electrostatics_uses_legacy_fp32() else wp_dtype # === Shared per-pair scalar cores (input precision) === # # These evaluate in whatever precision the caller passes. They used to be # hard-typed float64, which cost the float32 kernels a float64 exp and several # divides on every pair while buying nothing. erfc itself goes through # wp_erfc_input_precision, which uses native wp.erfc at float64 and the # ~1.5e-7 Abramowitz-Stegun approximation only at float32, where that error is # already below float32 epsilon. Accumulators in the calling kernels stay # float64 -- that is where the cancellation lives. # # Component-agnostic erfc calculus shared by the ewald_real forward / backward / # double-backward kernels. Charge-unbundled factors (no division by a charge, so # they stay finite when a charge is zero): # half_S(r) = (1/2) S(r), S(r) = erfc(a r)/r^3 + (2a/sqrt(pi)) e^{-a^2 r^2}/r^2 # half_dS(r) = (1/2) S'(r) # g(r) = (1/2) erfc(a r)/r (== _ewald_real_space_charge_grad_potential) # dg(r) = (1/2) d/dr[erfc(a r)/r] # The bundled force magnitude is fm = qi*qj*half_S = _ewald_real_space_force_magnitude. @wp.func def _ewald_half_force_scale(distance: Any, alpha: Any) -> Any: """``(1/2) S(r)`` -- charge-unbundled force-magnitude scale.""" two_over_sqrt_pi = type(distance)(_TWO_OVER_SQRT_PI) alpha_r = alpha * distance erfc_alpha_r = wp_erfc_input_precision(alpha_r) exp_term = wp.exp(-alpha_r * alpha_r) r2 = distance * distance s = erfc_alpha_r / (r2 * distance) + two_over_sqrt_pi * alpha * exp_term / r2 return type(distance)(0.5) * s @wp.func def _ewald_half_force_scale_deriv(distance: Any, alpha: Any) -> Any: """``(1/2) S'(r)`` -- radial derivative of the force-magnitude scale. .. math:: S'(r) = -3\\,\\mathrm{erfc}(a r)/r^4 - (2a/\\sqrt{\\pi}) e^{-a^2 r^2} (3/r^3 + 2 a^2 / r). """ two_over_sqrt_pi = type(distance)(_TWO_OVER_SQRT_PI) alpha_r = alpha * distance erfc_alpha_r = wp_erfc_input_precision(alpha_r) exp_term = wp.exp(-alpha_r * alpha_r) r2 = distance * distance r3 = r2 * distance r4 = r3 * distance three = type(distance)(3.0) s_prime = -three * erfc_alpha_r / r4 - two_over_sqrt_pi * alpha * ( exp_term * (three / r3 + type(distance)(2.0) * alpha * alpha / distance) ) return type(distance)(0.5) * s_prime @wp.func def _pair_virial_outer(sep: Any, force: Any) -> wp.mat33d: """``sep (x) force`` as a float64 3x3 -- the per-pair strain-virial contribution. Both arguments are vec3 in input precision; the outer product is accumulated in float64 (``W = sum_pairs sep (x) F``). Shared by the ``ewald_real`` forward matrix / CSR loops (the accumulation ``virial_acc += ...`` stays in the loop, so the float reduction order is unchanged). """ return wp.mat33d( wp.outer( wp.vec3d(wp.float64(sep[0]), wp.float64(sep[1]), wp.float64(sep[2])), wp.vec3d(wp.float64(force[0]), wp.float64(force[1]), wp.float64(force[2])), ) ) @wp.func def _ewald_charge_potential_deriv(distance: Any, alpha: Any) -> Any: """``g'(r) = (1/2) d/dr[erfc(a r)/r]``. .. math:: g'(r) = -\\tfrac{1}{2}\\left[(2a/\\sqrt{\\pi}) e^{-a^2 r^2}/r + \\mathrm{erfc}(a r)/r^2\\right]. """ two_over_sqrt_pi = type(distance)(_TWO_OVER_SQRT_PI) alpha_r = alpha * distance erfc_alpha_r = wp_erfc_input_precision(alpha_r) exp_term = wp.exp(-alpha_r * alpha_r) r2 = distance * distance return -type(distance)(0.5) * ( two_over_sqrt_pi * alpha * exp_term / distance + erfc_alpha_r / r2 ) # === Shared factory boilerplate (component-parameterized) === def _make_specialization_module_name( component: str, *, wp_dtype: type, batched: bool, neighbor_input: str | None = None, order: str = "forward", suffix: str | None = None, ) -> str: """Deterministic Warp ``module=`` name for one kernel specialization. A stable per-spec module name (rather than the mtime-newest cache dir) lets the dead-branch-elimination test locate generated source deterministically. The name is ``"{component}_{dt}_{batch}[_{neighbor_input}][_{tag}]"`` where ``dt`` is ``f64``/``f32``, ``batch`` is ``batch``/``single``, the optional ``neighbor_input`` segment is appended only when given (ewald_real), and the trailing ``tag`` is empty for ``order="forward"`` else ``suffix`` (or ``order``). """ dt = "f64" if wp_dtype is wp.float64 else "f32" batch = "batch" if batched else "single" name = f"{component}_{dt}_{batch}" if neighbor_input is not None: name += f"_{neighbor_input}" tag = suffix if suffix is not None else ("" if order == "forward" else order) if tag: name += f"_{tag}" return name def _require_component(component: str, expected: str) -> None: """Raise ``NotImplementedError`` unless ``component == expected``.""" if component != expected: raise NotImplementedError( f"only component={expected!r} is implemented; got {component!r}" ) def _validate_common_axes( wp_dtype: type, *, deriv_state: "_DerivState", cell_grad: bool, order: str, component: str, supported_orders: Sequence[str] = ("forward", "backward", "double_backward"), ) -> None: """Validate the axes shared by the ``ewald_real`` / ``ewald_recip`` factories. Checks the dtype, ``order`` membership, that ``deriv_state`` is a :class:`_DerivState`, the permanently-invalid ``cell_grad=True`` + ``deriv_state=E`` combination, and derivative orders requesting a non-force-bearing ``deriv_state``. The ``neighbor_input`` axis is component-specific and validated by the caller. (PME has a different axis set and does not use this helper.) """ _require_supported_dtype(wp_dtype) if order not in supported_orders: raise NotImplementedError( f"{component} factory supports order in " f"{tuple(supported_orders)}; got {order!r}" ) if not isinstance(deriv_state, _DerivState): raise ValueError(f"deriv_state must be a _DerivState; got {deriv_state!r}") if cell_grad and deriv_state in {_DerivState.E, _DerivState.E_dQ}: raise ValueError( f"cell_grad=True is invalid with deriv_state={deriv_state!r}: there are no " "force terms to sum into the virial. Use E_F or E_F_dQ." ) if order in ("backward", "double_backward") and deriv_state in { _DerivState.E, _DerivState.E_dQ, }: raise ValueError( f"order={order!r} requires deriv_state in (E_F, E_F_dQ); " f"got {deriv_state.name}. E_dQ is a forward-only direct-output " "specialization." ) # Sentinel slot dtype keys resolved per ``wp_dtype`` by :func:`_alloc_sentinels`. def _resolve_slot_dtype(slot_kind: str, wp_dtype: type) -> type: """Resolve a ``slot_spec`` dtype key to a concrete Warp dtype.""" info = _DTYPE_INFO[wp_dtype] if slot_kind == "vec": return info.vec if slot_kind == "mat": return info.mat if slot_kind == "vec2": return _VEC2_INFO[wp_dtype] if slot_kind == "scalar": return wp_dtype return { "f64": wp.float64, "i32": wp.int32, "vec3i": wp.vec3i, }[slot_kind] def _alloc_sentinels( wp_dtype: type, device: str, slot_spec: Mapping[str, tuple[tuple[int, ...], str]], ) -> dict[str, wp.array]: """Allocate zero-size sentinel arrays from a ``slot_spec``. ``slot_spec`` maps a parameter name to ``(shape, dtype_token)`` where ``dtype_token`` is one of ``"vec"`` / ``"mat"`` / ``"vec2"`` / ``"scalar"`` (resolved against ``wp_dtype``) or a fixed ``"f64"`` / ``"i32"`` / ``"vec3i"``. The dtype/shape of each sentinel must match its kernel slot exactly -- Warp type-checks even zero-size arrays at launch. Returns ------- dict[str, wp.array] Sentinels keyed by parameter name. """ _require_supported_dtype(wp_dtype) return { name: wp.empty(shape, dtype=_resolve_slot_dtype(token, wp_dtype), device=device) for name, (shape, token) in slot_spec.items() } # === Named + documented generated kernels (mirror of the NL convention) === # # The neighbor-list factory gives each specialization a descriptive ``__name__`` / # Warp ``key`` and a contract + "Specialization" docstring. These helpers reproduce # that convention locally (no import from ``neighbors`` -- avoids a cross-package # import edge). They mutate only kernels the factory itself builds; the reused # hand-written ``wp.Kernel`` objects (recip ``fill`` / forward ``virial``) are never # passed here. def _kernel_specialization_name( base: str, *, wp_dtype: type, features: Iterable[str] ) -> str: """Build a descriptive specialization name from a base + axis feature tokens. Format: ``"{base}__{feature_a}_{feature_b}_...__{dt}"`` where ``dt`` is ``f64``/``f32`` and ``features`` are already-lowercased axis tokens (empty tokens are dropped). """ dt = "f64" if wp_dtype is wp.float64 else "f32" feats = "_".join(f for f in features if f) if feats: return f"{base}__{feats}__{dt}" return f"{base}__{dt}" def _dtype_token(wp_dtype: type) -> str: """Short dtype token used in generated specialization names and docs.""" return "f64" if wp_dtype is wp.float64 else "f32" def _deriv_token(deriv_state: _DerivState | None) -> str: """Lowercase feature token for a derivative-state axis.""" if deriv_state is None: return "" return { _DerivState.E: "e", _DerivState.E_F: "e_f", _DerivState.E_F_dQ: "e_f_dq", _DerivState.E_dQ: "e_dq", }[deriv_state] def _set_fn_name(kernel: wp.Kernel, name: str) -> None: """Set a generated kernel's descriptive name. Updates the Warp identity ``key`` (used in generated symbol names / profiles) and the Python ``__name__`` / ``__qualname__`` on both the kernel object and its underlying function. Does **not** touch ``kernel.module.name`` (the per-spec ``module=`` the dead-branch test keys on). """ kernel.key = name kernel.__name__ = name kernel.func.__name__ = name kernel.func.__qualname__ = name def _set_fn_doc(kernel: wp.Kernel, doc: str) -> None: """Set a generated kernel's docstring (on both the kernel and its function).""" kernel.__doc__ = doc kernel.func.__doc__ = doc def _append_specialization_doc( base_doc: str | None, *, dtype: str, entries: Sequence[tuple[str, object]] ) -> str: """Append a numpydoc-style "Specialization" section to a kernel's contract doc. Parameters ---------- base_doc : str or None The kernel's static contract docstring (the inner ``def`` docstring). dtype : str ``"f64"`` / ``"f32"`` -- listed first in the section. entries : sequence of (str, object) ``(axis_name, value)`` pairs (e.g. ``("batched", True)``). """ base = (base_doc or "").rstrip() lines = ["Specialization", "--------------", f"* dtype = {dtype}"] lines += [f"* {name} = {value}" for name, value in entries] section = "\n".join(lines) return f"{base}\n\n{section}\n" if base else f"{section}\n" def _name_and_document_kernel( kernel: wp.Kernel, *, base: str, wp_dtype: type, features: Iterable[str], entries: Sequence[tuple[str, object]], ) -> None: """Apply the standard specialization name and docstring to a generated kernel.""" _set_fn_name( kernel, _kernel_specialization_name(base, wp_dtype=wp_dtype, features=features), ) _set_fn_doc( kernel, _append_specialization_doc( kernel.func.__doc__, dtype=_dtype_token(wp_dtype), entries=entries, ), ) # === Shared first-backward cache-scaling kernel === @lru_cache(maxsize=None) def get_backward_scale_kernel( wp_dtype: type, *, batched: bool, scale_positions: bool, scale_charges: bool, ) -> wp.Kernel: """Return a kernel that scales detached first-derivative caches. The registered Ewald chains precompute detached ``dE/dR`` / ``dE/dq`` caches in the forward pass. First backward only needs to multiply those caches by the per-system energy cotangent. This shared kernel fuses that multiplication for atom-major position and charge outputs. The returned kernel is cached via :func:`functools.lru_cache`; repeated calls with identical arguments return the same ``wp.Kernel`` object without recompilation. Parameters ---------- wp_dtype : type Warp scalar dtype, either ``wp.float32`` or ``wp.float64``. batched : bool If ``True``, the kernel reads per-atom system indices from ``batch_id`` to look up the correct energy cotangent; if ``False``, a single system is assumed. scale_positions : bool If ``True``, write scaled position gradients into ``grad_positions``. scale_charges : bool If ``True``, write scaled charge gradients into ``grad_charges``. Returns ------- wp.Kernel A compiled Warp kernel with the signature ``(grad_energy, batch_id, dEdR_cache, dEdq_cache, grad_positions, grad_charges, num_atoms)``. The kernel writes into ``grad_positions`` (when ``scale_positions=True``) and ``grad_charges`` (when ``scale_charges=True``). See Also -------- :class:`_DerivState` : Derivative-state axis controlling which caches are populated. """ _require_supported_dtype(wp_dtype) info = _DTYPE_INFO[wp_dtype] vec_dtype = info.vec BATCHED = wp.constant(bool(batched)) SCALE_POSITIONS = wp.constant(bool(scale_positions)) SCALE_CHARGES = wp.constant(bool(scale_charges)) suffix_parts = ["scale"] if scale_positions: suffix_parts.append("pos") if scale_charges: suffix_parts.append("charge") module_name = _make_specialization_module_name( "backward_scale", wp_dtype=wp_dtype, batched=batched, suffix="_".join(suffix_parts), ) @wp.kernel(module=module_name) def _backward_scale( grad_energy: wp.array(dtype=wp.float64), batch_id: wp.array(dtype=wp.int32), dEdR_cache: wp.array(dtype=vec_dtype), dEdq_cache: wp.array(dtype=wp.float64), grad_positions: wp.array(dtype=vec_dtype), grad_charges: wp.array(dtype=wp.float64), num_atoms: wp.int32, ) -> None: """Scale cached first derivatives by per-system energy cotangents.""" tid = wp.tid() if tid < num_atoms: if BATCHED: isys = batch_id[tid] else: isys = wp.int32(0) ge = grad_energy[isys] if SCALE_POSITIONS: dpos = dEdR_cache[tid] grad_positions[tid] = type(dpos)( type(dpos[0])(ge) * dpos[0], type(dpos[0])(ge) * dpos[1], type(dpos[0])(ge) * dpos[2], ) if SCALE_CHARGES: grad_charges[tid] = ge * dEdq_cache[tid] features = [ "batch" if batched else "single", "pos" if scale_positions else "", "charge" if scale_charges else "", ] _name_and_document_kernel( _backward_scale, base="backward_scale", wp_dtype=wp_dtype, features=features, entries=[ ("batched", batched), ("scale_positions", scale_positions), ("scale_charges", scale_charges), ], ) return _backward_scale