Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
21 changes: 20 additions & 1 deletion python/cugraph/cugraph/experimental/__init__.py
100644 → 100755
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,22 @@
strong_connected_component = experimental_warning_wrapper(
EXPERIMENTAL__strong_connected_component, _ns_name
)

from cugraph.experimental.isomorphism.subgraph_monomorphism import (
EXPERIMENTAL__subgraph_monomorphism,
)

subgraph_monomorphism = experimental_warning_wrapper(
EXPERIMENTAL__subgraph_monomorphism, _ns_name
)

from cugraph.experimental.isomorphism.motif import (
EXPERIMENTAL__MotifData,
EXPERIMENTAL__default_motif_library,
)

MotifData = experimental_warning_wrapper(EXPERIMENTAL__MotifData, _ns_name)

default_motif_library = experimental_warning_wrapper(
EXPERIMENTAL__default_motif_library, _ns_name
)
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
143 changes: 143 additions & 0 deletions python/cugraph/cugraph/experimental/isomorphism/motif.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# 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 dependency of cugraph (declared for tests only);
# import_optional defers the failure to first use, and only the private
# pattern-decomposition code paths use it — never MotifData instances.
nx = import_optional("networkx")


@dataclass
class EXPERIMENTAL__MotifData:
"""A small building-block graph ("motif") used to decompose the pattern
graph during motif-based subgraph monomorphism. Holds only plain Python
data (an edge list) plus, after precomputation, a cuDF table of the
motif's embeddings in the target graph.

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
size: int = field(init=False, default=0)
embeddings: object = field(init=False, default=None, repr=False)

def __post_init__(self):
# Materialize first so a generator input can't be silently
# exhausted between validation and use.
self.motif = list(self.motif)
vertices = set().union(*self.motif) if self.motif else set()
if not self.motif or vertices != set(range(len(vertices))):
raise ValueError(
"motif must be a non-empty edge list over contiguous vertices 0..k-1"
)
self.size = len(vertices)

def _to_nx(self):
"""Build a networkx.Graph of this motif (private; used by the CPU
pattern-decomposition step only)."""
graph = nx.Graph()
graph.add_nodes_from(range(self.size))
graph.add_edges_from(self.motif)
return graph

def copy(self):
"""Copy this MotifData. The embeddings table is copied shallowly
(shared data buffers, independent column metadata): the solver only
ever renames the copy's columns, so slices of the same motif can
share one embeddings table instead of duplicating it on the GPU."""
# Explicit class (not self.__class__) so copies of instances created
# through the experimental warning wrapper don't re-warn.
new_instance = EXPERIMENTAL__MotifData(motif=self.motif, name=self.name)
new_instance.size = self.size
if self.embeddings is not None:
new_instance.embeddings = self.embeddings.copy(deep=False)
return new_instance


# Internal alias: intra-package code (and type hints) use the plain name;
# the public export in cugraph.experimental applies the warning wrapper to
# the EXPERIMENTAL__-prefixed name above. Do not import this alias from
# outside the package — it bypasses the experimental warning. Note that
# instances made internally (default_motif_library, copy()) are of the raw
# class, so isinstance/== checks against the wrapped public class will not
# match them; experimental users should not rely on either.
MotifData = EXPERIMENTAL__MotifData


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"
elif num_vertices <= 2**32:
dtype = "uint32"
else:
dtype = "uint64"
return cudf.DataFrame(data, dtype=dtype)


def _make_m2_motif(edge_df, num_vertices):
"""Build the base single-edge ("M2") motif whose embeddings 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.
"""
if len(edge_df) >= 2**30:
# The concat below materializes 2 * len(edge_df) rows BEFORE the
# dedup, so at 2**30 input rows it exceeds cuDF's 2**31 - 1 row
# limit regardless of how many duplicates the dedup would remove;
# fail clearly rather than inside the concat. Support for larger
# targets would need a partitioned M2 table.
raise ValueError(
f"Target graph has {len(edge_df)} edges; the bidirectional "
"M2 motif table would exceed cuDF's 2**31 - 1 row limit."
)
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.embeddings = cudf.concat([df, df_rev], ignore_index=True).drop_duplicates(
ignore_index=True, keep="first"
)
return m2_motif


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

Passing these to ``subgraph_monomorphism`` 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)]),
]


default_motif_library = EXPERIMENTAL__default_motif_library
189 changes: 189 additions & 0 deletions python/cugraph/cugraph/experimental/isomorphism/slicing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# 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._to_nx())

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
]
# NOTE: find_boundary_nodes mutates adjacency_map (removes
# the slice's edges) before this candidate can still be
# rejected below. That is safe only because a rejected
# candidate here is an induced match over already-covered
# slices, whose edges were removed when first covered —
# i.e. the mutation is idempotent for rejected candidates.
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