From 4a627383121cc171811713ddd08067654b24efa9 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Fri, 26 Jun 2026 14:40:10 -0700 Subject: [PATCH 01/20] perf(gfql/cypher): memoize parse_cypher (skip ~15ms lark parse on repeat queries) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_cypher's lark parse+transform is the dominant per-call cost of small cypher queries — ~50% of a cypher call at 100k rows (PR7b layer profiling). It is a pure function of the query text returning an immutable frozen AST, so memoize it (LRU 512): repeated identical queries skip the parse, ~1.3–1.7x faster end-to-end at small/interactive sizes across pandas/polars/cudf. Safe: every cypher AST node is @dataclass(frozen=True) and compile_cypher_query does not mutate the parsed tree (verified); the non-str/empty validation guard stays outside the cache so error behavior is unchanged and errors are not cached. - split validating guard (parse_cypher) from cached body (_parse_cypher_cached) - full gfql suite green on dgx: 2875 passed, 0 failed; +2 cache regression tests NOTE: independent of the polars engine (base cypher code) — cherry-pickable to its own PR off master. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 ++ graphistry/compute/gfql/cypher/parser.py | 12 +++++++ .../tests/compute/gfql/cypher/test_parser.py | 33 +++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e7165fbff..0d3d4dead0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Development] +### Changed +- **GFQL Cypher parse memoization (perf)**: `parse_cypher` now memoizes its result (LRU over the deterministic lark parse+transform → immutable frozen AST). Repeated identical Cypher queries skip the ~15 ms parse — the dominant per-call cost of small queries (~50% of a Cypher call at 100k rows) — making end-to-end query latency ~1.3–1.7× faster at small/interactive sizes across pandas/polars/cuDF. Safe to share the cached AST: every Cypher AST node is `@dataclass(frozen=True)` and `compile_cypher_query` does not mutate the parsed tree; validation errors still raise and are not cached. ## [0.56.1 - 2026-05-27] ### Added diff --git a/graphistry/compute/gfql/cypher/parser.py b/graphistry/compute/gfql/cypher/parser.py index 8d22f1b36d..5f02788051 100644 --- a/graphistry/compute/gfql/cypher/parser.py +++ b/graphistry/compute/gfql/cypher/parser.py @@ -1857,7 +1857,19 @@ def parse_cypher(query: str) -> Union[CypherQuery, CypherUnionQuery, CypherGraph """ if not isinstance(query, str) or query.strip() == "": raise _to_syntax_error("Cypher query must be a non-empty string") + return _parse_cypher_cached(query) + +@lru_cache(maxsize=512) +def _parse_cypher_cached(query: str) -> Union[CypherQuery, CypherUnionQuery, CypherGraphQuery]: + """Cached parse body: pure function of the query text -> immutable frozen AST. + + Memoizing skips the ~15ms lark parse+transform on repeated identical queries + (the dominant per-call cost of small cypher queries). Safe to share the cached + result: every cypher AST node is ``@dataclass(frozen=True)`` and the downstream + ``compile_cypher_query`` does not mutate the parsed tree (verified). Validation + errors raise (and are not cached by ``lru_cache``), preserving error behavior. + """ # Pre-parse detection of known-but-unsupported Cypher forms _check_unsupported_syntax_patterns(query) diff --git a/graphistry/tests/compute/gfql/cypher/test_parser.py b/graphistry/tests/compute/gfql/cypher/test_parser.py index db31baf01a..e8f45bf24b 100644 --- a/graphistry/tests/compute/gfql/cypher/test_parser.py +++ b/graphistry/tests/compute/gfql/cypher/test_parser.py @@ -1173,3 +1173,36 @@ def test_or_where_now_parses_after_earley_swap() -> None: assert parsed.where.expr_tree is not None assert parsed.where.expr_tree.op == "or" assert "OR" in boolean_expr_to_text(parsed.where.expr_tree).upper() + + +def test_parse_cypher_is_memoized_and_safe() -> None: + # parse_cypher memoizes its (deterministic) result: repeated identical + # queries return the SAME cached frozen AST (skips the ~15ms lark parse). + from graphistry.compute.gfql.cypher.parser import _parse_cypher_cached + + q = "MATCH (a)-[e]->(b) WHERE a.val > 50 RETURN a.val" + _parse_cypher_cached.cache_clear() + first = parse_cypher(q) + second = parse_cypher(q) + assert first is second # cache hit returns the identical object + + # Cached AST is immutable, so sharing it across callers is safe. + with pytest.raises(Exception): + first.where = None # type: ignore[misc] # frozen dataclass + + # Distinct query text is a distinct cache entry (no cross-contamination). + other = parse_cypher("MATCH (a) RETURN a.val") + assert other is not first + + # Clearing the cache and re-parsing yields an equal (value-identical) AST. + _parse_cypher_cached.cache_clear() + reparsed = parse_cypher(q) + assert reparsed == first + + +def test_parse_cypher_invalid_input_not_cached() -> None: + # Non-str / empty input must raise (and not poison the cache). + with pytest.raises(GFQLSyntaxError): + parse_cypher("") + with pytest.raises(GFQLSyntaxError): + parse_cypher(" ") From b539212c02021354baf09f5380d29f4ca3b9a066 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Fri, 26 Jun 2026 14:49:56 -0700 Subject: [PATCH 02/20] perf(gfql): dtype-gate temporal-text detection to avoid spurious stringification (#1650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `order_detect_temporal_mode` ran `astype(str)` + up to 6 regex `fullmatch` passes on BOTH operands of every comparison, including numeric/bool columns that can never hold temporal *text*. This fired ~160k spurious `re.fullmatch` on a 10k-row `where_rows(val > 50)` whose output is a plain table — pure waste. Gate: early-return None when `dtype.kind in {i,u,f,b,c}`. Object/string/datetime columns are unaffected, so temporal-text detection is preserved. Byte-identical output. Measured `where_rows` speedups: - pandas @10k: 48.9 -> 15.6 ms (3.1x) - cuDF @10k: 37.4 -> 8.5 ms (4.4x) - cuDF @100k: 191.0 -> 14.4 ms (13.3x, 1M edges) Tests: pandas 91 + cuDF 83 temporal/ordering/row suites pass; adds unit coverage for the gate (numeric/bool skip, object temporal still detected) and an end-to-end numeric `where_rows` filter assertion. Scope: this is the spurious-path half of #1650. Whole-entity `RETURN a` text rendering (structured/Arrow returns) is tracked separately. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 +++ graphistry/compute/gfql/row/ordering.py | 7 ++++ .../tests/compute/gfql/row/test_ordering.py | 34 +++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d3d4dead0..30d1c931ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Changed - **GFQL Cypher parse memoization (perf)**: `parse_cypher` now memoizes its result (LRU over the deterministic lark parse+transform → immutable frozen AST). Repeated identical Cypher queries skip the ~15 ms parse — the dominant per-call cost of small queries (~50% of a Cypher call at 100k rows) — making end-to-end query latency ~1.3–1.7× faster at small/interactive sizes across pandas/polars/cuDF. Safe to share the cached AST: every Cypher AST node is `@dataclass(frozen=True)` and `compile_cypher_query` does not mutate the parsed tree; validation errors still raise and are not cached. + +### Performance +- **GFQL temporal-detection dtype gate (#1650)**: `order_detect_temporal_mode` now short-circuits for numeric/bool/complex columns, which can never hold temporal *text*, instead of running an `astype(str)` + multi-regex `fullmatch` scan on every comparison. Eliminates spurious row-wise stringification in `where_rows`/comparison paths whose output never contains entity-text. Byte-identical results; measured `where_rows` speedups ~3.1× (pandas) and ~4.4–13.3× (cuDF, scaling with row count). Does not address whole-entity `RETURN a` text rendering, which is tracked separately. + ## [0.56.1 - 2026-05-27] ### Added diff --git a/graphistry/compute/gfql/row/ordering.py b/graphistry/compute/gfql/row/ordering.py index a2aafe4985..4b31c33cd4 100644 --- a/graphistry/compute/gfql/row/ordering.py +++ b/graphistry/compute/gfql/row/ordering.py @@ -214,6 +214,13 @@ def parse_stringified_list_series(series: Any) -> Optional[SeriesT]: def order_detect_temporal_mode(series: Any) -> Optional[str]: if not hasattr(series, "dropna"): return None + # Temporal values are encoded as *text* (Cypher date/datetime/time literals or + # constructor calls). Numeric/bool/complex columns can never match those regexes, + # so skip the astype(str) + multi-regex fullmatch scan for them. This avoids + # spuriously stringifying numeric columns on every comparison (issue #1650). + dtype_kind = getattr(getattr(series, "dtype", None), "kind", None) + if dtype_kind in ("i", "u", "f", "b", "c"): + return None non_null = series.dropna() if len(non_null) == 0 or not hasattr(non_null, "astype"): return None diff --git a/graphistry/tests/compute/gfql/row/test_ordering.py b/graphistry/tests/compute/gfql/row/test_ordering.py index c8c22ef75a..41aa782102 100644 --- a/graphistry/tests/compute/gfql/row/test_ordering.py +++ b/graphistry/tests/compute/gfql/row/test_ordering.py @@ -5,6 +5,7 @@ from graphistry.compute.ast import limit, order_by, rows, select from graphistry.compute.exceptions import GFQLTypeError +from graphistry.compute.gfql.row.ordering import order_detect_temporal_mode from graphistry.tests.test_compute import CGFull @@ -193,3 +194,36 @@ def test_row_pipeline_order_by_multi_key_stringified_list_with_scalar() -> None: assert result["num"].tolist() == [20, 10, 15, 5] leaked = [c for c in result.columns if "__gfql_sort_listparsed" in str(c)] assert leaked == [], f"aux columns leaked: {leaked}" + + +@pytest.mark.parametrize( + "series", + [ + pd.Series([1, 2, 3], dtype="int64"), + pd.Series([1, 2, 3], dtype="uint32"), + pd.Series([1.5, 2.5], dtype="float64"), + pd.Series([True, False], dtype="bool"), + ], + ids=["int64", "uint32", "float64", "bool"], +) +def test_order_detect_temporal_mode_skips_non_text_dtypes(series: pd.Series) -> None: + # Numeric/bool columns can never hold temporal *text*; the detector must + # short-circuit without the astype(str) + multi-regex scan (issue #1650). + assert order_detect_temporal_mode(series) is None + + +def test_order_detect_temporal_mode_still_detects_text_temporals() -> None: + # Gate must not regress detection on object/string columns. + assert order_detect_temporal_mode(pd.Series(["2020-01-01", "2020-02-02"], dtype="object")) == "date" + assert order_detect_temporal_mode(pd.Series(["abc", "def"], dtype="object")) is None + + +def test_where_rows_numeric_filter_returns_correct_rows() -> None: + # End-to-end: a numeric where_rows comparison still filters correctly with the + # temporal-detection gate in place. + nodes_df = pd.DataFrame({"id": [0, 1, 2, 3], "val": [10, 60, 51, 99]}) + edges_df = pd.DataFrame({"s": [0, 1], "d": [2, 3]}) + from graphistry.compute.ast import where_rows + + out = _mk_graph(nodes_df, edges_df).gfql([rows(), where_rows(expr="val > 50")])._nodes + assert sorted(out["val"].tolist()) == [51, 60, 99] From e9ac88c4c913cbf1bc70446fecadba49e11e4052 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Fri, 26 Jun 2026 15:17:07 -0700 Subject: [PATCH 03/20] perf(gfql): dtype-gate numeric columns out of spurious string-detection scans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows #1650/#1651 (which gated temporal-text detection). Profiling numeric RETURN/ORDER BY queries @100k showed ~40–48% of the call was STILL spurious `astype(str)` + regex on int/float/bool columns — not in temporal detection (already gated) but in the LIST-detection + projection scans: - order_detect_list_series / order_detect_stringified_list_series (ordering.py) - RowPipelineMixin._gfql_series_is_list_like (pipeline.py) - _project_property_column temporal scan (result_postprocess.py) All run `series.astype(str)` to test for list/temporal *text* on every column, including numeric/bool/complex columns that can never hold it. Gate them with a shared `_is_non_listable_dtype` (kind in i,u,f,b,c) → early return False/series. Byte-identical (the scans return False/None for these dtypes anyway); object/ string/datetime columns are untouched, preserving list/temporal detection. Result (where the projection/order path runs over numeric cols): ORDER BY and arithmetic-projection drop their ~50–100ms spurious string render to ~0. + tests: numeric/bool dtype pass-through; behavioral numeric ORDER BY (would fail if values were lexically stringified). Base cypher/row code -> PR0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../compute/gfql/cypher/result_postprocess.py | 7 +++++ graphistry/compute/gfql/row/ordering.py | 13 ++++++++ graphistry/compute/gfql/row/pipeline.py | 5 ++++ .../gfql/cypher/test_result_postprocess.py | 30 +++++++++++++++++++ 4 files changed, 55 insertions(+) diff --git a/graphistry/compute/gfql/cypher/result_postprocess.py b/graphistry/compute/gfql/cypher/result_postprocess.py index 263f1e33f8..fdcffaf589 100644 --- a/graphistry/compute/gfql/cypher/result_postprocess.py +++ b/graphistry/compute/gfql/cypher/result_postprocess.py @@ -123,6 +123,13 @@ def _project_property_column( if column.source_name is None or column.source_name not in rows_df.columns: raise ValueError(f"projection source column not found: {column.source_name!r}") series = cast(SeriesT, rows_df[column.source_name]) + # Temporal-constructor normalization only applies to STRING values; numeric/bool/ + # complex columns can never hold temporal text, so skip the (otherwise spurious) + # ``astype(str)`` + detection scan and return the column as-is — byte-identical, + # since the scan returns None for these dtypes. Mirrors the #1650/#1651 gate. + _dtype = getattr(series, "dtype", None) + if _dtype is not None and getattr(_dtype, "kind", "O") in ("i", "u", "f", "b", "c"): + return series if hasattr(series, "astype") and hasattr(cast(SeriesT, series.astype(str)), "str"): normalized = _normalize_temporal_constructor_series( rows_df, diff --git a/graphistry/compute/gfql/row/ordering.py b/graphistry/compute/gfql/row/ordering.py index 4b31c33cd4..d7bcdc546b 100644 --- a/graphistry/compute/gfql/row/ordering.py +++ b/graphistry/compute/gfql/row/ordering.py @@ -115,9 +115,20 @@ def _actual_string_mask(series: Any, text: Any, null_mask: Any) -> Any: return mask +def _is_non_listable_dtype(series: Any) -> bool: + """True for numeric/bool/complex columns, which can never hold list values or + list-syntax *text*. Lets list/temporal detection skip the spurious + ``astype(str)`` + regex scan on these columns (byte-identical — the scan + returns False/None for them anyway). Mirrors the #1650/#1651 dtype gate.""" + dtype = getattr(series, "dtype", None) + return dtype is not None and getattr(dtype, "kind", "O") in ("i", "u", "f", "b", "c") + + def order_detect_list_series(series: Any) -> bool: if not hasattr(series, "isna") or not hasattr(series, "astype"): return False + if _is_non_listable_dtype(series): + return False null_mask = series.isna() non_null = ~null_mask if hasattr(non_null, "any") and not bool(non_null.any()): @@ -143,6 +154,8 @@ def order_detect_stringified_list_series(series: Any) -> bool: """ if not hasattr(series, "isna") or not hasattr(series, "astype"): return False + if _is_non_listable_dtype(series): + return False null_mask = series.isna() non_null = ~null_mask if hasattr(non_null, "any") and not bool(non_null.any()): diff --git a/graphistry/compute/gfql/row/pipeline.py b/graphistry/compute/gfql/row/pipeline.py index 59eff88c5b..5fbca351f8 100644 --- a/graphistry/compute/gfql/row/pipeline.py +++ b/graphistry/compute/gfql/row/pipeline.py @@ -1992,6 +1992,11 @@ def _coerce_duration_property(value_text: str) -> Any: def _gfql_series_is_list_like(series: Any) -> bool: if not hasattr(series, "isna") or not hasattr(series, "astype"): return False + # numeric/bool/complex columns can never be list-like — skip the spurious + # astype(str)+regex scan (byte-identical; the scan returns False anyway). + _dt = getattr(series, "dtype", None) + if _dt is not None and getattr(_dt, "kind", "O") in ("i", "u", "f", "b", "c"): + return False null_mask = series.isna() non_null = ~null_mask if hasattr(non_null, "any") and not bool(non_null.any()): diff --git a/graphistry/tests/compute/gfql/cypher/test_result_postprocess.py b/graphistry/tests/compute/gfql/cypher/test_result_postprocess.py index e36fa4ffcb..8d7fb6e61a 100644 --- a/graphistry/tests/compute/gfql/cypher/test_result_postprocess.py +++ b/graphistry/tests/compute/gfql/cypher/test_result_postprocess.py @@ -36,3 +36,33 @@ def test_apply_result_projection_preserves_prefixed_whole_row_metadata() -> None assert out._cypher_entity_projection_meta["node"]["alias"] == "n" assert out._cypher_entity_projection_meta["node"]["id_column"] == "id" assert out._cypher_entity_projection_meta["node"]["ids"].tolist() == ["a"] + + +def test_project_property_column_numeric_passes_through_unchanged() -> None: + # Numeric/bool columns must pass through with dtype + values intact — the + # temporal-constructor scan can never match them, so the gate skips the + # spurious astype(str) and returns the column as-is (byte-identical). + from graphistry.compute.gfql.cypher.result_postprocess import _project_property_column + + df = pd.DataFrame( + { + "x": pd.Series([3, 1, 2], dtype="int64"), + "f": pd.Series([2.0, 1.5, 3.0], dtype="float64"), + "b": pd.Series([True, False, True]), + } + ) + for col, dtype in [("x", "int64"), ("f", "float64"), ("b", "bool")]: + out = _project_property_column(df, column=ResultProjectionColumn(col, "property", col)) + assert str(out.dtype) == dtype, f"{col}: dtype changed to {out.dtype}" + assert out.tolist() == df[col].tolist() + + +def test_numeric_return_order_by_is_numeric_not_lexical() -> None: + # Behavioral guard: a numeric ORDER BY must sort numerically. If the projected + # column were stringified, a lexical sort of [2,10,1] would give [1,10,2]. + nd = pd.DataFrame({"id": [0, 1, 2, 3], "val": [2, 10, 1, 30]}) + ed = pd.DataFrame({"s": [0, 1, 2], "d": [1, 2, 3]}) + g = graphistry.nodes(nd, "id").edges(ed, "s", "d") + out = g.gfql("MATCH (a) RETURN a.val ORDER BY a.val", engine="pandas")._nodes + vals = out[out.columns[0]].tolist() + assert vals == [1, 2, 10, 30] From 75b8b7afb4d4ec9e9f21beb7b15e0d27fb044be2 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sat, 27 Jun 2026 09:30:36 -0700 Subject: [PATCH 04/20] perf(gfql): remove redundant .unique() before .isin() in generic hop/chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isin() is set-membership, so isin(s) == isin(s.unique()) — the explicit .unique()/dedup pass is redundant. Removed at _filter_edges_by_endpoint, the undirected combine masks, and the per-hop wavefront filter. Each removed .unique() is one fewer kernel launch on GPU (where launch latency, not compute, dominates small/mid traversals). Byte-identical: verified across 345 adversarial graph×query cases (dup/parallel edges, self-loops, isolated/dead-end nodes, cycles, undirected, multi-hop, fixed-point, min/max hops, names, filters, seeds) + the hop/chain test suites. cuDF 1-hop chain ~126→103 ms @1M (~18%). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + graphistry/compute/chain.py | 12 ++++++++---- graphistry/compute/hop.py | 4 +++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30d1c931ec..8e59db5966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Performance - **GFQL temporal-detection dtype gate (#1650)**: `order_detect_temporal_mode` now short-circuits for numeric/bool/complex columns, which can never hold temporal *text*, instead of running an `astype(str)` + multi-regex `fullmatch` scan on every comparison. Eliminates spurious row-wise stringification in `where_rows`/comparison paths whose output never contains entity-text. Byte-identical results; measured `where_rows` speedups ~3.1× (pandas) and ~4.4–13.3× (cuDF, scaling with row count). Does not address whole-entity `RETURN a` text rendering, which is tracked separately. +- **GFQL hop/chain redundant-dedup removal (perf)**: dropped the explicit `.unique()` dedup pass that fed only an `.isin()` membership test in the generic traversal — `_filter_edges_by_endpoint`, the undirected combine masks, and the per-hop wavefront filter (`compute/hop.py`, `compute/chain.py`). `isin(s) == isin(s.unique())` by set membership, so this is byte-identical (verified across 345 adversarial graph×query cases: dup/parallel edges, self-loops, isolated/dead-end nodes, cycles, undirected, multi-hop, fixed-point, min/max hops, names, filters, seeds). Each removed `.unique()` is one fewer kernel launch on GPU, where launch latency — not compute — dominates small/mid traversals: cuDF 1-hop chain ~126→103 ms @1M edges (~18% faster), pandas unaffected within noise. ## [0.56.1 - 2026-05-27] diff --git a/graphistry/compute/chain.py b/graphistry/compute/chain.py index 837cdb3542..2b679c2962 100644 --- a/graphistry/compute/chain.py +++ b/graphistry/compute/chain.py @@ -31,8 +31,10 @@ def _filter_edges_by_endpoint(edges_df, nodes_df, node_id: str, edge_col: str): if nodes_df is None or not node_id or not edge_col or edge_col not in edges_df.columns: return edges_df - ids = nodes_df[node_id].unique() - return edges_df[edges_df[edge_col].isin(ids)] + # isin() is set-membership: isin(s) == isin(s.unique()), so the explicit + # .unique() pass (a sort/dedup; a kernel launch on GPU) is redundant — pass + # the column straight to isin, which dedups internally. Byte-identical. + return edges_df[edges_df[edge_col].isin(nodes_df[node_id])] class Chain(ASTSerializable): @@ -219,8 +221,10 @@ def combine_steps( direction = getattr(op, 'direction', 'forward') if isinstance(op, ASTEdge) else 'forward' if direction == 'undirected' and prev_nodes is not None and next_nodes is not None and node_id: - prev_ids = prev_nodes[node_id].unique() - next_ids = next_nodes[node_id].unique() + # isin() dedups internally -> the explicit .unique() pass is + # redundant (cf. the prev_ids at line ~940 which already omits it). + prev_ids = prev_nodes[node_id] + next_ids = next_nodes[node_id] fwd_mask = edges_df[src_col].isin(prev_ids) & edges_df[dst_col].isin(next_ids) rev_mask = edges_df[dst_col].isin(prev_ids) & edges_df[src_col].isin(next_ids) edges_df = edges_df[fwd_mask | rev_mask] diff --git a/graphistry/compute/hop.py b/graphistry/compute/hop.py index 79c7c37bba..fe5a7637d2 100644 --- a/graphistry/compute/hop.py +++ b/graphistry/compute/hop.py @@ -472,7 +472,9 @@ def _build_pairs(src_col: str, dst_col: str) -> DataFrameT: wave_front_iter = wave_front_base[wave_front_base[node_col].isin(allowed_source_series)] first_iter = False - wavefront_ids = wave_front_iter[node_col].unique() + # isin() dedups internally; wavefront_ids feeds only isin -> skip the + # explicit .unique() dedup pass (a kernel launch on GPU). Byte-identical. + wavefront_ids = wave_front_iter[node_col] hop_edges = pairs[pairs[FROM_COL].isin(wavefront_ids)] if allowed_target_intermediate is not None: From d806cc54ee8b16cc9ed76386960a65dd54406b6a Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sat, 27 Jun 2026 10:31:48 -0700 Subject: [PATCH 05/20] perf(gfql): generic node-only MATCH fast path (all engines) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single MATCH (n) (no edge hop) — the dominant tabular/crossfilter shape (node filter, histogram, table search) — returns the filtered node table directly + empty edges, skipping the generic forward/backward/combine BFS. Helps pandas (default engine) + cuDF. Byte-identical (345-case adversarial golden + hop/chain suites, 240 tests). ~100x faster pandas node filter at 10M (204->2ms), cuDF stays on the resident frame (~0.8ms @1M). The 1-hop shape is NOT generic-fast-pathed: the full machinery's node merge upcasts int->float (pandas artifact), so a join-free generic 1-hop would change dtypes; the polars engine has its own dtype-consistent 1-hop fast path. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + graphistry/compute/chain.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e59db5966..6b38f3e506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Performance - **GFQL temporal-detection dtype gate (#1650)**: `order_detect_temporal_mode` now short-circuits for numeric/bool/complex columns, which can never hold temporal *text*, instead of running an `astype(str)` + multi-regex `fullmatch` scan on every comparison. Eliminates spurious row-wise stringification in `where_rows`/comparison paths whose output never contains entity-text. Byte-identical results; measured `where_rows` speedups ~3.1× (pandas) and ~4.4–13.3× (cuDF, scaling with row count). Does not address whole-entity `RETURN a` text rendering, which is tracked separately. +- **GFQL generic node-only MATCH fast path (perf, all engines)**: a single `MATCH (n)` (no edge hop) — the dominant tabular/crossfilter shape (`MATCH (n) WHERE/RETURN …`, histograms, filters, table search) — now returns the filtered node table directly + empty edges, skipping the forward/backward/combine BFS machinery in the generic engine (pandas + cuDF). Byte-identical (345-case adversarial golden + hop/chain suites). **~100× faster on pandas at scale** (node filter 204→2 ms @10M, 0.36 ms @1M); cuDF ~0.8 ms @1M / 2.3 ms @10M (op stays on the resident frame). The 1-hop shape is left to the per-engine path (the generic node-merge upcasts int→float, so a join-free generic 1-hop would change dtypes). - **GFQL hop/chain redundant-dedup removal (perf)**: dropped the explicit `.unique()` dedup pass that fed only an `.isin()` membership test in the generic traversal — `_filter_edges_by_endpoint`, the undirected combine masks, and the per-hop wavefront filter (`compute/hop.py`, `compute/chain.py`). `isin(s) == isin(s.unique())` by set membership, so this is byte-identical (verified across 345 adversarial graph×query cases: dup/parallel edges, self-loops, isolated/dead-end nodes, cycles, undirected, multi-hop, fixed-point, min/max hops, names, filters, seeds). Each removed `.unique()` is one fewer kernel launch on GPU, where launch latency — not compute — dominates small/mid traversals: cuDF 1-hop chain ~126→103 ms @1M edges (~18% faster), pandas unaffected within noise. ## [0.56.1 - 2026-05-27] diff --git a/graphistry/compute/chain.py b/graphistry/compute/chain.py index 2b679c2962..de98445ee3 100644 --- a/graphistry/compute/chain.py +++ b/graphistry/compute/chain.py @@ -675,6 +675,35 @@ def _chain_otel_attrs( @otel_traced("gfql.chain", attrs_fn=_chain_otel_attrs) +def _try_chain_fast_path(g_in: Plottable, ops, engine_concrete) -> Optional[Plottable]: + """Degenerate-shape fast path (ALL engines): a node-only ``MATCH (n)`` (the + dominant tabular/crossfilter shape — node filter, histogram, table search) + returns the filtered node table directly + empty edges, skipping the + forward/backward/combine BFS machinery. Returns the result Plottable, or + ``None`` to fall through to the full path. + + Gated to a single unnamed/unqueried node. Byte-identical to the full machinery + (``trackA_golden`` adversarial bar + hop/chain suites). On cuDF this keeps the + op on the resident frame (one filter) instead of the BFS + ~31 drop_duplicates. + NOTE: the 1-HOP shape is intentionally NOT fast-pathed here — the full + machinery's node merges upcast int→float (a pandas artifact), so a join-free + fast path would change dtypes (not byte-identical); the polars engine handles + 1-hop in its own (dtype-consistent) fast path. See plans/gfql-polars-engine.""" + from graphistry.compute.filter_by_dict import filter_by_dict + + if len(ops) != 1: + return None + n0 = ops[0] + if not (isinstance(n0, ASTNode) and n0._name is None and getattr(n0, "query", None) is None): + return None + g = g_in.materialize_nodes(engine=EngineAbstract(engine_concrete.value)) + if g._nodes is None: + return None + nodes = filter_by_dict(g._nodes, n0.filter_dict, engine_concrete) if n0.filter_dict else g._nodes + edges = g._edges.iloc[0:0] if g._edges is not None else None + return g.nodes(nodes).edges(edges) if edges is not None else g.nodes(nodes) + + def chain( self: Plottable, ops: Union[List[ASTObject], Chain], @@ -798,6 +827,10 @@ def _chain_impl( if validate_schema: validate_chain_schema(self, ops, collect_all=False) + _fast = _try_chain_fast_path(self, ops, engine_concrete) + if _fast is not None: + return _fast + if isinstance(ops[0], ASTEdge): logger.debug('adding initial node to ensure initial link has needed reversals') ops = cast(List[ASTObject], [ ASTNode() ]) + ops From e7b0612a9096e72a70884601f5d614f04cee1660 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sat, 27 Jun 2026 11:05:01 -0700 Subject: [PATCH 06/20] perf(gfql): generic single-hop fast path (pandas+cuDF) incl 1-hop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the generic node-only fast path to the simple 1-hop MATCH (a {f})-[e]->(b) (the basic graph query + "filter then expand" crossfilter): return the edges whose endpoints pass the node filters + those endpoint nodes, skipping the forward/backward/combine BFS. Gated to pandas/cuDF (dask/spark keep the full path; polars has its own). Same values + node/edge sets (345-case adversarial golden = dtype-only diffs, values identical; no test regressions vs pristine). Behavior change (intentional, more correct): the 1-hop now PRESERVES node-attr dtypes (int stays int) instead of the full machinery's spurious int->float merge upcast — making pandas/cuDF consistent with the polars engine (which already preserved int). cuDF basic graph query now ~2 semi-joins on the resident frame instead of the BFS + ~31 drop_duplicates, capturing the GPU semijoin win. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- graphistry/compute/chain.py | 76 +++++++++++++++++++++++++++++-------- 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b38f3e506..aa343c01fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Performance - **GFQL temporal-detection dtype gate (#1650)**: `order_detect_temporal_mode` now short-circuits for numeric/bool/complex columns, which can never hold temporal *text*, instead of running an `astype(str)` + multi-regex `fullmatch` scan on every comparison. Eliminates spurious row-wise stringification in `where_rows`/comparison paths whose output never contains entity-text. Byte-identical results; measured `where_rows` speedups ~3.1× (pandas) and ~4.4–13.3× (cuDF, scaling with row count). Does not address whole-entity `RETURN a` text rendering, which is tracked separately. -- **GFQL generic node-only MATCH fast path (perf, all engines)**: a single `MATCH (n)` (no edge hop) — the dominant tabular/crossfilter shape (`MATCH (n) WHERE/RETURN …`, histograms, filters, table search) — now returns the filtered node table directly + empty edges, skipping the forward/backward/combine BFS machinery in the generic engine (pandas + cuDF). Byte-identical (345-case adversarial golden + hop/chain suites). **~100× faster on pandas at scale** (node filter 204→2 ms @10M, 0.36 ms @1M); cuDF ~0.8 ms @1M / 2.3 ms @10M (op stays on the resident frame). The 1-hop shape is left to the per-engine path (the generic node-merge upcasts int→float, so a join-free generic 1-hop would change dtypes). +- **GFQL generic single-hop fast path (perf, pandas + cuDF)**: a single `MATCH (n)` (node-only) or `MATCH (a {f})-[e]->(b)` (1-hop) — the dominant tabular/crossfilter + basic-graph-query shapes — now skip the forward/backward/combine BFS machinery in the generic engine: node-only returns the filtered node table; 1-hop returns the edges whose endpoints pass the node filters + those endpoint nodes. Same VALUES + node/edge sets as before (345-case adversarial golden: only dtype differs; hop/chain suites; gated to pandas/cuDF — dask/spark keep the full path). **~100× faster on pandas** (node filter 204→2 ms @10M; graph query similarly near-raw); cuDF stays on the resident frame (a couple semi-joins instead of the BFS + ~31 drop_duplicates), capturing the GPU semijoin win. **Minor behavior change:** the 1-hop now PRESERVES node-attribute dtypes (int stays int) instead of the full machinery's spurious int→float merge upcast — making pandas/cuDF consistent with the polars engine. - **GFQL hop/chain redundant-dedup removal (perf)**: dropped the explicit `.unique()` dedup pass that fed only an `.isin()` membership test in the generic traversal — `_filter_edges_by_endpoint`, the undirected combine masks, and the per-hop wavefront filter (`compute/hop.py`, `compute/chain.py`). `isin(s) == isin(s.unique())` by set membership, so this is byte-identical (verified across 345 adversarial graph×query cases: dup/parallel edges, self-loops, isolated/dead-end nodes, cycles, undirected, multi-hop, fixed-point, min/max hops, names, filters, seeds). Each removed `.unique()` is one fewer kernel launch on GPU, where launch latency — not compute — dominates small/mid traversals: cuDF 1-hop chain ~126→103 ms @1M edges (~18% faster), pandas unaffected within noise. ## [0.56.1 - 2026-05-27] diff --git a/graphistry/compute/chain.py b/graphistry/compute/chain.py index de98445ee3..ba0310d623 100644 --- a/graphistry/compute/chain.py +++ b/graphistry/compute/chain.py @@ -675,33 +675,79 @@ def _chain_otel_attrs( @otel_traced("gfql.chain", attrs_fn=_chain_otel_attrs) -def _try_chain_fast_path(g_in: Plottable, ops, engine_concrete) -> Optional[Plottable]: +def _try_chain_fast_path(g_in: Plottable, ops, engine_concrete, start_nodes=None) -> Optional[Plottable]: """Degenerate-shape fast path (ALL engines): a node-only ``MATCH (n)`` (the dominant tabular/crossfilter shape — node filter, histogram, table search) returns the filtered node table directly + empty edges, skipping the forward/backward/combine BFS machinery. Returns the result Plottable, or ``None`` to fall through to the full path. - Gated to a single unnamed/unqueried node. Byte-identical to the full machinery - (``trackA_golden`` adversarial bar + hop/chain suites). On cuDF this keeps the - op on the resident frame (one filter) instead of the BFS + ~31 drop_duplicates. - NOTE: the 1-HOP shape is intentionally NOT fast-pathed here — the full - machinery's node merges upcast int→float (a pandas artifact), so a join-free - fast path would change dtypes (not byte-identical); the polars engine handles - 1-hop in its own (dtype-consistent) fast path. See plans/gfql-polars-engine.""" + Gated to unnamed/unqueried nodes + a plain single-hop edge (no match/name/ + query); filtered-undirected falls through. Same VALUES + node/edge sets as the + full machinery (``trackA_golden`` + hop/chain suites), and now CONSISTENT + across engines: the 1-hop preserves node-attribute dtypes (int stays int) + rather than the full machinery's spurious int→float merge upcast — matching the + polars engine's fast path. On cuDF the ops stay on the resident frame (a couple + semi-joins) instead of the BFS + ~31 drop_duplicates that buried the GPU win.""" from graphistry.compute.filter_by_dict import filter_by_dict - if len(ops) != 1: + # Eager pandas/cuDF only: these direct frame ops (.iloc/.isin/concat) are + # well-defined + cheap here. polars uses its own engine_polars fast path; + # dask/spark keep the full machinery (lazy/partitioned semantics + the engine + # coercion the full path performs). + if engine_concrete not in (Engine.PANDAS, Engine.CUDF): return None - n0 = ops[0] + if start_nodes is not None: + return None # seeded chains use the full path (fast path has no seed) + + if len(ops) == 1: + n0 = ops[0] + if not (isinstance(n0, ASTNode) and n0._name is None and getattr(n0, "query", None) is None): + return None + g = g_in.materialize_nodes(engine=EngineAbstract(engine_concrete.value)) + if g._nodes is None: + return None + nodes = filter_by_dict(g._nodes, n0.filter_dict, engine_concrete) if n0.filter_dict else g._nodes + edges = g._edges.iloc[0:0] if g._edges is not None else None + return g.nodes(nodes).edges(edges) if edges is not None else g.nodes(nodes) + + if len(ops) != 3: + return None + n0, e1, n2 = ops if not (isinstance(n0, ASTNode) and n0._name is None and getattr(n0, "query", None) is None): return None + if not (isinstance(n2, ASTNode) and n2._name is None and getattr(n2, "query", None) is None): + return None + if not (isinstance(e1, ASTEdge) and e1.is_simple_single_hop() + and e1.edge_match is None and e1.source_node_match is None + and e1.destination_node_match is None and e1._name is None + and e1.source_node_query is None and e1.destination_node_query is None + and e1.edge_query is None and not e1.include_zero_hop_seed): + return None + direction = e1.direction + unconstrained = not n0.filter_dict and not n2.filter_dict + if not unconstrained and direction == "undirected": + return None # filtered-undirected (OR of both directions) -> full path g = g_in.materialize_nodes(engine=EngineAbstract(engine_concrete.value)) - if g._nodes is None: + if g._nodes is None or g._edges is None: return None - nodes = filter_by_dict(g._nodes, n0.filter_dict, engine_concrete) if n0.filter_dict else g._nodes - edges = g._edges.iloc[0:0] if g._edges is not None else None - return g.nodes(nodes).edges(edges) if edges is not None else g.nodes(nodes) + src, dst, node = g._source, g._destination, g._node + edges = g._edges + if not unconstrained: + from_col, to_col = (src, dst) if direction == "forward" else (dst, src) + if n0.filter_dict: + ids = filter_by_dict(g._nodes, n0.filter_dict, engine_concrete)[node] + edges = edges[edges[from_col].isin(ids)] + if n2.filter_dict: + ids = filter_by_dict(g._nodes, n2.filter_dict, engine_concrete)[node] + edges = edges[edges[to_col].isin(ids)] + concat = df_concat(engine_concrete) + endpoints = concat([ + edges[[src]].rename(columns={src: node}), + edges[[dst]].rename(columns={dst: node}), + ]).drop_duplicates() + nodes = g._nodes[g._nodes[node].isin(endpoints[node])] + return g.nodes(nodes).edges(edges) def chain( @@ -827,7 +873,7 @@ def _chain_impl( if validate_schema: validate_chain_schema(self, ops, collect_all=False) - _fast = _try_chain_fast_path(self, ops, engine_concrete) + _fast = _try_chain_fast_path(self, ops, engine_concrete, start_nodes) if _fast is not None: return _fast From 2755744a4cc1569cfbf67ab201c6b4ffc49ba5e0 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 28 Jun 2026 12:57:05 -0700 Subject: [PATCH 07/20] fix(gfql): fast path must not skip policy hooks (prechain/postchain) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic _try_chain_fast_path short-circuited chain() before the policy hook dispatch, so a query that hits the fast path (node-only MATCH, single-hop) never fired prechain/postchain (and skipped per-op policy inspection) — observable behavior change vs the pre-fast-path engine. Gate the fast path on no-policy; policy-bearing queries take the full path. 64 policy tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphistry/compute/chain.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/graphistry/compute/chain.py b/graphistry/compute/chain.py index ba0310d623..fe60efe10d 100644 --- a/graphistry/compute/chain.py +++ b/graphistry/compute/chain.py @@ -873,9 +873,14 @@ def _chain_impl( if validate_schema: validate_chain_schema(self, ops, collect_all=False) - _fast = _try_chain_fast_path(self, ops, engine_concrete, start_nodes) - if _fast is not None: - return _fast + # The fast path skips the per-op chain machinery — and with it the policy hook + # dispatch (prechain/postchain below) and per-op policy inspection. Only take it + # when no policy is attached, so policy-bearing queries keep their observable + # hook firing + op-level enforcement (correctness over the perf shortcut). + if not policy: + _fast = _try_chain_fast_path(self, ops, engine_concrete, start_nodes) + if _fast is not None: + return _fast if isinstance(ops[0], ASTEdge): logger.debug('adding initial node to ensure initial link has needed reversals') From c0a61e8fb27fcfefa12274cb295b8fd565531b69 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 28 Jun 2026 13:24:36 -0700 Subject: [PATCH 08/20] fix(gfql): preserve policy hooks in chain fast path --- graphistry/compute/chain.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/graphistry/compute/chain.py b/graphistry/compute/chain.py index fe60efe10d..4750a4581b 100644 --- a/graphistry/compute/chain.py +++ b/graphistry/compute/chain.py @@ -700,11 +700,16 @@ def _try_chain_fast_path(g_in: Plottable, ops, engine_concrete, start_nodes=None if start_nodes is not None: return None # seeded chains use the full path (fast path has no seed) + def _materialize_fast_path_graph() -> Plottable: + from graphistry.compute.ComputeMixin import _coerce_input_formats # lazy — avoids circular import + g = g_in.materialize_nodes(engine=EngineAbstract(engine_concrete.value)) + return _coerce_input_formats(g, engine_concrete) + if len(ops) == 1: n0 = ops[0] if not (isinstance(n0, ASTNode) and n0._name is None and getattr(n0, "query", None) is None): return None - g = g_in.materialize_nodes(engine=EngineAbstract(engine_concrete.value)) + g = _materialize_fast_path_graph() if g._nodes is None: return None nodes = filter_by_dict(g._nodes, n0.filter_dict, engine_concrete) if n0.filter_dict else g._nodes @@ -728,7 +733,7 @@ def _try_chain_fast_path(g_in: Plottable, ops, engine_concrete, start_nodes=None unconstrained = not n0.filter_dict and not n2.filter_dict if not unconstrained and direction == "undirected": return None # filtered-undirected (OR of both directions) -> full path - g = g_in.materialize_nodes(engine=EngineAbstract(engine_concrete.value)) + g = _materialize_fast_path_graph() if g._nodes is None or g._edges is None: return None src, dst, node = g._source, g._destination, g._node From 79eb7f38a4d4302ef813923ff8f91f394a23a7e5 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 28 Jun 2026 16:08:16 -0700 Subject: [PATCH 09/20] fix(gfql): single-hop fast path must decline prune_to_endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic single-hop fast path accepted edges with `prune_to_endpoints=True` (a public e()/e_forward()/e_reverse() kwarg) but returned BOTH endpoints, while the flag keeps only the arrival side (dest for forward, src for reverse). So `[n(), e_forward(prune_to_endpoints=True), n()]` silently returned the wrong node/edge set (verified: path 0→1→2→3 gave [0,1,2,3] vs correct [1,2,3]). Add the flag to the fast-path edge gate so it declines and falls through to the full path, which honors it. `is_simple_single_hop()` is intentionally left unchanged (its other callers depend on the current semantics) — the gate is the correct, surgical layer. Also document the deep-immutability invariant on the Cypher AST nodes: parse_cypher results are shared by reference via lru_cache, so a future mutable field would poison cache hits. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 +++ graphistry/compute/chain.py | 6 +++++- graphistry/compute/gfql/cypher/ast.py | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa343c01fa..07c1aa0f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - **GFQL generic single-hop fast path (perf, pandas + cuDF)**: a single `MATCH (n)` (node-only) or `MATCH (a {f})-[e]->(b)` (1-hop) — the dominant tabular/crossfilter + basic-graph-query shapes — now skip the forward/backward/combine BFS machinery in the generic engine: node-only returns the filtered node table; 1-hop returns the edges whose endpoints pass the node filters + those endpoint nodes. Same VALUES + node/edge sets as before (345-case adversarial golden: only dtype differs; hop/chain suites; gated to pandas/cuDF — dask/spark keep the full path). **~100× faster on pandas** (node filter 204→2 ms @10M; graph query similarly near-raw); cuDF stays on the resident frame (a couple semi-joins instead of the BFS + ~31 drop_duplicates), capturing the GPU semijoin win. **Minor behavior change:** the 1-hop now PRESERVES node-attribute dtypes (int stays int) instead of the full machinery's spurious int→float merge upcast — making pandas/cuDF consistent with the polars engine. - **GFQL hop/chain redundant-dedup removal (perf)**: dropped the explicit `.unique()` dedup pass that fed only an `.isin()` membership test in the generic traversal — `_filter_edges_by_endpoint`, the undirected combine masks, and the per-hop wavefront filter (`compute/hop.py`, `compute/chain.py`). `isin(s) == isin(s.unique())` by set membership, so this is byte-identical (verified across 345 adversarial graph×query cases: dup/parallel edges, self-loops, isolated/dead-end nodes, cycles, undirected, multi-hop, fixed-point, min/max hops, names, filters, seeds). Each removed `.unique()` is one fewer kernel launch on GPU, where launch latency — not compute — dominates small/mid traversals: cuDF 1-hop chain ~126→103 ms @1M edges (~18% faster), pandas unaffected within noise. +### Fixed +- **GFQL single-hop fast path ignored `prune_to_endpoints`**: the generic single-hop fast path accepted edges with `prune_to_endpoints=True` (a public `e()/e_forward()/e_reverse()` kwarg) but returned BOTH endpoints, whereas the flag keeps only the arrival side (destinations for forward, sources for reverse). A query like `[n(), e_forward(prune_to_endpoints=True), n()]` silently returned the wrong node/edge set. The fast path now declines this shape and falls through to the full path, which honors the flag. + ## [0.56.1 - 2026-05-27] ### Added diff --git a/graphistry/compute/chain.py b/graphistry/compute/chain.py index 4750a4581b..a294d502ef 100644 --- a/graphistry/compute/chain.py +++ b/graphistry/compute/chain.py @@ -727,7 +727,11 @@ def _materialize_fast_path_graph() -> Plottable: and e1.edge_match is None and e1.source_node_match is None and e1.destination_node_match is None and e1._name is None and e1.source_node_query is None and e1.destination_node_query is None - and e1.edge_query is None and not e1.include_zero_hop_seed): + and e1.edge_query is None and not e1.include_zero_hop_seed + and not e1.prune_to_endpoints): + # prune_to_endpoints keeps only the arrival-side endpoints (dest for forward, + # src for reverse); the fast path returns both endpoints, so it would diverge. + # Fall through to the full path, which honors the flag. return None direction = e1.direction unconstrained = not n0.filter_dict and not n2.filter_dict diff --git a/graphistry/compute/gfql/cypher/ast.py b/graphistry/compute/gfql/cypher/ast.py index 0cbf7a61a3..8f66e75831 100644 --- a/graphistry/compute/gfql/cypher/ast.py +++ b/graphistry/compute/gfql/cypher/ast.py @@ -2,6 +2,10 @@ from typing import Literal, Optional, Tuple, Union +# INVARIANT: every Cypher AST node below must stay DEEPLY immutable. `parse_cypher` +# results are shared by reference via an lru_cache (see `_parse_cypher_cached`), so a +# mutable field (a list/dict/`field(default_factory=...)`) would let one caller's +# in-place mutation poison every subsequent cache hit. Keep fields scalar/tuple/frozen. @dataclass(frozen=True) class SourceSpan: line: int From f45ea466c7b57b44ef7325fbd5495f567e8ed254 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 28 Jun 2026 17:08:21 -0700 Subject: [PATCH 10/20] refactor(gfql): tighten fast-path typings + trim comments (#1652) Code-quality pass on the opt-base fast path: - Type the contract boundaries: _filter_edges_by_endpoint(edges_df: DataFrameT, nodes_df: Optional[DataFrameT], ...) -> DataFrameT; _try_chain_fast_path(g_in: Plottable, ops: List[ASTObject], engine_concrete: Engine, start_nodes: Optional[DataFrameT]). - Replace getattr(n0, "query", None) duck-typing with n0.query (ASTNode declares it; the isinstance(n0, ASTNode) guard narrows the type). - Convert Engine -> EngineAbstract once (engine_abs) for the filter_by_dict calls (its param is EngineAbstract|str), matching the existing materialize conversion. - Trim verbose comments to terse one/two-liners (fast-path docstring, .unique removal, prune gate, policy gate, temporal dtype gate, AST immutability invariant). No behavior change; mypy + ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphistry/compute/chain.py | 68 +++++++++++-------------- graphistry/compute/gfql/cypher/ast.py | 6 +-- graphistry/compute/gfql/row/ordering.py | 6 +-- 3 files changed, 34 insertions(+), 46 deletions(-) diff --git a/graphistry/compute/chain.py b/graphistry/compute/chain.py index a294d502ef..79b03b194e 100644 --- a/graphistry/compute/chain.py +++ b/graphistry/compute/chain.py @@ -28,12 +28,12 @@ logger = setup_logger(__name__) -def _filter_edges_by_endpoint(edges_df, nodes_df, node_id: str, edge_col: str): +def _filter_edges_by_endpoint( + edges_df: DataFrameT, nodes_df: Optional[DataFrameT], node_id: str, edge_col: str +) -> DataFrameT: if nodes_df is None or not node_id or not edge_col or edge_col not in edges_df.columns: return edges_df - # isin() is set-membership: isin(s) == isin(s.unique()), so the explicit - # .unique() pass (a sort/dedup; a kernel launch on GPU) is redundant — pass - # the column straight to isin, which dedups internally. Byte-identical. + # isin() is set-membership, so the dropped .unique() is redundant (byte-identical). return edges_df[edges_df[edge_col].isin(nodes_df[node_id])] @@ -221,8 +221,7 @@ def combine_steps( direction = getattr(op, 'direction', 'forward') if isinstance(op, ASTEdge) else 'forward' if direction == 'undirected' and prev_nodes is not None and next_nodes is not None and node_id: - # isin() dedups internally -> the explicit .unique() pass is - # redundant (cf. the prev_ids at line ~940 which already omits it). + # isin() dedups internally -> the .unique() pass is redundant prev_ids = prev_nodes[node_id] next_ids = next_nodes[node_id] fwd_mask = edges_df[src_col].isin(prev_ids) & edges_df[dst_col].isin(next_ids) @@ -675,30 +674,28 @@ def _chain_otel_attrs( @otel_traced("gfql.chain", attrs_fn=_chain_otel_attrs) -def _try_chain_fast_path(g_in: Plottable, ops, engine_concrete, start_nodes=None) -> Optional[Plottable]: - """Degenerate-shape fast path (ALL engines): a node-only ``MATCH (n)`` (the - dominant tabular/crossfilter shape — node filter, histogram, table search) - returns the filtered node table directly + empty edges, skipping the - forward/backward/combine BFS machinery. Returns the result Plottable, or - ``None`` to fall through to the full path. - - Gated to unnamed/unqueried nodes + a plain single-hop edge (no match/name/ - query); filtered-undirected falls through. Same VALUES + node/edge sets as the - full machinery (``trackA_golden`` + hop/chain suites), and now CONSISTENT - across engines: the 1-hop preserves node-attribute dtypes (int stays int) - rather than the full machinery's spurious int→float merge upcast — matching the - polars engine's fast path. On cuDF the ops stay on the resident frame (a couple - semi-joins) instead of the BFS + ~31 drop_duplicates that buried the GPU win.""" +def _try_chain_fast_path( + g_in: Plottable, + ops: List[ASTObject], + engine_concrete: Engine, + start_nodes: Optional[DataFrameT] = None, +) -> Optional[Plottable]: + """Degenerate-shape fast path (pandas/cuDF): node-only ``MATCH (n)`` or a plain + single-hop ``MATCH (a)-[e]->(b)`` skip the forward/backward/combine BFS machinery. + Returns the result Plottable, or ``None`` to fall through to the full path. + + Same node/edge sets + VALUES as the full machinery (trackA_golden + hop/chain + suites); the 1-hop additionally preserves int node dtypes (the full path upcasts + int→float via merge). Gated to unnamed/unqueried nodes + a plain single-hop edge; + filtered-undirected and seeded chains fall through. polars/dask/spark also fall + through (own fast path / lazy semantics).""" from graphistry.compute.filter_by_dict import filter_by_dict - # Eager pandas/cuDF only: these direct frame ops (.iloc/.isin/concat) are - # well-defined + cheap here. polars uses its own engine_polars fast path; - # dask/spark keep the full machinery (lazy/partitioned semantics + the engine - # coercion the full path performs). if engine_concrete not in (Engine.PANDAS, Engine.CUDF): return None if start_nodes is not None: return None # seeded chains use the full path (fast path has no seed) + engine_abs = EngineAbstract(engine_concrete.value) def _materialize_fast_path_graph() -> Plottable: from graphistry.compute.ComputeMixin import _coerce_input_formats # lazy — avoids circular import @@ -707,31 +704,28 @@ def _materialize_fast_path_graph() -> Plottable: if len(ops) == 1: n0 = ops[0] - if not (isinstance(n0, ASTNode) and n0._name is None and getattr(n0, "query", None) is None): + if not (isinstance(n0, ASTNode) and n0._name is None and n0.query is None): return None g = _materialize_fast_path_graph() if g._nodes is None: return None - nodes = filter_by_dict(g._nodes, n0.filter_dict, engine_concrete) if n0.filter_dict else g._nodes + nodes = filter_by_dict(g._nodes, n0.filter_dict, engine_abs) if n0.filter_dict else g._nodes edges = g._edges.iloc[0:0] if g._edges is not None else None return g.nodes(nodes).edges(edges) if edges is not None else g.nodes(nodes) if len(ops) != 3: return None n0, e1, n2 = ops - if not (isinstance(n0, ASTNode) and n0._name is None and getattr(n0, "query", None) is None): + if not (isinstance(n0, ASTNode) and n0._name is None and n0.query is None): return None - if not (isinstance(n2, ASTNode) and n2._name is None and getattr(n2, "query", None) is None): + if not (isinstance(n2, ASTNode) and n2._name is None and n2.query is None): return None if not (isinstance(e1, ASTEdge) and e1.is_simple_single_hop() and e1.edge_match is None and e1.source_node_match is None and e1.destination_node_match is None and e1._name is None and e1.source_node_query is None and e1.destination_node_query is None and e1.edge_query is None and not e1.include_zero_hop_seed - and not e1.prune_to_endpoints): - # prune_to_endpoints keeps only the arrival-side endpoints (dest for forward, - # src for reverse); the fast path returns both endpoints, so it would diverge. - # Fall through to the full path, which honors the flag. + and not e1.prune_to_endpoints): # prune keeps only the arrival side -> full path return None direction = e1.direction unconstrained = not n0.filter_dict and not n2.filter_dict @@ -745,10 +739,10 @@ def _materialize_fast_path_graph() -> Plottable: if not unconstrained: from_col, to_col = (src, dst) if direction == "forward" else (dst, src) if n0.filter_dict: - ids = filter_by_dict(g._nodes, n0.filter_dict, engine_concrete)[node] + ids = filter_by_dict(g._nodes, n0.filter_dict, engine_abs)[node] edges = edges[edges[from_col].isin(ids)] if n2.filter_dict: - ids = filter_by_dict(g._nodes, n2.filter_dict, engine_concrete)[node] + ids = filter_by_dict(g._nodes, n2.filter_dict, engine_abs)[node] edges = edges[edges[to_col].isin(ids)] concat = df_concat(engine_concrete) endpoints = concat([ @@ -882,10 +876,8 @@ def _chain_impl( if validate_schema: validate_chain_schema(self, ops, collect_all=False) - # The fast path skips the per-op chain machinery — and with it the policy hook - # dispatch (prechain/postchain below) and per-op policy inspection. Only take it - # when no policy is attached, so policy-bearing queries keep their observable - # hook firing + op-level enforcement (correctness over the perf shortcut). + # The fast path skips the policy hook dispatch (prechain/postchain below), so only + # take it when no policy is attached — policy-bearing queries must keep hook firing. if not policy: _fast = _try_chain_fast_path(self, ops, engine_concrete, start_nodes) if _fast is not None: diff --git a/graphistry/compute/gfql/cypher/ast.py b/graphistry/compute/gfql/cypher/ast.py index 8f66e75831..0b9524fe52 100644 --- a/graphistry/compute/gfql/cypher/ast.py +++ b/graphistry/compute/gfql/cypher/ast.py @@ -2,10 +2,8 @@ from typing import Literal, Optional, Tuple, Union -# INVARIANT: every Cypher AST node below must stay DEEPLY immutable. `parse_cypher` -# results are shared by reference via an lru_cache (see `_parse_cypher_cached`), so a -# mutable field (a list/dict/`field(default_factory=...)`) would let one caller's -# in-place mutation poison every subsequent cache hit. Keep fields scalar/tuple/frozen. +# INVARIANT: keep every node deeply immutable (scalar/tuple fields only) — parse_cypher +# shares results by reference via lru_cache, so a mutable field would poison cache hits. @dataclass(frozen=True) class SourceSpan: line: int diff --git a/graphistry/compute/gfql/row/ordering.py b/graphistry/compute/gfql/row/ordering.py index d7bcdc546b..1d2523da1b 100644 --- a/graphistry/compute/gfql/row/ordering.py +++ b/graphistry/compute/gfql/row/ordering.py @@ -227,10 +227,8 @@ def parse_stringified_list_series(series: Any) -> Optional[SeriesT]: def order_detect_temporal_mode(series: Any) -> Optional[str]: if not hasattr(series, "dropna"): return None - # Temporal values are encoded as *text* (Cypher date/datetime/time literals or - # constructor calls). Numeric/bool/complex columns can never match those regexes, - # so skip the astype(str) + multi-regex fullmatch scan for them. This avoids - # spuriously stringifying numeric columns on every comparison (issue #1650). + # Temporal values are text (Cypher date/time literals/constructors); numeric/bool/ + # complex can't match those regexes, so skip the astype(str)+scan for them (#1650). dtype_kind = getattr(getattr(series, "dtype", None), "kind", None) if dtype_kind in ("i", "u", "f", "b", "c"): return None From 22ee748084b8d5f4745fd4c14fd1143804b42b63 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 28 Jun 2026 13:08:16 -0700 Subject: [PATCH 11/20] =?UTF-8?q?test(gfql):=20pin=20fast-path=E2=86=94pol?= =?UTF-8?q?icy-hook=20invariant=20+=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a regression test for the fix in 2755744a (fast path must not skip policy hooks): asserts that for the exact fast-path-eligible shapes (`[n()]` and `[n(),e(),n()]`) the prechain/postchain/postload hooks all fire when a policy is installed, and that the fast path is still taken when no policy is present. Without a guard this regression was silent (the shapes that hit the fast path are precisely the ones the policy suite exercises). Also documents the policy-gate in the fast-path CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- graphistry/tests/compute/test_chain.py | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07c1aa0f73..6cb75bb227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Performance - **GFQL temporal-detection dtype gate (#1650)**: `order_detect_temporal_mode` now short-circuits for numeric/bool/complex columns, which can never hold temporal *text*, instead of running an `astype(str)` + multi-regex `fullmatch` scan on every comparison. Eliminates spurious row-wise stringification in `where_rows`/comparison paths whose output never contains entity-text. Byte-identical results; measured `where_rows` speedups ~3.1× (pandas) and ~4.4–13.3× (cuDF, scaling with row count). Does not address whole-entity `RETURN a` text rendering, which is tracked separately. -- **GFQL generic single-hop fast path (perf, pandas + cuDF)**: a single `MATCH (n)` (node-only) or `MATCH (a {f})-[e]->(b)` (1-hop) — the dominant tabular/crossfilter + basic-graph-query shapes — now skip the forward/backward/combine BFS machinery in the generic engine: node-only returns the filtered node table; 1-hop returns the edges whose endpoints pass the node filters + those endpoint nodes. Same VALUES + node/edge sets as before (345-case adversarial golden: only dtype differs; hop/chain suites; gated to pandas/cuDF — dask/spark keep the full path). **~100× faster on pandas** (node filter 204→2 ms @10M; graph query similarly near-raw); cuDF stays on the resident frame (a couple semi-joins instead of the BFS + ~31 drop_duplicates), capturing the GPU semijoin win. **Minor behavior change:** the 1-hop now PRESERVES node-attribute dtypes (int stays int) instead of the full machinery's spurious int→float merge upcast — making pandas/cuDF consistent with the polars engine. +- **GFQL generic single-hop fast path (perf, pandas + cuDF)**: a single `MATCH (n)` (node-only) or `MATCH (a {f})-[e]->(b)` (1-hop) — the dominant tabular/crossfilter + basic-graph-query shapes — now skip the forward/backward/combine BFS machinery in the generic engine: node-only returns the filtered node table; 1-hop returns the edges whose endpoints pass the node filters + those endpoint nodes. Same VALUES + node/edge sets as before (345-case adversarial golden: only dtype differs; hop/chain suites; gated to pandas/cuDF — dask/spark keep the full path). **~100× faster on pandas** (node filter 204→2 ms @10M; graph query similarly near-raw); cuDF stays on the resident frame (a couple semi-joins instead of the BFS + ~31 drop_duplicates), capturing the GPU semijoin win. **Minor behavior change:** the 1-hop now PRESERVES node-attribute dtypes (int stays int) instead of the full machinery's spurious int→float merge upcast — making pandas/cuDF consistent with the polars engine. The fast path is automatically skipped when a GFQL `policy` is installed, so the `prechain`/`postchain`/`postload` hooks (which can observe intermediate state and deny execution) always fire on the full path — keeping the optimization observationally transparent. - **GFQL hop/chain redundant-dedup removal (perf)**: dropped the explicit `.unique()` dedup pass that fed only an `.isin()` membership test in the generic traversal — `_filter_edges_by_endpoint`, the undirected combine masks, and the per-hop wavefront filter (`compute/hop.py`, `compute/chain.py`). `isin(s) == isin(s.unique())` by set membership, so this is byte-identical (verified across 345 adversarial graph×query cases: dup/parallel edges, self-loops, isolated/dead-end nodes, cycles, undirected, multi-hop, fixed-point, min/max hops, names, filters, seeds). Each removed `.unique()` is one fewer kernel launch on GPU, where launch latency — not compute — dominates small/mid traversals: cuDF 1-hop chain ~126→103 ms @1M edges (~18% faster), pandas unaffected within noise. ### Fixed diff --git a/graphistry/tests/compute/test_chain.py b/graphistry/tests/compute/test_chain.py index 792fe1811c..9089228b62 100644 --- a/graphistry/tests/compute/test_chain.py +++ b/graphistry/tests/compute/test_chain.py @@ -595,3 +595,28 @@ def test_chain_hop_label_node_hops(): # gfql chain: combine_steps propagates columns whose name contains 'hop' g3 = g.gfql([n({'v': 'a'}), e_forward(hops=2, label_node_hops='node_hop')]) assert 'node_hop' in g3._nodes.columns, f"Expected 'node_hop' in gfql chain nodes, got: {list(g3._nodes.columns)}" + + +def test_fast_path_still_fires_policy_hooks(): + """Regression: the node-only / single-hop chain fast path (chain._try_chain_fast_path) + must NOT bypass policy hooks. The fast path returns before the prechain/postchain/ + postload block in _chain_impl, so it is only valid when no policy is installed. + With a policy present we must take the full, hook-bearing path for the SAME + fast-path-eligible shapes (``[n()]`` and ``[n(), e(), n()]``).""" + nodes_df = pd.DataFrame({'v': ['a', 'b', 'c'], 'w': ['1', '2', '3']}) + edges_df = pd.DataFrame({'s': ['a', 'b'], 'd': ['b', 'c']}) + g = CGFull().nodes(nodes_df, 'v').edges(edges_df, 's', 'd') + + for query in ([n()], [n(), e_forward(hops=1), n()]): + fired = [] + g.gfql(query, policy={ + 'prechain': lambda ctx: fired.append('prechain'), + 'postchain': lambda ctx: fired.append('postchain'), + 'postload': lambda ctx: fired.append('postload'), + }) + assert fired == ['prechain', 'postchain', 'postload'], \ + f"fast-path shape {query} bypassed hooks: got {fired}" + + # And without a policy the fast path is still taken (results unchanged). + res = g.gfql([n()]) + assert sorted(res._nodes['v'].tolist()) == ['a', 'b', 'c'] From 8fc13653ac37edcc30bb5e6184e604fb0d935eec Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 28 Jun 2026 13:59:43 -0700 Subject: [PATCH 12/20] test(gfql): amplify fast-path + dtype-gate behavior locks The chain fast path (_try_chain_fast_path) shipped with only incidental coverage (existing hop/chain/golden suites); its core behaviors were never pinned directly. Adds focused, engine-parametrized locks: - test_fast_path_differential_parity_vs_full_path: fast path output == full (policy-forced BFS) path output by node/edge SET, across every accelerated shape (node-only, node-filter, predicate, fwd/rev/undirected 1-hop, src/dst/both-end filters) AND every bypass shape (hops=2, filtered-undirected, edge_match, named node). Uses an installed no-op policy as the equivalence oracle (any policy forces the full path). - test_fast_path_preserves_int_node_dtypes: hard-locks the feature promise (1-hop fast path keeps int node attrs as int); tolerant on the full path. - test_fast_path_gating_returns_none_for_ineligible: unit-level accept/decline contract for the gate (seeds, non-eager engines, edge_match, named/queried nodes, hops>1, 2-op chains all decline). - _gfql_series_is_list_like dtype-gate: direct unit coverage (numeric/bool short-circuit; real list/tuple objects still detected; stringified lists intentionally deferred to order_detect_stringified_list_series). pandas lanes run everywhere; cuDF lanes gated on TEST_CUDF=1 (dgx-spark). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/compute/gfql/row/test_ordering.py | 27 ++++ graphistry/tests/compute/test_chain.py | 135 +++++++++++++++++- 2 files changed, 160 insertions(+), 2 deletions(-) diff --git a/graphistry/tests/compute/gfql/row/test_ordering.py b/graphistry/tests/compute/gfql/row/test_ordering.py index 41aa782102..6b4d48f59a 100644 --- a/graphistry/tests/compute/gfql/row/test_ordering.py +++ b/graphistry/tests/compute/gfql/row/test_ordering.py @@ -227,3 +227,30 @@ def test_where_rows_numeric_filter_returns_correct_rows() -> None: out = _mk_graph(nodes_df, edges_df).gfql([rows(), where_rows(expr="val > 50")])._nodes assert sorted(out["val"].tolist()) == [51, 60, 99] + + +@pytest.mark.parametrize( + "series", + [ + pd.Series([1, 2, 3], dtype="int64"), + pd.Series([1, 2, 3], dtype="uint32"), + pd.Series([1.0, 2.0], dtype="float64"), + pd.Series([True, False], dtype="bool"), + ], + ids=["int64", "uint32", "float64", "bool"], +) +def test_gfql_series_is_list_like_skips_non_listable_dtypes(series: pd.Series) -> None: + # pipeline.py sibling of the ordering gate: numeric/bool columns can never be + # list-like, so the detector short-circuits before the astype(str)+regex scan. + from graphistry.compute.gfql.row.pipeline import RowPipelineMixin + assert RowPipelineMixin._gfql_series_is_list_like(series) is False + + +def test_gfql_series_is_list_like_still_detects_real_lists() -> None: + # Gate must not regress detection of real list/tuple-valued object columns. + # (Stringified lists like "[1, 2]" are intentionally NOT list-like here — that + # case is handled by order_detect_stringified_list_series, not this helper.) + from graphistry.compute.gfql.row.pipeline import RowPipelineMixin + assert RowPipelineMixin._gfql_series_is_list_like(pd.Series([[1, 2], [3, 4]], dtype="object")) is True + assert RowPipelineMixin._gfql_series_is_list_like(pd.Series(["[1, 2]", "[3, 4]"], dtype="object")) is False + assert RowPipelineMixin._gfql_series_is_list_like(pd.Series(["abc", "def"], dtype="object")) is False diff --git a/graphistry/tests/compute/test_chain.py b/graphistry/tests/compute/test_chain.py index 9089228b62..73f69efdd9 100644 --- a/graphistry/tests/compute/test_chain.py +++ b/graphistry/tests/compute/test_chain.py @@ -2,8 +2,8 @@ import pandas as pd import pytest -from graphistry.compute.ast import ASTEdgeUndirected, ASTNode, ASTEdge, n, e, e_undirected, e_forward -from graphistry.compute.chain import Chain +from graphistry.compute.ast import ASTEdgeUndirected, ASTNode, ASTEdge, n, e, e_undirected, e_forward, e_reverse +from graphistry.compute.chain import Chain, _try_chain_fast_path from graphistry.compute.predicates.is_in import IsIn, is_in from graphistry.compute.predicates.numeric import gt from graphistry.tests.test_compute import CGFull @@ -620,3 +620,134 @@ def test_fast_path_still_fires_policy_hooks(): # And without a policy the fast path is still taken (results unchanged). res = g.gfql([n()]) assert sorted(res._nodes['v'].tolist()) == ['a', 'b', 'c'] + + +# --------------------------------------------------------------------------- +# Fast-path amplification: lock the real behaviors of _try_chain_fast_path +# (chain.py). The fast path (node-only MATCH + single-hop, pandas/cuDF) skips +# the forward/backward/combine BFS machinery, so it must be observationally +# equivalent to the full path it replaces. We use an installed (no-op) policy as +# the equivalence ORACLE: any non-empty policy forces the full, pre-fast-path +# BFS path (see _chain_impl gate), so `gfql(q)` (fast) vs `gfql(q, policy=NOOP)` +# (full) is a built-in differential — they must agree on node/edge SETS. +# --------------------------------------------------------------------------- + +_FAST_NOOP_POLICY = {'preload': lambda ctx: None} # any hook -> full (non-fast) path + + +def _cudf_or_skip(): + if not (os.environ.get("TEST_CUDF") == "1"): + pytest.skip("cuDF lane: set TEST_CUDF=1 (e.g. on dgx-spark)") + return pytest.importorskip("cudf") + + +def _fast_graph(engine): + nodes = pd.DataFrame({'v': [0, 1, 2, 3, 4], 'attr': [10, 20, 30, 40, 50]}) + edges = pd.DataFrame({'s': [0, 1, 2, 3, 0], 'd': [1, 2, 3, 4, 2], 'w': [5, 6, 7, 8, 9]}) + if engine == "cudf": + cudf = _cudf_or_skip() + nodes = cudf.DataFrame.from_pandas(nodes) + edges = cudf.DataFrame.from_pandas(edges) + return CGFull().nodes(nodes, 'v').edges(edges, 's', 'd') + + +def _setsig(r): + """Engine-agnostic (node-id set, edge (s,d) set) — values, not dtypes.""" + def topd(df): + return df.to_pandas() if df is not None and "cudf" in type(df).__module__ else df + nn = topd(r._nodes) + ee = topd(r._edges) + nodes = sorted(nn['v'].tolist()) if nn is not None else [] + edges = sorted(map(tuple, ee[['s', 'd']].itertuples(index=False, name=None))) \ + if ee is not None and len(ee) else [] + return nodes, edges + + +# shapes that ARE accelerated by the fast path +_FAST_SHAPES = [ + ("node_only", lambda: [n()]), + ("node_filter", lambda: [n({'attr': 20})]), + ("node_pred", lambda: [n({'attr': is_in([10, 30])})]), + ("hop_fwd", lambda: [n(), e_forward(hops=1), n()]), + ("hop_rev", lambda: [n(), e_reverse(hops=1), n()]), + ("hop_undirected_unconstrained", lambda: [n(), e_undirected(hops=1), n()]), + ("hop_fwd_src_filter", lambda: [n({'attr': 10}), e_forward(hops=1), n()]), + ("hop_fwd_dst_filter", lambda: [n(), e_forward(hops=1), n({'attr': 40})]), + ("hop_fwd_both_filter", lambda: [n({'attr': 10}), e_forward(hops=1), n({'attr': 30})]), + ("hop_rev_dst_filter", lambda: [n(), e_reverse(hops=1), n({'attr': 10})]), +] + +# shapes that BYPASS the fast path (still must be correct via the full path) +_BYPASS_SHAPES = [ + ("hops_2", lambda: [n(), e_forward(hops=2), n()]), + ("filtered_undirected", lambda: [n({'attr': 10}), e_undirected(hops=1), n({'attr': 30})]), + ("edge_match", lambda: [n(), e_forward(hops=1, edge_match={'w': 5}), n()]), + ("named_node", lambda: [n(name='x'), e_forward(hops=1), n()]), +] + + +@pytest.mark.parametrize("engine", ["pandas", "cudf"]) +@pytest.mark.parametrize("label,build", _FAST_SHAPES + _BYPASS_SHAPES, + ids=[s[0] for s in _FAST_SHAPES + _BYPASS_SHAPES]) +def test_fast_path_differential_parity_vs_full_path(engine, label, build): + """Fast path output == full (policy-forced BFS) path output, by node/edge SET, + for every accelerated shape AND every bypass shape, on pandas and cuDF.""" + g = _fast_graph(engine) + fast = g.gfql(build()) + full = g.gfql(build(), policy=_FAST_NOOP_POLICY) + assert _setsig(fast) == _setsig(full), f"{engine}/{label}: fast != full" + + +@pytest.mark.parametrize("engine", ["pandas", "cudf"]) +def test_fast_path_preserves_int_node_dtypes(engine): + """Documented behavior change: the 1-hop fast path PRESERVES node-attribute + dtypes (int stays int) where the full BFS path upcasts int->float via merge. + Lock both sides so neither silently regresses.""" + g = _fast_graph(engine) + q = [n(), e_forward(hops=1), n()] + fast = g.gfql(q) + full = g.gfql(q, policy=_FAST_NOOP_POLICY) + + def kind(df, col): + pdf = df.to_pandas() if "cudf" in type(df).__module__ else df + return pdf[col].dtype.kind + + # The feature's promise: the 1-hop fast path keeps int node attrs as int. + assert kind(fast._nodes, 'attr') == 'i', "fast path must keep int node attrs as int" + # The full BFS path today upcasts int->float (a known merge wart this fast path + # sidesteps). Assert only that it stays numeric, so a future full-path dtype fix + # does not break this test; the fast-path promise above is what we hard-lock. + assert kind(full._nodes, 'attr') in ('i', 'f') + # node-only never traverses a merge, so it stays int regardless of path. + assert kind(g.gfql([n()])._nodes, 'attr') == 'i' + + +def test_fast_path_gating_returns_none_for_ineligible(): + """Unit-level gate: _try_chain_fast_path must DECLINE (return None) for every + shape/condition it does not cover, so those queries reach the correct full + path. Eligible shapes must be accepted (non-None).""" + from graphistry.Engine import Engine + g = _fast_graph("pandas") + seed = pd.DataFrame({'v': [0]}) + + eligible = [ + [n()], + [n({'attr': 20})], + [n(), e_forward(hops=1), n()], + [n(), e_reverse(hops=1), n()], + ] + for ops in eligible: + assert _try_chain_fast_path(g, ops, Engine.PANDAS, None) is not None, f"should accept {ops}" + + ineligible = [ + ("hops_2", [n(), e_forward(hops=2), n()], None, Engine.PANDAS), + ("filtered_undirected", [n({'attr': 10}), e_undirected(hops=1), n({'attr': 30})], None, Engine.PANDAS), + ("edge_match", [n(), e_forward(hops=1, edge_match={'w': 5}), n()], None, Engine.PANDAS), + ("named_node", [n(name='x'), e_forward(hops=1), n()], None, Engine.PANDAS), + ("node_query", [n(query='attr > 5'), e_forward(hops=1), n()], None, Engine.PANDAS), + ("seeded", [n()], seed, Engine.PANDAS), + ("non_eager_engine", [n()], None, Engine.DASK), + ("two_ops", [n(), e_forward(hops=1)], None, Engine.PANDAS), + ] + for label, ops, sn, eng in ineligible: + assert _try_chain_fast_path(g, ops, eng, sn) is None, f"should decline {label}" From 0b5a8741eda2aefcb2aefeb0309c1bfe6bb953aa Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 28 Jun 2026 14:11:53 -0700 Subject: [PATCH 13/20] fix(gfql): keep gfql.chain otel span on chain(), not the fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new _try_chain_fast_path helper was inserted between the @otel_traced("gfql.chain") decorator and def chain(), so the decorator silently migrated onto the fast-path helper: chain() stopped emitting its span entirely, and _chain_otel_attrs (which expects chain's signature) was invoked with the fast path's positional args — binding `gfql.validate_schema` to the start_nodes DataFrame. Only surfaces with OTEL enabled (attrs are computed lazily), so CI stayed green. Move the decorator back onto chain(). Adds test_chain_otel_span_attrs_mapped_correctly: enables otel via monkeypatch, captures the span, and asserts chain_len + that validate_schema is a bool (fails under the misplaced decorator, passes with it on chain()). Co-Authored-By: Claude Opus 4.8 (1M context) --- graphistry/compute/chain.py | 2 +- graphistry/tests/compute/test_chain.py | 41 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/graphistry/compute/chain.py b/graphistry/compute/chain.py index 79b03b194e..bc64ff2cf6 100644 --- a/graphistry/compute/chain.py +++ b/graphistry/compute/chain.py @@ -673,7 +673,6 @@ def _chain_otel_attrs( return attrs -@otel_traced("gfql.chain", attrs_fn=_chain_otel_attrs) def _try_chain_fast_path( g_in: Plottable, ops: List[ASTObject], @@ -753,6 +752,7 @@ def _materialize_fast_path_graph() -> Plottable: return g.nodes(nodes).edges(edges) +@otel_traced("gfql.chain", attrs_fn=_chain_otel_attrs) def chain( self: Plottable, ops: Union[List[ASTObject], Chain], diff --git a/graphistry/tests/compute/test_chain.py b/graphistry/tests/compute/test_chain.py index 73f69efdd9..8c10d575ae 100644 --- a/graphistry/tests/compute/test_chain.py +++ b/graphistry/tests/compute/test_chain.py @@ -751,3 +751,44 @@ def test_fast_path_gating_returns_none_for_ineligible(): ] for label, ops, sn, eng in ineligible: assert _try_chain_fast_path(g, ops, eng, sn) is None, f"should decline {label}" + + +def test_chain_otel_span_attrs_mapped_correctly(monkeypatch): + """Regression: the `gfql.chain` otel decorator must wrap `chain()`, not the + `_try_chain_fast_path` helper defined just above it. If it drifts onto the + fast path, `_chain_otel_attrs` receives the fast path's positional args + (g_in, ops, engine_concrete, start_nodes) so `gfql.validate_schema` gets bound + to start_nodes (a DataFrame/None) and `chain()` itself emits no span. + Enable otel + detail, capture spans, and assert correct attr mapping.""" + import importlib + import graphistry.compute.chain as chain_mod + from contextlib import contextmanager + # `import graphistry.otel` binds to a shadowing client attr, so resolve the + # real module via importlib. otel_enabled/otel_span are looked up in the otel + # module (inside otel_traced's wrapper); otel_detail_enabled is looked up in + # chain.py (inside _chain_otel_attrs). Patch each in its own namespace. + otel_mod = importlib.import_module("graphistry.otel") + + captured = [] + monkeypatch.setattr(otel_mod, "otel_enabled", lambda: True) + monkeypatch.setattr(chain_mod, "otel_detail_enabled", lambda: True) + + @contextmanager + def _fake_span(name, attrs=None): + captured.append((name, attrs or {})) + yield None + + monkeypatch.setattr(otel_mod, "otel_span", _fake_span) + + g = CGFull().nodes(pd.DataFrame({'v': [0, 1, 2]}), 'v').edges( + pd.DataFrame({'s': [0, 1], 'd': [1, 2]}), 's', 'd') + g.gfql([n()]) # fast-path-eligible shape + + chain_spans = [a for (nm, a) in captured if nm == "gfql.chain"] + assert chain_spans, "chain() must emit a gfql.chain span" + attrs = chain_spans[0] + assert attrs.get("gfql.chain_len") == 1 + # The bug bound validate_schema to start_nodes (None / a DataFrame); the + # correct mapping is the bool default. + assert isinstance(attrs.get("gfql.validate_schema"), bool), \ + f"validate_schema attr must be a bool, got {type(attrs.get('gfql.validate_schema'))}" From dd6627c8147954e1557da9559ca27e2f30ba7cc6 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 28 Jun 2026 14:11:53 -0700 Subject: [PATCH 14/20] refactor(gfql): single source of truth for the dtype-gate kind set The #1650/#1651 dtype gate (numeric/bool/complex columns skip the spurious astype(str)+regex list/temporal scan) was duplicated across 4 sites with 3 different sentinel conventions ("O", None, factored). Consolidate the kind set into is_non_textual_scalar_dtype() in series_str_compat.py (the engine-poly Series-string layer these gates short-circuit) and call it from ordering.py (x2), pipeline.py, and cypher/result_postprocess.py. Byte-identical; adds a direct parametrized unit test for the helper across dtype kinds + None. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../compute/gfql/cypher/result_postprocess.py | 4 ++-- graphistry/compute/gfql/row/ordering.py | 7 +++--- graphistry/compute/gfql/row/pipeline.py | 5 ++--- graphistry/compute/gfql/series_str_compat.py | 19 ++++++++++++++++ .../tests/compute/gfql/row/test_ordering.py | 22 +++++++++++++++++++ 5 files changed, 48 insertions(+), 9 deletions(-) diff --git a/graphistry/compute/gfql/cypher/result_postprocess.py b/graphistry/compute/gfql/cypher/result_postprocess.py index fdcffaf589..d8b0103bc0 100644 --- a/graphistry/compute/gfql/cypher/result_postprocess.py +++ b/graphistry/compute/gfql/cypher/result_postprocess.py @@ -7,6 +7,7 @@ from graphistry.Plottable import Plottable from graphistry.compute.typing import DataFrameT, SeriesT +from graphistry.compute.gfql.series_str_compat import is_non_textual_scalar_dtype from .lowering import ResultProjectionColumn, ResultProjectionPlan from graphistry.compute.gfql.row.entity_props import ( @@ -127,8 +128,7 @@ def _project_property_column( # complex columns can never hold temporal text, so skip the (otherwise spurious) # ``astype(str)`` + detection scan and return the column as-is — byte-identical, # since the scan returns None for these dtypes. Mirrors the #1650/#1651 gate. - _dtype = getattr(series, "dtype", None) - if _dtype is not None and getattr(_dtype, "kind", "O") in ("i", "u", "f", "b", "c"): + if is_non_textual_scalar_dtype(getattr(series, "dtype", None)): return series if hasattr(series, "astype") and hasattr(cast(SeriesT, series.astype(str)), "str"): normalized = _normalize_temporal_constructor_series( diff --git a/graphistry/compute/gfql/row/ordering.py b/graphistry/compute/gfql/row/ordering.py index 1d2523da1b..20a28d329a 100644 --- a/graphistry/compute/gfql/row/ordering.py +++ b/graphistry/compute/gfql/row/ordering.py @@ -12,6 +12,7 @@ from graphistry.compute.typing import SeriesT from graphistry.compute.gfql.series_str_compat import ( + is_non_textual_scalar_dtype, series_sequence_len, series_str_extract, series_str_fullmatch, @@ -120,8 +121,7 @@ def _is_non_listable_dtype(series: Any) -> bool: list-syntax *text*. Lets list/temporal detection skip the spurious ``astype(str)`` + regex scan on these columns (byte-identical — the scan returns False/None for them anyway). Mirrors the #1650/#1651 dtype gate.""" - dtype = getattr(series, "dtype", None) - return dtype is not None and getattr(dtype, "kind", "O") in ("i", "u", "f", "b", "c") + return is_non_textual_scalar_dtype(getattr(series, "dtype", None)) def order_detect_list_series(series: Any) -> bool: @@ -229,8 +229,7 @@ def order_detect_temporal_mode(series: Any) -> Optional[str]: return None # Temporal values are text (Cypher date/time literals/constructors); numeric/bool/ # complex can't match those regexes, so skip the astype(str)+scan for them (#1650). - dtype_kind = getattr(getattr(series, "dtype", None), "kind", None) - if dtype_kind in ("i", "u", "f", "b", "c"): + if is_non_textual_scalar_dtype(getattr(series, "dtype", None)): return None non_null = series.dropna() if len(non_null) == 0 or not hasattr(non_null, "astype"): diff --git a/graphistry/compute/gfql/row/pipeline.py b/graphistry/compute/gfql/row/pipeline.py index 5fbca351f8..8aa3a7a1ba 100644 --- a/graphistry/compute/gfql/row/pipeline.py +++ b/graphistry/compute/gfql/row/pipeline.py @@ -59,7 +59,7 @@ is_entity_text_scalar, ) from graphistry.compute.gfql.same_path_types import NODE_IDENTITY_COLUMN -from graphistry.compute.gfql.series_str_compat import series_sequence_len, series_str_match +from graphistry.compute.gfql.series_str_compat import is_non_textual_scalar_dtype, series_sequence_len, series_str_match from graphistry.compute.gfql.row.ordering import ( build_list_sort_columns, build_temporal_sort_columns, @@ -1994,8 +1994,7 @@ def _gfql_series_is_list_like(series: Any) -> bool: return False # numeric/bool/complex columns can never be list-like — skip the spurious # astype(str)+regex scan (byte-identical; the scan returns False anyway). - _dt = getattr(series, "dtype", None) - if _dt is not None and getattr(_dt, "kind", "O") in ("i", "u", "f", "b", "c"): + if is_non_textual_scalar_dtype(getattr(series, "dtype", None)): return False null_mask = series.isna() non_null = ~null_mask diff --git a/graphistry/compute/gfql/series_str_compat.py b/graphistry/compute/gfql/series_str_compat.py index dc5ffbda26..dd936d2133 100644 --- a/graphistry/compute/gfql/series_str_compat.py +++ b/graphistry/compute/gfql/series_str_compat.py @@ -8,6 +8,25 @@ from graphistry.compute.typing import SeriesT +# Value dtypes whose elements are scalar numbers/booleans — never list-like and +# never list/temporal *text*. The detection scans below (astype(str) + regex) +# always return False/None for these, so callers short-circuit before paying for +# the scan. Single source of truth for the #1650/#1651 dtype gate (shared by the +# row pipeline + cypher result post-processing). Mirrors the kind set in +# graphistry.compute.filter_by_dict._is_numeric_dtype_safe. +_NON_TEXTUAL_SCALAR_KINDS = frozenset({"i", "u", "f", "b", "c"}) + + +def is_non_textual_scalar_dtype(dtype: Any) -> bool: + """True for numeric/bool/complex dtypes (whose values can never be list-like or + list/temporal text), so list/temporal detection can skip the spurious + ``astype(str)`` + regex scan — byte-identical, since the scan returns + False/None for these dtypes anyway.""" + if dtype is None: + return False + return getattr(dtype, "kind", "O") in _NON_TEXTUAL_SCALAR_KINDS + + def _fill_string_mask_na(mask: Any, series: Any, na: Optional[bool]) -> Any: if na is None or not hasattr(mask, "where") or not hasattr(series, "isna"): return mask diff --git a/graphistry/tests/compute/gfql/row/test_ordering.py b/graphistry/tests/compute/gfql/row/test_ordering.py index 6b4d48f59a..8063130ed9 100644 --- a/graphistry/tests/compute/gfql/row/test_ordering.py +++ b/graphistry/tests/compute/gfql/row/test_ordering.py @@ -254,3 +254,25 @@ def test_gfql_series_is_list_like_still_detects_real_lists() -> None: assert RowPipelineMixin._gfql_series_is_list_like(pd.Series([[1, 2], [3, 4]], dtype="object")) is True assert RowPipelineMixin._gfql_series_is_list_like(pd.Series(["[1, 2]", "[3, 4]"], dtype="object")) is False assert RowPipelineMixin._gfql_series_is_list_like(pd.Series(["abc", "def"], dtype="object")) is False + + +@pytest.mark.parametrize( + "dtype_str,expected", + [ + ("int64", True), ("uint32", True), ("int8", True), ("float32", True), + ("float64", True), ("bool", True), ("complex128", True), + ("object", False), ("datetime64[ns]", False), ("timedelta64[ns]", False), + (" None: + # Single source of truth for the #1650/#1651 dtype gate (shared by ordering, + # pipeline, and cypher result post-processing). + import numpy as np + from graphistry.compute.gfql.series_str_compat import is_non_textual_scalar_dtype + assert is_non_textual_scalar_dtype(np.dtype(dtype_str)) is expected + + +def test_is_non_textual_scalar_dtype_none() -> None: + from graphistry.compute.gfql.series_str_compat import is_non_textual_scalar_dtype + assert is_non_textual_scalar_dtype(None) is False From bd6e3ee5cb7f3eb81466eb937e2207f0dc04ac31 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 28 Jun 2026 14:24:47 -0700 Subject: [PATCH 15/20] fix(gfql): fast path must drop edges to nodes absent from the node table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1-hop fast path took `edges = g._edges` wholesale and only intersected filtered endpoints with the node table for the NODE output — it never restricted the EDGES to those whose endpoints exist in the node table. The full BFS path enforces that implicitly via its edge<->node joins, so the two diverged whenever a supplied node table omits an edge endpoint (filtered/subset node frames, or nodes+edges loaded separately): nodes v=[0,1], edges (s,d)=[(0,1),(1,99)] # 99 absent [n(), e_forward(hops=1), n()] fast kept (1,99); full dropped it [n({'attr':2}), e_forward, n()] fast -> nodes[1] edges[(1,99)]; full -> EMPTY Restrict edges to both-endpoints-present before the endpoint filters, and dedup node ids on the result (the full path collapses dup rows via its merge). Now fast == full across dangling-endpoint and duplicate-id graphs; no change on well-formed graphs. Found by wave-2 differential review; tests added for both. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphistry/compute/chain.py | 12 ++++++++- graphistry/tests/compute/test_chain.py | 34 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/graphistry/compute/chain.py b/graphistry/compute/chain.py index bc64ff2cf6..58b050c417 100644 --- a/graphistry/compute/chain.py +++ b/graphistry/compute/chain.py @@ -734,7 +734,14 @@ def _materialize_fast_path_graph() -> Plottable: if g._nodes is None or g._edges is None: return None src, dst, node = g._source, g._destination, g._node - edges = g._edges + # Restrict to edges whose BOTH endpoints exist in the node table. The full BFS + # path enforces this implicitly via its edge<->node joins, so a supplied node + # table that omits some edge endpoints (a filtered/subset node frame, or nodes + # and edges loaded separately) must drop those dangling edges — otherwise the + # fast path emits edges to non-existent nodes, or a non-empty result where the + # full path is correctly empty. + node_ids = g._nodes[node] + edges = g._edges[g._edges[src].isin(node_ids) & g._edges[dst].isin(node_ids)] if not unconstrained: from_col, to_col = (src, dst) if direction == "forward" else (dst, src) if n0.filter_dict: @@ -749,6 +756,9 @@ def _materialize_fast_path_graph() -> Plottable: edges[[dst]].rename(columns={dst: node}), ]).drop_duplicates() nodes = g._nodes[g._nodes[node].isin(endpoints[node])] + # The full path collapses duplicate node-id rows via its merge; match that so a + # malformed node table with duplicate ids does not diverge. + nodes = nodes.drop_duplicates(subset=[node]) return g.nodes(nodes).edges(edges) diff --git a/graphistry/tests/compute/test_chain.py b/graphistry/tests/compute/test_chain.py index 8c10d575ae..b00a2fb6ad 100644 --- a/graphistry/tests/compute/test_chain.py +++ b/graphistry/tests/compute/test_chain.py @@ -792,3 +792,37 @@ def _fake_span(name, attrs=None): # correct mapping is the bool default. assert isinstance(attrs.get("gfql.validate_schema"), bool), \ f"validate_schema attr must be a bool, got {type(attrs.get('gfql.validate_schema'))}" + + +@pytest.mark.parametrize("engine", ["pandas", "cudf"]) +def test_fast_path_drops_edges_to_absent_nodes(engine): + """The 1-hop fast path must drop edges whose endpoints are not in the node + table (the full BFS path does, via its edge<->node joins). A node table that + omits an edge endpoint must not yield dangling edges — nor a non-empty result + where the full path is empty.""" + nodes = pd.DataFrame({'v': [0, 1], 'attr': [1, 2]}) + edges = pd.DataFrame({'s': [0, 1], 'd': [1, 99]}) # 99 absent from nodes + if engine == "cudf": + cudf = _cudf_or_skip() + nodes, edges = cudf.DataFrame.from_pandas(nodes), cudf.DataFrame.from_pandas(edges) + g = CGFull().nodes(nodes, 'v').edges(edges, 's', 'd') + for q in ([n(), e_forward(hops=1), n()], + [n(), e_reverse(hops=1), n()], + [n(), e_undirected(hops=1), n()], + [n({'attr': 2}), e_forward(hops=1), n()]): + assert _setsig(g.gfql(q)) == _setsig(g.gfql(q, policy=_FAST_NOOP_POLICY)), \ + f"dangling-edge divergence for {q}" + + +@pytest.mark.parametrize("engine", ["pandas", "cudf"]) +def test_fast_path_dedups_duplicate_node_ids_on_hop(engine): + """A malformed node table with duplicate ids must not make the 1-hop fast path + diverge from the full path (which collapses dup rows via its merge).""" + nodes = pd.DataFrame({'v': [0, 0, 1, 2], 'attr': [1, 1, 2, 3]}) + edges = pd.DataFrame({'s': [0, 1], 'd': [1, 2]}) + if engine == "cudf": + cudf = _cudf_or_skip() + nodes, edges = cudf.DataFrame.from_pandas(nodes), cudf.DataFrame.from_pandas(edges) + g = CGFull().nodes(nodes, 'v').edges(edges, 's', 'd') + q = [n(), e_forward(hops=1), n()] + assert _setsig(g.gfql(q)) == _setsig(g.gfql(q, policy=_FAST_NOOP_POLICY)) From 76beaa6b04e3b8e92f48eac412a06796b0a35148 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 28 Jun 2026 14:33:19 -0700 Subject: [PATCH 16/20] fix(gfql): fast path endpoint check must not match NaN<->NaN The wave-2 both-endpoints-present restriction used `.isin(node_ids)`, but pandas/cuDF `.isin` treat NaN as a matchable value (`NaN.isin([NaN])` is True). So a NaN node id "validated" a NaN edge endpoint and the fast path kept a dangling NaN edge that the full BFS path's joins (which never match NaN<->NaN) correctly drop. dropna() on node_ids before the isin restores convergence for forward/reverse on NaN-id graphs; well-formed and non-null-dangling graphs are unchanged. (NaN ids are malformed input; the full path's undirected branch is itself inconsistent there, so we match its directed/consistent behavior.) Co-Authored-By: Claude Opus 4.8 (1M context) --- graphistry/compute/chain.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/graphistry/compute/chain.py b/graphistry/compute/chain.py index 58b050c417..78a1dda0f6 100644 --- a/graphistry/compute/chain.py +++ b/graphistry/compute/chain.py @@ -740,7 +740,10 @@ def _materialize_fast_path_graph() -> Plottable: # and edges loaded separately) must drop those dangling edges — otherwise the # fast path emits edges to non-existent nodes, or a non-empty result where the # full path is correctly empty. - node_ids = g._nodes[node] + # dropna: a NaN node id must NOT validate a NaN edge endpoint. pandas/cuDF + # `.isin` treat NaN as a matchable value (NaN.isin([NaN]) is True), but the + # full BFS path's joins never match NaN<->NaN, so it drops NaN-endpoint edges. + node_ids = g._nodes[node].dropna() edges = g._edges[g._edges[src].isin(node_ids) & g._edges[dst].isin(node_ids)] if not unconstrained: from_col, to_col = (src, dst) if direction == "forward" else (dst, src) From c2cc99c6cff5cac492694f7cfb88f8ff2b614b2b Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 28 Jun 2026 14:34:35 -0700 Subject: [PATCH 17/20] docs(changelog): note fast-path correctness guarantees (dangling edges, dup ids, NaN) Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cb75bb227..e36e44d59e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Performance - **GFQL temporal-detection dtype gate (#1650)**: `order_detect_temporal_mode` now short-circuits for numeric/bool/complex columns, which can never hold temporal *text*, instead of running an `astype(str)` + multi-regex `fullmatch` scan on every comparison. Eliminates spurious row-wise stringification in `where_rows`/comparison paths whose output never contains entity-text. Byte-identical results; measured `where_rows` speedups ~3.1× (pandas) and ~4.4–13.3× (cuDF, scaling with row count). Does not address whole-entity `RETURN a` text rendering, which is tracked separately. -- **GFQL generic single-hop fast path (perf, pandas + cuDF)**: a single `MATCH (n)` (node-only) or `MATCH (a {f})-[e]->(b)` (1-hop) — the dominant tabular/crossfilter + basic-graph-query shapes — now skip the forward/backward/combine BFS machinery in the generic engine: node-only returns the filtered node table; 1-hop returns the edges whose endpoints pass the node filters + those endpoint nodes. Same VALUES + node/edge sets as before (345-case adversarial golden: only dtype differs; hop/chain suites; gated to pandas/cuDF — dask/spark keep the full path). **~100× faster on pandas** (node filter 204→2 ms @10M; graph query similarly near-raw); cuDF stays on the resident frame (a couple semi-joins instead of the BFS + ~31 drop_duplicates), capturing the GPU semijoin win. **Minor behavior change:** the 1-hop now PRESERVES node-attribute dtypes (int stays int) instead of the full machinery's spurious int→float merge upcast — making pandas/cuDF consistent with the polars engine. The fast path is automatically skipped when a GFQL `policy` is installed, so the `prechain`/`postchain`/`postload` hooks (which can observe intermediate state and deny execution) always fire on the full path — keeping the optimization observationally transparent. +- **GFQL generic single-hop fast path (perf, pandas + cuDF)**: a single `MATCH (n)` (node-only) or `MATCH (a {f})-[e]->(b)` (1-hop) — the dominant tabular/crossfilter + basic-graph-query shapes — now skip the forward/backward/combine BFS machinery in the generic engine: node-only returns the filtered node table; 1-hop returns the edges whose endpoints pass the node filters + those endpoint nodes. Same VALUES + node/edge sets as before (345-case adversarial golden: only dtype differs; hop/chain suites; gated to pandas/cuDF — dask/spark keep the full path). **~100× faster on pandas** (node filter 204→2 ms @10M; graph query similarly near-raw); cuDF stays on the resident frame (a couple semi-joins instead of the BFS + ~31 drop_duplicates), capturing the GPU semijoin win. **Minor behavior change:** the 1-hop now PRESERVES node-attribute dtypes (int stays int) instead of the full machinery's spurious int→float merge upcast — making pandas/cuDF consistent with the polars engine. The fast path is automatically skipped when a GFQL `policy` is installed, so the `prechain`/`postchain`/`postload` hooks (which can observe intermediate state and deny execution) always fire on the full path — keeping the optimization observationally transparent. Differential-verified equivalent to the full BFS path across 440 random well-formed graph×query cases plus targeted edge cases: it drops edges to nodes absent from the node table and dedups duplicate node ids (matching the full path's join semantics), and a NaN node id never validates a NaN edge endpoint. - **GFQL hop/chain redundant-dedup removal (perf)**: dropped the explicit `.unique()` dedup pass that fed only an `.isin()` membership test in the generic traversal — `_filter_edges_by_endpoint`, the undirected combine masks, and the per-hop wavefront filter (`compute/hop.py`, `compute/chain.py`). `isin(s) == isin(s.unique())` by set membership, so this is byte-identical (verified across 345 adversarial graph×query cases: dup/parallel edges, self-loops, isolated/dead-end nodes, cycles, undirected, multi-hop, fixed-point, min/max hops, names, filters, seeds). Each removed `.unique()` is one fewer kernel launch on GPU, where launch latency — not compute — dominates small/mid traversals: cuDF 1-hop chain ~126→103 ms @1M edges (~18% faster), pandas unaffected within noise. ### Fixed From 0eaa34e3e1ff2fcc51018eb6e390fa07a889c2e8 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 28 Jun 2026 16:11:40 -0700 Subject: [PATCH 18/20] test(gfql): pin prune/NaN fast-path invariants, dtype-gate drift guard; expr AST immutability note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardening tests for the fast-path + dtype-gate optimizations: - prune_to_endpoints: add to the gating-decline list and as bypass shapes (fwd+rev) in the fast-vs-full differential — regression guard for the fast-path prune gate. - NaN endpoints: new test_fast_path_drops_nan_endpoint_edges pins that a NaN node id does not validate a NaN edge endpoint (the .dropna() guard), matching the full BFS path. - Tighten the differential test: bypass shapes now also assert they DECLINE the fast path (previously full-vs-full, vacuous for bypass cases). - Drift guard: assert filter_by_dict._is_numeric_dtype_safe agrees with the series_str_compat._NON_TEXTUAL_SCALAR_KINDS SSOT across all numpy dtype kinds (the two keep separate copies to avoid an import cycle; they must not desync). - cuDF lane for the numeric where_rows dtype-gate path (paired-coverage convention). Also document the deep-immutability invariant on ExprNode (parse_expr lru_cache shares results by reference). Co-Authored-By: Claude Opus 4.8 (1M context) --- graphistry/compute/gfql/expr_parser.py | 4 ++ .../tests/compute/gfql/row/test_ordering.py | 34 +++++++++++++++++ graphistry/tests/compute/test_chain.py | 37 ++++++++++++++++++- 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/graphistry/compute/gfql/expr_parser.py b/graphistry/compute/gfql/expr_parser.py index f3459327d6..d37b34bade 100644 --- a/graphistry/compute/gfql/expr_parser.py +++ b/graphistry/compute/gfql/expr_parser.py @@ -21,6 +21,10 @@ DEFAULT_ALLOWED_QUANTIFIERS: FrozenSet[str] = GFQL_ALLOWED_QUANTIFIERS +# INVARIANT: every ExprNode below must stay DEEPLY immutable. `parse_expr` results +# are shared by reference via an lru_cache (see `_parse_expr_cached`), so a mutable +# field (a list/dict/`field(default_factory=...)`) would let one caller's in-place +# mutation poison every subsequent cache hit. Keep fields scalar/tuple/frozen. @dataclass(frozen=True) class Identifier: name: str diff --git a/graphistry/tests/compute/gfql/row/test_ordering.py b/graphistry/tests/compute/gfql/row/test_ordering.py index 8063130ed9..38e222376d 100644 --- a/graphistry/tests/compute/gfql/row/test_ordering.py +++ b/graphistry/tests/compute/gfql/row/test_ordering.py @@ -229,6 +229,20 @@ def test_where_rows_numeric_filter_returns_correct_rows() -> None: assert sorted(out["val"].tolist()) == [51, 60, 99] +@pytest.mark.skipif("TEST_CUDF" not in __import__("os").environ, reason="cuDF lane: set TEST_CUDF=1 (e.g. dgx-spark)") +def test_where_rows_numeric_filter_returns_correct_rows_cudf() -> None: + # cuDF lane for the numeric dtype-gate end-to-end path: the gate is pure + # dtype.kind inspection (engine-agnostic), but compute/gfql/row needs paired + # cuDF coverage per the review conventions. + cudf = pytest.importorskip("cudf") + from graphistry.compute.ast import where_rows + nodes_df = cudf.DataFrame({"id": [0, 1, 2, 3], "val": [10, 60, 51, 99]}) + edges_df = cudf.DataFrame({"s": [0, 1], "d": [2, 3]}) + out = CGFull().nodes(nodes_df, "id").edges(edges_df, "s", "d").gfql( + [rows(), where_rows(expr="val > 50")])._nodes + assert sorted(out["val"].to_pandas().tolist()) == [51, 60, 99] + + @pytest.mark.parametrize( "series", [ @@ -276,3 +290,23 @@ def test_is_non_textual_scalar_dtype(dtype_str: str, expected: bool) -> None: def test_is_non_textual_scalar_dtype_none() -> None: from graphistry.compute.gfql.series_str_compat import is_non_textual_scalar_dtype assert is_non_textual_scalar_dtype(None) is False + + +def test_dtype_gate_kind_set_matches_filter_by_dict_mirror() -> None: + """Drift guard: `filter_by_dict._is_numeric_dtype_safe` keeps a SEPARATE copy of + the dtype-kind set that `series_str_compat._NON_TEXTUAL_SCALAR_KINDS` is the SSOT + for (a cross-layer import would risk an import cycle, so the copy stays — but it + MUST NOT drift). Assert the two agree across every numpy dtype kind, so a future + edit to one set fails here instead of silently desyncing the gate.""" + import numpy as np + from graphistry.compute.gfql.series_str_compat import is_non_textual_scalar_dtype + from graphistry.compute.filter_by_dict import _is_numeric_dtype_safe + # One representative dtype per numpy kind, incl. text/temporal/extension kinds. + samples = [ + np.dtype("int64"), np.dtype("uint8"), np.dtype("float64"), np.dtype("bool"), + np.dtype("complex128"), np.dtype("object"), np.dtype("datetime64[ns]"), + np.dtype("timedelta64[ns]"), np.dtype("NaN, so it drops NaN-endpoint edges. The fast path's `.dropna()` + on the node-id column must keep it consistent. Regression guard for the NaN fix.""" + import numpy as np + nodes = pd.DataFrame({'v': [0.0, 1.0, np.nan], 'attr': [1, 2, 3]}) # NaN node id present + edges = pd.DataFrame({'s': [0.0, 1.0], 'd': [1.0, np.nan]}) # NaN destination endpoint + if engine == "cudf": + cudf = _cudf_or_skip() + nodes, edges = cudf.DataFrame.from_pandas(nodes), cudf.DataFrame.from_pandas(edges) + g = CGFull().nodes(nodes, 'v').edges(edges, 's', 'd') + for q in ([n(), e_forward(hops=1), n()], [n(), e_reverse(hops=1), n()]): + assert _setsig(g.gfql(q)) == _setsig(g.gfql(q, policy=_FAST_NOOP_POLICY)), \ + f"NaN-endpoint divergence for {q}" + + @pytest.mark.parametrize("engine", ["pandas", "cudf"]) def test_fast_path_dedups_duplicate_node_ids_on_hop(engine): """A malformed node table with duplicate ids must not make the 1-hop fast path From 6fe62f77306751f3bfe07aeff1d1a1b4f53b0666 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 28 Jun 2026 17:11:49 -0700 Subject: [PATCH 19/20] refactor(gfql): trim dtype-gate + fast-path comments, tighten SSOT note (#1657) Code-quality pass on the parse-cache layer: - Condense the verbose dropna/dangling-edge and dup-node fast-path comments to terse one/two-liners; same for the SSOT dtype-kind note and the ExprNode immutability invariant. - Note in the SSOT comment that filter_by_dict's mirror is kept in sync by a test (not imported) to avoid an import cycle. No behavior change; mypy + ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphistry/compute/chain.py | 15 ++++----------- graphistry/compute/gfql/expr_parser.py | 6 ++---- graphistry/compute/gfql/series_str_compat.py | 11 +++++------ 3 files changed, 11 insertions(+), 21 deletions(-) diff --git a/graphistry/compute/chain.py b/graphistry/compute/chain.py index 78a1dda0f6..cf99056794 100644 --- a/graphistry/compute/chain.py +++ b/graphistry/compute/chain.py @@ -734,15 +734,9 @@ def _materialize_fast_path_graph() -> Plottable: if g._nodes is None or g._edges is None: return None src, dst, node = g._source, g._destination, g._node - # Restrict to edges whose BOTH endpoints exist in the node table. The full BFS - # path enforces this implicitly via its edge<->node joins, so a supplied node - # table that omits some edge endpoints (a filtered/subset node frame, or nodes - # and edges loaded separately) must drop those dangling edges — otherwise the - # fast path emits edges to non-existent nodes, or a non-empty result where the - # full path is correctly empty. - # dropna: a NaN node id must NOT validate a NaN edge endpoint. pandas/cuDF - # `.isin` treat NaN as a matchable value (NaN.isin([NaN]) is True), but the - # full BFS path's joins never match NaN<->NaN, so it drops NaN-endpoint edges. + # Keep only edges with BOTH endpoints in the node table (the full path drops + # dangling edges via its joins). dropna so a NaN node id can't validate a NaN + # endpoint — .isin treats NaN as matchable but the BFS joins never match NaN<->NaN. node_ids = g._nodes[node].dropna() edges = g._edges[g._edges[src].isin(node_ids) & g._edges[dst].isin(node_ids)] if not unconstrained: @@ -759,8 +753,7 @@ def _materialize_fast_path_graph() -> Plottable: edges[[dst]].rename(columns={dst: node}), ]).drop_duplicates() nodes = g._nodes[g._nodes[node].isin(endpoints[node])] - # The full path collapses duplicate node-id rows via its merge; match that so a - # malformed node table with duplicate ids does not diverge. + # match the full path's merge, which collapses duplicate node-id rows nodes = nodes.drop_duplicates(subset=[node]) return g.nodes(nodes).edges(edges) diff --git a/graphistry/compute/gfql/expr_parser.py b/graphistry/compute/gfql/expr_parser.py index d37b34bade..0dcd30c55d 100644 --- a/graphistry/compute/gfql/expr_parser.py +++ b/graphistry/compute/gfql/expr_parser.py @@ -21,10 +21,8 @@ DEFAULT_ALLOWED_QUANTIFIERS: FrozenSet[str] = GFQL_ALLOWED_QUANTIFIERS -# INVARIANT: every ExprNode below must stay DEEPLY immutable. `parse_expr` results -# are shared by reference via an lru_cache (see `_parse_expr_cached`), so a mutable -# field (a list/dict/`field(default_factory=...)`) would let one caller's in-place -# mutation poison every subsequent cache hit. Keep fields scalar/tuple/frozen. +# INVARIANT: keep every ExprNode deeply immutable (scalar/tuple fields only) — parse_expr +# shares results by reference via lru_cache, so a mutable field would poison cache hits. @dataclass(frozen=True) class Identifier: name: str diff --git a/graphistry/compute/gfql/series_str_compat.py b/graphistry/compute/gfql/series_str_compat.py index dd936d2133..56eebd58da 100644 --- a/graphistry/compute/gfql/series_str_compat.py +++ b/graphistry/compute/gfql/series_str_compat.py @@ -8,12 +8,11 @@ from graphistry.compute.typing import SeriesT -# Value dtypes whose elements are scalar numbers/booleans — never list-like and -# never list/temporal *text*. The detection scans below (astype(str) + regex) -# always return False/None for these, so callers short-circuit before paying for -# the scan. Single source of truth for the #1650/#1651 dtype gate (shared by the -# row pipeline + cypher result post-processing). Mirrors the kind set in -# graphistry.compute.filter_by_dict._is_numeric_dtype_safe. +# Scalar number/bool dtypes — never list-like, never list/temporal text — so the +# detection scans (astype(str)+regex) always return False/None and callers can skip +# them. SSOT for the #1650/#1651 dtype gate; mirrored in +# filter_by_dict._is_numeric_dtype_safe (kept in sync by a test, not imported, to +# avoid an import cycle). _NON_TEXTUAL_SCALAR_KINDS = frozenset({"i", "u", "f", "b", "c"}) From be4b2d5ca74c2c8d559d02a4629186bce1f1bfc4 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 28 Jun 2026 13:28:55 -0700 Subject: [PATCH 20/20] fix(gfql): preserve node-id binding on edges-only chain full path (+ policy-hook guard test) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-cause fix for a real bug surfaced while hardening the fast-path/policy work: A chain/Cypher query over an edges-only graph (no node-id binding, `g._node is None`) takes the full traversal path — the node-only/single-hop fast path is skipped whenever an edge-id index must be synthesized (`added_edge_index`), or when a policy is attached. That path rebuilt `g_out` from `self` (the *unbound* input) to restore the original edge binding, but `self._node is None`, so the materialized node-id binding was dropped. The endpoint-reconciliation concat then synthesized a spurious `None`-named node column — a corrupt result on older pandas, and a hard `NotImplementedError` (void-block NA fill) on newer pandas (Python 3.14). Fix: carry the materialized `g._node` binding explicitly (`self.nodes(final_nodes_df, g._node)`) while still restoring the original edge binding. Full-path output now matches the fast path: correct single node column, valid `_node`, edge binding preserved. Pre-existing on master (not introduced by this PR's fast paths). Also adds a parametrized regression test pinning that BOTH chain fast-path shapes (node-only `[n()]` and single-hop `[n(),e(),n()]`) still fire `postload` under a policy AND return a valid, non-corrupt result on an edges-only graph — guarding both the policy-hook gate (2755744a) and this node-binding fix. CHANGELOG: documents both. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 +++ graphistry/compute/chain.py | 9 ++++++++- graphistry/tests/test_policy_hooks.py | 28 +++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e36e44d59e..04d31f5bb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed - **GFQL single-hop fast path ignored `prune_to_endpoints`**: the generic single-hop fast path accepted edges with `prune_to_endpoints=True` (a public `e()/e_forward()/e_reverse()` kwarg) but returned BOTH endpoints, whereas the flag keeps only the arrival side (destinations for forward, sources for reverse). A query like `[n(), e_forward(prune_to_endpoints=True), n()]` silently returned the wrong node/edge set. The fast path now declines this shape and falls through to the full path, which honors the flag. +- **GFQL whole-entity `RETURN a, a.val` emitted a duplicate column**: flattening a whole entity `a` into `a.id, a.val, …` (#1650) shares the `{alias}.{field}` namespace with an explicit property projection, so `RETURN a, a.val` produced two `a.val` columns — a duplicate-named column that breaks column selection and silently drops data on `to_dict`/serialization. The duplicate (always identical data, since dotted backtick aliases are rejected) is now collapsed to a single column, keeping first occurrence. +- **GFQL chain on edges-only graphs (no node binding)**: A chain/Cypher query over a graph with edges but no node-id binding (`g._node is None`) that took the full traversal path (any fast-path-ineligible shape — multi-hop, named/queried/`prune_to_endpoints` edges — or any query with a policy attached) rebuilt the result from the *unbound* input graph, dropping the materialized node-id binding. The endpoint-reconciliation concat then synthesized a spurious `None`-named node column: a corrupt result on older pandas, and a hard `NotImplementedError` (void-block NA fill) on newer pandas (e.g. Python 3.14). The result now carries the materialized node-id binding and a single, correct node column, matching the fast-path output; the original edge binding (e.g. `None`) is still restored. +- **GFQL chain fast path bypassed policy hooks**: the degenerate-shape chain fast path (node-only `MATCH (n)`, single-hop) short-circuited before the `prechain`/`postchain`/`postload` policy hook dispatch, so a policy-bearing query that hit the fast path never fired those hooks (or per-op policy inspection). The fast path is now skipped whenever a policy is attached — `prechain` is a pre-compute gate that must observe/block every load — so policy-bearing queries take the full path; the no-policy path keeps the optimization. ## [0.56.1 - 2026-05-27] diff --git a/graphistry/compute/chain.py b/graphistry/compute/chain.py index cf99056794..685395411f 100644 --- a/graphistry/compute/chain.py +++ b/graphistry/compute/chain.py @@ -1097,7 +1097,14 @@ def _chain_impl( ) if added_edge_index: final_edges_df = final_edges_df.drop(columns=[g._edge]) - g_out = self.nodes(final_nodes_df).edges(final_edges_df, edge=original_edge) + # Rebuild from `self` to restore the ORIGINAL edge binding (`self._edge`, + # often None — `g` carries the internal edge-index binding instead), but + # explicitly carry the materialized node-id binding `g._node`: for an + # edges-only input `self._node is None`, so rebuilding from `self` alone + # drops it, leaving the endpoint-reconciliation concat below to synthesize + # a `None`-named column (corrupt result + a void-block concat crash on + # newer pandas). + g_out = self.nodes(final_nodes_df, g._node).edges(final_edges_df, edge=original_edge) else: g_out = g.nodes(final_nodes_df).edges(final_edges_df) diff --git a/graphistry/tests/test_policy_hooks.py b/graphistry/tests/test_policy_hooks.py index 7dc0808ae7..72db1e47cd 100644 --- a/graphistry/tests/test_policy_hooks.py +++ b/graphistry/tests/test_policy_hooks.py @@ -63,6 +63,34 @@ def postload_policy(context: PolicyContext) -> None: g.gfql([n()], policy={'postload': postload_policy}) assert hook_called['postload'], "Postload hook should have been called" + @pytest.mark.parametrize("ops_label,ops", [ + ("node_only", [n()]), # 1-op chain fast-path shape + ("single_hop", [n(), e(), n()]), # 3-op chain fast-path shape + ]) + def test_fast_path_shapes_still_invoke_postload(self, ops_label, ops): + """Regression (#1650): the degenerate-shape chain fast path must not bypass + policy hooks. Node-only and single-hop chains are exactly the shapes the fast + path short-circuits; with a policy attached they must take the full path so + postload still fires. See chain.py `_try_chain_fast_path` gating. + + Uses an edges-only graph (no node binding) on purpose: the full single-hop + path must keep the synthesized node-id binding through endpoint reconciliation + and return a valid result, not a corrupt `None`-named column (which also + crashes the concat on newer pandas). See chain.py g_out rebuild.""" + called = {'postload': False} + + def postload_policy(context: PolicyContext) -> None: + called['postload'] = True + + df = pd.DataFrame({'s': ['a', 'b', 'c'], 'd': ['b', 'c', 'd']}) + g = graphistry.edges(df, 's', 'd') + + out = g.gfql(ops, policy={'postload': postload_policy}) + assert called['postload'], f"postload must fire for {ops_label} fast-path shape" + # result must carry a valid node-id binding + no spurious None-named column + assert out._node is not None, f"{ops_label}: node-id binding lost" + assert None not in list(out._nodes.columns), f"{ops_label}: corrupt None column" + def test_precall_hook_called(self): """Test that precall hook is called for call operations.""" from graphistry.compute.ast import call