Skip to content

kups.core.neighborlist

Neighbor lists, periodic edges, and composable search pipelines.

Dense and cell-list searches share masks and compaction. CellListCache retains cell occupants for neighbor searches and incremental pair evaluation.

AdaptiveNeighborList

Bases: NeighborList[Literal[2]]

Neighbor list that dispatches to the cheapest backing implementation.

Holds (implementation, cost) pairs and, on each call, picks the implementation whose :class:NeighborListCost is smallest for that call's counts. Augment by appending pairs to :attr:implementations; the seeded implementations share the UniversalNeighborlistParameters capacities, so growing one grows the shared state.

Attributes:

Name Type Description
implementations tuple[NeighborListCandidate, ...]

Candidate :class:NeighborListCandidate pairs. Ties resolve to the earlier entry.

Example
nl = AdaptiveNeighborList.from_state(state, cutoffs)
edges = nl(particles, systems)
Source code in src/kups/core/neighborlist/adaptive.py
@dataclass
class AdaptiveNeighborList(NeighborList[Literal[2]]):
    """Neighbor list that dispatches to the cheapest backing implementation.

    Holds ``(implementation, cost)`` pairs and, on each call, picks the
    implementation whose :class:`NeighborListCost` is smallest for that call's
    counts. Augment by appending pairs to :attr:`implementations`; the seeded
    implementations share the ``UniversalNeighborlistParameters`` capacities, so
    growing one grows the shared state.

    Attributes:
        implementations: Candidate :class:`NeighborListCandidate` pairs. Ties
            resolve to the earlier entry.

    Example:
        ```python
        nl = AdaptiveNeighborList.from_state(state, cutoffs)
        edges = nl(particles, systems)
        ```
    """

    implementations: tuple[NeighborListCandidate, ...]

    @classmethod
    def new[S](
        cls,
        state: S,
        lens: Lens[S, IsUniversalNeighborlistParams],
        cutoffs: Table[SystemId, Array],
    ) -> AdaptiveNeighborList:
        """Seed the library implementations with their default cost guesses.

        Args:
            state: Object exposing ``UniversalNeighborlistParameters`` via ``lens``.
            lens: Lens focusing the shared ``IsUniversalNeighborlistParams``.
            cutoffs: Per-system cutoffs bound onto each implementation.

        Returns:
            An ``AdaptiveNeighborList`` over dense, cell-list, and all-dense.
        """
        return cls(
            (
                NeighborListCandidate(
                    DenseNearestNeighborList.new(state, lens, cutoffs), dense_cost
                ),
                NeighborListCandidate(
                    CellListNeighborList.new(state, lens, cutoffs), cell_list_cost
                ),
                NeighborListCandidate(
                    AllDenseNearestNeighborList.new(state, lens, cutoffs),
                    all_dense_cost,
                ),
            )
        )

    @classmethod
    def from_state(
        cls,
        state: IsNeighborListState[IsUniversalNeighborlistParams],
        cutoffs: Table[SystemId, Array],
    ) -> AdaptiveNeighborList:
        """Seed from a state exposing ``neighborlist_params``."""
        return cls.new(state, lens(lambda s: s.neighborlist_params), cutoffs)

    def _choose(self, num_particles: int, num_systems: int) -> NeighborList[Literal[2]]:
        """Return the cheapest implementation for the given counts."""
        return min(
            self.implementations,
            key=lambda candidate: candidate.cost(num_particles, num_systems),
        ).neighborlist

    def pair_candidates(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        inclusion: bool,
        exclusion: bool,
    ) -> CandidateBatch[Literal[2]]:
        """Full neighbors with shared group masks and retained image flags.

        Apply group masks shared by every pair term before compaction. Terms
        with differing policies apply their remaining masks during evaluation.
        Uses the same selectors and capacity repair as normal graph construction.
        The selected implementation must satisfy ``SelectableNeighborList``.
        """
        selected = self._choose(keys.size, systems.size)
        if not isinstance(selected, SelectableNeighborList):
            raise TypeError("Pair candidates require a SelectableNeighborList")
        masks: list[Mask[Literal[2]]] = [InBoundsMask()]
        if inclusion:
            masks.append(InclusionMatchMask())
        if exclusion:
            masks.append(ExclusionMask())
        else:
            # Always exclude a particle's zero-shift interaction with itself.
            masks.append(
                lambda batch, ctx: (
                    jnp.not_equal(*Index.match(batch.key_idx, batch.query_idx))
                    | ~batch.is_minimum_image
                )
            )
        masks.append(DistanceCutoffMask(selected.cutoffs))
        batch, keep, ctx = select_candidates(
            keys, systems, selected.selector(keys.size, systems), tuple(masks)
        )
        return ReduceCompactor(selected.avg_edges.multiply(keys.size)).compact_batch(
            keep, batch, ctx
        )

    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints],
    ) -> Edges[Literal[2]]: ...
    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[Literal[2]]: ...
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints] | None = None,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[Literal[2]]:
        nl = self._choose(keys.size, systems.size)
        if queries is not None:
            return nl(keys, systems, queries=queries)
        return nl(keys, systems, queried_keys=queried_keys)

from_state(state, cutoffs) classmethod

Seed from a state exposing neighborlist_params.

Source code in src/kups/core/neighborlist/adaptive.py
@classmethod
def from_state(
    cls,
    state: IsNeighborListState[IsUniversalNeighborlistParams],
    cutoffs: Table[SystemId, Array],
) -> AdaptiveNeighborList:
    """Seed from a state exposing ``neighborlist_params``."""
    return cls.new(state, lens(lambda s: s.neighborlist_params), cutoffs)

new(state, lens, cutoffs) classmethod

Seed the library implementations with their default cost guesses.

Parameters:

Name Type Description Default
state S

Object exposing UniversalNeighborlistParameters via lens.

required
lens Lens[S, IsUniversalNeighborlistParams]

Lens focusing the shared IsUniversalNeighborlistParams.

required
cutoffs Table[SystemId, Array]

Per-system cutoffs bound onto each implementation.

required

Returns:

Type Description
AdaptiveNeighborList

An AdaptiveNeighborList over dense, cell-list, and all-dense.

Source code in src/kups/core/neighborlist/adaptive.py
@classmethod
def new[S](
    cls,
    state: S,
    lens: Lens[S, IsUniversalNeighborlistParams],
    cutoffs: Table[SystemId, Array],
) -> AdaptiveNeighborList:
    """Seed the library implementations with their default cost guesses.

    Args:
        state: Object exposing ``UniversalNeighborlistParameters`` via ``lens``.
        lens: Lens focusing the shared ``IsUniversalNeighborlistParams``.
        cutoffs: Per-system cutoffs bound onto each implementation.

    Returns:
        An ``AdaptiveNeighborList`` over dense, cell-list, and all-dense.
    """
    return cls(
        (
            NeighborListCandidate(
                DenseNearestNeighborList.new(state, lens, cutoffs), dense_cost
            ),
            NeighborListCandidate(
                CellListNeighborList.new(state, lens, cutoffs), cell_list_cost
            ),
            NeighborListCandidate(
                AllDenseNearestNeighborList.new(state, lens, cutoffs),
                all_dense_cost,
            ),
        )
    )

pair_candidates(keys, systems, *, inclusion, exclusion)

Full neighbors with shared group masks and retained image flags.

Apply group masks shared by every pair term before compaction. Terms with differing policies apply their remaining masks during evaluation. Uses the same selectors and capacity repair as normal graph construction. The selected implementation must satisfy SelectableNeighborList.

Source code in src/kups/core/neighborlist/adaptive.py
def pair_candidates(
    self,
    keys: Table[ParticleId, NeighborListPoints],
    systems: Table[SystemId, NeighborListSystems],
    *,
    inclusion: bool,
    exclusion: bool,
) -> CandidateBatch[Literal[2]]:
    """Full neighbors with shared group masks and retained image flags.

    Apply group masks shared by every pair term before compaction. Terms
    with differing policies apply their remaining masks during evaluation.
    Uses the same selectors and capacity repair as normal graph construction.
    The selected implementation must satisfy ``SelectableNeighborList``.
    """
    selected = self._choose(keys.size, systems.size)
    if not isinstance(selected, SelectableNeighborList):
        raise TypeError("Pair candidates require a SelectableNeighborList")
    masks: list[Mask[Literal[2]]] = [InBoundsMask()]
    if inclusion:
        masks.append(InclusionMatchMask())
    if exclusion:
        masks.append(ExclusionMask())
    else:
        # Always exclude a particle's zero-shift interaction with itself.
        masks.append(
            lambda batch, ctx: (
                jnp.not_equal(*Index.match(batch.key_idx, batch.query_idx))
                | ~batch.is_minimum_image
            )
        )
    masks.append(DistanceCutoffMask(selected.cutoffs))
    batch, keep, ctx = select_candidates(
        keys, systems, selected.selector(keys.size, systems), tuple(masks)
    )
    return ReduceCompactor(selected.avg_edges.multiply(keys.size)).compact_batch(
        keep, batch, ctx
    )

AllDenseNearestNeighborList

Dense O(N²) neighbor list considering all pairs across all systems.

This implementation generates all possible particle pairs without spatial optimization. It is only suitable for very small systems or testing.

Warning: This crosses system boundaries! Only use for single-system simulations. For multiple systems, use DenseNearestNeighborList instead.

Complexity: O(N²) where N is the total number of particles across all systems.

Attributes:

Name Type Description
avg_edges Capacity[int]

Capacity manager for edge array.

avg_image_candidates Capacity[int]

Capacity manager for image candidate pairs.

cutoffs Table[SystemId, Array]

Per-system cutoff distances used by this neighbor list.

Example
# Construct from state and a lens to the neighbor list parameters:
nl = AllDenseNearestNeighborList.new(state, lens(lambda s: s.nl_params), cutoffs)

# Or, if the state implements IsNeighborListState:
nl = AllDenseNearestNeighborList.from_state(state, cutoffs)

edges = nl(particles, systems)
Source code in src/kups/core/neighborlist/all_dense.py
@dataclass
class AllDenseNearestNeighborList:
    """Dense O(N²) neighbor list considering all pairs across all systems.

    This implementation generates all possible particle pairs without spatial
    optimization. It is only suitable for very small systems or testing.

    **Warning**: This crosses system boundaries! Only use for single-system
    simulations. For multiple systems, use
    [DenseNearestNeighborList][kups.core.neighborlist.DenseNearestNeighborList]
    instead.

    Complexity: O(N²) where N is the total number of particles across all systems.

    Attributes:
        avg_edges: Capacity manager for edge array.
        avg_image_candidates: Capacity manager for image candidate pairs.
        cutoffs: Per-system cutoff distances used by this neighbor list.

    Example:
        ```python
        # Construct from state and a lens to the neighbor list parameters:
        nl = AllDenseNearestNeighborList.new(state, lens(lambda s: s.nl_params), cutoffs)

        # Or, if the state implements IsNeighborListState:
        nl = AllDenseNearestNeighborList.from_state(state, cutoffs)

        edges = nl(particles, systems)
        ```
    """

    avg_edges: Capacity[int]
    avg_image_candidates: Capacity[int]
    cutoffs: Table[SystemId, Array]

    @classmethod
    def new[S](
        cls,
        state: S,
        lens: Lens[S, IsAllDenseNeighborListParams],
        cutoffs: Table[SystemId, Array],
    ) -> AllDenseNearestNeighborList:
        params = lens.get(state)
        return AllDenseNearestNeighborList(
            avg_edges=LensCapacity(params.avg_edges, lens.focus(lambda x: x.avg_edges)),
            avg_image_candidates=LensCapacity(
                params.avg_image_candidates,
                lens.focus(lambda x: x.avg_image_candidates),
            ),
            cutoffs=cutoffs,
        )

    @classmethod
    def from_state(
        cls,
        state: IsNeighborListState[IsAllDenseNeighborListParams],
        cutoffs: Table[SystemId, Array],
    ) -> AllDenseNearestNeighborList:
        return cls.new(state, lens(lambda s: s.neighborlist_params), cutoffs)

    def selector(
        self, query_size: int, systems: Table[SystemId, NeighborListSystems]
    ) -> AllDenseSelector:
        """Build the candidate selector shared by graph and pair evaluation."""
        return AllDenseSelector(
            cutoffs=Table.broadcast_to(self.cutoffs, systems),
            max_image_candidates=self.avg_image_candidates.multiply(query_size),
        )

    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints],
    ) -> Edges[Literal[2]]: ...
    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[Literal[2]]: ...
    @jit
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints] | None = None,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[Literal[2]]:
        if keys.data.inclusion.num_labels >= 2:
            logging.warning(
                "AllDenseNearestNeighborList is intended for single-system simulations. "
                "Performance may be degraded when using multiple systems. "
                "Consider using DenseNearestNeighborList or CellListNeighborList instead."
            )
        return build_radius_graph(
            self, keys, systems, queries=queries, queried_keys=queried_keys
        )

selector(query_size, systems)

Build the candidate selector shared by graph and pair evaluation.

Source code in src/kups/core/neighborlist/all_dense.py
def selector(
    self, query_size: int, systems: Table[SystemId, NeighborListSystems]
) -> AllDenseSelector:
    """Build the candidate selector shared by graph and pair evaluation."""
    return AllDenseSelector(
        cutoffs=Table.broadcast_to(self.cutoffs, systems),
        max_image_candidates=self.avg_image_candidates.multiply(query_size),
    )

AllDenseSelector

Selector that emits every (i, j) pair across all systems.

Source code in src/kups/core/neighborlist/all_dense.py
@dataclass
class AllDenseSelector:
    """Selector that emits every ``(i, j)`` pair across all systems."""

    cutoffs: Table[SystemId, Array]
    max_image_candidates: Capacity[int]

    def __call__(self, ctx: PipelineContext) -> CandidateBatch[Literal[2]]:
        query = ctx.query_table
        candidates = _all_subselect(ctx.keys, query, ctx.systems)
        candidates = lift_query_candidates(candidates, ctx)
        return replicate_for_images(
            candidates,
            ctx.keys,
            ctx.edge_query_table,
            ctx.systems,
            self.cutoffs,
            self.max_image_candidates,
        )

CandidateBatch

Candidate set of degree D carried through the pipeline.

Reuses Edges[D] for the (indices, shifts) layout (indices shape (n, D), shifts shape (n, D-1, 3)); adds the is_minimum_image flag that ExclusionMask needs to keep non-minimum periodic copies of excluded pairs.

Attributes:

Name Type Description
edges Edges[D]

Candidate edges (indices + fractional shifts), keyed by PipelineContext.edge_keys.

is_minimum_image Array

(n,) bool — True where the candidate's shift equals the minimum-image shift; False for non-MIC replicated copies emitted by selectors that handle PBC image expansion.

Source code in src/kups/core/neighborlist/types.py
@dataclass
class CandidateBatch[D: int]:
    """Candidate set of degree ``D`` carried through the pipeline.

    Reuses [`Edges[D]`][kups.core.neighborlist.edges.Edges] for the
    `(indices, shifts)` layout (`indices` shape `(n, D)`,
    `shifts` shape `(n, D-1, 3)`); adds the
    ``is_minimum_image`` flag that
    [`ExclusionMask`][kups.core.neighborlist.masks.ExclusionMask] needs to
    keep non-minimum periodic copies of excluded pairs.

    Attributes:
        edges: Candidate edges (indices + fractional shifts), keyed by
            [`PipelineContext.edge_keys`][kups.core.neighborlist.types.PipelineContext.edge_keys].
        is_minimum_image: ``(n,)`` bool — True where the candidate's shift
            equals the minimum-image shift; False for non-MIC replicated
            copies emitted by selectors that handle PBC image expansion.
    """

    edges: Edges[D]
    is_minimum_image: Array

    @property
    def key_idx(self) -> Index[ParticleId]:
        """Pair-specific: key-side index of shape ``(n,)``. Only meaningful for ``D == 2``."""
        return self.edges.indices[:, 0]

    @property
    def query_idx(self) -> Index[ParticleId]:
        """Pair-specific: query-side index of shape ``(n,)``. Only meaningful for ``D == 2``."""
        return self.edges.indices[:, 1]

key_idx property

Pair-specific: key-side index of shape (n,). Only meaningful for D == 2.

query_idx property

Pair-specific: query-side index of shape (n,). Only meaningful for D == 2.

CandidateSelector

Bases: Protocol

Produces a CandidateBatch[D] from the pipeline context.

Owns all candidate-set construction, including any PBC image replication required when max(cutoff/perp_axis) > 0.5.

Source code in src/kups/core/neighborlist/types.py
class CandidateSelector[D: int](Protocol):
    """Produces a ``CandidateBatch[D]`` from the pipeline context.

    Owns all candidate-set construction, including any PBC image
    replication required when ``max(cutoff/perp_axis) > 0.5``.
    """

    def __call__(self, ctx: PipelineContext) -> CandidateBatch[D]: ...

CellListCache

Bases: NamedTuple

Slot-sorted particle rows with per-cell occupancy and stencil tables.

Slots 0 .. n-1 hold the particles, slots up to n_pad are padding, and slot n_pad is the inactive sentinel filling empty table entries. Inactive rows occupy no cell entry and emit no candidates.

Attributes:

Name Type Description
rows CellRows[Data]

Per-slot rows, leaves (n_pad + 1, ...).

slot_of_row Array

Slot of each original particle row, (n,).

cells Array

Slot ids per cell, (n_cells + 1, cell_capacity), sentinel filled; the last row is the always-empty sentinel cell.

stencil Array

Neighbor cell ids per cell, (n_cells + 1, stencil_width), invalid entries routed to the sentinel cell.

bins Array

Per-system bin counts, (n_systems, 3).

block_bounds BlockBounds

Bounds of the active rows in each run of key_block_size slots.

Source code in src/kups/core/neighborlist/cell_list_cache.py
class CellListCache[Data](NamedTuple):
    """Slot-sorted particle rows with per-cell occupancy and stencil tables.

    Slots ``0 .. n-1`` hold the particles, slots up to ``n_pad`` are padding,
    and slot ``n_pad`` is the inactive sentinel filling empty table entries.
    Inactive rows occupy no cell entry and emit no candidates.

    Attributes:
        rows: Per-slot rows, leaves ``(n_pad + 1, ...)``.
        slot_of_row: Slot of each original particle row, ``(n,)``.
        cells: Slot ids per cell, ``(n_cells + 1, cell_capacity)``, sentinel
            filled; the last row is the always-empty sentinel cell.
        stencil: Neighbor cell ids per cell, ``(n_cells + 1, stencil_width)``,
            invalid entries routed to the sentinel cell.
        bins: Per-system bin counts, ``(n_systems, 3)``.
        block_bounds: Bounds of the active rows in each run of
            ``key_block_size`` slots.
    """

    rows: CellRows[Data]
    slot_of_row: Array
    cells: Array
    stencil: Array
    bins: Array
    block_bounds: BlockBounds

    @property
    def block_size(self) -> int:
        """Slots per spatial block."""
        return self.sentinel_slot // self.block_bounds.lower.shape[1]

    def block_rows(self, blocks: Array) -> CellRows[Data]:
        """Rows of whole blocks, leaves ``(*blocks.shape, block_size, ...)``.

        Block ids must be in bounds; callers mask padded ids themselves.
        """
        size = self.block_size
        n = self.sentinel_slot
        return jax.tree.map(
            lambda x: x[:n].reshape(n // size, size, *x.shape[1:])[blocks],
            self.rows,
        )

    @property
    def sentinel_slot(self) -> int:
        return self.rows.frac.shape[0] - 1

    @property
    def sentinel_cell(self) -> int:
        return self.stencil.shape[0] - 1

    @property
    def max_cells_per_system(self) -> int:
        """Allocated capacity, derived from the stored array shapes."""
        return self.sentinel_cell // self.bins.shape[0]

    def candidates(self, cell: Array) -> Array:
        """Candidate slots of the given cells, ``(..., stencil_width * cell_capacity)``."""
        return self.cells[self.stencil[cell]].reshape(
            *cell.shape,
            self.stencil.shape[1] * self.cells.shape[1],
        )

    def select(self, ctx: PipelineContext) -> Candidates:
        """Look up occupants for ``CellListSelector`` using the stored grid.

        The cache must correspond to ``ctx.keys`` in original row order.
        Candidate ids are mapped back to those rows; the shared selector uses
        original coordinates when computing periodic shifts.
        """
        query = ctx.query_table
        _, cell = assign_cells(
            query.data.positions,
            query.data.system.indices_in(ctx.systems.keys),
            query.data.inclusion.valid_mask,
            self.bins,
            self.max_cells_per_system,
            ctx.systems.data.cell,
        )
        slots = (
            self.candidates(cell) if ctx.keys.size else jnp.zeros((query.size, 0), int)
        )
        row_of_slot = jnp.full(self.sentinel_slot + 1, ctx.keys.size, dtype=int)
        row_of_slot = row_of_slot.at[self.slot_of_row].set(jnp.arange(ctx.keys.size))
        return Candidates(
            Index(ctx.keys.keys, row_of_slot[slots].ravel(), _cls=ctx.keys.cls),
            Index(
                query.keys,
                jnp.repeat(jnp.arange(query.size), slots.shape[1]),
                _cls=query.cls,
            ),
        )

    def candidate_chunks(self, cell: Array, chunk_size: int) -> Array:
        """Group candidate columns into blocks, with a leading chunk axis.

        Columns from every neighboring cell share a block. Entirely unused
        blocks can be skipped without reducing cell occupancy capacity.
        The shape is ``(n_chunks, *cell.shape, stencil_width * chunk_size)``;
        the last block is sentinel-padded.
        """
        if chunk_size < 1:
            raise ValueError("chunk_size must be positive")
        width, capacity = self.stencil.shape[1], self.cells.shape[1]
        indices = self.candidates(cell).reshape(*cell.shape, width, capacity)
        padding = -capacity % chunk_size
        indices = jnp.pad(
            indices,
            ((0, 0),) * (indices.ndim - 1) + ((0, padding),),
            constant_values=self.sentinel_slot,
        )
        n_chunks = (capacity + padding) // chunk_size
        indices = indices.reshape(*cell.shape, width, n_chunks, chunk_size)
        return jnp.moveaxis(indices, -2, 0).reshape(
            n_chunks, *cell.shape, width * chunk_size
        )

    def bin_rows[D](
        self,
        particles: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, HasCell[AnyPeriodicity]],
        data: D,
    ) -> CellRows[D]:
        """Bin further particles (e.g. proposal queries) into this table's cells."""
        return cell_rows(particles, systems, self.bins, self.max_cells_per_system, data)

block_size property

Slots per spatial block.

max_cells_per_system property

Allocated capacity, derived from the stored array shapes.

bin_rows(particles, systems, data)

Bin further particles (e.g. proposal queries) into this table's cells.

Source code in src/kups/core/neighborlist/cell_list_cache.py
def bin_rows[D](
    self,
    particles: Table[ParticleId, NeighborListPoints],
    systems: Table[SystemId, HasCell[AnyPeriodicity]],
    data: D,
) -> CellRows[D]:
    """Bin further particles (e.g. proposal queries) into this table's cells."""
    return cell_rows(particles, systems, self.bins, self.max_cells_per_system, data)

block_rows(blocks)

Rows of whole blocks, leaves (*blocks.shape, block_size, ...).

Block ids must be in bounds; callers mask padded ids themselves.

Source code in src/kups/core/neighborlist/cell_list_cache.py
def block_rows(self, blocks: Array) -> CellRows[Data]:
    """Rows of whole blocks, leaves ``(*blocks.shape, block_size, ...)``.

    Block ids must be in bounds; callers mask padded ids themselves.
    """
    size = self.block_size
    n = self.sentinel_slot
    return jax.tree.map(
        lambda x: x[:n].reshape(n // size, size, *x.shape[1:])[blocks],
        self.rows,
    )

candidate_chunks(cell, chunk_size)

Group candidate columns into blocks, with a leading chunk axis.

Columns from every neighboring cell share a block. Entirely unused blocks can be skipped without reducing cell occupancy capacity. The shape is (n_chunks, *cell.shape, stencil_width * chunk_size); the last block is sentinel-padded.

Source code in src/kups/core/neighborlist/cell_list_cache.py
def candidate_chunks(self, cell: Array, chunk_size: int) -> Array:
    """Group candidate columns into blocks, with a leading chunk axis.

    Columns from every neighboring cell share a block. Entirely unused
    blocks can be skipped without reducing cell occupancy capacity.
    The shape is ``(n_chunks, *cell.shape, stencil_width * chunk_size)``;
    the last block is sentinel-padded.
    """
    if chunk_size < 1:
        raise ValueError("chunk_size must be positive")
    width, capacity = self.stencil.shape[1], self.cells.shape[1]
    indices = self.candidates(cell).reshape(*cell.shape, width, capacity)
    padding = -capacity % chunk_size
    indices = jnp.pad(
        indices,
        ((0, 0),) * (indices.ndim - 1) + ((0, padding),),
        constant_values=self.sentinel_slot,
    )
    n_chunks = (capacity + padding) // chunk_size
    indices = indices.reshape(*cell.shape, width, n_chunks, chunk_size)
    return jnp.moveaxis(indices, -2, 0).reshape(
        n_chunks, *cell.shape, width * chunk_size
    )

candidates(cell)

Candidate slots of the given cells, (..., stencil_width * cell_capacity).

Source code in src/kups/core/neighborlist/cell_list_cache.py
def candidates(self, cell: Array) -> Array:
    """Candidate slots of the given cells, ``(..., stencil_width * cell_capacity)``."""
    return self.cells[self.stencil[cell]].reshape(
        *cell.shape,
        self.stencil.shape[1] * self.cells.shape[1],
    )

select(ctx)

Look up occupants for CellListSelector using the stored grid.

The cache must correspond to ctx.keys in original row order. Candidate ids are mapped back to those rows; the shared selector uses original coordinates when computing periodic shifts.

Source code in src/kups/core/neighborlist/cell_list_cache.py
def select(self, ctx: PipelineContext) -> Candidates:
    """Look up occupants for ``CellListSelector`` using the stored grid.

    The cache must correspond to ``ctx.keys`` in original row order.
    Candidate ids are mapped back to those rows; the shared selector uses
    original coordinates when computing periodic shifts.
    """
    query = ctx.query_table
    _, cell = assign_cells(
        query.data.positions,
        query.data.system.indices_in(ctx.systems.keys),
        query.data.inclusion.valid_mask,
        self.bins,
        self.max_cells_per_system,
        ctx.systems.data.cell,
    )
    slots = (
        self.candidates(cell) if ctx.keys.size else jnp.zeros((query.size, 0), int)
    )
    row_of_slot = jnp.full(self.sentinel_slot + 1, ctx.keys.size, dtype=int)
    row_of_slot = row_of_slot.at[self.slot_of_row].set(jnp.arange(ctx.keys.size))
    return Candidates(
        Index(ctx.keys.keys, row_of_slot[slots].ravel(), _cls=ctx.keys.cls),
        Index(
            query.keys,
            jnp.repeat(jnp.arange(query.size), slots.shape[1]),
            _cls=query.cls,
        ),
    )

CellListCacheParameters

Static shapes of a cell table.

Attributes:

Name Type Description
chunk_size int

Query rows processed per local scan step; the padded slot count is a multiple of it.

max_cells_per_system int

Capacity for spatial bins per system (prod(bins) <= max_cells_per_system is asserted).

cell_capacity int

Maximum active particles per bin (asserted).

stencil_width int

Distinct neighbor cells per cell. 27 is always valid; with fewer than three bins along an axis the stencil wraps onto duplicates and prod(min(bins, 3)) suffices (asserted). Smaller widths shrink every candidate gather.

key_chunk_size int | None

Optional keys per block: columns of each neighboring cell for key_layout="cells", or particle slots for "slots". Empty blocks skip pair evaluation; None uses one block. This changes traversal, not the occupancy limit.

key_layout Literal['cells', 'slots']

Read neighboring cells, or traverse all particle slots. Slot traversal still applies the exact distance and pair masks.

max_images_per_pair int

Capacity for each pair's periodic-image window. Estimated from the cutoff and cell geometry; one in the minimum-image regime.

key_block_size int

Slots per spatial block. Slots are ordered by a spatial bisection tree at build time and each block keeps fractional bounds of its active rows, maintained on updates. Local fused evaluations then skip blocks that are provably beyond the cutoff of every query and gather only the remaining blocks, in batches of key_chunk_size slots. Exact: skipped pairs are outside the cutoff for the evaluated minimum image. Culling applies only with key_layout="slots", max_images_per_pair == 1 and at most 64 query rows per evaluation, summed over phases and systems (fused.MAX_DENSE_QUERY_ROWS); otherwise the unculled traversal runs and the bounds are maintained unused.

Source code in src/kups/core/neighborlist/cell_list_cache.py
@dataclass
class CellListCacheParameters:
    """Static shapes of a cell table.

    Attributes:
        chunk_size: Query rows processed per local scan step; the
            padded slot count is a multiple of it.
        max_cells_per_system: Capacity for spatial bins per system
            (``prod(bins) <= max_cells_per_system`` is asserted).
        cell_capacity: Maximum active particles per bin (asserted).
        stencil_width: Distinct neighbor cells per cell. 27 is always valid;
            with fewer than three bins along an axis the stencil wraps onto
            duplicates and ``prod(min(bins, 3))`` suffices (asserted).
            Smaller widths shrink every candidate gather.
        key_chunk_size: Optional keys per block: columns of each neighboring cell
            for ``key_layout="cells"``, or particle slots for ``"slots"``.
            Empty blocks skip pair evaluation; ``None`` uses one block.
            This changes traversal, not the occupancy limit.
        key_layout: Read neighboring cells, or traverse all particle slots.
            Slot traversal still applies the exact distance and pair masks.
        max_images_per_pair: Capacity for each pair's periodic-image window.
            Estimated from the cutoff and cell geometry; one in the
            minimum-image regime.
        key_block_size: Slots per spatial block. Slots are ordered by a
            spatial bisection tree at build time and each block keeps
            fractional bounds of its active rows, maintained on updates.
            Local fused evaluations then skip blocks that are provably
            beyond the cutoff of every query and gather only the remaining
            blocks, in batches of ``key_chunk_size`` slots. Exact: skipped
            pairs are outside the cutoff for the evaluated minimum image.
            Culling applies only with ``key_layout="slots"``,
            ``max_images_per_pair == 1`` and at most 64 query rows per
            evaluation, summed over phases and systems
            (``fused.MAX_DENSE_QUERY_ROWS``); otherwise the unculled
            traversal runs and the bounds are maintained unused.
    """

    chunk_size: int = field(static=True, default=32)
    max_cells_per_system: int = field(static=True, default=512)
    cell_capacity: int = field(static=True, default=8)
    stencil_width: int = field(static=True, default=27)
    key_chunk_size: int | None = field(static=True, default=None, kw_only=True)
    key_layout: Literal["cells", "slots"] = field(
        static=True, default="cells", kw_only=True
    )
    max_images_per_pair: int = field(static=True, default=1, kw_only=True)
    key_block_size: int = field(static=True, default=8, kw_only=True)

    def __post_init__(self) -> None:
        if min(self.chunk_size, self.max_cells_per_system, self.cell_capacity) <= 0:
            raise ValueError("Cell table capacities and chunk_size must be positive")
        if self.key_chunk_size is not None and self.key_chunk_size < 1:
            raise ValueError("key_chunk_size must be positive")
        if self.key_layout not in ("cells", "slots"):
            raise ValueError("key_layout must be 'cells' or 'slots'")
        if not 1 <= self.stencil_width <= 27:
            raise ValueError("stencil_width must be between 1 and 27")
        if self.max_images_per_pair < 1:
            raise ValueError("max_images_per_pair must be positive")
        if self.key_block_size < 1:
            raise ValueError("key_block_size must be positive")

    @property
    def slot_multiple(self) -> int:
        """Padded slot counts are multiples of the query chunk and block size."""
        return math.lcm(self.chunk_size, self.key_block_size)

    @classmethod
    @no_jax_tracing
    def estimate(
        cls,
        particles: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, HasCell[AnyPeriodicity]],
        cutoffs: Table[SystemId, Array],
        *,
        chunk_size: int = 64,
        occupancy_factor: float = 2.0,
        occupancy_headroom: int = 32,
        key_chunk_size: int | Literal["auto"] | None = None,
        key_layout: Literal["cells", "slots", "auto"] = "cells",
        key_block_size: int = 8,
    ) -> Self:
        """Estimate parameters from a concrete state.

        Sizes ``cell_capacity`` from the exact per-cell occupancy of the
        active particles with multiplicative and additive headroom to absorb
        density growth (e.g. GCMC insertions); violations at runtime fail the
        assertions in [`build_cell_list_cache`][kups.core.neighborlist.cell_list_cache.build_cell_list_cache].

        Args:
            particles: Current (possibly buffered) particle table.
            systems: System table with cells.
            cutoffs: Per-system cutoff radii.
            chunk_size: Query rows per local scan step.
            occupancy_factor: Multiplier on the observed maximum occupancy.
            occupancy_headroom: Additive slack on top of the scaled occupancy.
            key_chunk_size: Keys per block, or ``"auto"`` to round cell occupancy
                up to a multiple of 32. For slot traversal, rounds total occupancy
                plus additive headroom up to a multiple of 128. Later blocks
                remain available as density grows; ``None`` uses one block.
            key_layout: ``"auto"`` selects slots when every system's stencil
                covers all cells and either slot blocks are culled or, for a
                single system, the estimated slot block is smaller than the
                combined cell blocks. Otherwise retains cell traversal.
            key_block_size: Slots per spatial block. Blocks are culled only
                in the minimum-image regime, where ``key_layout="auto"`` then
                prefers slots and ``key_chunk_size="auto"`` sizes gathered
                batches to the blocks near a typical query.

        Returns:
            Static parameters for the table.
        """
        cutoff = Table.broadcast_to(cutoffs, systems).data
        bins, images = _cell_grid(systems.data, cutoff)
        bins_np, images = np.asarray(bins), np.asarray(images)
        max_cells = int(bins_np.prod(axis=-1).max())
        max_occupancy, system_active = _occupancy(
            particles, systems, bins, max_cells=max_cells
        )
        max_occupancy, system_active = int(max_occupancy), np.asarray(system_active)
        active = int(system_active.sum())
        stencil_width = int(np.minimum(bins_np, 3).prod(axis=-1).max())
        cell_chunk_size = max(32, -(-max_occupancy // 32) * 32)
        occupancy = active + occupancy_headroom
        slot_chunk_size = max(128, -(-occupancy // 128) * 128)
        max_images = int(images.prod(axis=-1).max())
        # Slot blocks are culled only in the minimum-image regime.
        culled = max_images == 1
        if key_layout == "auto":
            limits = np.where(np.asarray(systems.data.cell.periodic), 3, 2)
            key_layout = (
                "slots"
                if bool((bins_np <= limits).all())
                and (
                    culled
                    or systems.size == 1
                    and slot_chunk_size < stencil_width * cell_chunk_size
                )
                else "cells"
            )
        if key_chunk_size == "auto":
            key_chunk_size = (
                slot_chunk_size if key_layout == "slots" else cell_chunk_size
            )
            if culled and key_layout == "slots":
                key_chunk_size = _estimate_block_batch(
                    systems, cutoff, system_active, particles.size, key_block_size
                )
        return cls(
            chunk_size=chunk_size,
            key_chunk_size=key_chunk_size,
            key_layout=key_layout,
            max_cells_per_system=max_cells,
            cell_capacity=max(
                1, int(max_occupancy * occupancy_factor) + occupancy_headroom
            ),
            stencil_width=stencil_width,
            max_images_per_pair=max_images,
            key_block_size=key_block_size,
        )

slot_multiple property

Padded slot counts are multiples of the query chunk and block size.

estimate(particles, systems, cutoffs, *, chunk_size=64, occupancy_factor=2.0, occupancy_headroom=32, key_chunk_size=None, key_layout='cells', key_block_size=8) classmethod

Estimate parameters from a concrete state.

Sizes cell_capacity from the exact per-cell occupancy of the active particles with multiplicative and additive headroom to absorb density growth (e.g. GCMC insertions); violations at runtime fail the assertions in build_cell_list_cache.

Parameters:

Name Type Description Default
particles Table[ParticleId, NeighborListPoints]

Current (possibly buffered) particle table.

required
systems Table[SystemId, HasCell[AnyPeriodicity]]

System table with cells.

required
cutoffs Table[SystemId, Array]

Per-system cutoff radii.

required
chunk_size int

Query rows per local scan step.

64
occupancy_factor float

Multiplier on the observed maximum occupancy.

2.0
occupancy_headroom int

Additive slack on top of the scaled occupancy.

32
key_chunk_size int | Literal['auto'] | None

Keys per block, or "auto" to round cell occupancy up to a multiple of 32. For slot traversal, rounds total occupancy plus additive headroom up to a multiple of 128. Later blocks remain available as density grows; None uses one block.

None
key_layout Literal['cells', 'slots', 'auto']

"auto" selects slots when every system's stencil covers all cells and either slot blocks are culled or, for a single system, the estimated slot block is smaller than the combined cell blocks. Otherwise retains cell traversal.

'cells'
key_block_size int

Slots per spatial block. Blocks are culled only in the minimum-image regime, where key_layout="auto" then prefers slots and key_chunk_size="auto" sizes gathered batches to the blocks near a typical query.

8

Returns:

Type Description
Self

Static parameters for the table.

Source code in src/kups/core/neighborlist/cell_list_cache.py
@classmethod
@no_jax_tracing
def estimate(
    cls,
    particles: Table[ParticleId, NeighborListPoints],
    systems: Table[SystemId, HasCell[AnyPeriodicity]],
    cutoffs: Table[SystemId, Array],
    *,
    chunk_size: int = 64,
    occupancy_factor: float = 2.0,
    occupancy_headroom: int = 32,
    key_chunk_size: int | Literal["auto"] | None = None,
    key_layout: Literal["cells", "slots", "auto"] = "cells",
    key_block_size: int = 8,
) -> Self:
    """Estimate parameters from a concrete state.

    Sizes ``cell_capacity`` from the exact per-cell occupancy of the
    active particles with multiplicative and additive headroom to absorb
    density growth (e.g. GCMC insertions); violations at runtime fail the
    assertions in [`build_cell_list_cache`][kups.core.neighborlist.cell_list_cache.build_cell_list_cache].

    Args:
        particles: Current (possibly buffered) particle table.
        systems: System table with cells.
        cutoffs: Per-system cutoff radii.
        chunk_size: Query rows per local scan step.
        occupancy_factor: Multiplier on the observed maximum occupancy.
        occupancy_headroom: Additive slack on top of the scaled occupancy.
        key_chunk_size: Keys per block, or ``"auto"`` to round cell occupancy
            up to a multiple of 32. For slot traversal, rounds total occupancy
            plus additive headroom up to a multiple of 128. Later blocks
            remain available as density grows; ``None`` uses one block.
        key_layout: ``"auto"`` selects slots when every system's stencil
            covers all cells and either slot blocks are culled or, for a
            single system, the estimated slot block is smaller than the
            combined cell blocks. Otherwise retains cell traversal.
        key_block_size: Slots per spatial block. Blocks are culled only
            in the minimum-image regime, where ``key_layout="auto"`` then
            prefers slots and ``key_chunk_size="auto"`` sizes gathered
            batches to the blocks near a typical query.

    Returns:
        Static parameters for the table.
    """
    cutoff = Table.broadcast_to(cutoffs, systems).data
    bins, images = _cell_grid(systems.data, cutoff)
    bins_np, images = np.asarray(bins), np.asarray(images)
    max_cells = int(bins_np.prod(axis=-1).max())
    max_occupancy, system_active = _occupancy(
        particles, systems, bins, max_cells=max_cells
    )
    max_occupancy, system_active = int(max_occupancy), np.asarray(system_active)
    active = int(system_active.sum())
    stencil_width = int(np.minimum(bins_np, 3).prod(axis=-1).max())
    cell_chunk_size = max(32, -(-max_occupancy // 32) * 32)
    occupancy = active + occupancy_headroom
    slot_chunk_size = max(128, -(-occupancy // 128) * 128)
    max_images = int(images.prod(axis=-1).max())
    # Slot blocks are culled only in the minimum-image regime.
    culled = max_images == 1
    if key_layout == "auto":
        limits = np.where(np.asarray(systems.data.cell.periodic), 3, 2)
        key_layout = (
            "slots"
            if bool((bins_np <= limits).all())
            and (
                culled
                or systems.size == 1
                and slot_chunk_size < stencil_width * cell_chunk_size
            )
            else "cells"
        )
    if key_chunk_size == "auto":
        key_chunk_size = (
            slot_chunk_size if key_layout == "slots" else cell_chunk_size
        )
        if culled and key_layout == "slots":
            key_chunk_size = _estimate_block_batch(
                systems, cutoff, system_active, particles.size, key_block_size
            )
    return cls(
        chunk_size=chunk_size,
        key_chunk_size=key_chunk_size,
        key_layout=key_layout,
        max_cells_per_system=max_cells,
        cell_capacity=max(
            1, int(max_occupancy * occupancy_factor) + occupancy_headroom
        ),
        stencil_width=stencil_width,
        max_images_per_pair=max_images,
        key_block_size=key_block_size,
    )

CellListCacheUpdatePatch

Bases: Patch[State]

Accept-conditional update of a persistent cell table.

Accepted rows refresh their coordinates and payload. Only rows changing cells are removed and reinserted (overflow asserted); moves within a cell retain membership. On CPU, a wholly rejected proposal skips both operations. Slots retain their original row identity, so slot_of_row stays valid. Spatial sort order is not maintained after moves. Changed rows are ranked within their destination cell by counting earlier rows bound for it.

Attributes:

Name Type Description
slots Array

Slots of the changed rows, (k,).

system_idx Index[SystemId]

Original proposal system ids for acceptance; new.system maps inactive rows to zero for geometry and cannot replace these ids.

new CellRows[Data]

Rows after the update, leaves (k, ...).

lens Lens[State, CellListCache[Data]]

Lens to the cell table in the state.

Source code in src/kups/core/neighborlist/cell_list_cache.py
@dataclass
class CellListCacheUpdatePatch[State, Data](Patch[State]):
    """Accept-conditional update of a persistent cell table.

    Accepted rows refresh their coordinates and payload. Only rows changing
    cells are removed and reinserted (overflow asserted); moves within a cell
    retain membership. On CPU, a wholly rejected proposal skips both operations.
    Slots retain their original row identity, so ``slot_of_row`` stays valid.
    Spatial sort order is not maintained after moves. Changed rows are ranked
    within their destination cell by counting earlier rows bound for it.

    Attributes:
        slots: Slots of the changed rows, ``(k,)``.
        system_idx: Original proposal system ids for acceptance; ``new.system``
            maps inactive rows to zero for geometry and cannot replace these ids.
        new: Rows after the update, leaves ``(k, ...)``.
        lens: Lens to the cell table in the state.
    """

    slots: Array
    system_idx: Index[SystemId]
    new: CellRows[Data]
    lens: Lens[State, CellListCache[Data]] = field(static=True)

    def __call__(self, state: State, accept: Accept) -> State:
        table = self.lens.get(state)
        n_pad, n_cells = table.sentinel_slot, table.sentinel_cell
        old_cell = table.rows.cell[self.slots]
        accept = Table.broadcast_to(accept, Table(table.rows.system.keys, table.bins))
        old_accept = accept.at(
            table.rows.system[self.slots], args={"mode": "fill", "fill_value": False}
        ).get()
        new_accept = accept.at(
            self.system_idx, args={"mode": "fill", "fill_value": False}
        ).get()
        # Buffered deletions have no new system; insertions have no old cell.
        # Match Table.update_if's acceptance over the old/new system indices.
        ok = (self.slots < n_pad) & (((old_cell != n_cells) & old_accept) | new_accept)

        def apply(table: CellListCache[Data]) -> CellListCache[Data]:
            # Insertion/deletion also changes the cell via the inactive sentinel.
            relocate = ok & (old_cell != self.new.cell)

            def move_cells(cells: Array) -> Array:
                column = jnp.argmax(cells[old_cell] == self.slots[:, None], axis=-1)
                column = jnp.where(
                    relocate & (old_cell != n_cells), column, cells.shape[1]
                )
                cells = cells.at[old_cell, column].set(n_pad, mode="drop")
                # Rank incoming rows per cell to assign distinct free columns:
                # count earlier rows bound for the same cell (changed rows are few).
                insert = relocate & (self.new.cell != n_cells)
                target = jnp.where(insert, self.new.cell, n_cells)
                earlier = jnp.tri(target.size, k=-1, dtype=bool)
                rank = (earlier & (target[:, None] == target[None])).sum(-1)
                free = cells[target] == n_pad
                free_count = jnp.cumsum(free, axis=-1)
                position = jnp.argmax(free_count == (rank + 1)[:, None], axis=-1)
                runtime_assert(
                    ((free.sum(-1) > rank) | ~insert).all(),
                    "cell_capacity exceeded during cell table update; increase CellListCacheParameters.cell_capacity.",
                )
                position = jnp.where(insert, position, cells.shape[1])
                return cells.at[target, position].set(self.slots, mode="drop")

            cells = skip_on_cpu(
                jnp.any(relocate), move_cells, lambda cells: cells, table.cells
            )

            # Rejected or padded rows target an out-of-bounds slot and drop.
            target = jnp.where(ok, self.slots, n_pad + 1)

            def write(current: Array, new: Array) -> Array:
                return current.at[target].set(new.astype(current.dtype), mode="drop")

            def rewrite[Key: SupportsSorting](
                current: Array | Index[Key], new: Array | Index[Key]
            ) -> Array | Index[Key]:
                if isinstance(current, Index):
                    assert isinstance(new, Index), "Expected matching Index leaves"
                    # Retain the cache's static vocabulary and count bound.
                    return current.with_raw_indices(
                        write(current.raw_indices, new.indices_in(current.keys))
                    )
                assert isinstance(new, Array), "Expected matching array leaves"
                return write(current, new)

            rows: CellRows[Data] = jax.tree.map(
                rewrite, table.rows, self.new, is_leaf=lambda x: isinstance(x, Index)
            )
            table = bind(table, lambda t: (t.rows, t.cells)).set((rows, cells))
            size = table.block_size
            n_blocks = n_pad // size
            block = jnp.where(ok, self.slots // size, n_blocks)
            touched = table.block_rows(jnp.minimum(block, n_blocks - 1))
            bounds = BlockBounds.of(
                touched.frac.reshape(-1, 3),
                touched.system.reshape(-1),
                touched.cell.reshape(-1) != n_cells,
                size,
            )
            return bind(table, lambda t: t.block_bounds).apply(
                lambda old: jax.tree.map(
                    lambda o, n: o.at[:, block].set(n, mode="drop"), old, bounds
                )
            )

        table = skip_on_cpu(jnp.any(ok), apply, lambda table: table, table)
        return self.lens.set(state, table)

CellListNeighborList

Neighbor list using spatial hashing with cell lists.

This is the recommended implementation when the cutoff is much smaller than the box size. It divides space into a grid of cells and only checks pairs in neighboring cells.

Honors the cell's per-axis periodic mask: stencil offsets that cross a non-periodic face are routed to an out-of-bounds bin (no key matches), and minimum-image shifts are zero on non-periodic axes.

cache optionally supplies an initialized CellListCache for the current particles and cutoff. It retains occupant slots and neighboring cells between calls; accepted moves must update it along with the particles. Rebuild it when the cell, cutoff, or particle row layout changes. Without a cache, the same grid algorithm joins particle and query cell hashes.

Candidate storage is O(N) for well-distributed particles at fixed density and cutoff. Uncached lookup sorts cell hashes; cached lookup gathers slots. Efficiency improves as cutoff/box ratio decreases.

Attributes:

Name Type Description
avg_candidates Capacity[int]

Capacity for candidate pair storage (from cell list).

avg_edges Capacity[int]

Capacity for final edge array.

cells Capacity[int]

Capacity for cell hash table (grows with box_size³/cutoff³).

avg_image_candidates Capacity[int]

Capacity for image candidate pairs.

cutoffs Table[SystemId, Array]

Per-system cutoff distances used by this neighbor list.

cache CellListLookup | None

Current cell occupants, or None to rebuild the hash lookup.

Algorithm
  1. Partition space into grid cells of size ~cutoff
  2. Hash each particle to its cell
  3. For each particle, check only neighboring 27 cells (3D)
  4. Filter candidates by actual distance
When to use
  • When cutoff/box_size << 1 (cutoff much smaller than box)
  • Typically cutoff/box < 0.3 for good efficiency
  • Nonperiodic outliers remain searchable in boundary bins; many outliers can increase the candidate count.
Example
# Example: 10 Å cutoff in 50 Å box → cutoff/box = 0.2 -- Good for CellList
nl = CellListNeighborList.new(state, lens(lambda s: s.nl_params), cutoffs)

# Or, if the state implements IsNeighborListState:
nl = CellListNeighborList.from_state(state, cutoffs)

edges = nl(particles, systems)
Source code in src/kups/core/neighborlist/cell_list.py
@dataclass
class CellListNeighborList:
    """Neighbor list using spatial hashing with cell lists.

    This is the recommended implementation when the cutoff is much smaller than
    the box size. It divides space into a grid of cells and only checks pairs in
    neighboring cells.

    Honors the cell's per-axis ``periodic`` mask: stencil offsets that cross a
    non-periodic face are routed to an out-of-bounds bin (no key matches), and
    minimum-image shifts are zero on non-periodic axes.

    ``cache`` optionally supplies an initialized ``CellListCache`` for the
    current particles and cutoff. It retains occupant slots and neighboring
    cells between calls; accepted moves must update it along with the particles.
    Rebuild it when the cell, cutoff, or particle row layout changes.
    Without a cache, the same grid algorithm joins particle and query cell hashes.

    Candidate storage is O(N) for well-distributed particles at fixed density
    and cutoff. Uncached lookup sorts cell hashes; cached lookup gathers slots.
    Efficiency improves as cutoff/box ratio decreases.

    Attributes:
        avg_candidates: Capacity for candidate pair storage (from cell list).
        avg_edges: Capacity for final edge array.
        cells: Capacity for cell hash table (grows with box_size³/cutoff³).
        avg_image_candidates: Capacity for image candidate pairs.
        cutoffs: Per-system cutoff distances used by this neighbor list.
        cache: Current cell occupants, or ``None`` to rebuild the hash lookup.

    Algorithm:
        1. Partition space into grid cells of size ~cutoff
        2. Hash each particle to its cell
        3. For each particle, check only neighboring 27 cells (3D)
        4. Filter candidates by actual distance

    When to use:
        - When cutoff/box_size << 1 (cutoff much smaller than box)
        - Typically cutoff/box < 0.3 for good efficiency
        - Nonperiodic outliers remain searchable in boundary bins; many outliers
          can increase the candidate count.

    Example:
        ```python
        # Example: 10 Å cutoff in 50 Å box → cutoff/box = 0.2 -- Good for CellList
        nl = CellListNeighborList.new(state, lens(lambda s: s.nl_params), cutoffs)

        # Or, if the state implements IsNeighborListState:
        nl = CellListNeighborList.from_state(state, cutoffs)

        edges = nl(particles, systems)
        ```
    """

    avg_candidates: Capacity[int]
    avg_edges: Capacity[int]
    cells: Capacity[int]
    avg_image_candidates: Capacity[int]
    cutoffs: Table[SystemId, Array]
    cache: CellListLookup | None = field(default=None, kw_only=True)

    @classmethod
    def new[S](
        cls,
        state: S,
        lens: Lens[S, IsCellListParams],
        cutoffs: Table[SystemId, Array],
    ) -> CellListNeighborList:
        params = lens.get(state)
        return CellListNeighborList(
            avg_candidates=LensCapacity(
                params.avg_candidates, lens.focus(lambda x: x.avg_candidates)
            ),
            avg_edges=LensCapacity(params.avg_edges, lens.focus(lambda x: x.avg_edges)),
            avg_image_candidates=LensCapacity(
                params.avg_image_candidates,
                lens.focus(lambda x: x.avg_image_candidates),
            ),
            cells=LensCapacity(params.cells, lens.focus(lambda x: x.cells), base=1),
            cutoffs=cutoffs,
        )

    @classmethod
    def from_state(
        cls,
        state: IsNeighborListState[IsCellListParams],
        cutoffs: Table[SystemId, Array],
    ) -> CellListNeighborList:
        return cls.new(state, lens(lambda s: s.neighborlist_params), cutoffs)

    def selector(
        self, query_size: int, systems: Table[SystemId, NeighborListSystems]
    ) -> CellListSelector:
        """Build the candidate selector shared by graph and pair evaluation."""
        return CellListSelector(
            cutoffs=Table.broadcast_to(self.cutoffs, systems),
            max_cells=self.cells,
            max_candidates=self.avg_candidates.multiply(query_size),
            max_image_candidates=self.avg_image_candidates.multiply(query_size),
            cache=self.cache,
        )

    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints],
    ) -> Edges[Literal[2]]: ...
    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[Literal[2]]: ...
    @jit
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints] | None = None,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[Literal[2]]:
        return build_radius_graph(
            self, keys, systems, queries=queries, queried_keys=queried_keys
        )

selector(query_size, systems)

Build the candidate selector shared by graph and pair evaluation.

Source code in src/kups/core/neighborlist/cell_list.py
def selector(
    self, query_size: int, systems: Table[SystemId, NeighborListSystems]
) -> CellListSelector:
    """Build the candidate selector shared by graph and pair evaluation."""
    return CellListSelector(
        cutoffs=Table.broadcast_to(self.cutoffs, systems),
        max_cells=self.cells,
        max_candidates=self.avg_candidates.multiply(query_size),
        max_image_candidates=self.avg_image_candidates.multiply(query_size),
        cache=self.cache,
    )

CellListSelector

Selector for the cell-list algorithm.

Joins cell hashes, or reads occupants from an optional initialized cache, then replicates per image multiplicity when max(cutoff/perp) > 0.5. Set promise_unique_cells when the caller guarantees at least three cells along every periodic axis, so each query's stencil cells are already distinct; this skips the per-query cell deduplication in the uncached lookup.

Source code in src/kups/core/neighborlist/cell_list.py
@dataclass
class CellListSelector:
    """Selector for the cell-list algorithm.

    Joins cell hashes, or reads occupants from an optional initialized cache,
    then replicates per image multiplicity when ``max(cutoff/perp) > 0.5``.
    Set ``promise_unique_cells`` when the caller guarantees at least three cells
    along every periodic axis,
    so each query's stencil cells are already distinct; this skips the
    per-query cell deduplication in the uncached lookup.
    """

    cutoffs: Table[SystemId, Array]
    max_cells: Capacity[int]
    max_candidates: Capacity[int]
    max_image_candidates: Capacity[int]
    promise_unique_cells: bool = field(default=False, static=True)
    cache: CellListLookup | None = field(default=None, kw_only=True)

    def __call__(self, ctx: PipelineContext) -> CandidateBatch[Literal[2]]:
        query = ctx.query_table
        candidates = (
            self.cache.select(ctx)
            if self.cache is not None
            else _cell_list_subselect(
                ctx.keys,
                query,
                ctx.systems,
                cutoffs=self.cutoffs.data,
                max_num_cells=self.max_cells,
                max_num_candidates=self.max_candidates,
                promise_unique_cells=self.promise_unique_cells,
            )
        )
        candidates = lift_query_candidates(candidates, ctx)
        return replicate_for_images(
            candidates,
            ctx.keys,
            ctx.edge_query_table,
            ctx.systems,
            self.cutoffs,
            self.max_image_candidates,
        )

CellRows

Bases: NamedTuple

Binned particle rows: what a cell table stores per slot.

Attributes:

Name Type Description
frac Array

Folded fractional positions, (..., 3).

system Index[SystemId]

System index (0 where inactive).

inclusion Index[InclusionId]

Inclusion index; out of bounds for inactive rows.

exclusion Index[ExclusionId]

Exclusion index.

data Data

Caller payload (e.g. kernel features), leaves (..., ...).

cell Array

Global cell id; the sentinel cell where inactive.

Source code in src/kups/core/neighborlist/cell_list_cache.py
class CellRows[Data](NamedTuple):
    """Binned particle rows: what a cell table stores per slot.

    Attributes:
        frac: Folded fractional positions, ``(..., 3)``.
        system: System index (0 where inactive).
        inclusion: Inclusion index; out of bounds for inactive rows.
        exclusion: Exclusion index.
        data: Caller payload (e.g. kernel features), leaves ``(..., ...)``.
        cell: Global cell id; the sentinel cell where inactive.
    """

    frac: Array
    system: Index[SystemId]
    inclusion: Index[InclusionId]
    exclusion: Index[ExclusionId]
    data: Data
    cell: Array

    @property
    def positions(self) -> Array:
        """Fractional coordinates for the neighbor pipeline."""
        return self.frac

    @staticmethod
    def concatenate[D](*parts: CellRows[D]) -> CellRows[D]:
        """Join rows, aligning index vocabularies and preserving count bounds."""

        def concatenate[Key: SupportsSorting](
            *leaves: Array | Index[Key],
        ) -> Array | Index[Key]:
            if isinstance(leaves[0], Index):
                indices = [x for x in leaves if isinstance(x, Index)]
                if len(indices) != len(leaves):
                    raise TypeError("Expected matching Index leaves")
                return Index.concatenate(*indices)
            arrays = [x for x in leaves if isinstance(x, Array)]
            if len(arrays) != len(leaves):
                raise TypeError("Expected matching array leaves")
            return jnp.concatenate(arrays)

        return jax.tree.map(concatenate, *parts, is_leaf=lambda x: isinstance(x, Index))

    def pad(self, padding: int, *, sentinel_cell: int) -> Self:
        """Append inactive rows with finite geometry and zero-filled payloads."""
        fills = CellRows(
            jnp.array(0.0),
            tree_map(lambda _: jnp.array(0), self.system),
            tree_map(lambda _: jnp.array(self.inclusion.num_labels), self.inclusion),
            tree_map(lambda _: jnp.array(self.exclusion.num_labels), self.exclusion),
            tree_map(lambda _: jnp.array(0), self.data),
            jnp.array(sentinel_cell),
        )
        return jax.tree.map(
            lambda x, fill: jnp.concatenate(
                [x, jnp.full((padding, *x.shape[1:]), fill, dtype=x.dtype)]
            ),
            self,
            fills,
        )

positions property

Fractional coordinates for the neighbor pipeline.

concatenate(*parts) staticmethod

Join rows, aligning index vocabularies and preserving count bounds.

Source code in src/kups/core/neighborlist/cell_list_cache.py
@staticmethod
def concatenate[D](*parts: CellRows[D]) -> CellRows[D]:
    """Join rows, aligning index vocabularies and preserving count bounds."""

    def concatenate[Key: SupportsSorting](
        *leaves: Array | Index[Key],
    ) -> Array | Index[Key]:
        if isinstance(leaves[0], Index):
            indices = [x for x in leaves if isinstance(x, Index)]
            if len(indices) != len(leaves):
                raise TypeError("Expected matching Index leaves")
            return Index.concatenate(*indices)
        arrays = [x for x in leaves if isinstance(x, Array)]
        if len(arrays) != len(leaves):
            raise TypeError("Expected matching array leaves")
        return jnp.concatenate(arrays)

    return jax.tree.map(concatenate, *parts, is_leaf=lambda x: isinstance(x, Index))

pad(padding, *, sentinel_cell)

Append inactive rows with finite geometry and zero-filled payloads.

Source code in src/kups/core/neighborlist/cell_list_cache.py
def pad(self, padding: int, *, sentinel_cell: int) -> Self:
    """Append inactive rows with finite geometry and zero-filled payloads."""
    fills = CellRows(
        jnp.array(0.0),
        tree_map(lambda _: jnp.array(0), self.system),
        tree_map(lambda _: jnp.array(self.inclusion.num_labels), self.inclusion),
        tree_map(lambda _: jnp.array(self.exclusion.num_labels), self.exclusion),
        tree_map(lambda _: jnp.array(0), self.data),
        jnp.array(sentinel_cell),
    )
    return jax.tree.map(
        lambda x, fill: jnp.concatenate(
            [x, jnp.full((padding, *x.shape[1:]), fill, dtype=x.dtype)]
        ),
        self,
        fills,
    )

Compactor

Bases: Protocol

Produces compacted Edges[D] from the accumulated keep mask.

Source code in src/kups/core/neighborlist/types.py
class Compactor[D: int](Protocol):
    """Produces compacted ``Edges[D]`` from the accumulated ``keep`` mask."""

    def __call__(
        self, keep: Array, batch: CandidateBatch[D], ctx: PipelineContext
    ) -> Edges[D]: ...

DenseNearestNeighborList

Dense O(N²) neighbor list respecting system boundaries.

This implementation generates all particle pairs within each system separately, avoiding cross-system interactions. Efficient when the cutoff is comparable to the box size (cutoff/box ~ 1).

Complexity: O(N² / K²) where N is total particles and K is number of systems.

Attributes:

Name Type Description
avg_candidates Capacity[int]

Capacity for candidate pair storage.

avg_edges Capacity[int]

Capacity for final edge array.

avg_image_candidates Capacity[int]

Capacity for image candidate pairs.

cutoffs Table[SystemId, Array]

Per-system cutoff distances used by this neighbor list.

When to use
  • When cutoff/box_size ~ 1 (cutoff comparable to box dimensions)
  • Small box relative to cutoff (few cells would fit)
  • Non-periodic systems
Example
# Example: 15 Å cutoff in 20 Å box → cutoff/box = 0.75
nl = DenseNearestNeighborList.new(state, lens(lambda s: s.nl_params), cutoffs)

# Or, if the state implements IsNeighborListState:
nl = DenseNearestNeighborList.from_state(state, cutoffs)

edges = nl(particles, systems)
Source code in src/kups/core/neighborlist/dense.py
@dataclass
class DenseNearestNeighborList:
    """Dense O(N²) neighbor list respecting system boundaries.

    This implementation generates all particle pairs within each system
    separately, avoiding cross-system interactions. Efficient when the cutoff
    is comparable to the box size (cutoff/box ~ 1).

    Complexity: O(N² / K²) where N is total particles and K is number of systems.

    Attributes:
        avg_candidates: Capacity for candidate pair storage.
        avg_edges: Capacity for final edge array.
        avg_image_candidates: Capacity for image candidate pairs.
        cutoffs: Per-system cutoff distances used by this neighbor list.

    When to use:
        - When cutoff/box_size ~ 1 (cutoff comparable to box dimensions)
        - Small box relative to cutoff (few cells would fit)
        - Non-periodic systems

    Example:
        ```python
        # Example: 15 Å cutoff in 20 Å box → cutoff/box = 0.75
        nl = DenseNearestNeighborList.new(state, lens(lambda s: s.nl_params), cutoffs)

        # Or, if the state implements IsNeighborListState:
        nl = DenseNearestNeighborList.from_state(state, cutoffs)

        edges = nl(particles, systems)
        ```
    """

    avg_candidates: Capacity[int]
    avg_edges: Capacity[int]
    avg_image_candidates: Capacity[int]
    cutoffs: Table[SystemId, Array]

    @classmethod
    def new[S](
        cls,
        state: S,
        lens: Lens[S, IsDenseNeighborlistParams],
        cutoffs: Table[SystemId, Array],
    ) -> DenseNearestNeighborList:
        params = lens.get(state)
        return DenseNearestNeighborList(
            avg_candidates=LensCapacity(
                params.avg_candidates, lens.focus(lambda x: x.avg_candidates)
            ),
            avg_edges=LensCapacity(params.avg_edges, lens.focus(lambda x: x.avg_edges)),
            avg_image_candidates=LensCapacity(
                params.avg_image_candidates,
                lens.focus(lambda x: x.avg_image_candidates),
            ),
            cutoffs=cutoffs,
        )

    @classmethod
    def from_state(
        cls,
        state: IsNeighborListState[IsDenseNeighborlistParams],
        cutoffs: Table[SystemId, Array],
    ) -> DenseNearestNeighborList:
        return cls.new(state, lens(lambda s: s.neighborlist_params), cutoffs)

    def selector(
        self, query_size: int, systems: Table[SystemId, NeighborListSystems]
    ) -> DenseSelector:
        """Build the candidate selector shared by graph and pair evaluation."""
        return DenseSelector(
            cutoffs=Table.broadcast_to(self.cutoffs, systems),
            max_candidates=self.avg_candidates.multiply(query_size),
            max_image_candidates=self.avg_image_candidates.multiply(query_size),
        )

    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints],
    ) -> Edges[Literal[2]]: ...
    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[Literal[2]]: ...
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints] | None = None,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[Literal[2]]:
        return build_radius_graph(
            self, keys, systems, queries=queries, queried_keys=queried_keys
        )

selector(query_size, systems)

Build the candidate selector shared by graph and pair evaluation.

Source code in src/kups/core/neighborlist/dense.py
def selector(
    self, query_size: int, systems: Table[SystemId, NeighborListSystems]
) -> DenseSelector:
    """Build the candidate selector shared by graph and pair evaluation."""
    return DenseSelector(
        cutoffs=Table.broadcast_to(self.cutoffs, systems),
        max_candidates=self.avg_candidates.multiply(query_size),
        max_image_candidates=self.avg_image_candidates.multiply(query_size),
    )

DenseSelector

Selector for the per-system dense O(N²/K²) algorithm.

Source code in src/kups/core/neighborlist/dense.py
@dataclass
class DenseSelector:
    """Selector for the per-system dense ``O(N²/K²)`` algorithm."""

    cutoffs: Table[SystemId, Array]
    max_candidates: Capacity[int]
    max_image_candidates: Capacity[int]

    def __call__(self, ctx: PipelineContext) -> CandidateBatch[Literal[2]]:
        query = ctx.query_table
        candidates = _dense_subselect(
            ctx.keys, query, ctx.systems, max_num_candidates=self.max_candidates
        )
        candidates = lift_query_candidates(candidates, ctx)
        return replicate_for_images(
            candidates,
            ctx.keys,
            ctx.edge_query_table,
            ctx.systems,
            self.cutoffs,
            self.max_image_candidates,
        )

DistanceCutoffMask

Drops candidates whose squared real-space distance exceeds cutoff².

Source code in src/kups/core/neighborlist/masks.py
@dataclass
class DistanceCutoffMask:
    """Drops candidates whose squared real-space distance exceeds ``cutoff²``."""

    cutoffs: Table[SystemId, Array]

    def __call__(
        self, batch: CandidateBatch[Literal[2]], ctx: PipelineContext
    ) -> Array:
        cutoffs = Table.broadcast_to(self.cutoffs, ctx.systems)
        shifts = batch.edges.shifts[:, 0, :]
        frame_table: Table[SystemId, MaterializedFrame] = ctx.systems.map_data(
            lambda s: s.cell.frame.materialize()
        )
        key, query = ctx.endpoints(batch)
        frames: MaterializedFrame = frame_table[key.system]
        dist_sq = real_distance_sq(key.positions, query.positions, frames, shifts)
        return dist_sq < cutoffs[key.system] ** 2

Edges

Bases: Sliceable

Represents edges (connections) between particles in a molecular system.

An edge connects Degree particles, where degree=2 represents pairwise interactions (bonds), degree=3 represents three-body interactions (angles), etc.

For periodic systems, edges include shift vectors that indicate how many cells to traverse when computing distances between connected particles.

Class Type Parameters:

Name Bound or Constraints Description Default
Degree int

Number of particles connected by each edge (static type check)

required

Attributes:

Name Type Description
indices Index[ParticleId]

Particle indices for each edge, shape (n_edges, Degree)

shifts Array

Periodic shift vectors, shape (n_edges, Degree-1, 3). Shift vectors for the 2nd through Degree-th particle relative to the first.

Example
# Pairwise edges (bonds) between particles
edges = Edges(
    indices=jnp.array([[0, 1], [1, 2], [0, 2]]),  # 3 edges
    shifts=jnp.array([[[0, 0, 0]], [[0, 0, 0]], [[1, 0, 0]]])  # 3rd edge crosses boundary
)
Source code in src/kups/core/neighborlist/edges.py
@dataclass
class Edges[Degree: int](Sliceable):
    """Represents edges (connections) between particles in a molecular system.

    An edge connects `Degree` particles, where degree=2 represents pairwise
    interactions (bonds), degree=3 represents three-body interactions (angles), etc.

    For periodic systems, edges include shift vectors that indicate how many
    cells to traverse when computing distances between connected particles.

    Type Parameters:
        Degree: Number of particles connected by each edge (static type check)

    Attributes:
        indices: Particle indices for each edge, shape `(n_edges, Degree)`
        shifts: Periodic shift vectors, shape `(n_edges, Degree-1, 3)`.
            Shift vectors for the 2nd through Degree-th particle relative to the first.

    Example:
        ```python
        # Pairwise edges (bonds) between particles
        edges = Edges(
            indices=jnp.array([[0, 1], [1, 2], [0, 2]]),  # 3 edges
            shifts=jnp.array([[[0, 0, 0]], [[0, 0, 0]], [[1, 0, 0]]])  # 3rd edge crosses boundary
        )
        ```
    """

    # The degree is purely for type checking and does not affect runtime behavior
    indices: Index[ParticleId]  # (n_edges, Degree)
    shifts: Array  # (n_edges, Degree - 1, 3)

    def __post_init__(self) -> None:
        if not isinstance(self.shifts, Array):
            return
        target_shape = (
            *self.indices.shape[:-1],
            self.indices.shape[-1] - 1 if self.indices.shape[-1] > 1 else 0,
            3,
        )
        assert self.shifts.shape == target_shape, (
            f"Shifts must have shape {target_shape}, got {self.shifts.shape}"
        )

    def difference_vectors(
        self,
        particles: Table[ParticleId, HasPositionsAndSystemIndex],
        systems: Table[SystemId, HasCell[AnyPeriodicity]],
    ) -> Array:
        """Compute difference vectors between connected particles.

        For each edge, computes the vector from the first particle to each
        subsequent particle, accounting for periodic boundary conditions.

        Args:
            particles: Particle positions with system index information.
            systems: System data with cell for periodic boundary conditions.

        Returns:
            Array of shape `(n_edges, Degree-1, 3)` containing difference vectors.
        """

        shifts = self.absolute_shifts(particles, systems)
        pos = particles[self.indices].positions
        return pos[:, 1:] - pos[:, :1] + shifts

    def absolute_shifts(
        self,
        particles: Table[ParticleId, HasPositionsAndSystemIndex],
        systems: Table[SystemId, HasCell[AnyPeriodicity]],
    ) -> Array:
        """Compute absolute shift vectors for all particles in each edge.

        Converts relative shifts to absolute Cartesian shift vectors.

        Args:
            particles: Particle data with system index information.
            systems: System data with cell for periodic boundary conditions.

        Returns:
            Array of shape `(n_edges, Degree-1, 3)` containing absolute shift vectors.
        """
        lattice = systems.map_data(lambda x: x.cell.materialize())
        cells = lattice[particles[self.indices[:, 0]].system][:, None]
        return cells.frame.to_real(self.shifts)

    @property
    def degree(self) -> int:
        return self.indices.shape[-1]

    @override
    def __len__(self) -> int:
        return self.indices.shape[0]

absolute_shifts(particles, systems)

Compute absolute shift vectors for all particles in each edge.

Converts relative shifts to absolute Cartesian shift vectors.

Parameters:

Name Type Description Default
particles Table[ParticleId, HasPositionsAndSystemIndex]

Particle data with system index information.

required
systems Table[SystemId, HasCell[AnyPeriodicity]]

System data with cell for periodic boundary conditions.

required

Returns:

Type Description
Array

Array of shape (n_edges, Degree-1, 3) containing absolute shift vectors.

Source code in src/kups/core/neighborlist/edges.py
def absolute_shifts(
    self,
    particles: Table[ParticleId, HasPositionsAndSystemIndex],
    systems: Table[SystemId, HasCell[AnyPeriodicity]],
) -> Array:
    """Compute absolute shift vectors for all particles in each edge.

    Converts relative shifts to absolute Cartesian shift vectors.

    Args:
        particles: Particle data with system index information.
        systems: System data with cell for periodic boundary conditions.

    Returns:
        Array of shape `(n_edges, Degree-1, 3)` containing absolute shift vectors.
    """
    lattice = systems.map_data(lambda x: x.cell.materialize())
    cells = lattice[particles[self.indices[:, 0]].system][:, None]
    return cells.frame.to_real(self.shifts)

difference_vectors(particles, systems)

Compute difference vectors between connected particles.

For each edge, computes the vector from the first particle to each subsequent particle, accounting for periodic boundary conditions.

Parameters:

Name Type Description Default
particles Table[ParticleId, HasPositionsAndSystemIndex]

Particle positions with system index information.

required
systems Table[SystemId, HasCell[AnyPeriodicity]]

System data with cell for periodic boundary conditions.

required

Returns:

Type Description
Array

Array of shape (n_edges, Degree-1, 3) containing difference vectors.

Source code in src/kups/core/neighborlist/edges.py
def difference_vectors(
    self,
    particles: Table[ParticleId, HasPositionsAndSystemIndex],
    systems: Table[SystemId, HasCell[AnyPeriodicity]],
) -> Array:
    """Compute difference vectors between connected particles.

    For each edge, computes the vector from the first particle to each
    subsequent particle, accounting for periodic boundary conditions.

    Args:
        particles: Particle positions with system index information.
        systems: System data with cell for periodic boundary conditions.

    Returns:
        Array of shape `(n_edges, Degree-1, 3)` containing difference vectors.
    """

    shifts = self.absolute_shifts(particles, systems)
    pos = particles[self.indices].positions
    return pos[:, 1:] - pos[:, :1] + shifts

EmptyNeighborList

Neighbor list that emits an :class:Edges[D] with zero rows.

The degree field is the runtime arity carried by the emitted edges; it must match the type parameter D.

Attributes:

Name Type Description
degree int

Edge arity (Literal[0] for point clouds, higher for unified graph constructors that need a degree-aware empty NL).

Source code in src/kups/core/neighborlist/fixed.py
@dataclass
class EmptyNeighborList[D: int]:
    """Neighbor list that emits an :class:`Edges[D]` with zero rows.

    The ``degree`` field is the runtime arity carried by the emitted edges;
    it must match the type parameter ``D``.

    Attributes:
        degree: Edge arity (``Literal[0]`` for point clouds, higher for
            unified graph constructors that need a degree-aware empty NL).
    """

    degree: int = field(static=True, default=0)

    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints],
    ) -> Edges[D]: ...

    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[D]: ...

    @jit
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints] | None = None,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[D]:
        assert queries is None or queried_keys is None, (
            "Neighbor-list calls cannot combine queries with queried_keys."
        )
        del queries, systems, queried_keys
        shift_inner = self.degree - 1 if self.degree > 1 else 0
        return Edges(
            indices=Index(keys.keys, jnp.zeros((0, self.degree), dtype=int)),
            shifts=jnp.zeros((0, shift_inner, 3), dtype=int),
        )

ExclusionMask

Drops minimum-image pairs that share an exclusion segment.

Non-minimum-image periodic copies of excluded pairs survive (allowed when batch.is_minimum_image is False for that copy).

Source code in src/kups/core/neighborlist/masks.py
@dataclass
class ExclusionMask:
    """Drops minimum-image pairs that share an exclusion segment.

    Non-minimum-image periodic copies of excluded pairs survive (allowed when
    ``batch.is_minimum_image`` is False for that copy).
    """

    def __call__(
        self, batch: CandidateBatch[Literal[2]], ctx: PipelineContext
    ) -> Array:
        key, query = ctx.endpoints(batch)
        key_excl, query_excl = Index.match(key.exclusion, query.exclusion)
        return (key_excl != query_excl) | ~batch.is_minimum_image

FixedEdgesNeighborList

Neighbor list for a fixed topology edge set.

Full self-graph calls return all fixed topology rows with shifts computed from the current particle positions. Affected self-graph calls pass keyword-only queried_keys after updated particle data has been written into keys; the neighbor list returns only fixed rows touched by those affected keys ids. queries is reserved for true bipartite neighbor-list implementations and is not a fixed-edge update mechanism.

Attributes:

Name Type Description
indices Index[ParticleId]

Fixed edge topology. Shifts are intentionally not stored; they are computed from the call's current particle positions.

avg_edges Capacity[int] | None

Update-only average affected-edge capacity per affected keys id. Full calls ignore this field and use the stored topology length; affected calls default to the full edge-buffer size when it is not provided.

Source code in src/kups/core/neighborlist/fixed.py
@dataclass
class FixedEdgesNeighborList[D: int]:
    """Neighbor list for a fixed topology edge set.

    Full self-graph calls return all fixed topology rows with shifts computed
    from the current particle positions. Affected self-graph calls pass
    keyword-only ``queried_keys`` after updated particle data has been written
    into ``keys``; the neighbor list returns only fixed rows touched by those
    affected ``keys`` ids. ``queries`` is reserved for true bipartite
    neighbor-list implementations and is not a fixed-edge update mechanism.

    Attributes:
        indices: Fixed edge topology. Shifts are intentionally not stored;
            they are computed from the call's current particle positions.
        avg_edges: Update-only average affected-edge capacity per affected
            ``keys`` id. Full calls ignore this field and use the stored
            topology length; affected calls default to the full edge-buffer
            size when it is not provided.
    """

    indices: Index[ParticleId]
    avg_edges: Capacity[int] | None = None

    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints],
    ) -> Edges[D]: ...

    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[D]: ...

    @jit
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints] | None = None,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[D]:
        assert queries is None or queried_keys is None, (
            "Neighbor-list calls cannot combine queries with queried_keys."
        )
        assert queries is None, "FixedEdgesNeighborList only supports self-graph calls."
        if queried_keys is None:
            max_edges = FixedCapacity(len(self.indices))
        else:
            max_edges = (
                self.avg_edges.multiply(queried_keys.size)
                if self.avg_edges is not None
                else FixedCapacity(len(self.indices))
            )

        pipeline = Pipeline[D](
            selector=_FixedEdgesSelector(self.indices),
            masks=(TouchesQueriedKeysMask(),),
            compactor=ReduceCompactor(max_edges),
        )
        return pipeline(keys, systems, queried_keys=queried_keys)

InBoundsMask

Drops candidates whose key/query indices fall outside the valid inclusion-segment range.

Implements the per-side inclusion.valid_mask check used to guard scatter/gather lookups when the candidate buffer is padded.

Source code in src/kups/core/neighborlist/masks.py
@dataclass
class InBoundsMask:
    """Drops candidates whose key/query indices fall outside the valid inclusion-segment range.

    Implements the per-side ``inclusion.valid_mask`` check used to
    guard scatter/gather lookups when the candidate buffer is padded.
    """

    def __call__[D: int](self, batch: CandidateBatch[D], ctx: PipelineContext) -> Array:
        if ctx.queries is None:
            return ctx.keys[batch.edges.indices].inclusion.valid_mask.all(axis=-1)
        key, query = ctx.endpoints(batch)
        return key.inclusion.valid_mask & query.inclusion.valid_mask

InclusionGroupSelector

Pairs every particle with every other in the same inclusion segment.

Ignores the cutoff entirely. Shifts are int-typed minimum-image fractional rounds — matches today's all_connected_neighborlist (which is Ewald-only and assumed fully periodic).

Source code in src/kups/core/neighborlist/all_connected.py
@dataclass
class InclusionGroupSelector:
    """Pairs every particle with every other in the same inclusion segment.

    Ignores the cutoff entirely. Shifts are int-typed minimum-image
    fractional rounds — matches today's ``all_connected_neighborlist``
    (which is Ewald-only and assumed fully periodic).
    """

    capacity: Capacity[int]

    def __call__(self, ctx: PipelineContext) -> CandidateBatch[Literal[2]]:
        query = ctx.query_table
        inclusion = ctx.keys.data.inclusion
        selection_result = subselect(
            *Index.match(
                inclusion,
                query.data.inclusion.update_labels(inclusion.keys, allow_missing=True),
            ),
            output_buffer_size=self.capacity,
            num_segments=inclusion.num_labels,
        )
        candidates = Candidates(
            key_idx=Index(ctx.keys.keys, selection_result.scatter_idxs),
            query_idx=Index(query.keys, selection_result.gather_idxs),
        )
        candidates = lift_query_candidates(candidates, ctx)
        deltas = (
            ctx.keys[candidates.key_idx].positions
            - ctx.edge_query_table[candidates.query_idx].positions
        )
        shifts = jnp.round(deltas).astype(int)
        return candidates_to_batch(
            candidates,
            shifts,
            jnp.ones((candidates.key_idx.size,), dtype=bool),
        )

InclusionMatchMask

Drops candidates whose key/query inclusion segments differ.

Source code in src/kups/core/neighborlist/masks.py
@dataclass
class InclusionMatchMask:
    """Drops candidates whose key/query inclusion segments differ."""

    def __call__[D: int](self, batch: CandidateBatch[D], ctx: PipelineContext) -> Array:
        if ctx.queries is None:
            edge_incl = ctx.keys[batch.edges.indices].inclusion
            incl, first_incl = Index.match(edge_incl, edge_incl[:, :1])
            return (incl == first_incl).all(axis=-1)

        key, query = ctx.endpoints(batch)
        key_incl, query_incl = Index.match(key.inclusion, query.inclusion)
        return key_incl == query_incl

IsAllDenseNeighborListParams

Bases: Protocol

Protocol for parameters required by AllDenseNearestNeighborList.

Source code in src/kups/core/neighborlist/all_dense.py
class IsAllDenseNeighborListParams(Protocol):
    """Protocol for parameters required by ``AllDenseNearestNeighborList``."""

    @property
    def avg_edges(self) -> int: ...
    @property
    def avg_image_candidates(self) -> int: ...

IsCellListParams

Bases: Protocol

Protocol for parameters required by CellListNeighborList.

Source code in src/kups/core/neighborlist/cell_list.py
class IsCellListParams(Protocol):
    """Protocol for parameters required by ``CellListNeighborList``."""

    @property
    def avg_candidates(self) -> int: ...
    @property
    def avg_edges(self) -> int: ...
    @property
    def cells(self) -> int: ...
    @property
    def avg_image_candidates(self) -> int: ...

IsDenseNeighborlistParams

Bases: Protocol

Protocol for parameters required by DenseNearestNeighborList.

Source code in src/kups/core/neighborlist/dense.py
class IsDenseNeighborlistParams(Protocol):
    """Protocol for parameters required by ``DenseNearestNeighborList``."""

    @property
    def avg_candidates(self) -> int: ...
    @property
    def avg_edges(self) -> int: ...
    @property
    def avg_image_candidates(self) -> int: ...

IsNeighborListState

Bases: Protocol

Protocol for states that expose neighbor list parameters.

A state satisfying this protocol can be passed to from_state() on any neighbor list class. The type parameter P determines which neighbor list types the state can construct (e.g., IsAllDenseNeighborListParams, IsDenseNeighborlistParams, IsCellListParams, or IsUniversalNeighborlistParams).

Source code in src/kups/core/neighborlist/types.py
class IsNeighborListState[P](Protocol):
    """Protocol for states that expose neighbor list parameters.

    A state satisfying this protocol can be passed to ``from_state()`` on any
    neighbor list class. The type parameter ``P`` determines which neighbor
    list types the state can construct (e.g., ``IsAllDenseNeighborListParams``,
    ``IsDenseNeighborlistParams``, ``IsCellListParams``, or
    ``IsUniversalNeighborlistParams``).
    """

    @property
    def neighborlist_params(self) -> P: ...

IsUniversalNeighborlistParams

Bases: Protocol

Protocol for parameters required by any neighbor list implementation.

A superset of IsAllDenseNeighborListParams, IsDenseNeighborlistParams, and IsCellListParams. Satisfying this protocol allows constructing any of the three neighbor list types.

Source code in src/kups/core/neighborlist/types.py
class IsUniversalNeighborlistParams(Protocol):
    """Protocol for parameters required by any neighbor list implementation.

    A superset of ``IsAllDenseNeighborListParams``, ``IsDenseNeighborlistParams``,
    and ``IsCellListParams``. Satisfying this protocol allows constructing any
    of the three neighbor list types.
    """

    @property
    def avg_edges(self) -> int: ...
    @property
    def avg_candidates(self) -> int: ...
    @property
    def avg_image_candidates(self) -> int: ...
    @property
    def cells(self) -> int: ...

Mask

Bases: Protocol

Returns this criterion's bool array; pipeline conjuncts the results.

The degree parameter tracks which candidate arity the mask accepts. Pair masks implement Mask[Literal[2]] by annotating their batch argument as CandidateBatch[Literal[2]]; degree-agnostic masks use a generic __call__ method.

Cannot change batch.edges, batch.is_minimum_image, or the candidate count. Pure (batch, ctx) -> Array.

Source code in src/kups/core/neighborlist/types.py
class Mask[D: int](Protocol):
    """Returns this criterion's bool array; pipeline conjuncts the results.

    The degree parameter tracks which candidate arity the mask accepts. Pair
    masks implement ``Mask[Literal[2]]`` by annotating their ``batch`` argument
    as ``CandidateBatch[Literal[2]]``; degree-agnostic masks use a generic
    ``__call__`` method.

    Cannot change ``batch.edges``, ``batch.is_minimum_image``, or the
    candidate count. Pure ``(batch, ctx) -> Array``.
    """

    def __call__(self, batch: CandidateBatch[D], ctx: PipelineContext) -> Array: ...

MaskOnlyCompactor

Bases: Compactor[D]

In-place compaction: failing entries become OOB indices and zero shifts.

No size change; preserves the candidate count from the selector. Pair candidates are already in their output index space.

Source code in src/kups/core/neighborlist/compact.py
@dataclass
class MaskOnlyCompactor[D: int](Compactor[D]):
    """In-place compaction: failing entries become OOB indices and zero shifts.

    No size change; preserves the candidate count from the selector. Pair
    candidates are already in their output index space.
    """

    def __call__(
        self,
        keep: Array,
        batch: CandidateBatch[D],
        ctx: PipelineContext,
    ) -> Edges[D]:
        indices = batch.edges.indices.apply_mask(keep[:, None])
        shifts = where_broadcast_last(keep, batch.edges.shifts, 0)
        return Edges(indices, shifts)

MirrorPairEdges

Bases: Postprocessor[Literal[2]]

Append reversed pair edges for undirected graph outputs.

The default mirrors only self-graph update calls selected by ctx.queried_keys. Full self-neighbor calls already emit both directions before compaction, while queried_keys calls operate on affected ids in the already-updated keys table and are deduplicated by QueriedKeysDedupMask. Their reverse edges are restored after compaction.

Attributes:

Name Type Description
only_when_queried_keys bool

When True, no-op unless ctx.queried_keys is active; ctx.queries is not involved. Set to False for pipelines whose selector emits only one direction even in full calls.

Source code in src/kups/core/neighborlist/postprocess.py
@dataclass
class MirrorPairEdges(Postprocessor[Literal[2]]):
    """Append reversed pair edges for undirected graph outputs.

    The default mirrors only self-graph update calls selected by
    ``ctx.queried_keys``. Full self-neighbor calls already emit both directions
    before compaction, while ``queried_keys`` calls operate on affected ids in
    the already-updated ``keys`` table and are deduplicated by
    ``QueriedKeysDedupMask``. Their reverse edges are restored after compaction.

    Attributes:
        only_when_queried_keys: When ``True``, no-op unless ``ctx.queried_keys``
            is active; ``ctx.queries`` is not involved. Set to ``False`` for
            pipelines whose selector emits only one direction even in full
            calls.
    """

    only_when_queried_keys: bool = field(default=True, static=True)

    def __call__(
        self, edges: Edges[Literal[2]], ctx: PipelineContext
    ) -> Edges[Literal[2]]:
        if self.only_when_queried_keys and ctx.queried_keys is None:
            return edges

        mirrored_indices = Index.concatenate(edges.indices, edges.indices[:, ::-1])
        mirrored_shifts = jnp.concatenate([edges.shifts, -edges.shifts], axis=0)
        return Edges(mirrored_indices, mirrored_shifts)

NeighborList

Bases: Protocol

Protocol for neighbor list construction algorithms.

Implementations find groups of particles within a cutoff distance, handling periodic boundary conditions and inclusion/exclusion masks. The degree parameter tracks the arity of the emitted edge tuples.

Source code in src/kups/core/neighborlist/types.py
class NeighborList[D: int](Protocol):
    """Protocol for neighbor list construction algorithms.

    Implementations find groups of particles within a cutoff distance, handling
    periodic boundary conditions and inclusion/exclusion masks. The degree
    parameter tracks the arity of the emitted edge tuples.
    """

    @overload
    def __call__[P: NeighborListPoints](
        self,
        keys: Table[ParticleId, P],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, P],
    ) -> Edges[D]: ...
    @overload
    def __call__[P: NeighborListPoints](
        self,
        keys: Table[ParticleId, P],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[D]: ...
    def __call__[P: NeighborListPoints](
        self,
        keys: Table[ParticleId, P],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, P] | None = None,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[D]:
        """Find particle groups for a self-graph or bipartite query.

        ``queries`` and ``queried_keys`` are mutually exclusive; the overloads
        above reject calls that pass both.

        Args:
            keys: Particle table that the returned ``Edges`` index into. With
                neither ``queries`` nor ``queried_keys``, the neighbor list
                builds a self-graph over ``keys``.
            systems: Indexed system data with cell information.
            queries: Optional bipartite query table. Each edge then connects a
                ``keys`` particle to a ``queries`` particle.
            queried_keys: Optional subset of ``keys`` particle ids whose
                incident self-graph edges should be returned. The caller must
                already have written any updated particle data into ``keys``.

        Returns:
            Edges whose columns index ``keys`` for self-graph calls.
        """
        ...

__call__(keys, systems, *, queries=None, queried_keys=None)

__call__(
    keys: Table[ParticleId, P],
    systems: Table[SystemId, NeighborListSystems],
    *,
    queries: Table[ParticleId, P],
) -> Edges[D]
__call__(
    keys: Table[ParticleId, P],
    systems: Table[SystemId, NeighborListSystems],
    *,
    queried_keys: Index[ParticleId] | None = None,
) -> Edges[D]

Find particle groups for a self-graph or bipartite query.

queries and queried_keys are mutually exclusive; the overloads above reject calls that pass both.

Parameters:

Name Type Description Default
keys Table[ParticleId, P]

Particle table that the returned Edges index into. With neither queries nor queried_keys, the neighbor list builds a self-graph over keys.

required
systems Table[SystemId, NeighborListSystems]

Indexed system data with cell information.

required
queries Table[ParticleId, P] | None

Optional bipartite query table. Each edge then connects a keys particle to a queries particle.

None
queried_keys Index[ParticleId] | None

Optional subset of keys particle ids whose incident self-graph edges should be returned. The caller must already have written any updated particle data into keys.

None

Returns:

Type Description
Edges[D]

Edges whose columns index keys for self-graph calls.

Source code in src/kups/core/neighborlist/types.py
def __call__[P: NeighborListPoints](
    self,
    keys: Table[ParticleId, P],
    systems: Table[SystemId, NeighborListSystems],
    *,
    queries: Table[ParticleId, P] | None = None,
    queried_keys: Index[ParticleId] | None = None,
) -> Edges[D]:
    """Find particle groups for a self-graph or bipartite query.

    ``queries`` and ``queried_keys`` are mutually exclusive; the overloads
    above reject calls that pass both.

    Args:
        keys: Particle table that the returned ``Edges`` index into. With
            neither ``queries`` nor ``queried_keys``, the neighbor list
            builds a self-graph over ``keys``.
        systems: Indexed system data with cell information.
        queries: Optional bipartite query table. Each edge then connects a
            ``keys`` particle to a ``queries`` particle.
        queried_keys: Optional subset of ``keys`` particle ids whose
            incident self-graph edges should be returned. The caller must
            already have written any updated particle data into ``keys``.

    Returns:
        Edges whose columns index ``keys`` for self-graph calls.
    """
    ...

NeighborListCandidate

A backing neighbor list paired with its cost estimator.

Attributes:

Name Type Description
neighborlist NeighborList[Literal[2]]

The candidate implementation.

cost NeighborListCost

Estimates the implementation's cost for a call's counts.

Source code in src/kups/core/neighborlist/adaptive.py
@dataclass
class NeighborListCandidate:
    """A backing neighbor list paired with its cost estimator.

    Attributes:
        neighborlist: The candidate implementation.
        cost: Estimates the implementation's cost for a call's counts.
    """

    neighborlist: NeighborList[Literal[2]]
    cost: NeighborListCost = field(static=True)

NeighborListCost

Bases: Protocol

Estimates the relative runtime cost of a neighbor list for one call.

Lower is cheaper; :class:AdaptiveNeighborList dispatches to the minimum. Return math.inf to mark an implementation invalid for the given shape.

Source code in src/kups/core/neighborlist/adaptive.py
class NeighborListCost(Protocol):
    """Estimates the relative runtime cost of a neighbor list for one call.

    Lower is cheaper; :class:`AdaptiveNeighborList` dispatches to the minimum.
    Return ``math.inf`` to mark an implementation invalid for the given shape.
    """

    def __call__(self, num_particles: int, num_systems: int) -> float: ...

NeighborListFactory

Bases: Protocol

Constructs a pair :class:NeighborList for a given state and cutoffs.

Used by radius-based potential factories so the construction strategy can be swapped without coupling the potential to a concrete neighbor-list class. The library default is :meth:kups.core.neighborlist.AdaptiveNeighborList.from_state, which is contravariant-compatible with any state satisfying :class:IsNeighborListState.

The State type parameter is contravariant (it appears only in input position in __call__), so a factory written against a broader state protocol can be passed where a narrower one is expected.

Source code in src/kups/core/neighborlist/types.py
class NeighborListFactory[State](Protocol):
    """Constructs a pair :class:`NeighborList` for a given state and cutoffs.

    Used by radius-based potential factories so the construction strategy
    can be swapped without coupling the potential to a concrete
    neighbor-list class. The library default is
    :meth:`kups.core.neighborlist.AdaptiveNeighborList.from_state`,
    which is contravariant-compatible with any state satisfying
    :class:`IsNeighborListState`.

    The ``State`` type parameter is contravariant (it appears only in
    input position in ``__call__``), so a factory written against a
    broader state protocol can be passed where a narrower one is expected.
    """

    def __call__(
        self,
        state: State,
        cutoffs: Table[SystemId, Array],
    ) -> NeighborList[Literal[2]]: ...

Pipeline

Selector → mask sequence → compactor → postprocessors.

Attributes:

Name Type Description
selector CandidateSelector[D]

Produces a CandidateBatch[D] (handles PBC replication).

masks tuple[Mask[D], ...]

Tuple of mask criteria over CandidateBatch[D]; results are conjuncted via &.

compactor Compactor[D]

Produces compacted Edges[D] from the accumulated mask.

postprocessors tuple[Postprocessor[D], ...]

Edge transforms applied sequentially after compaction.

Source code in src/kups/core/neighborlist/pipeline.py
@dataclass
class Pipeline[D: int]:
    """Selector → mask sequence → compactor → postprocessors.

    Attributes:
        selector: Produces a ``CandidateBatch[D]`` (handles PBC replication).
        masks: Tuple of mask criteria over ``CandidateBatch[D]``; results
            are conjuncted via ``&``.
        compactor: Produces compacted ``Edges[D]`` from the accumulated mask.
        postprocessors: Edge transforms applied sequentially after compaction.
    """

    selector: CandidateSelector[D]
    masks: tuple[Mask[D], ...] = field(static=True)
    compactor: Compactor[D]
    postprocessors: tuple[Postprocessor[D], ...] = field(default=(), static=True)

    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints],
    ) -> Edges[D]: ...
    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[D]: ...
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints] | None = None,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[D]:
        batch, keep, ctx = select_candidates(
            keys,
            systems,
            self.selector,
            self.masks,
            queries=queries,
            queried_keys=queried_keys,
        )
        edges = self.compactor(keep, batch, ctx)
        for postprocessor in self.postprocessors:
            edges = postprocessor(edges, ctx)
        return edges

PipelineContext

Read-only inputs shared by every mask and the compactor.

Positions in keys and optional queries are in fractional coordinates (transformed by [_prepare][kups.core.neighborlist.pipeline._prepare]).

Edges index keys, or for bipartite queries the union of keys and queries, whose particle ids must then be disjoint.

Attributes:

Name Type Description
keys Table[ParticleId, NeighborListPoints]

Key particle table in fractional coords.

queries Table[ParticleId, NeighborListPoints] | None

Query particle table in fractional coords for true bipartite queries. None for full self-graphs and queried_keys updates.

systems Table[SystemId, NeighborListSystems]

Indexed system data with cell information.

queried_keys Index[ParticleId] | None

keys ids to update/query, or None for a full self-graph or bipartite query.

Source code in src/kups/core/neighborlist/types.py
@dataclass
class PipelineContext:
    """Read-only inputs shared by every mask and the compactor.

    Positions in ``keys`` and optional ``queries`` are in **fractional**
    coordinates (transformed by
    [`_prepare`][kups.core.neighborlist.pipeline._prepare]).

    Edges index ``keys``, or for bipartite queries the union of ``keys`` and
    ``queries``, whose particle ids must then be disjoint.

    Attributes:
        keys: Key particle table in fractional coords.
        queries: Query particle table in fractional coords for true bipartite
            queries. ``None`` for full self-graphs and ``queried_keys`` updates.
        systems: Indexed system data with cell information.
        queried_keys: ``keys`` ids to update/query, or ``None`` for a full
            self-graph or bipartite query.
    """

    keys: Table[ParticleId, NeighborListPoints]
    queries: Table[ParticleId, NeighborListPoints] | None
    systems: Table[SystemId, NeighborListSystems]
    queried_keys: Index[ParticleId] | None

    @skip_post_init_if_disabled
    def __post_init__(self) -> None:
        assert self.queries is None or self.queried_keys is None, (
            "PipelineContext cannot combine queries with queried_keys."
        )
        assert self.queries is None or set(self.keys.keys).isdisjoint(
            self.queries.keys
        ), "Bipartite queries need particle ids disjoint from the keys."

    @property
    def edge_keys(self) -> tuple[ParticleId, ...]:
        """Particle ids addressed by candidate and edge indices."""
        if self.queries is None:
            return self.keys.keys
        return tuple(sorted(self.keys.keys + self.queries.keys))

    @property
    def edge_query_table(self) -> Table[ParticleId, NeighborListPoints]:
        """Table holding the second endpoint of each pair."""
        return self.queries if self.queries is not None else self.keys

    @property
    def query_table(self) -> Table[ParticleId, NeighborListPoints]:
        """Table used to enumerate query candidates.

        ``queries`` is reserved for true bipartite calls. ``queried_keys``
        selects a self-graph update subset from ``keys`` and is lifted back to
        ``keys`` ids before masks and compaction see the batch.
        """
        if self.queries is not None:
            return self.queries
        if self.queried_keys is None:
            return self.keys
        return self.keys.subset(self.queried_keys)

    def endpoints[D: int](
        self, batch: CandidateBatch[D]
    ) -> tuple[NeighborListPoints, NeighborListPoints]:
        """Key and query rows of each candidate pair."""
        query = self.edge_query_table
        return (
            self.keys[batch.key_idx.update_labels(self.keys.keys, allow_missing=True)],
            query[batch.query_idx.update_labels(query.keys, allow_missing=True)],
        )

edge_keys property

Particle ids addressed by candidate and edge indices.

edge_query_table property

Table holding the second endpoint of each pair.

query_table property

Table used to enumerate query candidates.

queries is reserved for true bipartite calls. queried_keys selects a self-graph update subset from keys and is lifted back to keys ids before masks and compaction see the batch.

endpoints(batch)

Key and query rows of each candidate pair.

Source code in src/kups/core/neighborlist/types.py
def endpoints[D: int](
    self, batch: CandidateBatch[D]
) -> tuple[NeighborListPoints, NeighborListPoints]:
    """Key and query rows of each candidate pair."""
    query = self.edge_query_table
    return (
        self.keys[batch.key_idx.update_labels(self.keys.keys, allow_missing=True)],
        query[batch.query_idx.update_labels(query.keys, allow_missing=True)],
    )

Postprocessor

Bases: Protocol

Transforms compacted edges using the pipeline context.

Postprocessors run sequentially after compaction. They may change the number of rows, but must preserve the edge degree D.

Source code in src/kups/core/neighborlist/types.py
class Postprocessor[D: int](Protocol):
    """Transforms compacted edges using the pipeline context.

    Postprocessors run sequentially after compaction. They may change the
    number of rows, but must preserve the edge degree ``D``.
    """

    def __call__(self, edges: Edges[D], ctx: PipelineContext) -> Edges[D]: ...

PrecomputedEdgesSelector

Selector that wraps precomputed Edges for both refine variants.

Precomputed edges index keys, or for a bipartite queries call the union of keys and queries, matching the neighbor-list convention.

Attributes:

Name Type Description
candidates Edges[Literal[2]]

Precomputed edges.

recompute_mic_shifts bool

When True, drop the precomputed shifts and recompute minimum-image shifts on the current positions (RefineCutoffNeighborList — the precomputed shifts may be stale relative to the current cell). When False, reuse candidates.shifts directly (RefineMaskNeighborList). is_minimum_image is always all-True (no image replication).

Source code in src/kups/core/neighborlist/refine.py
@dataclass
class PrecomputedEdgesSelector:
    """Selector that wraps precomputed ``Edges`` for both refine variants.

    Precomputed edges index ``keys``, or for a bipartite ``queries`` call the
    union of ``keys`` and ``queries``, matching the neighbor-list convention.

    Attributes:
        candidates: Precomputed edges.
        recompute_mic_shifts: When ``True``, drop the precomputed shifts and
            recompute minimum-image shifts on the current positions
            (``RefineCutoffNeighborList`` — the precomputed shifts may be
            stale relative to the current cell). When ``False``, reuse
            ``candidates.shifts`` directly (``RefineMaskNeighborList``).
            ``is_minimum_image`` is always all-True (no image replication).
    """

    candidates: Edges[Literal[2]]
    recompute_mic_shifts: bool = field(static=True, default=False)

    def __call__(self, ctx: PipelineContext) -> CandidateBatch[Literal[2]]:
        edges = Edges(
            self.candidates.indices.update_labels(ctx.edge_keys),
            self.candidates.shifts,
        )
        batch = CandidateBatch(
            edges=edges,
            is_minimum_image=jnp.ones((len(self.candidates),), dtype=bool),
        )
        if not self.recompute_mic_shifts:
            return batch
        key, query = ctx.endpoints(batch)
        shifts = ctx.systems.data.cell.minimum_image_shifts(
            key.positions - query.positions
        )
        return bind(batch, lambda b: b.edges.shifts).set(shifts[:, None])

QueriedKeysDedupMask

Deduplicate self-graph update candidates.

Pair selectors emit candidates in keys space. When ctx.queried_keys is set, the query side was restricted to those affected keys rows. We keep edges whose key endpoint is unaffected, plus one orientation for edges where both endpoints are affected: key > query, or for self-image rows (i, i, ±s) the lexicographically positive shift. MirrorPairEdges restores the reverse orientation after compaction.

Returns all-True for full self-graphs and bipartite queries.

Source code in src/kups/core/neighborlist/masks.py
@dataclass
class QueriedKeysDedupMask:
    """Deduplicate self-graph update candidates.

    Pair selectors emit candidates in ``keys`` space. When ``ctx.queried_keys``
    is set, the query side was restricted to those affected ``keys`` rows.
    We keep edges whose key endpoint is unaffected, plus one orientation for
    edges where both endpoints are affected: ``key > query``, or for self-image
    rows ``(i, i, ±s)`` the lexicographically positive shift.
    ``MirrorPairEdges`` restores the reverse orientation after compaction.

    Returns all-True for full self-graphs and bipartite queries.
    """

    def __call__(
        self, batch: CandidateBatch[Literal[2]], ctx: PipelineContext
    ) -> Array:
        if ctx.queried_keys is None:
            return jnp.ones((batch.key_idx.size,), dtype=bool)
        key, query = Index.match(batch.key_idx, batch.query_idx)
        shift = batch.edges.shifts[:, 0, :]
        first_nonzero = jnp.take_along_axis(
            shift, jnp.argmax(shift != 0, axis=-1)[:, None], axis=-1
        )[:, 0]
        return (
            ~batch.key_idx.isin(ctx.queried_keys)
            | (key > query)
            | ((key == query) & (first_nonzero > 0))
        )

ReduceCompactor

Bases: Compactor[D]

Compact surviving candidates to a size-bounded Edges[D].

Compacts whole candidate rows, so pair neighbor lists and fixed higher-degree topology share the same implementation.

Source code in src/kups/core/neighborlist/compact.py
@dataclass
class ReduceCompactor[D: int](Compactor[D]):
    """Compact surviving candidates to a size-bounded ``Edges[D]``.

    Compacts whole candidate rows, so pair neighbor lists and fixed higher-degree
    topology share the same implementation.
    """

    avg_edges: Capacity[int]

    def __call__(
        self,
        keep: Array,
        batch: CandidateBatch[D],
        ctx: PipelineContext,
    ) -> Edges[D]:
        return self.compact_batch(keep, batch, ctx).edges

    def compact_batch(
        self,
        keep: Array,
        batch: CandidateBatch[D],
        ctx: PipelineContext,
    ) -> CandidateBatch[D]:
        """Compact edges together with the image flags needed by pair kernels."""
        max_edges = self.avg_edges.generate_assertion(keep.sum())
        sort_idxs = jnp.where(keep, size=max_edges.size, fill_value=keep.size)[0]

        def gather(values: Array) -> Array:
            if keep.size == 0:
                # JAX cannot gather a padded output from an empty source array.
                return jnp.zeros((max_edges.size, *values.shape[1:]), values.dtype)
            return values.at[sort_idxs].get(
                mode="fill", fill_value=0, indices_are_sorted=True
            )

        compacted = jax.tree.map(gather, batch)
        return CandidateBatch(
            Edges(
                compacted.edges.indices.apply_mask(sort_idxs[:, None] < keep.size),
                compacted.edges.shifts,
            ),
            compacted.is_minimum_image,
        )

compact_batch(keep, batch, ctx)

Compact edges together with the image flags needed by pair kernels.

Source code in src/kups/core/neighborlist/compact.py
def compact_batch(
    self,
    keep: Array,
    batch: CandidateBatch[D],
    ctx: PipelineContext,
) -> CandidateBatch[D]:
    """Compact edges together with the image flags needed by pair kernels."""
    max_edges = self.avg_edges.generate_assertion(keep.sum())
    sort_idxs = jnp.where(keep, size=max_edges.size, fill_value=keep.size)[0]

    def gather(values: Array) -> Array:
        if keep.size == 0:
            # JAX cannot gather a padded output from an empty source array.
            return jnp.zeros((max_edges.size, *values.shape[1:]), values.dtype)
        return values.at[sort_idxs].get(
            mode="fill", fill_value=0, indices_are_sorted=True
        )

    compacted = jax.tree.map(gather, batch)
    return CandidateBatch(
        Edges(
            compacted.edges.indices.apply_mask(sort_idxs[:, None] < keep.size),
            compacted.edges.shifts,
        ),
        compacted.is_minimum_image,
    )

RefineCutoffNeighborList

Refine precomputed edges by re-checking distances with new cutoffs.

This neighbor list takes an existing set of candidate edges and filters them by computing actual distances and comparing to cutoffs. Enables sharing a single conservative neighbor list across multiple potentials with different cutoff distances.

Key benefit: Compute expensive neighbor list once with maximum cutoff, then refine for each potential with its specific cutoff (e.g., Lennard-Jones at 10 Å, Coulomb at 15 Å).

Attributes:

Name Type Description
candidates Edges[Literal[2]]

Precomputed edges to refine (should be conservative/over-inclusive).

avg_edges Capacity[int]

Capacity for output edge array.

cutoffs Table[SystemId, Array]

Per-system cutoff distances used by this refinement.

Use cases
  • Multiple potentials sharing one neighbor list with different cutoffs
  • Multi-stage neighbor list construction (coarse then fine)
  • Adaptive cutoffs that change during simulation
  • Using a static "super" neighbor list with varying actual cutoffs
Example
# Compute base neighbor list once with maximum cutoff
max_cutoff = 15.0  # Maximum of all potential cutoffs
base_edges = base_nl(particles, cells)

# Share across potentials with different cutoffs
lj_nl = RefineCutoffNeighborList(
    candidates=base_edges, avg_edges=cap1, cutoffs=lj_cutoffs
)
lj_edges = lj_nl(particles, cells)  # LJ cutoff

coulomb_nl = RefineCutoffNeighborList(
    candidates=base_edges, avg_edges=cap2, cutoffs=coulomb_cutoffs
)
coulomb_edges = coulomb_nl(particles, cells)  # Coulomb cutoff
Source code in src/kups/core/neighborlist/refine.py
@dataclass
class RefineCutoffNeighborList:
    """Refine precomputed edges by re-checking distances with new cutoffs.

    This neighbor list takes an existing set of candidate edges and filters them
    by computing actual distances and comparing to cutoffs. Enables sharing a
    single conservative neighbor list across multiple potentials with different
    cutoff distances.

    **Key benefit**: Compute expensive neighbor list once with maximum cutoff,
    then refine for each potential with its specific cutoff (e.g., Lennard-Jones
    at 10 Å, Coulomb at 15 Å).

    Attributes:
        candidates: Precomputed edges to refine (should be conservative/over-inclusive).
        avg_edges: Capacity for output edge array.
        cutoffs: Per-system cutoff distances used by this refinement.

    Use cases:
        - Multiple potentials sharing one neighbor list with different cutoffs
        - Multi-stage neighbor list construction (coarse then fine)
        - Adaptive cutoffs that change during simulation
        - Using a static "super" neighbor list with varying actual cutoffs

    Example:
        ```python
        # Compute base neighbor list once with maximum cutoff
        max_cutoff = 15.0  # Maximum of all potential cutoffs
        base_edges = base_nl(particles, cells)

        # Share across potentials with different cutoffs
        lj_nl = RefineCutoffNeighborList(
            candidates=base_edges, avg_edges=cap1, cutoffs=lj_cutoffs
        )
        lj_edges = lj_nl(particles, cells)  # LJ cutoff

        coulomb_nl = RefineCutoffNeighborList(
            candidates=base_edges, avg_edges=cap2, cutoffs=coulomb_cutoffs
        )
        coulomb_edges = coulomb_nl(particles, cells)  # Coulomb cutoff
        ```
    """

    candidates: Edges[Literal[2]]
    avg_edges: Capacity[int]
    cutoffs: Table[SystemId, Array]

    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints],
    ) -> Edges[Literal[2]]: ...

    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[Literal[2]]: ...

    @jit
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints] | None = None,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[Literal[2]]:
        resolved_keys, resolved_queries = _resolve_precomputed_inputs(
            keys, queries, queried_keys
        )
        query_size = (
            queried_keys.size
            if queried_keys is not None
            else (queries.size if queries is not None else keys.size)
        )
        cutoffs = Table.broadcast_to(self.cutoffs, systems)
        pipeline = Pipeline[Literal[2]](
            selector=PrecomputedEdgesSelector(
                self.candidates, recompute_mic_shifts=True
            ),
            masks=(
                InBoundsMask(),
                InclusionMatchMask(),
                DistanceCutoffMask(cutoffs=cutoffs),
                TouchesQueriedKeysMask(),
                ExclusionMask(),
            ),
            compactor=ReduceCompactor(avg_edges=self.avg_edges.multiply(query_size)),
        )
        if resolved_queries is not None:
            return pipeline(resolved_keys, systems, queries=resolved_queries)
        return pipeline(resolved_keys, systems, queried_keys=queried_keys)

RefineMaskNeighborList

Refine a precomputed neighbor list by applying inclusion/exclusion masks.

This neighbor list takes an existing set of candidate edges and filters them based on segmentation masks, without recomputing distances. Enables sharing a single base neighbor list across multiple potentials with different interaction rules.

Key benefit: Compute expensive neighbor list once, apply different masks for different potentials (e.g., Lennard-Jones excludes 1-4 interactions, Coulomb has different exclusions).

Attributes:

Name Type Description
candidates Edges[Literal[2]]

Precomputed edges to refine

Use cases
  • Multiple potentials sharing one neighbor list with different exclusions
  • Excluding bonded pairs (1-2, 1-3, 1-4) from non-bonded interactions
  • Applying group-specific interaction rules
  • Multi-scale simulations with different interaction levels
Example
# Compute base neighbor list once
base_edges = base_nl(particles, cells)

# Share across potentials with different masks
lj_nl = RefineMaskNeighborList(candidates=base_edges)
lj_edges = lj_nl(lj_particles, cells)  # 1-4 exclusions

coulomb_nl = RefineMaskNeighborList(candidates=base_edges)
coulomb_edges = coulomb_nl(coulomb_particles, cells)  # 1-2 exclusions only
Source code in src/kups/core/neighborlist/refine.py
@dataclass
class RefineMaskNeighborList:
    """Refine a precomputed neighbor list by applying inclusion/exclusion masks.

    This neighbor list takes an existing set of candidate edges and filters them
    based on segmentation masks, without recomputing distances. Enables sharing
    a single base neighbor list across multiple potentials with different
    interaction rules.

    **Key benefit**: Compute expensive neighbor list once, apply different masks
    for different potentials (e.g., Lennard-Jones excludes 1-4 interactions,
    Coulomb has different exclusions).

    Attributes:
        candidates: Precomputed edges to refine

    Use cases:
        - Multiple potentials sharing one neighbor list with different exclusions
        - Excluding bonded pairs (1-2, 1-3, 1-4) from non-bonded interactions
        - Applying group-specific interaction rules
        - Multi-scale simulations with different interaction levels

    Example:
        ```python
        # Compute base neighbor list once
        base_edges = base_nl(particles, cells)

        # Share across potentials with different masks
        lj_nl = RefineMaskNeighborList(candidates=base_edges)
        lj_edges = lj_nl(lj_particles, cells)  # 1-4 exclusions

        coulomb_nl = RefineMaskNeighborList(candidates=base_edges)
        coulomb_edges = coulomb_nl(coulomb_particles, cells)  # 1-2 exclusions only
        ```
    """

    candidates: Edges[Literal[2]]

    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints],
    ) -> Edges[Literal[2]]: ...

    @overload
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[Literal[2]]: ...

    @jit
    def __call__(
        self,
        keys: Table[ParticleId, NeighborListPoints],
        systems: Table[SystemId, NeighborListSystems],
        *,
        queries: Table[ParticleId, NeighborListPoints] | None = None,
        queried_keys: Index[ParticleId] | None = None,
    ) -> Edges[Literal[2]]:
        resolved_keys, resolved_queries = _resolve_precomputed_inputs(
            keys, queries, queried_keys
        )
        pipeline = Pipeline[Literal[2]](
            selector=PrecomputedEdgesSelector(self.candidates),
            masks=(
                InBoundsMask(),
                InclusionMatchMask(),
                TouchesQueriedKeysMask(),
                ExclusionMask(),
            ),
            compactor=MaskOnlyCompactor(),
        )
        if resolved_queries is not None:
            return pipeline(resolved_keys, systems, queries=resolved_queries)
        return pipeline(resolved_keys, systems, queried_keys=queried_keys)

SelectableNeighborList

Bases: NeighborList[D], Protocol

Neighbor list whose selector can be reused with different pipeline masks.

This additional capability lets pair evaluators retain image metadata and defer group masks that differ between terms. Ordinary NeighborList implementations need only provide their existing edge-producing call.

Source code in src/kups/core/neighborlist/types.py
@runtime_checkable
class SelectableNeighborList[D: int](NeighborList[D], Protocol):
    """Neighbor list whose selector can be reused with different pipeline masks.

    This additional capability lets pair evaluators retain image metadata and
    defer group masks that differ between terms. Ordinary ``NeighborList``
    implementations need only provide their existing edge-producing call.
    """

    @property
    def cutoffs(self) -> Table[SystemId, Array]: ...

    @property
    def avg_edges(self) -> Capacity[int]: ...

    def selector(
        self, query_size: int, systems: Table[SystemId, NeighborListSystems]
    ) -> CandidateSelector[D]: ...

SkinMargin

Per-system completeness accounting of a stored skin list.

Attributes:

Name Type Description
consumed Array

Worst-case distance (Å) by which atom motion and cell deformation since the build can have pulled a non-listed pair inward, (n_sys,).

budget Array

Distance (Å) such a pair had to spare at build time — the effective skin r_build - cutoff, (n_sys,).

Source code in src/kups/core/neighborlist/verlet.py
@dataclass
class SkinMargin:
    """Per-system completeness accounting of a stored skin list.

    Attributes:
        consumed: Worst-case distance (Å) by which atom motion and cell
            deformation since the build can have pulled a non-listed pair
            inward, ``(n_sys,)``.
        budget: Distance (Å) such a pair had to spare at build time — the
            effective skin ``r_build - cutoff``, ``(n_sys,)``.
    """

    consumed: Array
    budget: Array

    @property
    def headroom(self) -> Array:
        """``budget - consumed``; the stored list is complete while ``>= 0``."""
        return self.budget - self.consumed

headroom property

budget - consumed; the stored list is complete while >= 0.

SkinReference

Geometry snapshot taken when the skin list was built.

skin_margin measures the drift of the current geometry relative to this snapshot. The arrays must not alias the live position/cell buffers (donated jitted steps would then receive the same buffer twice).

Attributes:

Name Type Description
positions Array

Cartesian positions at the build, (N, 3).

cell Cell[AnyPeriodicity]

(n_sys,)-batched cell at the build.

Source code in src/kups/core/neighborlist/verlet.py
@dataclass
class SkinReference:
    """Geometry snapshot taken when the skin list was built.

    [`skin_margin`][kups.core.neighborlist.verlet.skin_margin] measures the
    drift of the current geometry relative to this snapshot. The arrays must
    not alias the live position/cell buffers (donated jitted steps would then
    receive the same buffer twice).

    Attributes:
        positions: Cartesian positions at the build, ``(N, 3)``.
        cell: ``(n_sys,)``-batched cell at the build.
    """

    positions: Array
    cell: Cell[AnyPeriodicity]

TouchesQueriedKeysMask

Keep fixed-topology rows touched by ctx.queried_keys.

When no affected-index subset is active, every row is kept. This lets fixed topology use the same pipeline for full and patch-shaped calls.

Source code in src/kups/core/neighborlist/masks.py
@dataclass
class TouchesQueriedKeysMask[D: int]:
    """Keep fixed-topology rows touched by ``ctx.queried_keys``.

    When no affected-index subset is active, every row is kept. This lets fixed
    topology use the same pipeline for full and patch-shaped calls.
    """

    def __call__(self, batch: CandidateBatch[D], ctx: PipelineContext) -> Array:
        if ctx.queried_keys is None:
            return jnp.ones((len(batch.edges),), dtype=bool)
        return batch.edges.indices.isin(ctx.queried_keys).any(-1)

UniversalNeighborlistParameters

Concrete parameter dataclass satisfying IsUniversalNeighborlistParams.

Holds the capacity hints needed by every neighbor list implementation. Use the estimate() classmethod to compute reasonable initial values from system geometry rather than guessing manually.

Attributes:

Name Type Description
avg_edges int

Average number of edges per particle (for edge capacity).

avg_candidates int

Average number of candidate pairs per particle.

avg_image_candidates int

Average number of candidate pairs per particle after periodic-image replication (equals avg_candidates when every cutoff stays within the minimum-image regime).

cells int

Maximum number of spatial hash cells across all systems.

Source code in src/kups/core/neighborlist/parameters.py
@dataclass
class UniversalNeighborlistParameters:
    """Concrete parameter dataclass satisfying ``IsUniversalNeighborlistParams``.

    Holds the capacity hints needed by every neighbor list implementation.
    Use the ``estimate()`` classmethod to compute reasonable initial values
    from system geometry rather than guessing manually.

    Attributes:
        avg_edges: Average number of edges per particle (for edge capacity).
        avg_candidates: Average number of candidate pairs per particle.
        avg_image_candidates: Average number of candidate pairs per particle after
            periodic-image replication (equals ``avg_candidates`` when every cutoff
            stays within the minimum-image regime).
        cells: Maximum number of spatial hash cells across all systems.
    """

    avg_edges: int = field(static=True)
    avg_candidates: int = field(static=True)
    avg_image_candidates: int = field(static=True)
    cells: int = field(static=True)

    @classmethod
    @no_jax_tracing
    def estimate(
        cls,
        particles_per_system: Table[SystemId, Array],
        systems: Table[SystemId, NeighborListSystems],
        cutoffs: Table[SystemId, Array],
        *,
        base: float = 2,
        multiplier: float = 1.0,
    ) -> UniversalNeighborlistParameters:
        """Estimate parameters for all neighbor list types from system geometry.

        Computes conservative initial capacities based on particle density
        and cutoff radii. The estimates are rounded up to the next power of
        ``base`` to amortize future resizing.

        Args:
            particles_per_system: Number of particles per system.
            systems: System data with cell information.
            cutoffs: Cutoff distance per system.
            base: Base for power-of rounding (default 2).
            multiplier: Safety factor applied to the estimate (default 1.0).

        Returns:
            A ``UniversalNeighborlistParameters`` instance with estimated values.
        """
        sys = Table.join(systems, particles_per_system, cutoffs)
        candidates, image_candidates, edges, cells = map(
            int, _capacity_estimates(*sys.data, base, multiplier)
        )
        return UniversalNeighborlistParameters(
            avg_edges=edges,
            avg_candidates=candidates,
            avg_image_candidates=image_candidates,
            cells=cells,
        )

estimate(particles_per_system, systems, cutoffs, *, base=2, multiplier=1.0) classmethod

Estimate parameters for all neighbor list types from system geometry.

Computes conservative initial capacities based on particle density and cutoff radii. The estimates are rounded up to the next power of base to amortize future resizing.

Parameters:

Name Type Description Default
particles_per_system Table[SystemId, Array]

Number of particles per system.

required
systems Table[SystemId, NeighborListSystems]

System data with cell information.

required
cutoffs Table[SystemId, Array]

Cutoff distance per system.

required
base float

Base for power-of rounding (default 2).

2
multiplier float

Safety factor applied to the estimate (default 1.0).

1.0

Returns:

Type Description
UniversalNeighborlistParameters

A UniversalNeighborlistParameters instance with estimated values.

Source code in src/kups/core/neighborlist/parameters.py
@classmethod
@no_jax_tracing
def estimate(
    cls,
    particles_per_system: Table[SystemId, Array],
    systems: Table[SystemId, NeighborListSystems],
    cutoffs: Table[SystemId, Array],
    *,
    base: float = 2,
    multiplier: float = 1.0,
) -> UniversalNeighborlistParameters:
    """Estimate parameters for all neighbor list types from system geometry.

    Computes conservative initial capacities based on particle density
    and cutoff radii. The estimates are rounded up to the next power of
    ``base`` to amortize future resizing.

    Args:
        particles_per_system: Number of particles per system.
        systems: System data with cell information.
        cutoffs: Cutoff distance per system.
        base: Base for power-of rounding (default 2).
        multiplier: Safety factor applied to the estimate (default 1.0).

    Returns:
        A ``UniversalNeighborlistParameters`` instance with estimated values.
    """
    sys = Table.join(systems, particles_per_system, cutoffs)
    candidates, image_candidates, edges, cells = map(
        int, _capacity_estimates(*sys.data, base, multiplier)
    )
    return UniversalNeighborlistParameters(
        avg_edges=edges,
        avg_candidates=candidates,
        avg_image_candidates=image_candidates,
        cells=cells,
    )

all_connected_neighborlist(keys, systems, *, queries=None, queried_keys=None)

all_connected_neighborlist(
    keys: Table[ParticleId, P],
    systems: Table[SystemId, NeighborListSystems],
    *,
    queries: Table[ParticleId, P],
) -> Edges[Literal[2]]
all_connected_neighborlist(
    keys: Table[ParticleId, P],
    systems: Table[SystemId, NeighborListSystems],
    *,
    queried_keys: Index[ParticleId] | None = None,
) -> Edges[Literal[2]]

Neighbor list connecting all pairs sharing the same inclusion segment, ignoring distance.

Connects every particle pair that belongs to the same inclusion segment and has differing exclusion segment IDs. The cell is used only to compute minimum-image shifts.

Requires max_count to be set on the inclusion Index.

Source code in src/kups/core/neighborlist/all_connected.py
def all_connected_neighborlist[P: NeighborListPoints](
    keys: Table[ParticleId, P],
    systems: Table[SystemId, NeighborListSystems],
    *,
    queries: Table[ParticleId, P] | None = None,
    queried_keys: Index[ParticleId] | None = None,
) -> Edges[Literal[2]]:
    """Neighbor list connecting all pairs sharing the same inclusion segment, ignoring distance.

    Connects every particle pair that belongs to the same inclusion segment and has
    differing exclusion segment IDs. The cell is used only to compute
    minimum-image shifts.

    Requires ``max_count`` to be set on the inclusion ``Index``.
    """
    max_count = keys.data.inclusion.max_count
    assert max_count is not None, "inclusion.max_count must be set"
    query_size = (
        queried_keys.size
        if queried_keys is not None
        else (queries.size if queries is not None else keys.size)
    )
    capacity = FixedCapacity(max_count).multiply(min(keys.size, query_size))

    pipeline = Pipeline[Literal[2]](
        selector=InclusionGroupSelector(capacity=capacity),
        masks=(ExclusionMask(), QueriedKeysDedupMask()),
        compactor=ReduceCompactor(avg_edges=capacity),
        postprocessors=(MirrorPairEdges(),),
    )
    if queries is not None:
        return pipeline(keys, systems, queries=queries)
    return pipeline(keys, systems, queried_keys=queried_keys)

all_dense_cost(num_particles, num_systems)

Default cost for :class:AllDenseNearestNeighborList.

O(N^2) across all particles, so it ties dense for a single system and is invalid (inf) for multiple systems, which it would incorrectly merge.

Source code in src/kups/core/neighborlist/adaptive.py
def all_dense_cost(num_particles: int, num_systems: int) -> float:
    """Default cost for :class:`AllDenseNearestNeighborList`.

    ``O(N^2)`` across all particles, so it ties dense for a single system and is
    invalid (``inf``) for multiple systems, which it would incorrectly merge.
    """
    return num_particles**2 if num_systems <= 1 else math.inf

build_cell_list_cache(particles, systems, cutoffs, data, parameters)

Bin, sort, and tabulate the particles of a point cloud.

Parameters:

Name Type Description Default
particles Table[ParticleId, NeighborListPoints]

Particle table (positions, system, inclusion, exclusion).

required
systems Table[SystemId, HasCell[AnyPeriodicity]]

System table with cells.

required
cutoffs Table[SystemId, Array]

Per-system cutoff radii sizing the bins.

required
data Data

Per-particle payload to carry per slot, leaves (n, ...).

required
parameters CellListCacheParameters

Static table shapes.

required

Returns:

Type Description
CellListCache[Data]

The cell table.

Source code in src/kups/core/neighborlist/cell_list_cache.py
def build_cell_list_cache[Data](
    particles: Table[ParticleId, NeighborListPoints],
    systems: Table[SystemId, HasCell[AnyPeriodicity]],
    cutoffs: Table[SystemId, Array],
    data: Data,
    parameters: CellListCacheParameters,
) -> CellListCache[Data]:
    """Bin, sort, and tabulate the particles of a point cloud.

    Args:
        particles: Particle table (positions, system, inclusion, exclusion).
        systems: System table with cells.
        cutoffs: Per-system cutoff radii sizing the bins.
        data: Per-particle payload to carry per slot, leaves ``(n, ...)``.
        parameters: Static table shapes.

    Returns:
        The cell table.
    """
    n = particles.size
    n_pad = -(-n // parameters.slot_multiple) * parameters.slot_multiple
    max_cells = parameters.max_cells_per_system
    n_cells = systems.size * max_cells
    cutoff = Table.broadcast_to(cutoffs, systems).data
    runtime_assert(
        (
            candidate_image_counts(systems.data.cell, cutoff).prod(axis=-1)
            <= parameters.max_images_per_pair
        ).all(),
        "max_images_per_pair exceeded; re-estimate CellListCacheParameters for "
        "the current cutoffs and cells.",
    )
    bins = num_cells(systems.data, cutoff)
    runtime_assert(
        (jnp.prod(bins, axis=-1) <= max_cells).all(),
        "max_cells_per_system exceeded; increase CellListCacheParameters.max_cells_per_system.",
    )

    rows = cell_rows(particles, systems, bins, max_cells, data)
    block_size = parameters.key_block_size
    order = _spatial_order(
        rows.frac,
        rows.system,
        rows.cell < n_cells,
        systems.map_data(lambda s: s.cell.perpendicular_lengths),
        n_pad,
        block_size,
    )
    slot_by_cell = jnp.argsort(rows.cell[order], stable=True)
    sorted_cell = rows.cell[order][slot_by_cell]
    rank = jnp.arange(n) - jnp.searchsorted(sorted_cell, sorted_cell, side="left")
    runtime_assert(
        ((rank < parameters.cell_capacity) | (sorted_cell == n_cells)).all(),
        "cell_capacity exceeded; increase CellListCacheParameters.cell_capacity.",
    )
    cells = (
        jnp.full((n_cells + 1, parameters.cell_capacity), n_pad, dtype=int)
        .at[sorted_cell, rank]
        .set(slot_by_cell, mode="drop")
        # Inactive particles were scattered into the sentinel-cell row; reset it.
        .at[n_cells]
        .set(n_pad)
    )

    sorted_rows: CellRows[Data] = jax.tree.map(lambda x: x[order], rows)
    padded_rows = sorted_rows.pad(n_pad + 1 - n, sentinel_cell=n_cells)
    bounds = BlockBounds.of(
        padded_rows.frac[:n_pad],
        padded_rows.system[:n_pad],
        padded_rows.cell[:n_pad] != n_cells,
        block_size,
    )
    return CellListCache(
        rows=padded_rows,
        slot_of_row=jnp.zeros(n, dtype=order.dtype).at[order].set(jnp.arange(n)),
        cells=cells,
        stencil=neighbor_cells(
            jnp.arange(n_cells + 1),
            bins,
            max_cells,
            systems.data.cell,
            width=parameters.stencil_width,
        ),
        bins=bins,
        block_bounds=bounds,
    )

cell_candidates(keys, queries, index, systems, cutoffs, max_images_per_pair)

Adapt a cell-table query to the standard neighbor pipeline.

index contains key slots per query row. Without queries the rows of keys query each other, so geometry and masks have a single implementation. Periodic images expand only this chunk, with capacity bounded per pair.

Source code in src/kups/core/neighborlist/cell_list_cache.py
def cell_candidates[Data](
    keys: CellRows[Data],
    queries: CellRows[Data] | None,
    index: Array,
    systems: Table[SystemId, HasCell[AnyPeriodicity]],
    cutoffs: Table[SystemId, Array],
    max_images_per_pair: int,
) -> tuple[CandidateBatch[Literal[2]], PipelineContext]:
    """Adapt a cell-table query to the standard neighbor pipeline.

    ``index`` contains key slots per query row. Without ``queries`` the rows of
    ``keys`` query each other, so geometry and masks have a single
    implementation. Periodic images expand only this chunk, with capacity
    bounded per pair.
    """
    key_table = Table.arange(keys, label=ParticleId)
    query_table = (
        key_table
        if queries is None
        else Table(
            tuple(ParticleId(key_table.size + i) for i in range(len(index))), queries
        )
    )
    ctx = PipelineContext(
        key_table, None if queries is None else query_table, systems, None
    )
    candidates = Candidates(
        Index(key_table.keys, index.ravel()),
        Index(query_table.keys, jnp.repeat(jnp.arange(len(index)), index.shape[1])),
    )
    if max_images_per_pair == 1:
        return make_batch_with_mic(candidates, key_table, query_table, systems), ctx
    return replicate_for_images(
        candidates,
        key_table,
        query_table,
        systems,
        cutoffs,
        FixedCapacity(index.size * max_images_per_pair),
    ), ctx

cell_list_cost(num_particles, num_systems)

Default cost for :class:CellListNeighborList (O(N) with a large constant, crossing dense at _CELL_LIST_CROSSOVER particles per system).

Source code in src/kups/core/neighborlist/adaptive.py
def cell_list_cost(num_particles: int, num_systems: int) -> float:
    """Default cost for :class:`CellListNeighborList` (``O(N)`` with a large
    constant, crossing dense at ``_CELL_LIST_CROSSOVER`` particles per system)."""
    return _CELL_LIST_CROSSOVER * num_particles

cell_rows(particles, systems, bins, max_cells, data)

Fold and bin particles into global cells system * max_cells + local.

Particles with an out-of-bounds inclusion index (the buffered-row convention) are inactive and land in the sentinel cell n_systems * max_cells.

Parameters:

Name Type Description Default
particles Table[ParticleId, NeighborListPoints]

Particle table (positions, system, inclusion, exclusion).

required
systems Table[SystemId, HasCell[AnyPeriodicity]]

System table with cells.

required
bins Array

Per-system bin counts, (n_systems, 3).

required
max_cells int

Allocated cell capacity per system.

required
data Data

Per-particle payload carried along.

required

Returns:

Type Description
CellRows[Data]

The binned rows.

Source code in src/kups/core/neighborlist/cell_list_cache.py
def cell_rows[Data](
    particles: Table[ParticleId, NeighborListPoints],
    systems: Table[SystemId, HasCell[AnyPeriodicity]],
    bins: Array,
    max_cells: int,
    data: Data,
) -> CellRows[Data]:
    """Fold and bin particles into global cells ``system * max_cells + local``.

    Particles with an out-of-bounds inclusion index (the buffered-row
    convention) are inactive and land in the sentinel cell ``n_systems *
    max_cells``.

    Args:
        particles: Particle table (positions, system, inclusion, exclusion).
        systems: System table with cells.
        bins: Per-system bin counts, ``(n_systems, 3)``.
        max_cells: Allocated cell capacity per system.
        data: Per-particle payload carried along.

    Returns:
        The binned rows.
    """
    points = particles.data
    active = points.inclusion.valid_mask
    system_ids = jnp.where(active, points.system.indices_in(systems.keys), 0)
    system = Index(systems.keys, system_ids)
    frames = systems.map_data(lambda s: s.cell.frame.materialize())
    frac = frames[system].to_fractional(
        jnp.where(active[:, None], points.positions, 0.0)
    )
    frac, cell = assign_cells(
        frac, system_ids, active, bins, max_cells, systems.data.cell
    )
    inclusion = points.inclusion.apply_mask(cell < bins.shape[0] * max_cells)
    return CellRows(frac, system, inclusion, points.exclusion, data, cell)

dense_cost(num_particles, num_systems)

Default cost for :class:DenseNearestNeighborList (O(N^2/K)).

Source code in src/kups/core/neighborlist/adaptive.py
def dense_cost(num_particles: int, num_systems: int) -> float:
    """Default cost for :class:`DenseNearestNeighborList` (``O(N^2/K)``)."""
    return num_particles**2 / max(num_systems, 1)

effective_build_radii(cutoffs, skin, cell)

Per-system build radius: cutoff + skin, clamped to a single image.

Reusing stored edges keeps exactly one periodic image per pair, so the build radius must stay below half the cell's smallest perpendicular length on every periodic axis — beyond that, second images enter the build sphere and the reuse path would drop them. A cell that compresses mid-run thus degrades to a thinner effective skin (more frequent rebuilds) instead of an incomplete list. No clamp applies in vacuum.

Parameters:

Name Type Description Default
cutoffs Array

True cutoffs (Å), (n_sys,).

required
skin ArrayLike

Requested skin width (Å).

required
cell Cell[AnyPeriodicity]

(n_sys,)-batched cell the build runs in.

required

Returns:

Type Description
Array

Build radii (Å), (n_sys,). radii - cutoffs is the effective skin.

Source code in src/kups/core/neighborlist/verlet.py
def effective_build_radii(
    cutoffs: Array, skin: ArrayLike, cell: Cell[AnyPeriodicity]
) -> Array:
    """Per-system build radius: ``cutoff + skin``, clamped to a single image.

    Reusing stored edges keeps exactly one periodic image per pair, so the
    build radius must stay below half the cell's smallest perpendicular length
    on every periodic axis — beyond that, second images enter the build sphere
    and the reuse path would drop them. A cell that compresses mid-run thus
    degrades to a thinner effective skin (more frequent rebuilds) instead of an
    incomplete list. No clamp applies in vacuum.

    Args:
        cutoffs: True cutoffs (Å), ``(n_sys,)``.
        skin: Requested skin width (Å).
        cell: ``(n_sys,)``-batched cell the build runs in.

    Returns:
        Build radii (Å), ``(n_sys,)``. ``radii - cutoffs`` is the effective skin.
    """
    perp = cell.perpendicular_lengths
    limit = 0.5 * jnp.min(jnp.where(jnp.array(cell.periodic), perp, jnp.inf), axis=-1)
    return jnp.minimum(cutoffs + skin, limit)

neighborlist_changes(neighborlist, lh, rh, systems, compaction=0.5)

Compute added/removed edges from a particle change in a single call.

Appends proposed positions to the particle array and queries both old and new interactions at once, then splits the result by filtering edge indices into removed (before) and added (after) sets.

Parameters:

Name Type Description Default
neighborlist NeighborList[Literal[2]]

Neighbor list implementation.

required
lh Table[ParticleId, NeighborListPoints]

Full original particle table.

required
rh WithIndices[ParticleId, Table[ParticleId, NeighborListPoints]]

Proposed changes — rh.indices maps entries to particle IDs in lh, rh.data holds the new particle data.

required
systems Table[SystemId, NeighborListSystems]

Per-system data (cells, etc.).

required
compaction float

Fraction of total edges allocated per output (0–1). 0.5 means each of added/removed gets half the buffer. 1.0 means no compaction — full buffer with masking only.

0.5

Returns:

Type Description
NeighborListChangesResult

NeighborListChangesResult(added, removed).

Source code in src/kups/core/neighborlist/changes.py
@jit(static_argnames=("compaction",))
def neighborlist_changes(
    neighborlist: NeighborList[Literal[2]],
    lh: Table[ParticleId, NeighborListPoints],
    rh: WithIndices[ParticleId, Table[ParticleId, NeighborListPoints]],
    systems: Table[SystemId, NeighborListSystems],
    compaction: float = 0.5,
) -> NeighborListChangesResult:
    """Compute added/removed edges from a particle change in a single call.

    Appends proposed positions to the particle array and queries both old
    and new interactions at once, then splits the result by filtering
    edge indices into ``removed`` (before) and ``added`` (after) sets.

    Args:
        neighborlist: Neighbor list implementation.
        lh: Full original particle table.
        rh: Proposed changes — ``rh.indices`` maps entries to particle IDs
            in ``lh``, ``rh.data`` holds the new particle data.
        systems: Per-system data (cells, etc.).
        compaction: Fraction of total edges allocated per output (0–1).
            0.5 means each of added/removed gets half the buffer.
            1.0 means no compaction — full buffer with masking only.

    Returns:
        ``NeighborListChangesResult(added, removed)``.
    """
    # Build a single self-graph query with both old and proposed particles in
    # the key table. ``queried_keys`` enumerates edges incident to the changed
    # old slots and the proposed rows. Concatenating in the shifted key space
    # maps out-of-bounds (no-op) old slots to the combined OOB sentinel rather
    # than ``len(lh)``, so an empty per-system change cannot alias the first
    # appended particle and duplicate its edges.
    keys_combined = Table.union((lh, rh.data))
    queried_keys = Index.concatenate(rh.indices, rh.data.index, shift_keys=True)

    # single neighborlist call
    all_edges = neighborlist(keys_combined, systems, queried_keys=queried_keys)

    # Each combined row stands for an ``lh`` particle: itself, or the one it replaces.
    targets = Index.concatenate(lh.index, rh.indices.update_labels(lh.keys))
    remapped = Table(keys_combined.keys, targets)[all_edges.indices]
    old = all_edges.indices.update_labels(lh.keys, allow_missing=True)
    # Removed edges exist in the original set (both endpoints are old rows).
    removed_mask = old.valid_mask.all(-1)
    # Stale edges touch a replaced old row; added edges touch a proposed row.
    is_stale = old.isin(rh.indices).any(-1)
    added_mask = all_edges.indices.valid_mask.all(-1) & ~removed_mask & ~is_stale

    # compact each output
    n_total = len(all_edges)
    shifts = all_edges.shifts

    def _mask_only(
        mask: Array, indices: Index[ParticleId], shifts: Array
    ) -> Edges[Literal[2]]:
        return Edges(
            indices.apply_mask(mask[:, None]), where_broadcast_last(mask, shifts, 0)
        )

    def _compact(mask: Array, label: str) -> Edges[Literal[2]]:
        count = mask.sum()
        runtime_assert(
            count <= capacity,
            f"neighborlist_changes: {label} edges ({{count}}) exceed "
            f"capacity ({{capacity}})",
            fmt_args={"count": count, "capacity": jnp.array(capacity)},
        )
        sel: Array = jnp.where(mask, size=capacity, fill_value=n_total - 1)[0]
        valid = mask.at[sel].get(mode="fill", fill_value=False)
        return _mask_only(valid, remapped[sel], shifts[sel])

    if compaction >= 1.0:
        return NeighborListChangesResult(
            _mask_only(added_mask, remapped, shifts),
            _mask_only(removed_mask, remapped, shifts),
        )

    capacity = int(n_total * compaction)
    return NeighborListChangesResult(
        _compact(added_mask, "added"), _compact(removed_mask, "removed")
    )

skin_margin(particles, systems, reference, cutoffs, skin)

How much of the skin list's safety margin the geometry has used up.

A skin list built at radius r_build stays complete for the true cutoff as long as no pair that was outside r_build at build time has come inside cutoff since. Two things move pairs inward:

  1. Cell deformation. Between the build and now the cell changed by the linear map F = h_ref⁻¹ h_now (row-vector convention), which maps every build-time pair vector d — including those to periodic images — to d @ F. A linear map cannot shrink any vector by more than its smallest singular value: |d @ F| >= σ_min(F) |d| for all d. So the affine part of the motion leaves every non-listed pair at distance at least σ_min(F) r_build, an inward move of at most r_build (1 - σ_min(F)) — and none at all if the cell only expanded (σ_min >= 1). Because σ_min sees the whole map, pure shear counts like any other strain, unlike per-axis length ratios.
  2. Atom motion on top of the deformation. Each atom's non-affine displacement is u_i = x_i - x_i_ref @ F — what remains after riding the cell — minimum-image wrapped in the current cell so that a boundary crossing (even along a sheared lattice vector) is undone exactly (a genuine non-affine drift beyond half a cell would be under-measured, but rebuilds fire at skin scale long before that). A pair distance changes by at most the two endpoint displacements, 2 max|u|.

The stored list is therefore complete while, per system,

consumed := 2 max|u| + r_build max(0, 1 - σ_min(F))  <=  r_build - cutoff =: budget

i.e. while the worst-case inward motion of a non-listed pair (consumed) has not eaten the extra radius the build added on top of the cutoff (budget). Pairs never span systems, so the accounting is fully per system: one hot system neither charges nor rebuilds the others.

Parameters:

Name Type Description Default
particles Table[ParticleId, HasPositionsAndSystemIndex]

Current particle table (positions and system index).

required
systems Table[SystemId, NeighborListSystems]

Current system table (cells).

required
reference SkinReference

Positions and cell snapshot taken at the last build.

required
cutoffs Table[SystemId, Array]

True cutoffs (Å) per system.

required
skin ArrayLike

Requested skin width (Å) the list was built with.

required

Returns:

Type Description
Table[SystemId, SkinMargin]

Per-system SkinMargin

Table[SystemId, SkinMargin]

table (consumed and budget, both in Å).

Source code in src/kups/core/neighborlist/verlet.py
def skin_margin(
    particles: Table[ParticleId, HasPositionsAndSystemIndex],
    systems: Table[SystemId, NeighborListSystems],
    reference: SkinReference,
    cutoffs: Table[SystemId, Array],
    skin: ArrayLike,
) -> Table[SystemId, SkinMargin]:
    """How much of the skin list's safety margin the geometry has used up.

    A skin list built at radius ``r_build`` stays complete for the true
    ``cutoff`` as long as no pair that was *outside* ``r_build`` at build time
    has come *inside* ``cutoff`` since. Two things move pairs inward:

    1. **Cell deformation.** Between the build and now the cell changed by the
       linear map ``F = h_ref⁻¹ h_now`` (row-vector convention), which maps
       every build-time pair vector ``d`` — including those to periodic images —
       to ``d @ F``. A linear map cannot shrink any vector by more than its
       smallest singular value: ``|d @ F| >= σ_min(F) |d|`` for all ``d``. So
       the affine part of the motion leaves every non-listed pair at distance
       at least ``σ_min(F) r_build``, an inward move of at most
       ``r_build (1 - σ_min(F))`` — and none at all if the cell only expanded
       (``σ_min >= 1``). Because ``σ_min`` sees the whole map, pure shear
       counts like any other strain, unlike per-axis length ratios.
    2. **Atom motion on top of the deformation.** Each atom's *non-affine*
       displacement is ``u_i = x_i - x_i_ref @ F`` — what remains after riding
       the cell — minimum-image wrapped in the current cell so that a boundary
       crossing (even along a sheared lattice vector) is undone exactly (a
       genuine non-affine drift beyond half a cell would be under-measured, but
       rebuilds fire at skin scale long before that). A pair distance changes
       by at most the two endpoint displacements, ``2 max|u|``.

    The stored list is therefore complete while, per system,

        consumed := 2 max|u| + r_build max(0, 1 - σ_min(F))  <=  r_build - cutoff =: budget

    i.e. while the worst-case inward motion of a non-listed pair (*consumed*)
    has not eaten the extra radius the build added on top of the cutoff
    (*budget*). Pairs never span systems, so the accounting is fully per
    system: one hot system neither charges nor rebuilds the others.

    Args:
        particles: Current particle table (positions and system index).
        systems: Current system table (cells).
        reference: Positions and cell snapshot taken at the last build.
        cutoffs: True cutoffs (Å) per system.
        skin: Requested skin width (Å) the list was built with.

    Returns:
        Per-system [`SkinMargin`][kups.core.neighborlist.verlet.SkinMargin]
        table (``consumed`` and ``budget``, both in Å).
    """
    cell_now = systems.data.cell
    system = particles.data.system
    cutoff_values = Table.broadcast_to(cutoffs, systems).data
    deform = reference.cell.inverse_vectors @ cell_now.vectors  # d_now = d_ref @ F
    # u_i = x_i - x_i_ref @ F, min-image wrapped
    co_moved = jnp.einsum(
        "ni,nij->nj", reference.positions, systems.set_data(deform)[system]
    )
    residual = systems[system].cell.wrap(particles.data.positions - co_moved)
    u_max = particles.data.system.max_over(jnp.linalg.norm(residual, axis=-1)).data
    u_max = jnp.maximum(u_max, 0.0)  # empty segments reduce to -inf
    # σ_min(F) from the smallest eigenvalue of the 3x3 Gram matrix F Fᵀ (the
    # clamp guards tiny negative rounding noise).
    gram = deform @ jnp.swapaxes(deform, -1, -2)
    sigma_min = jnp.sqrt(jnp.maximum(_sym3_min_eigenvalue(gram), 0.0))
    r_build = effective_build_radii(cutoff_values, skin, reference.cell)
    consumed = 2.0 * u_max + r_build * jnp.maximum(0.0, 1.0 - sigma_min)
    return Table(systems.keys, SkinMargin(consumed, r_build - cutoff_values))