Skip to content

Commit 6417cdd

Browse files
authored
Merge pull request #2045 from graphistry/fix/gfql-2020-alias-prefilter-alignment
fix(gfql): apply single-alias predicate pushdown masks by position (#2020)
2 parents 70efbd3 + c3ea9d2 commit 6417cdd

4 files changed

Lines changed: 63 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1919
### Fixed
2020

2121
- **cuDF 26.2 compatibility: `cudf.from_pandas` replaces the removed `cudf.DataFrame.from_pandas` at the five cuDF-only product sites (`ai_utils`, `umap_utils`, `feature_utils`) and in the test fixtures (#2043)**; the three cuDF chain differential cases that disagree with the full path on cuDF 26.2 are marked expected-failure on that line with the tracking issue, so the GPU lane reports them instead of crashing before them.
22+
- **GFQL pandas/cuDF: several single-alias `IN` (and other pushed-down) predicates across a hop no longer raise `Unalignable boolean Series` (#2020)**: the predicate pushdown filtered an alias frame by label after an earlier pushdown had already narrowed it, while the mask it evaluated carried a fresh positional index. Rows are now kept by position, which is the contract of a mask computed on the same rows; results equal the polars engine and the scalar `=` form.
2223
* GFQL: two basic Cypher shapes fell off the seeded fast paths on every engine. A seeded typed hop whose RETURN projects properties of more than the destination alias (`MATCH (m {id})-[r:T]->(p) RETURN m.id, r.since, p.name`) went through the row pipeline's connected-bindings frame builder; the seeded fast path now projects any number of properties from any alias of the hop (seed node, edge, destination node) from the rows it already holds, one row per matched edge, and still declines anything beyond one hop or an absent property to the full path. A seeded single-node pattern (`MATCH (p {id}) RETURN p` / `RETURN p.a, p.b`) ran the full two-pass chain over the node table with no index consulted; a new `seeded_node_lookup` fast path resolves the seed through the resident node-id index, a resident node-property index, or one scalar scan and projects the matched rows directly. Seed resolution is shared with the seeded typed hop, so a seed predicate on an indexed property that is not the node binding now uses the property index there too. On the SNB SF0.1 fixture the IS1 text went from 19 ms to 4 ms and the node-only lookup from 14 ms to 1 ms on pandas; both shapes report `fast_path served` in `gfql_explain` on pandas, polars and cuDF.
2324
* GFQL: native Polars single-node lookups resolve seeds through resident node-property indexes, and a directed scalar one-hop chain whose resident node-id and adjacency indexes cover it (typed edges, destination filters, named patterns) is served by the seeded reduction used by Cypher, with full-path table order and alias flags preserved and the engagement visible in `gfql_explain` (#2033).
2425
* GFQL: native op-list chains (`g.gfql([n(...), e_forward(...), n(...)])`) resolve a seed predicate through the resident node-id or node-property index and serve named patterns on the chain fast path; before, a named single-node op always ran the full two-pass chain, a named seeded hop declined the fast path whenever the traversal indexes were resident (deferring to an indexed kernel that had already declined), and a seed on a property other than the node binding was a full node-table scan (#2027). The alias flag columns now sit where the full path places them.

bin/test-polars.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ POLARS_TEST_FILES=(
115115
graphistry/tests/compute/gfql/cypher/test_grouped_aggregate_cross_alias.py
116116
# module-level `importorskip("polars")` files that previously ran in no lane at all
117117
graphistry/tests/compute/gfql/test_engine_polars_narrow_combine.py
118+
graphistry/tests/compute/gfql/row/test_alias_prefilter_alignment_2020.py
118119
graphistry/tests/compute/gfql/test_engine_polars_semi_key_dedup.py
119120
graphistry/tests/compute/gfql/test_engine_polars_call_modality.py
120121
graphistry/tests/compute/gfql/test_engine_polars_gpu.py

graphistry/compute/gfql/row/pipeline.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,13 @@ def is_row_pipeline_call(function: str) -> bool:
236236
return function in ROW_PIPELINE_CALLS
237237

238238

239+
240+
def _rows_where(frame: DataFrameT, mask: SeriesT) -> DataFrameT:
241+
"""Keep the rows where ``mask`` is True, by position: a mask evaluated on a renamed
242+
view of ``frame`` may carry a fresh RangeIndex while ``frame`` keeps the labels of an
243+
earlier filter, so label alignment (``.loc``) is not the contract here."""
244+
return frame[mask.values]
245+
239246
class RowPipelineMixin:
240247
# Mirrors the GFQL execution-context fields declared on Plottable: this mixin
241248
# is also used by `_RowPipelineAdapter`, which is not a PlotterBase, so the
@@ -3929,7 +3936,7 @@ def _gfql_apply_alias_prefilter(
39293936
view = frame.rename(columns={c: f"{alias}.{c}" for c in frame.columns})
39303937
value = self._gfql_eval_string_expr(view, spec["text"])
39313938
mask = self._gfql_bool_mask(view, value)
3932-
frame = frame.loc[mask]
3939+
frame = _rows_where(frame, mask)
39333940
elif kind == "search_any":
39343941
from graphistry.compute.gfql.search_any import search_any_mask
39353942
term = spec.get("term")
@@ -3974,7 +3981,7 @@ def _gfql_apply_alias_prefilter(
39743981
value=spec.get("columns"),
39753982
language="cypher",
39763983
)
3977-
frame = frame.loc[mask]
3984+
frame = _rows_where(frame, mask)
39783985
return frame
39793986

39803987
def _gfql_connected_bindings_state(
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Single-alias predicate pushdown keeps working after an earlier pushdown narrowed the frame (#2020)."""
2+
import pandas as pd
3+
import pytest
4+
5+
import graphistry
6+
7+
Q = ("MATCH (a)-[e]->(t) WHERE a.type IN ['person','company'] AND e.e_type IN ['sent','transfer'] "
8+
"AND t.type IN ['transaction','account'] RETURN t.id AS id")
9+
10+
11+
def _graph(engine):
12+
nodes = pd.DataFrame({"id": ["a", "b", "c", "tx1", "tx2"],
13+
"type": ["person", "person", "company", "transaction", "transaction"]})
14+
edges = pd.DataFrame({"src": ["a", "b", "a", "tx1", "tx2"], "dst": ["b", "c", "tx1", "tx2", "c"],
15+
"e_type": ["knows", "works_at", "sent", "transfer", "received"]})
16+
if engine == "polars":
17+
pl = pytest.importorskip("polars")
18+
nodes, edges = pl.from_pandas(nodes), pl.from_pandas(edges)
19+
elif engine == "cudf":
20+
cudf = pytest.importorskip("cudf")
21+
nodes, edges = cudf.from_pandas(nodes), cudf.from_pandas(edges)
22+
return graphistry.edges(edges, "src", "dst").nodes(nodes, "id")
23+
24+
25+
def _ids(res):
26+
nodes = res._nodes
27+
df = nodes.to_pandas() if hasattr(nodes, "to_pandas") else pd.DataFrame(nodes)
28+
return sorted(df["id"].tolist())
29+
30+
31+
@pytest.mark.parametrize("engine", ["pandas", "polars", "cudf"])
32+
def test_three_in_predicates_across_a_hop_agree_on_every_engine(engine):
33+
assert _ids(_graph(engine).gfql(Q, engine=engine)) == ["tx1"]
34+
35+
36+
@pytest.mark.parametrize("engine", ["pandas", "cudf"])
37+
def test_pushdown_on_a_frame_with_filtered_labels_matches_the_scalar_form(engine):
38+
g = _graph(engine)
39+
scalar = Q.replace("t.type IN ['transaction','account']", "t.type = 'transaction'")
40+
assert _ids(g.gfql(Q, engine=engine)) == _ids(g.gfql(scalar, engine=engine)) == ["tx1"]
41+
42+
43+
@pytest.mark.parametrize("engine", ["pandas", "cudf"])
44+
def test_pushdown_on_a_non_range_indexed_node_frame(engine):
45+
g = _graph("pandas")
46+
nodes = g._nodes.iloc[[4, 2, 0, 3, 1]] # labels out of order, no RangeIndex
47+
if engine == "cudf":
48+
cudf = pytest.importorskip("cudf")
49+
nodes = cudf.from_pandas(nodes)
50+
g = _graph("cudf")
51+
g = g.nodes(nodes)
52+
assert _ids(g.gfql(Q, engine=engine)) == ["tx1"]

0 commit comments

Comments
 (0)