Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion python/cugraph/cugraph/experimental/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION.
# SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

from pylibcugraph.utilities.api_tools import (
Expand All @@ -15,3 +15,16 @@
strong_connected_component = experimental_warning_wrapper(
EXPERIMENTAL__strong_connected_component, _ns_name
)

from cugraph.experimental.isomorphism.subgraph_isomorphism import EXPERIMENTAL__subgraph_isomorphism

subgraph_isomorphism = experimental_warning_wrapper(
EXPERIMENTAL__subgraph_isomorphism, _ns_name
)

# MotifData and default_motif_library are plain data helpers, not
# algorithms, exported without the experimental wrapper
from cugraph.experimental.isomorphism.motif import (
MotifData,
default_motif_library,
)
2 changes: 2 additions & 0 deletions python/cugraph/cugraph/experimental/isomorphism/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
104 changes: 104 additions & 0 deletions python/cugraph/cugraph/experimental/isomorphism/motif.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

from __future__ import annotations

from dataclasses import dataclass, field

import cudf

from cugraph.utilities.utils import import_optional

# networkx is an optional (test) dependency of cugraph; only the code paths
# that use it require it to be installed.
nx = import_optional("networkx")


def count_unique(edge_list):
"""Count the number of unique vertices in an edge list."""
return len(set().union(*edge_list))


@dataclass
class MotifData:
"""A small building-block graph ("motif") used to decompose the pattern
graph during motif-based subgraph isomorphism.

Parameters
----------
name : str
Identifier for the motif, used in the solver's decomposition report.
motif : list of (int, int)
Edge list over vertices ``0..k-1`` defining the motif graph.
"""

name: str
motif: list
graph: object = field(init=False, default=None, repr=False)
size: int = field(init=False, default=0)
isomorphisms: object = field(init=False, default=None, repr=False)

def __post_init__(self):
self.size = count_unique(self.motif) if self.motif else 1
self.graph = nx.Graph()
self.graph.add_nodes_from(range(self.size))
self.graph.add_edges_from(self.motif)

def copy(self):
"""Copy this MotifData; the isomorphisms DataFrame is deep-copied."""
new_instance = self.__class__(motif=self.motif, name=self.name)
new_instance.graph = self.graph
new_instance.size = self.size
if self.isomorphisms is not None:
new_instance.isomorphisms = self.isomorphisms.copy()
return new_instance


def data_to_dataframe(data, num_vertices):
# Down-cast by vertex-id range; every motif table uses the same rule so
# cudf merge keys stay dtype-consistent across tables.
if num_vertices <= 256:
dtype = "uint8"
elif num_vertices <= 65536:
dtype = "uint16"
else:
dtype = "uint32"
return cudf.DataFrame(data, dtype=dtype)


def make_m2_motif(edge_df, num_vertices):
"""Build the base single-edge ("M2") motif whose isomorphisms table is
the bidirectional, de-duplicated edge list of the target graph.

The concat + drop_duplicates normalizes the input regardless of whether
``edge_df`` is already symmetrized or holds each edge in one direction.

Parameters
----------
edge_df : cudf.DataFrame
Two columns (source, destination) of target edges in the compact
``0..num_vertices-1`` vertex space, with self-loops already removed.
num_vertices : int
Number of vertices in the target graph.
"""
m2_motif = MotifData(name="M2", motif=[(0, 1)])
df = data_to_dataframe(edge_df.to_cupy(), num_vertices)
df_rev = df[[1, 0]]
df_rev.columns = [0, 1]
m2_motif.isomorphisms = cudf.concat(
[df, df_rev], ignore_index=True
).drop_duplicates(ignore_index=True, keep="first")
return m2_motif


def default_motif_library():
"""Return a small library of 3-vertex motifs usable as building blocks.

Passing these to ``subgraph_isomorphism`` makes the solver precompute
their embeddings in the target graph (a full solve per motif), which can
speed up large patterns at the cost of upfront work and memory.
"""
return [
MotifData(name="M3-path", motif=[(0, 1), (1, 2)]),
MotifData(name="M3-triangle", motif=[(0, 1), (1, 2), (0, 2)]),
]
183 changes: 183 additions & 0 deletions python/cugraph/cugraph/experimental/isomorphism/slicing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

from __future__ import annotations

from dataclasses import dataclass, field
from typing import List, Tuple

from cugraph.experimental.isomorphism.motif import MotifData
from cugraph.utilities.utils import import_optional

nx = import_optional("networkx")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer we use cudf/cupy instead of networkx both for scalability and dependency reasons.



@dataclass
class SlicingResults:
"""
Stores the results from pattern graph slicing into motifs.

Attributes:
chosen_motifs: A list of MotifData objects selected during slicing.
slices: Each sublist contains pattern vertex ids for a slice.
boundaries: A list where each element represents the boundary
conditions for a slice as tuples of
([slice_index, vertex_index], vertex_index).
intersections: Each set contains pattern vertices at the intersection
of a slice and its boundary.
"""

chosen_motifs: List[MotifData] = field(default_factory=list)
slices: List[List[int]] = field(default_factory=list)
boundaries: List[List[Tuple[List[int], int]]] = field(default_factory=list)
intersections: List[set] = field(default_factory=list)

def extend(self, other: "SlicingResults") -> None:
"""Appends the content of another SlicingResults instance to the
current one."""
self.chosen_motifs.extend(other.chosen_motifs)
self.slices.extend(other.slices)
self.boundaries.extend(other.boundaries)
self.intersections.extend(other.intersections)


def slice_pattern_graph_using_motifs(
pattern_graph: nx.Graph,
motif_metadata: List[MotifData],
) -> SlicingResults:
"""Slice the pattern graph into subgraphs based on predefined motifs."""
graph = nx.Graph()
graph.add_nodes_from(pattern_graph.nodes)
graph.add_edges_from(pattern_graph.edges)
results = SlicingResults()
boundary_nodes: set = set()

# Precompute adjacency list for fast lookups
adjacency_map = build_adjacency_map(graph)

def _extract_next_slice(
graph: nx.Graph,
motif_metadata: List[MotifData],
adjacency_map: dict,
boundary_nodes: set,
existing_slices: List[List[int]],
) -> SlicingResults | None:
"""Attempt to extract the next valid motif slice."""
remaining_nodes = set(graph.nodes)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again; I think it would be better to just use raw arrays - I don't think the conversion should be too hard.

existing_slices_node = set(
node for slice_ in existing_slices for node in slice_
)

# Start from the largest and most complex motifs
for motif_data in motif_metadata[::-1]:
if len(remaining_nodes) < motif_data.size:
continue

# Node-induced subgraph matches of the motif in the residual
# pattern graph (same semantics as rustworkx vf2_mapping with
# subgraph=True).
matcher = nx.algorithms.isomorphism.GraphMatcher(graph, motif_data.graph)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not performant code - I think we'd want to implement graph matching in cugraph/cudf, or I suppose we could allow the user to specify a graph matching function - networkx could be one option.


for mapping in matcher.subgraph_isomorphisms_iter():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, this is a lot of CPU code in a GPU library...

# mapping is {pattern_node: motif_node}; invert it so that
# slice_nodes[i] is the pattern vertex matched to motif
# vertex i. Dict iteration order carries no such guarantee.
inv = {motif_node: pat_node for pat_node, motif_node in mapping.items()}
slice_nodes = [inv[i] for i in range(motif_data.size)]
slice_set = set(slice_nodes)

# The found slice should cover at least one existing
# boundary node
if boundary_nodes and not (slice_set & boundary_nodes):
continue

combined_nodes = slice_set | existing_slices_node
slice_edges = [
(slice_nodes[u], slice_nodes[v]) for (u, v) in motif_data.motif
]
updated_boundary = find_boundary_nodes(
combined_nodes, slice_edges, adjacency_map
)

nodes_to_remove = slice_set - updated_boundary
# The found slice should contribute to the removal of pattern
# graph nodes. If no nodes are removed, it indicates a
# potential infinite loop.
if not nodes_to_remove:
# If the slice fully covers existing slices, skip it
if any(not (slice_set - set(slice_)) for slice_ in existing_slices):
continue
graph.remove_nodes_from(list(nodes_to_remove))

intersection = slice_set & boundary_nodes
boundary_nodes.clear()
boundary_nodes.update(updated_boundary)
boundary_condition = [
(
find_element_index(existing_slices, node),
slice_nodes.index(node),
)
for node in intersection
]
return SlicingResults(
[motif_data.copy()],
[slice_nodes],
[boundary_condition],
[intersection],
)
return None

while graph.number_of_nodes():
_step_res = _extract_next_slice(
graph,
motif_metadata,
adjacency_map,
boundary_nodes,
results.slices,
)

if not _step_res:
raise ValueError("Cannot slice the Pattern Graph.")

results.extend(_step_res)

return results


def build_adjacency_map(graph: nx.Graph) -> dict:
"""Build adjacency map from graph edges."""
adjacency_map = {u: set() for u in graph.nodes}
for u, v in graph.edges:
adjacency_map[u].add(v)
adjacency_map[v].add(u)
return adjacency_map


def find_element_index(slices: List[List[int]], target: int) -> List[int]:
"""Find the index of an element in a ragged 2D list.
Return [slice_index, vertex_index] if found, else []."""
for i, group in enumerate(slices):
if target in group:
return [i, group.index(target)]
return []


def find_boundary_nodes(
combined_nodes: set,
current_slice_edges: list,
adjacency_map: dict,
) -> set:
"""
Find updated boundary nodes after extracting a motif slice.
A node is a boundary if it connects to any untouched node outside the
current slice.
"""
# Remove the adjacent node if the edge is covered by the current slice
for node1, node2 in current_slice_edges:
if node1 in adjacency_map and node2 in adjacency_map[node1]:
adjacency_map[node1].remove(node2)
if node2 in adjacency_map and node1 in adjacency_map[node2]:
adjacency_map[node2].remove(node1)

# Identify boundary nodes
return {node for node in combined_nodes if adjacency_map.get(node, set())}
Loading
Loading