Skip to content

kups.potential.common.graph

Graph construction from atomic coordinates for potential evaluation.

This module builds molecular graphs (point clouds, hypergraphs) from Indexed particle data for potential energy evaluation. Graphs honor the cell's per-axis periodic mask via the neighbor list, support multiple independent systems, and allow efficient incremental construction for Monte Carlo via probes.

Key components:

GRAPH_GEOMETRY = lens(lambda inp: inp.graph).nest(POINTCLOUD_GEOMETRY) module-attribute

Adapter from a graph-bearing potential input to Geometry.

POINTCLOUD_GEOMETRY = lens(_pointcloud_geometry) module-attribute

Geometry adapter shared by graph, fused, and reciprocal-space inputs.

GraphConstructor

Constructs graphs from a degree-parametrized neighbor list.

Attributes:

Name Type Description
particles View[State, Table[ParticleId, P]]

View extracting Indexed[ParticleId, P].

systems View[State, Table[SystemId, S]]

View extracting Indexed[SystemId, S] (cell).

neighborlist View[State, NeighborList[Degree]]

View extracting the neighbor list implementation for the state.

probe Probe[State, Ptch, IsParticleProbe[P]] | None

Optional particle probe, which may also supply neighbor lists.

Source code in src/kups/potential/common/graph.py
@dataclass
class GraphConstructor[
    State,
    Ptch: Patch[Any],
    P: NeighborListPoints,
    S: HasCell[AnyPeriodicity],
    Degree: int,
]:
    """Constructs graphs from a degree-parametrized neighbor list.

    Attributes:
        particles: View extracting ``Indexed[ParticleId, P]``.
        systems: View extracting ``Indexed[SystemId, S]`` (cell).
        neighborlist: View extracting the neighbor list implementation for the state.
        probe: Optional particle probe, which may also supply neighbor lists.
    """

    particles: View[State, Table[ParticleId, P]] = field(static=True)
    systems: View[State, Table[SystemId, S]] = field(static=True)
    neighborlist: View[State, NeighborList[Degree]] = field(static=True)
    probe: Probe[State, Ptch, IsParticleProbe[P]] | None = field(static=True)

    @jit(static_argnames=("old_graph",))
    def __call__(
        self, state: State, patch: Ptch | None, old_graph: bool = False
    ) -> HyperGraph[P, S, Degree]:
        keys = self.particles(state)
        systems = self.systems(state)

        if patch is not None and self.probe is None:
            if old_graph:
                return self(state, None, old_graph=True)
            new_state: State = patch(
                state, systems.set_data(jnp.ones(len(systems), dtype=jnp.bool_))
            )
            return self(new_state, None, old_graph=True)

        if patch is None:
            nnlist = self.neighborlist(state)
            edges = nnlist(keys, systems)
        else:
            assert self.probe is not None, "Expected probe to be set."
            probe = self.probe(state, patch)
            update = probe.particles
            indices = update.indices
            if not old_graph:
                keys = keys.update(indices, update.data)
            if isinstance(probe, IsGraphProbe):
                nnlist = (
                    probe.neighborlist_before if old_graph else probe.neighborlist_after
                )
            else:
                nnlist = self.neighborlist(state)
            edges = nnlist(keys, systems, queried_keys=indices)
        return HyperGraph(keys, systems, edges)

GraphInputConstructor

Bases: InputConstructor[State, GraphPotentialInput[Params, P, S, Degree], Ptch]

Construct full or affected graph inputs for the shared sum composers.

A graph probe restricts proposals to affected interactions, keeping parameters from the current state. Without a probe, apply the patch to the full state before reading parameters and constructing the graph. old_input selects the current state in either case.

Source code in src/kups/potential/common/graph.py
@dataclass
class GraphInputConstructor[
    State,
    Ptch: Patch[Any],
    P: NeighborListPoints,
    S: HasCell[AnyPeriodicity],
    Degree: int,
    Params,
](InputConstructor[State, GraphPotentialInput[Params, P, S, Degree], Ptch]):
    """Construct full or affected graph inputs for the shared sum composers.

    A graph probe restricts proposals to affected interactions, keeping
    parameters from the current state. Without a probe, apply the patch to
    the full state before reading parameters and constructing the graph.
    ``old_input`` selects the current state in either case.
    """

    graph_constructor: GraphConstructor[State, Ptch, P, S, Degree] = field(static=True)
    parameter_view: View[State, Params] = field(static=True)

    def __call__(
        self,
        state: State,
        patch: Ptch | None,
        old_input: bool = False,
    ) -> GraphPotentialInput[Params, P, S, Degree]:
        if patch is not None and self.graph_constructor.probe is None:
            if not old_input:
                systems = self.graph_constructor.systems(state)
                state = patch(
                    state, systems.set_data(jnp.ones(len(systems), dtype=jnp.bool_))
                )
            patch = None
        return GraphPotentialInput(
            self.parameter_view(state),
            self.graph_constructor(state, patch, old_graph=old_input),
        )

GraphPairEnergy

Derive edge energies and per-system totals from a pair term.

Graph edges already encode inclusion, exclusion and periodic-image rules. The pair term supplies the features, cutoff and kernel. Directed edges contribute half their energy to each system's total. fuse_pair_potentials can collect the same term to share geometry and neighbor traversal with other pair terms.

Source code in src/kups/potential/common/graph.py
@dataclass
class GraphPairEnergy[
    Params,
    Part: HasPositionsAndSystemIndex,
    Feat,
]:
    """Derive edge energies and per-system totals from a pair term.

    Graph edges already encode inclusion, exclusion and periodic-image rules.
    The pair term supplies the features, cutoff and kernel. Directed edges
    contribute half their energy to each system's total. ``fuse_pair_potentials``
    can collect the same term to share geometry and neighbor traversal with
    other pair terms.
    """

    pair: PairEnergy[Params, Part, Feat] = field(static=True)

    @jit
    def edge_energies(
        self,
        inp: GraphPotentialInput[Params, Part, HasCell[AnyPeriodicity], Literal[2]],
    ) -> Array:
        """Evaluate the pair term on each directed edge, ignoring padded edges."""
        keep = jnp.ones(len(inp.graph.edges), dtype=bool)
        return graph_pair_energies(self.pair, inp.parameters, inp.graph, keep, keep)

    def __call__[State](
        self,
        inp: GraphPotentialInput[Params, Part, HasCell[AnyPeriodicity], Literal[2]],
    ) -> WithPatch[Table[SystemId, Energy], IdPatch[State]]:
        system = inp.graph.edge_batch_mask.update_labels(inp.graph.systems.keys).to_cls(
            inp.graph.systems.cls
        )
        energies = system.sum_over(self.edge_energies(inp)) / 2
        return WithPatch(energies, IdPatch[State]())

edge_energies(inp)

Evaluate the pair term on each directed edge, ignoring padded edges.

Source code in src/kups/potential/common/graph.py
@jit
def edge_energies(
    self,
    inp: GraphPotentialInput[Params, Part, HasCell[AnyPeriodicity], Literal[2]],
) -> Array:
    """Evaluate the pair term on each directed edge, ignoring padded edges."""
    keep = jnp.ones(len(inp.graph.edges), dtype=bool)
    return graph_pair_energies(self.pair, inp.parameters, inp.graph, keep, keep)

GraphPotentialInput

Bases: NamedTuple, Generic[Params, Part, Sys, Degree]

Input bundle for graph-based potential energy functions.

Source code in src/kups/potential/common/graph.py
class GraphPotentialInput(NamedTuple, Generic[Params, Part, Sys, Degree]):
    """Input bundle for graph-based potential energy functions."""

    parameters: Params
    graph: HyperGraph[Part, Sys, Degree]

HyperGraph

Bases: PointCloud[Part, Sys], Generic[Part, Sys, Degree]

Point cloud with edges representing particle interactions.

Generic in Part (particle data), Sys (system data), and Degree (number of particles per edge: 2=pairs, 3=triplets).

Attributes:

Name Type Description
particles Table[ParticleId, Part]

Inherited -- indexed particle data.

systems Table[SystemId, Sys]

Inherited -- indexed system data.

edges Edges[Degree]

Edge connectivity with Index[ParticleId] indices and periodic shifts.

Source code in src/kups/potential/common/graph.py
@dataclass
class HyperGraph(PointCloud[Part, Sys], Generic[Part, Sys, Degree]):
    """Point cloud with edges representing particle interactions.

    Generic in ``Part`` (particle data), ``Sys`` (system data), and
    ``Degree`` (number of particles per edge: 2=pairs, 3=triplets).

    Attributes:
        particles: Inherited -- indexed particle data.
        systems: Inherited -- indexed system data.
        edges: Edge connectivity with ``Index[ParticleId]`` indices and periodic shifts.
    """

    edges: Edges[Degree]

    @property
    def edge_offsets(self) -> Array:
        return self.edges.shifts

    @property
    def edge_shifts(self) -> Array:
        return self.edges.difference_vectors(self.particles, self.systems)

    @property
    def edge_batch_mask(self) -> Index[SystemId]:
        return self.particles[self.edges.indices[:, 0]].system

    @overload
    def sorted_by_system(
        self,
        sort_edges: bool = ...,
        *,
        return_sort_order: Literal[False] = ...,
    ) -> HyperGraph[Part, Sys, Degree]: ...
    @overload
    def sorted_by_system(
        self,
        sort_edges: bool = ...,
        *,
        return_sort_order: Literal[True],
    ) -> tuple[HyperGraph[Part, Sys, Degree], Array]: ...
    def sorted_by_system(
        self,
        sort_edges: bool = False,
        *,
        return_sort_order: bool = False,
    ) -> HyperGraph[Part, Sys, Degree] | tuple[HyperGraph[Part, Sys, Degree], Array]:
        """Sort particles by system index and remap edges accordingly.

        Args:
            sort_edges: If True, also sort edges by the system of their first
                particle.
            return_sort_order: If True, also return the sort permutation.
        """
        n = len(self.particles)
        sort_order = jnp.argsort(self.particles.data.system.raw_indices, stable=True)
        sorted_data = bind(self.particles.data).at((sort_order,)).get()
        sorted_particles = Table(
            self.particles.keys, sorted_data, _cls=self.particles.cls
        )
        # Each particle's new row, so edges follow their endpoints.
        inverse_order = Table(
            self.particles.keys,
            Index(
                self.particles.keys,
                jnp.zeros_like(sort_order).at[sort_order].set(jnp.arange(n)),
                max_count=self.edges.indices.max_count,
                _cls=self.particles.cls,
            ),
        )
        remapped_indices = inverse_order[self.edges.indices]
        sorted_edges = Edges(indices=remapped_indices, shifts=self.edges.shifts)
        if sort_edges:
            edge_systems = sorted_particles[remapped_indices[:, 0]].system
            edge_order = jnp.argsort(edge_systems.raw_indices, stable=True)
            sorted_edges = Edges(
                indices=remapped_indices[edge_order],
                shifts=sorted_edges.shifts[edge_order],
            )
        result = HyperGraph(sorted_particles, self.systems, sorted_edges)
        if return_sort_order:
            return result, sort_order
        return result

sorted_by_system(sort_edges=False, *, return_sort_order=False)

sorted_by_system(
    sort_edges: bool = ...,
    *,
    return_sort_order: Literal[False] = ...,
) -> HyperGraph[Part, Sys, Degree]
sorted_by_system(
    sort_edges: bool = ...,
    *,
    return_sort_order: Literal[True],
) -> tuple[HyperGraph[Part, Sys, Degree], Array]

Sort particles by system index and remap edges accordingly.

Parameters:

Name Type Description Default
sort_edges bool

If True, also sort edges by the system of their first particle.

False
return_sort_order bool

If True, also return the sort permutation.

False
Source code in src/kups/potential/common/graph.py
def sorted_by_system(
    self,
    sort_edges: bool = False,
    *,
    return_sort_order: bool = False,
) -> HyperGraph[Part, Sys, Degree] | tuple[HyperGraph[Part, Sys, Degree], Array]:
    """Sort particles by system index and remap edges accordingly.

    Args:
        sort_edges: If True, also sort edges by the system of their first
            particle.
        return_sort_order: If True, also return the sort permutation.
    """
    n = len(self.particles)
    sort_order = jnp.argsort(self.particles.data.system.raw_indices, stable=True)
    sorted_data = bind(self.particles.data).at((sort_order,)).get()
    sorted_particles = Table(
        self.particles.keys, sorted_data, _cls=self.particles.cls
    )
    # Each particle's new row, so edges follow their endpoints.
    inverse_order = Table(
        self.particles.keys,
        Index(
            self.particles.keys,
            jnp.zeros_like(sort_order).at[sort_order].set(jnp.arange(n)),
            max_count=self.edges.indices.max_count,
            _cls=self.particles.cls,
        ),
    )
    remapped_indices = inverse_order[self.edges.indices]
    sorted_edges = Edges(indices=remapped_indices, shifts=self.edges.shifts)
    if sort_edges:
        edge_systems = sorted_particles[remapped_indices[:, 0]].system
        edge_order = jnp.argsort(edge_systems.raw_indices, stable=True)
        sorted_edges = Edges(
            indices=remapped_indices[edge_order],
            shifts=sorted_edges.shifts[edge_order],
        )
    result = HyperGraph(sorted_particles, self.systems, sorted_edges)
    if return_sort_order:
        return result, sort_order
    return result

IsGraphInput

Bases: Protocol

A potential input exposing a graph of particles and systems.

Source code in src/kups/potential/common/graph.py
class IsGraphInput(Protocol):
    """A potential input exposing a ``graph`` of particles and systems."""

    @property
    def graph(
        self,
    ) -> PointCloud[HasPositionsAndSystemIndex, HasCell[AnyPeriodicity]]: ...

IsGraphProbe

Bases: IsParticleProbe[P], Protocol

Particle probe supplying neighbor lists for incremental graph construction.

Source code in src/kups/potential/common/graph.py
@runtime_checkable
class IsGraphProbe[P: NeighborListPoints, Degree: int](IsParticleProbe[P], Protocol):
    """Particle probe supplying neighbor lists for incremental graph construction."""

    @property
    def neighborlist_after(self) -> NeighborList[Degree]: ...
    @property
    def neighborlist_before(self) -> NeighborList[Degree]: ...

IsParticleProbe

Bases: Protocol

Probe result identifying changed particles.

Source code in src/kups/potential/common/graph.py
class IsParticleProbe[P: NeighborListPoints](Protocol):
    """Probe result identifying changed particles."""

    @property
    def particles(self) -> WithIndices[ParticleId, P]: ...

PointCloud

Bases: Generic[Part, Sys]

Indexed particles and systems, the base for all graph representations.

Generic in Part (particle data with positions and system assignment) and Sys (system data with cell).

Attributes:

Name Type Description
particles Table[ParticleId, Part]

Indexed particle data with positions and system assignment.

systems Table[SystemId, Sys]

Indexed system data with cell information.

Source code in src/kups/potential/common/graph.py
@dataclass
class PointCloud(Generic[Part, Sys]):
    """Indexed particles and systems, the base for all graph representations.

    Generic in ``Part`` (particle data with positions and system assignment)
    and ``Sys`` (system data with cell).

    Attributes:
        particles: Indexed particle data with positions and system assignment.
        systems: Indexed system data with cell information.
    """

    particles: Table[ParticleId, Part]
    systems: Table[SystemId, Sys]

    @property
    def batch_size(self) -> int:
        return self.particles.data.system.num_labels

graph_pair_energies(pair, parameters, graph, inclusion, exclusion)

Evaluate pair terms on compacted edges with their remaining group masks.

Source code in src/kups/potential/common/graph.py
def graph_pair_energies[Params, Part: HasPositionsAndSystemIndex, Feat](
    pair: PairTerm[Params, Part, Feat],
    parameters: Params,
    graph: HyperGraph[Part, HasCell[AnyPeriodicity], Literal[2]],
    inclusion: Array,
    exclusion: Array,
) -> Array:
    """Evaluate pair terms on compacted edges with their remaining group masks."""
    particles = graph.particles[graph.edges.indices]
    system = particles.system[:, 0]
    valid = (
        graph.edges.indices.indices_in(graph.particles.keys) < graph.particles.size
    ).all(-1)
    valid &= system.valid_mask & jnp.equal(*Index.match(system, particles.system[:, 1]))
    rij = jnp.where(valid[:, None], graph.edge_shifts[:, 0], 0.0)
    # Combining periodic geometry with pair algebra can crash the CUDA MLIR
    # fusion compiler in CSE. Preserve this boundary without changing autodiff.
    rij, r2 = jax.lax.optimization_barrier((rij, (rij**2).sum(-1)))
    pairs = PairBatch(rij, r2, system, valid, inclusion, exclusion)
    features = graph.particles.map_data(pair.features)[graph.edges.indices]
    return pair.evaluate(
        parameters,
        tree_map(lambda x: x[:, 0], features),
        tree_map(lambda x: x[:, 1], features),
        pairs,
    )