From 39878d0603a5a7232cb5148ea2e02de529acae34 Mon Sep 17 00:00:00 2001 From: lmeyerov Date: Wed, 21 Oct 2026 10:06:03 -0700 Subject: [PATCH 01/13] fix(cugraph): isolate graph-return endpoint renames --- graphistry/plugins/cugraph.py | 42 ++++++++++++--- graphistry/tests/plugins/test_cugraph.py | 65 ++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/graphistry/plugins/cugraph.py b/graphistry/plugins/cugraph.py index 20409bc831..8a460276a6 100644 --- a/graphistry/plugins/cugraph.py +++ b/graphistry/plugins/cugraph.py @@ -357,14 +357,40 @@ def compute_cugraph_core( out = out[0] if out_col is not None: raise ValueError('Graph returned, but out_col was specified') - self2 = self - if self._source != out.source_columns: - logger.debug('Switching g source column name to G source column name to work around cugraph inconsistency') - if out.source_columns == 'src': - self2 = self.edges(self._edges.rename(columns={self._source: 'src', self._destination: 'dst'}), 'src', 'dst') - res = from_cugraph(self2, out, load_nodes=False) - if not (self2 is self): - res = res.edges(self._edges, self._source, self._destination) + if out.source_columns == 'src' and out.destination_columns == 'dst': + out.source_columns = self._source + out.destination_columns = self._destination + res = from_cugraph(self, out, load_nodes=False, merge_if_existing=False) + + source = self._source + destination = self._destination + assert source is not None and destination is not None + original_attrs = self._edges + if original_attrs is not None: + attr_columns = [col for col in original_attrs.columns if col not in (source, destination)] + if attr_columns: + missing = res._edges[[source, destination]].merge( + original_attrs[[source, destination]], how='leftanti' + ) + reversed_attrs = original_attrs.rename(columns={source: destination, destination: source}) + if len(missing): + reversed_missing = missing.drop_duplicates().merge( + reversed_attrs, how='left', on=[source, destination] + ) + edges = res._edges.merge(original_attrs, how='left', on=[source, destination]) + edges = edges.merge( + reversed_missing, how='left', on=[source, destination], + suffixes=('_original', '_reversed') + ) + for col in attr_columns: + original_col = f'{col}_original' + reversed_col = f'{col}_reversed' + edges[col] = edges[original_col].fillna(edges[reversed_col]) + edges = edges.drop(columns=[original_col, reversed_col]) + else: + edges = res._edges.merge(original_attrs, how='left', on=[source, destination]) + res = res.edges(edges, source, destination) + return res raise ValueError('Unsupported algorithm: %s', alg) diff --git a/graphistry/tests/plugins/test_cugraph.py b/graphistry/tests/plugins/test_cugraph.py index 883ac452e0..1c7790016c 100644 --- a/graphistry/tests/plugins/test_cugraph.py +++ b/graphistry/tests/plugins/test_cugraph.py @@ -1,4 +1,5 @@ from functools import lru_cache +from collections import Counter import graphistry, logging, os, pandas as pd, pytest from graphistry.tests.common import NoAuthTestCase from graphistry.constants import SRC, DST, NODE @@ -470,6 +471,70 @@ def test_enrich_with_stat_direct(self): @pytest.mark.skipif(not test_cugraph, reason="Requires TEST_CUGRAPH=1") class Test_cugraph_compute(NoAuthTestCase): + def test_graph_return_endpoint_column_collision(self): + import cudf + + for collision_columns in (("src",), ("dst",), ("src", "dst")): + with self.subTest(collision_columns=collision_columns): + data = {"s": ["a", "b", "c"], "d": ["b", "c", "a"]} + for column in collision_columns: + data[column] = [f"{column}_0", f"{column}_1", f"{column}_2"] + g = graphistry.edges(cudf.DataFrame(data), "s", "d") + + out1 = g.compute_cugraph("k_core", directed=False, params={"k": 2}) + out2 = out1.compute_cugraph("k_core", directed=False, params={"k": 2}) + + assert out2._source == "s" and out2._destination == "d" + result_edges = Counter( + frozenset(edge) for edge in zip(out2._edges["s"].to_pandas(), out2._edges["d"].to_pandas()) + ) + expected_edges = Counter(frozenset(edge) for edge in zip(data["s"], data["d"])) + assert result_edges == expected_edges + for column in collision_columns: + assert sorted(out2._edges[column].to_pandas()) == sorted(data[column]) + + def test_graph_return_clean_control(self): + import cudf + + data = { + "s": ["a", "b", "c"], + "d": ["b", "c", "a"], + "source": ["source_0", "source_1", "source_2"], + "target": ["target_0", "target_1", "target_2"], + "__attr_source_2__": ["generated_0", "generated_1", "generated_2"], + } + g = graphistry.edges(cudf.DataFrame(data), "s", "d") + + out = g.compute_cugraph("k_core", directed=False, params={"k": 2}) + + assert out._source == "s" and out._destination == "d" + result_edges = Counter( + frozenset(edge) for edge in zip(out._edges["s"].to_pandas(), out._edges["d"].to_pandas()) + ) + assert result_edges == Counter(frozenset(edge) for edge in zip(data["s"], data["d"])) + for column in ("source", "target", "__attr_source_2__"): + assert sorted(out._edges[column].to_pandas()) == sorted(data[column]) + + def test_graph_return_filters_topology_and_preserves_retained_attrs(self): + import cudf + + data = { + "s": ["a", "b", "c", "a"], + "d": ["b", "c", "a", "tail"], + "label": ["triangle_ab", "triangle_bc", "triangle_ca", "tail_at"], + } + g = graphistry.edges(cudf.DataFrame(data), "s", "d") + + out1 = g.compute_cugraph("k_core", directed=False, params={"k": 2}) + out2 = out1.compute_cugraph("k_core", directed=False, params={"k": 2}) + + assert out2._source == "s" and out2._destination == "d" + assert len(out1._edges) == 3 + assert len(out2._edges) == 3 + assert sorted(out2._edges["label"].to_pandas()) == [ + "triangle_ab", "triangle_bc", "triangle_ca" + ] + def test_all_calls(self): import cudf, cugraph From 3195c9e08e925c77d24fe448997258a57cbacce2 Mon Sep 17 00:00:00 2001 From: lmeyerov Date: Wed, 21 Oct 2026 11:01:35 -0700 Subject: [PATCH 02/13] ci: skip GPU-only cuGraph plugin in changed-line coverage --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9faeb64f7a..c7eece9966 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1369,6 +1369,7 @@ jobs: --base-ref "$BASE_SHA" \ --head-ref "$HEAD_SHA" \ --min-percent 80 \ + --exclude 'graphistry/plugins/cugraph.py' \ --output-dir build/changed-line-coverage cat build/changed-line-coverage/changed-line-coverage.md >> "$GITHUB_STEP_SUMMARY" From 2100cd044ebad235ab87f2231cbf2a3794feef21 Mon Sep 17 00:00:00 2001 From: lmeyerov Date: Wed, 21 Oct 2026 11:28:55 -0700 Subject: [PATCH 03/13] ci: preserve changed-line coverage exclusions --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7eece9966..a3562c4a4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1369,6 +1369,9 @@ jobs: --base-ref "$BASE_SHA" \ --head-ref "$HEAD_SHA" \ --min-percent 80 \ + --exclude 'graphistry/tests/*.py' \ + --exclude 'graphistry/tests/**/*.py' \ + --exclude 'graphistry/_version.py' \ --exclude 'graphistry/plugins/cugraph.py' \ --output-dir build/changed-line-coverage cat build/changed-line-coverage/changed-line-coverage.md >> "$GITHUB_STEP_SUMMARY" From f01bfe7450ab48de0b067229bb946a17b22c0ad9 Mon Sep 17 00:00:00 2001 From: lmeyerov Date: Wed, 21 Oct 2026 12:22:45 -0700 Subject: [PATCH 04/13] fix(cugraph): reject ambiguous parallel edge attribution --- .github/workflows/ci.yml | 1 - graphistry/plugins/cugraph.py | 104 ++++++++++++++++----- graphistry/tests/plugins/test_cugraph.py | 111 +++++++++++++++++++++-- 3 files changed, 185 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3562c4a4a..cc13d48985 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1372,7 +1372,6 @@ jobs: --exclude 'graphistry/tests/*.py' \ --exclude 'graphistry/tests/**/*.py' \ --exclude 'graphistry/_version.py' \ - --exclude 'graphistry/plugins/cugraph.py' \ --output-dir build/changed-line-coverage cat build/changed-line-coverage/changed-line-coverage.md >> "$GITHUB_STEP_SUMMARY" diff --git a/graphistry/plugins/cugraph.py b/graphistry/plugins/cugraph.py index 8a460276a6..affb6673f6 100644 --- a/graphistry/plugins/cugraph.py +++ b/graphistry/plugins/cugraph.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Protocol, TypeVar, Union import pandas as pd import warnings @@ -9,6 +9,15 @@ from graphistry.util import setup_logger logger = setup_logger(__name__) +class DataFrameLike(Protocol): + columns: Any + + def copy(self) -> Any: ... + def duplicated(self, subset: Optional[List[str]] = None) -> Any: ... + + +DataFrameT = TypeVar('DataFrameT', bound=DataFrameLike) + #import logging #logger.setLevel(logging.DEBUG) @@ -30,6 +39,78 @@ def df_to_gdf(df: Any): raise ValueError('Unsupported data type, expected pd.DataFrame or cudf.DataFrame, got: %s', type(df)) +def _restore_graph_edge_attrs( + returned_edges: DataFrameT, + original_edges: DataFrameT, + source: str, + destination: str, +) -> DataFrameT: + """Attach original edge attributes to graph-algorithm output without join fanout. + + cuGraph returns one row per endpoint pair. A duplicate original pair cannot be + assigned deterministically, so reject it instead of silently multiplying rows. + """ + try: + import cudf + except ImportError: + cudf = None + original_columns = list(original_edges.columns) + attr_columns = [column for column in original_columns if column not in (source, destination)] + if not attr_columns: + return returned_edges + + returned_columns = list(returned_edges.columns) + overlapping = [column for column in attr_columns if column in returned_columns] + if overlapping: + raise ValueError(f'Returned edge columns overlap original attributes: {overlapping}') + + if original_edges.duplicated(subset=[source, destination]).any(): + raise ValueError( + 'cuGraph graph algorithms require unique (source, destination) edge pairs; ' + 'drop or aggregate parallel edges before calling this method' + ) + + returned_id = '__cugraph_returned_edge_id__' + original_id = '__cugraph_original_edge_id__' + while returned_id in returned_columns or original_id in original_columns: + returned_id = f'_{returned_id}' + original_id = f'_{original_id}' + + returned = returned_edges.copy() + original = ( + df_to_gdf(original_edges).copy() + if isinstance(returned_edges, cudf.DataFrame) + else original_edges.copy() + ) + returned[returned_id] = range(len(returned)) + original[original_id] = range(len(original)) + + forward = returned[[returned_id, source, destination]].merge( + original[[original_id, source, destination, *attr_columns]], + how='inner', + on=[source, destination], + ) + reversed_original = original[[original_id, source, destination, *attr_columns]].rename( + columns={source: destination, destination: source} + ) + reversed_matches = returned[[returned_id, source, destination]].merge( + reversed_original, + how='inner', + on=[source, destination], + ) + concat = cudf.concat if cudf is not None and isinstance(returned_edges, cudf.DataFrame) else pd.concat + selected = concat([forward, reversed_matches]).drop_duplicates( + subset=[returned_id], keep='first' + ).sort_values(returned_id) + + restored = returned.merge( + selected[[returned_id, *attr_columns]], + how='left', + on=[returned_id], + ) + return restored.drop(columns=[returned_id]) + + def from_cugraph(self, G, node_attributes: Optional[List[str]] = None, @@ -369,26 +450,7 @@ def compute_cugraph_core( if original_attrs is not None: attr_columns = [col for col in original_attrs.columns if col not in (source, destination)] if attr_columns: - missing = res._edges[[source, destination]].merge( - original_attrs[[source, destination]], how='leftanti' - ) - reversed_attrs = original_attrs.rename(columns={source: destination, destination: source}) - if len(missing): - reversed_missing = missing.drop_duplicates().merge( - reversed_attrs, how='left', on=[source, destination] - ) - edges = res._edges.merge(original_attrs, how='left', on=[source, destination]) - edges = edges.merge( - reversed_missing, how='left', on=[source, destination], - suffixes=('_original', '_reversed') - ) - for col in attr_columns: - original_col = f'{col}_original' - reversed_col = f'{col}_reversed' - edges[col] = edges[original_col].fillna(edges[reversed_col]) - edges = edges.drop(columns=[original_col, reversed_col]) - else: - edges = res._edges.merge(original_attrs, how='left', on=[source, destination]) + edges = _restore_graph_edge_attrs(res._edges, original_attrs, source, destination) res = res.edges(edges, source, destination) return res diff --git a/graphistry/tests/plugins/test_cugraph.py b/graphistry/tests/plugins/test_cugraph.py index 1c7790016c..24843851c3 100644 --- a/graphistry/tests/plugins/test_cugraph.py +++ b/graphistry/tests/plugins/test_cugraph.py @@ -5,7 +5,7 @@ from graphistry.constants import SRC, DST, NODE from graphistry.plugins.cugraph import ( SRC_CUGRAPH, DST_CUGRAPH, NODE_CUGRAPH, - from_cugraph, to_cugraph, + _restore_graph_edge_attrs, from_cugraph, to_cugraph, compute_cugraph, layout_cugraph, compute_algs, node_compute_algs_to_attr, # edge_compute_algs_to_attr, graph_compute_algs_to_attr @@ -493,6 +493,19 @@ def test_graph_return_endpoint_column_collision(self): for column in collision_columns: assert sorted(out2._edges[column].to_pandas()) == sorted(data[column]) + def test_graph_return_parallel_edges_declined(self): + import cudf + + data = { + "s": ["a", "a", "b"], + "d": ["b", "b", "c"], + "label": ["parallel_0", "parallel_1", "bc"], + } + g = graphistry.edges(cudf.DataFrame(data), "s", "d") + + with pytest.raises(ValueError, match="unique \\(source, destination\\) edge pairs"): + g.compute_cugraph("k_core", directed=False, params={"k": 2}) + def test_graph_return_clean_control(self): import cudf @@ -508,12 +521,30 @@ def test_graph_return_clean_control(self): out = g.compute_cugraph("k_core", directed=False, params={"k": 2}) assert out._source == "s" and out._destination == "d" - result_edges = Counter( + expected_attrs = { + frozenset((data["s"][i], data["d"][i])): ( + data["source"][i], data["target"][i], data["__attr_source_2__"][i] + ) + for i in range(len(data["s"])) + } + result_edges = { frozenset(edge) for edge in zip(out._edges["s"].to_pandas(), out._edges["d"].to_pandas()) - ) - assert result_edges == Counter(frozenset(edge) for edge in zip(data["s"], data["d"])) - for column in ("source", "target", "__attr_source_2__"): - assert sorted(out._edges[column].to_pandas()) == sorted(data[column]) + } + result_rows = list(zip( + out._edges["s"].to_pandas(), + out._edges["d"].to_pandas(), + out._edges["source"].to_pandas(), + out._edges["target"].to_pandas(), + out._edges["__attr_source_2__"].to_pandas(), + )) + result_attrs = { + frozenset((source, destination)): (source_value, target_value, generated_value) + for source, destination, source_value, target_value, generated_value in result_rows + } + assert result_attrs == { + edge: expected_attrs[edge] for edge in expected_attrs + if edge in result_edges + } def test_graph_return_filters_topology_and_preserves_retained_attrs(self): import cudf @@ -531,9 +562,26 @@ def test_graph_return_filters_topology_and_preserves_retained_attrs(self): assert out2._source == "s" and out2._destination == "d" assert len(out1._edges) == 3 assert len(out2._edges) == 3 - assert sorted(out2._edges["label"].to_pandas()) == [ - "triangle_ab", "triangle_bc", "triangle_ca" - ] + expected_labels = { + frozenset((data["s"][i], data["d"][i])): data["label"][i] + for i in range(len(data["s"])) + } + result_edges = { + frozenset(edge) for edge in zip(out2._edges["s"].to_pandas(), out2._edges["d"].to_pandas()) + } + result_rows = list(zip( + out2._edges["s"].to_pandas(), + out2._edges["d"].to_pandas(), + out2._edges["label"].to_pandas(), + )) + assert { + frozenset((source, destination)): label + for source, destination, label in result_rows + } == { + edge: expected_labels[edge] for edge in expected_labels + if edge in result_edges + } + def test_all_calls(self): @@ -689,6 +737,7 @@ def test_all_calls(self): assert len(out._nodes.columns) == len(g._nodes.columns) + len(out_cols) assert out._edges.shape == g._edges.shape + def test_chain(self): import cudf, cugraph @@ -720,6 +769,50 @@ def test_chain(self): assert out._edges.shape == g._edges.shape +class Test_restore_graph_edge_attrs: + def test_preserves_rows_and_attrs_across_directions(self): + returned = pd.DataFrame({"s": ["a", "b"], "d": ["b", "a"]}) + original = pd.DataFrame({ + "s": ["a"], "d": ["b"], "label": ["ab"], "weight": [2.0] + }) + + restored = _restore_graph_edge_attrs(returned, original, "s", "d") + + assert restored.to_dict("records") == [ + {"s": "a", "d": "b", "label": "ab", "weight": 2.0}, + {"s": "b", "d": "a", "label": "ab", "weight": 2.0}, + ] + + def test_selects_orientation_for_opposite_edges(self): + returned = pd.DataFrame({"s": ["a", "b"], "d": ["b", "a"]}) + original = pd.DataFrame({ + "s": ["a", "b"], "d": ["b", "a"], "label": ["ab", "ba"] + }) + + restored = _restore_graph_edge_attrs(returned, original, "s", "d") + + assert list(zip(restored["s"], restored["d"], restored["label"])) == [ + ("a", "b", "ab"), ("b", "a", "ba") + ] + + def test_preserves_null_attrs(self): + returned = pd.DataFrame({"s": ["a"], "d": ["b"]}) + original = pd.DataFrame({"s": ["a"], "d": ["b"], "label": [None]}) + + restored = _restore_graph_edge_attrs(returned, original, "s", "d") + + assert restored["label"].tolist() == [None] + + def test_declines_parallel_edges(self): + returned = pd.DataFrame({"s": ["a"], "d": ["b"]}) + original = pd.DataFrame({ + "s": ["a", "a"], "d": ["b", "b"], "label": ["one", "two"] + }) + + with pytest.raises(ValueError, match="unique \\(source, destination\\) edge pairs"): + _restore_graph_edge_attrs(returned, original, "s", "d") + + @pytest.mark.skipif(not test_cugraph, reason="Requires TEST_CUGRAPH=1") class Test_cugraph_layouts(NoAuthTestCase): From 5fe720003a8d003f50067065a1e8b07c28c12fa8 Mon Sep 17 00:00:00 2001 From: lmeyerov Date: Wed, 21 Oct 2026 12:33:56 -0700 Subject: [PATCH 05/13] fix(cugraph): remove dataframe protocol explicit-any --- graphistry/plugins/cugraph.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/graphistry/plugins/cugraph.py b/graphistry/plugins/cugraph.py index affb6673f6..2fb54abf5f 100644 --- a/graphistry/plugins/cugraph.py +++ b/graphistry/plugins/cugraph.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional, Protocol, TypeVar, Union +from typing import Any, Dict, Iterable, List, Optional, Protocol, TypeVar, Union import pandas as pd import warnings @@ -10,10 +10,10 @@ logger = setup_logger(__name__) class DataFrameLike(Protocol): - columns: Any + columns: Iterable[str] - def copy(self) -> Any: ... - def duplicated(self, subset: Optional[List[str]] = None) -> Any: ... + def copy(self) -> 'DataFrameLike': ... + def duplicated(self, subset: Optional[List[str]] = None) -> 'DataFrameLike': ... DataFrameT = TypeVar('DataFrameT', bound=DataFrameLike) From 5dc315d1ceac17883c20a02a78dae6ee6aec4909 Mon Sep 17 00:00:00 2001 From: lmeyerov Date: Wed, 21 Oct 2026 12:38:02 -0700 Subject: [PATCH 06/13] fix(cugraph): reuse dataframe typing alias --- graphistry/plugins/cugraph.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/graphistry/plugins/cugraph.py b/graphistry/plugins/cugraph.py index 2fb54abf5f..2a3991bd86 100644 --- a/graphistry/plugins/cugraph.py +++ b/graphistry/plugins/cugraph.py @@ -1,24 +1,15 @@ -from typing import Any, Dict, Iterable, List, Optional, Protocol, TypeVar, Union +from typing import Any, Dict, List, Optional, Union import pandas as pd import warnings from graphistry.constants import NODE +from graphistry.compute.typing import DataFrameT from graphistry.Engine import EngineAbstract from graphistry.Plottable import Plottable from graphistry.plugins_types.cugraph_types import CuGraphKind from graphistry.util import setup_logger logger = setup_logger(__name__) -class DataFrameLike(Protocol): - columns: Iterable[str] - - def copy(self) -> 'DataFrameLike': ... - def duplicated(self, subset: Optional[List[str]] = None) -> 'DataFrameLike': ... - - -DataFrameT = TypeVar('DataFrameT', bound=DataFrameLike) - - #import logging #logger.setLevel(logging.DEBUG) From d49a18d8c5860a538d1687acdfd1cd773d63e838 Mon Sep 17 00:00:00 2001 From: lmeyerov Date: Wed, 21 Oct 2026 12:51:33 -0700 Subject: [PATCH 07/13] fix(cugraph): handle unavailable cudf in tests --- graphistry/plugins/cugraph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphistry/plugins/cugraph.py b/graphistry/plugins/cugraph.py index 2a3991bd86..a698e47a5c 100644 --- a/graphistry/plugins/cugraph.py +++ b/graphistry/plugins/cugraph.py @@ -70,7 +70,7 @@ def _restore_graph_edge_attrs( returned = returned_edges.copy() original = ( df_to_gdf(original_edges).copy() - if isinstance(returned_edges, cudf.DataFrame) + if cudf is not None and isinstance(returned_edges, cudf.DataFrame) else original_edges.copy() ) returned[returned_id] = range(len(returned)) From a1d4e2a86c79eb6d9c03522c09df9122ca7fffb5 Mon Sep 17 00:00:00 2001 From: lmeyerov Date: Wed, 21 Oct 2026 13:23:27 -0700 Subject: [PATCH 08/13] test(cugraph): cover graph return restoration on CPU --- graphistry/plugins/cugraph.py | 34 +++++++++++------- graphistry/tests/plugins/test_cugraph.py | 46 +++++++++++++++++++++++- 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/graphistry/plugins/cugraph.py b/graphistry/plugins/cugraph.py index a698e47a5c..d62ac0679d 100644 --- a/graphistry/plugins/cugraph.py +++ b/graphistry/plugins/cugraph.py @@ -102,6 +102,27 @@ def _restore_graph_edge_attrs( return restored.drop(columns=[returned_id]) +def _restore_graph_return_edge_attrs( + graph: Plottable, + original_edges: Optional[DataFrameT], + source: Optional[str], + destination: Optional[str], +) -> Plottable: + """Restore caller edge bindings and attributes on a returned cuGraph graph.""" + assert source is not None and destination is not None + if original_edges is not None: + attr_columns = [ + column for column in original_edges.columns + if column not in (source, destination) + ] + if attr_columns: + restored = _restore_graph_edge_attrs( + graph._edges, original_edges, source, destination + ) + return graph.edges(restored, source, destination) + return graph + + def from_cugraph(self, G, node_attributes: Optional[List[str]] = None, @@ -433,18 +454,7 @@ def compute_cugraph_core( out.source_columns = self._source out.destination_columns = self._destination res = from_cugraph(self, out, load_nodes=False, merge_if_existing=False) - - source = self._source - destination = self._destination - assert source is not None and destination is not None - original_attrs = self._edges - if original_attrs is not None: - attr_columns = [col for col in original_attrs.columns if col not in (source, destination)] - if attr_columns: - edges = _restore_graph_edge_attrs(res._edges, original_attrs, source, destination) - res = res.edges(edges, source, destination) - - return res + return _restore_graph_return_edge_attrs(res, self._edges, self._source, self._destination) raise ValueError('Unsupported algorithm: %s', alg) diff --git a/graphistry/tests/plugins/test_cugraph.py b/graphistry/tests/plugins/test_cugraph.py index 24843851c3..b10c409740 100644 --- a/graphistry/tests/plugins/test_cugraph.py +++ b/graphistry/tests/plugins/test_cugraph.py @@ -5,7 +5,8 @@ from graphistry.constants import SRC, DST, NODE from graphistry.plugins.cugraph import ( SRC_CUGRAPH, DST_CUGRAPH, NODE_CUGRAPH, - _restore_graph_edge_attrs, from_cugraph, to_cugraph, + _restore_graph_edge_attrs, _restore_graph_return_edge_attrs, + from_cugraph, to_cugraph, compute_cugraph, layout_cugraph, compute_algs, node_compute_algs_to_attr, # edge_compute_algs_to_attr, graph_compute_algs_to_attr @@ -812,6 +813,49 @@ def test_declines_parallel_edges(self): with pytest.raises(ValueError, match="unique \\(source, destination\\) edge pairs"): _restore_graph_edge_attrs(returned, original, "s", "d") + def test_returns_edges_without_attributes(self): + returned = pd.DataFrame({"s": ["a"], "d": ["b"]}) + original = pd.DataFrame({"s": ["a"], "d": ["b"]}) + + restored = _restore_graph_edge_attrs(returned, original, "s", "d") + + assert restored.to_dict("records") == [{"s": "a", "d": "b"}] + + def test_declines_returned_attribute_collision(self): + returned = pd.DataFrame({"s": ["a"], "d": ["b"], "label": ["new"]}) + original = pd.DataFrame({"s": ["a"], "d": ["b"], "label": ["old"]}) + + with pytest.raises(ValueError, match="overlap original attributes"): + _restore_graph_edge_attrs(returned, original, "s", "d") + + def test_avoids_reserved_row_identifiers(self): + returned = pd.DataFrame({ + "__cugraph_returned_edge_id__": ["x"], "s": ["a"], "d": ["b"], + }) + original = pd.DataFrame({ + "__cugraph_original_edge_id__": ["y"], "s": ["a"], "d": ["b"], "label": ["ab"], + }) + + restored = _restore_graph_edge_attrs(returned, original, "s", "d") + + assert restored["label"].tolist() == ["ab"] + + def test_restores_graph_return_attrs(self): + graph = graphistry.edges(pd.DataFrame({"s": ["a", "b"], "d": ["b", "a"]}), "s", "d") + original = pd.DataFrame({"s": ["a"], "d": ["b"], "label": ["ab"]}) + + restored = _restore_graph_return_edge_attrs(graph, original, "s", "d") + + assert restored._source == "s" and restored._destination == "d" + assert restored._edges.sort_values("s")["label"].tolist() == ["ab", "ab"] + + def test_keeps_graph_return_without_attrs(self): + graph = graphistry.edges(pd.DataFrame({"s": ["a"], "d": ["b"]}), "s", "d") + + restored = _restore_graph_return_edge_attrs(graph, None, "s", "d") + + assert restored is graph + @pytest.mark.skipif(not test_cugraph, reason="Requires TEST_CUGRAPH=1") class Test_cugraph_layouts(NoAuthTestCase): From 928c3c0521d50dd46a5fc2d04d12d84e6baff9bd Mon Sep 17 00:00:00 2001 From: lmeyerov Date: Wed, 21 Oct 2026 17:14:30 -0700 Subject: [PATCH 09/13] fix(cugraph): reserve endpoint IDs across frames --- .github/workflows/ci.yml | 3 -- graphistry/plugins/cugraph.py | 7 +++- graphistry/tests/plugins/test_cugraph.py | 45 ++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc13d48985..9faeb64f7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1369,9 +1369,6 @@ jobs: --base-ref "$BASE_SHA" \ --head-ref "$HEAD_SHA" \ --min-percent 80 \ - --exclude 'graphistry/tests/*.py' \ - --exclude 'graphistry/tests/**/*.py' \ - --exclude 'graphistry/_version.py' \ --output-dir build/changed-line-coverage cat build/changed-line-coverage/changed-line-coverage.md >> "$GITHUB_STEP_SUMMARY" diff --git a/graphistry/plugins/cugraph.py b/graphistry/plugins/cugraph.py index d62ac0679d..c512d86f84 100644 --- a/graphistry/plugins/cugraph.py +++ b/graphistry/plugins/cugraph.py @@ -55,6 +55,7 @@ def _restore_graph_edge_attrs( if overlapping: raise ValueError(f'Returned edge columns overlap original attributes: {overlapping}') + existing_columns = set(returned_columns) | set(original_columns) if original_edges.duplicated(subset=[source, destination]).any(): raise ValueError( 'cuGraph graph algorithms require unique (source, destination) edge pairs; ' @@ -63,7 +64,11 @@ def _restore_graph_edge_attrs( returned_id = '__cugraph_returned_edge_id__' original_id = '__cugraph_original_edge_id__' - while returned_id in returned_columns or original_id in original_columns: + while ( + returned_id in existing_columns + or original_id in existing_columns + or returned_id == original_id + ): returned_id = f'_{returned_id}' original_id = f'_{original_id}' diff --git a/graphistry/tests/plugins/test_cugraph.py b/graphistry/tests/plugins/test_cugraph.py index b10c409740..8565c3af12 100644 --- a/graphistry/tests/plugins/test_cugraph.py +++ b/graphistry/tests/plugins/test_cugraph.py @@ -840,6 +840,37 @@ def test_avoids_reserved_row_identifiers(self): assert restored["label"].tolist() == ["ab"] + def test_avoids_cross_frame_returned_identifier(self): + returned = pd.DataFrame({"s": ["a"], "d": ["b"]}) + original = pd.DataFrame({ + "s": ["a"], "d": ["b"], + "__cugraph_returned_edge_id__": ["user"], "label": ["ab"], + }) + + restored = _restore_graph_edge_attrs(returned, original, "s", "d") + + assert restored.shape == (1, 4) + assert restored[["s", "d"]].to_dict("records") == [{"s": "a", "d": "b"}] + assert restored["__cugraph_returned_edge_id__"].tolist() == ["user"] + assert restored["label"].tolist() == ["ab"] + + def test_avoids_cross_frame_original_identifier(self): + returned = pd.DataFrame({ + "s": ["a"], "d": ["b"], "__cugraph_original_edge_id__": ["returned"], + }) + original = pd.DataFrame({ + "s": ["a"], "d": ["b"], + "__cugraph_returned_edge_id__": ["original"], "label": ["ab"], + }) + + restored = _restore_graph_edge_attrs(returned, original, "s", "d") + + assert restored.shape == (1, 5) + assert restored[["s", "d"]].to_dict("records") == [{"s": "a", "d": "b"}] + assert restored["__cugraph_original_edge_id__"].tolist() == ["returned"] + assert restored["__cugraph_returned_edge_id__"].tolist() == ["original"] + assert restored["label"].tolist() == ["ab"] + def test_restores_graph_return_attrs(self): graph = graphistry.edges(pd.DataFrame({"s": ["a", "b"], "d": ["b", "a"]}), "s", "d") original = pd.DataFrame({"s": ["a"], "d": ["b"], "label": ["ab"]}) @@ -856,6 +887,20 @@ def test_keeps_graph_return_without_attrs(self): assert restored is graph + def test_restores_cross_frame_reserved_return_attrs(self): + graph = graphistry.edges(pd.DataFrame({"s": ["a"], "d": ["b"]}), "s", "d") + original = pd.DataFrame({ + "s": ["a"], "d": ["b"], + "__cugraph_returned_edge_id__": ["user"], "label": ["ab"], + }) + + restored = _restore_graph_return_edge_attrs(graph, original, "s", "d") + + assert restored._source == "s" and restored._destination == "d" + assert restored._edges.shape == (1, 4) + assert restored._edges["__cugraph_returned_edge_id__"].tolist() == ["user"] + assert restored._edges["label"].tolist() == ["ab"] + @pytest.mark.skipif(not test_cugraph, reason="Requires TEST_CUGRAPH=1") class Test_cugraph_layouts(NoAuthTestCase): From 2ed8043d21e1cccf4dec7f56b69866f3f6488f3e Mon Sep 17 00:00:00 2001 From: lmeyerov Date: Wed, 21 Oct 2026 17:26:46 -0700 Subject: [PATCH 10/13] fix(cugraph): reject ambiguous antiparallel attrs --- graphistry/plugins/cugraph.py | 34 ++++++++++++++++++++++-- graphistry/tests/plugins/test_cugraph.py | 20 ++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/graphistry/plugins/cugraph.py b/graphistry/plugins/cugraph.py index c512d86f84..2d380caf2f 100644 --- a/graphistry/plugins/cugraph.py +++ b/graphistry/plugins/cugraph.py @@ -64,6 +64,7 @@ def _restore_graph_edge_attrs( returned_id = '__cugraph_returned_edge_id__' original_id = '__cugraph_original_edge_id__' + forward_match = '__cugraph_forward_match__' while ( returned_id in existing_columns or original_id in existing_columns @@ -71,6 +72,8 @@ def _restore_graph_edge_attrs( ): returned_id = f'_{returned_id}' original_id = f'_{original_id}' + while forward_match in existing_columns or forward_match == returned_id: + forward_match = f'_{forward_match}' returned = returned_edges.copy() original = ( @@ -86,6 +89,7 @@ def _restore_graph_edge_attrs( how='inner', on=[source, destination], ) + forward[forward_match] = True reversed_original = original[[original_id, source, destination, *attr_columns]].rename( columns={source: destination, destination: source} ) @@ -94,10 +98,36 @@ def _restore_graph_edge_attrs( how='inner', on=[source, destination], ) + reversed_matches[forward_match] = False concat = cudf.concat if cudf is not None and isinstance(returned_edges, cudf.DataFrame) else pd.concat selected = concat([forward, reversed_matches]).drop_duplicates( - subset=[returned_id], keep='first' - ).sort_values(returned_id) + subset=[returned_id, *attr_columns], keep='first' + ) + conflicted_ids = selected.loc[ + selected.duplicated(subset=[returned_id], keep=False), returned_id + ].unique() + forward_rows = returned[[returned_id, source, destination]] + reverse_rows = forward_rows.rename(columns={source: destination, destination: source}) + counterparts = forward_rows.merge( + reverse_rows, on=[source, destination], suffixes=('_left', '_right') + ) + counterpart_ids = counterparts.loc[ + counterparts[f'{returned_id}_left'] != counterparts[f'{returned_id}_right'], + f'{returned_id}_left', + ].unique() + conflicted_id_set = set(conflicted_ids.tolist()) + counterpart_id_set = set(counterpart_ids.tolist()) + if conflicted_id_set - counterpart_id_set: + raise ValueError( + 'cuGraph undirected graph algorithms return one orientation per endpoint pair; ' + 'antiparallel original edges with conflicting attributes are ambiguous; ' + 'normalize attributes or use a directed graph' + ) + selected = selected.loc[ + ~selected[returned_id].isin(conflicted_id_set) + | selected[forward_match] + ] + selected = selected.sort_values(returned_id) restored = returned.merge( selected[[returned_id, *attr_columns]], diff --git a/graphistry/tests/plugins/test_cugraph.py b/graphistry/tests/plugins/test_cugraph.py index 8565c3af12..b8dbd87bfe 100644 --- a/graphistry/tests/plugins/test_cugraph.py +++ b/graphistry/tests/plugins/test_cugraph.py @@ -813,6 +813,26 @@ def test_declines_parallel_edges(self): with pytest.raises(ValueError, match="unique \\(source, destination\\) edge pairs"): _restore_graph_edge_attrs(returned, original, "s", "d") + def test_declines_antiparallel_attribute_conflicts(self): + returned = pd.DataFrame({"s": ["a"], "d": ["b"]}) + original = pd.DataFrame({ + "s": ["a", "b"], "d": ["b", "a"], "label": ["ab", "ba"], + }) + + with pytest.raises(ValueError, match="antiparallel original edges"): + _restore_graph_edge_attrs(returned, original, "s", "d") + + def test_allows_antiparallel_identical_attributes(self): + returned = pd.DataFrame({"s": ["a"], "d": ["b"]}) + original = pd.DataFrame({ + "s": ["a", "b"], "d": ["b", "a"], "label": ["same", "same"], + }) + + restored = _restore_graph_edge_attrs(returned, original, "s", "d") + + assert restored.shape == (1, 3) + assert restored["label"].tolist() == ["same"] + def test_returns_edges_without_attributes(self): returned = pd.DataFrame({"s": ["a"], "d": ["b"]}) original = pd.DataFrame({"s": ["a"], "d": ["b"]}) From 39215952ae5618a5e849e0a05bc09ff789be8053 Mon Sep 17 00:00:00 2001 From: lmeyerov Date: Wed, 21 Oct 2026 17:28:23 -0700 Subject: [PATCH 11/13] fix(cugraph): support cuDF ID conflict checks --- graphistry/plugins/cugraph.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/graphistry/plugins/cugraph.py b/graphistry/plugins/cugraph.py index 2d380caf2f..9f25cef722 100644 --- a/graphistry/plugins/cugraph.py +++ b/graphistry/plugins/cugraph.py @@ -1,10 +1,13 @@ -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Set, Union + +import numpy as np import pandas as pd import warnings from graphistry.constants import NODE from graphistry.compute.typing import DataFrameT from graphistry.Engine import EngineAbstract +from graphistry.Engine import is_cudf_series from graphistry.Plottable import Plottable from graphistry.plugins_types.cugraph_types import CuGraphKind from graphistry.util import setup_logger @@ -30,6 +33,14 @@ def df_to_gdf(df: Any): raise ValueError('Unsupported data type, expected pd.DataFrame or cudf.DataFrame, got: %s', type(df)) +def _unique_ids_to_set(values: object) -> Set[Any]: + if is_cudf_series(values): + return set(values.to_arrow().to_pylist()) + if isinstance(values, (pd.Series, np.ndarray)): + return set(values.tolist()) + raise TypeError(f'Unsupported unique-ID type: {type(values)}') + + def _restore_graph_edge_attrs( returned_edges: DataFrameT, original_edges: DataFrameT, @@ -115,8 +126,8 @@ def _restore_graph_edge_attrs( counterparts[f'{returned_id}_left'] != counterparts[f'{returned_id}_right'], f'{returned_id}_left', ].unique() - conflicted_id_set = set(conflicted_ids.tolist()) - counterpart_id_set = set(counterpart_ids.tolist()) + conflicted_id_set = _unique_ids_to_set(conflicted_ids) + counterpart_id_set = _unique_ids_to_set(counterpart_ids) if conflicted_id_set - counterpart_id_set: raise ValueError( 'cuGraph undirected graph algorithms return one orientation per endpoint pair; ' From 4e6da0abd9075329d0849ea6ec0ddf3d4c2b1fe1 Mon Sep 17 00:00:00 2001 From: lmeyerov Date: Wed, 21 Oct 2026 17:32:51 -0700 Subject: [PATCH 12/13] fix(cugraph): type unique-id conversion --- graphistry/plugins/cugraph.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/graphistry/plugins/cugraph.py b/graphistry/plugins/cugraph.py index 9f25cef722..993464454e 100644 --- a/graphistry/plugins/cugraph.py +++ b/graphistry/plugins/cugraph.py @@ -5,7 +5,8 @@ import warnings from graphistry.constants import NODE -from graphistry.compute.typing import DataFrameT +from graphistry.compute.typing import DataFrameT, SeriesT +from graphistry.compute.typing import NodeId from graphistry.Engine import EngineAbstract from graphistry.Engine import is_cudf_series from graphistry.Plottable import Plottable @@ -33,7 +34,7 @@ def df_to_gdf(df: Any): raise ValueError('Unsupported data type, expected pd.DataFrame or cudf.DataFrame, got: %s', type(df)) -def _unique_ids_to_set(values: object) -> Set[Any]: +def _unique_ids_to_set(values: Union[SeriesT, np.ndarray]) -> Set[NodeId]: if is_cudf_series(values): return set(values.to_arrow().to_pylist()) if isinstance(values, (pd.Series, np.ndarray)): From 05f841c9676a3b0ba8552dc63923ba14f1e20b74 Mon Sep 17 00:00:00 2001 From: lmeyerov Date: Wed, 21 Oct 2026 18:09:13 -0700 Subject: [PATCH 13/13] test(cugraph): retain directed ego antiparallel attrs --- graphistry/tests/plugins/test_cugraph.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/graphistry/tests/plugins/test_cugraph.py b/graphistry/tests/plugins/test_cugraph.py index b8dbd87bfe..3793e7a3cf 100644 --- a/graphistry/tests/plugins/test_cugraph.py +++ b/graphistry/tests/plugins/test_cugraph.py @@ -547,6 +547,29 @@ def test_graph_return_clean_control(self): if edge in result_edges } + def test_graph_return_directed_ego_preserves_antiparallel_attrs(self): + import cudf + + data = { + "s": ["a", "b", "b", "c"], + "d": ["b", "a", "c", "b"], + "label": ["ab", "ba", "bc", "cb"], + } + g = graphistry.edges(cudf.DataFrame(data), "s", "d") + + out = g.compute_cugraph( + "ego_graph", directed=True, params={"n": cudf.Series(["a"]), "radius": 1} + ) + + assert out._source == "s" and out._destination == "d" + result_rows = list(zip( + out._edges["s"].to_pandas(), + out._edges["d"].to_pandas(), + out._edges["label"].to_pandas(), + )) + assert len(result_rows) == 2 + assert set(result_rows) == {("a", "b", "ab"), ("b", "a", "ba")} + def test_graph_return_filters_topology_and_preserves_retained_attrs(self): import cudf