@@ -74,16 +74,73 @@ def is_simple_equality_edge_match(
7474 return True
7575
7676
77- def _build_edge_keep_mask (
78- edges : DataFrameT , edge_match : EdgeMatch , engine : Engine , xp : "object"
79- ) -> Optional [ArrayLike ]:
80- """Boolean array over ORIGINAL edge rows (length E, same indexing as
81- ``AdjacencyIndex.other_values`` / ``row_positions``) selecting rows that satisfy
82- a simple-equality ``edge_match``.
83-
84- Built via each frame's native ``col == val`` (so cudf string columns stay on the
85- cudf layer instead of a cupy string compare). Returns ``None`` on ANY unexpected
86- shape or error, so the caller falls back to scan rather than risk a divergence.
77+ class _EdgeMatchRowFilter :
78+ """Evaluates a simple-equality ``edge_match`` on the CSR-matched edge rows only.
79+
80+ The mask is read exactly once per hop, as ``rows[keep[rows]]`` — at the handful of
81+ positions the adjacency lookup returned. Materializing it over all E edges first
82+ therefore put an O(E) predicate scan inside an O(degree) traversal, which is what
83+ made the indexed path scale with the graph instead of with the answer. Evaluating
84+ ``col == val`` on the gathered candidate rows makes the predicate proportional to
85+ the edges the traversal actually visits, and never examines more elements in total
86+ than the eager mask did.
87+
88+ Column values are compared with each frame's native ``==`` (so cudf string columns
89+ stay on the cudf layer rather than becoming a cupy string compare), matching the
90+ eager form exactly.
91+ """
92+
93+ __slots__ = ("_series" , "_items" , "_engine" )
94+
95+ def __init__ (self , series : dict , items : list , engine : Engine ) -> None :
96+ self ._series = series
97+ self ._items = items
98+ self ._engine = engine
99+
100+ def mask_for (self , rows : ArrayLike ) -> Optional [ArrayLike ]:
101+ """Boolean array over ``rows`` (positional, same order), or ``None`` on any
102+ unexpected shape/error so the caller falls back to the scan."""
103+ try :
104+ mask : Optional [ArrayLike ] = None
105+ for col , val in self ._items :
106+ sub = _gather_series (self ._series [col ], rows , self ._engine )
107+ # Null-safe materialization: on null-carrying columns (pandas nullable
108+ # Int64/boolean/string, polars nulls — which the NaN->null coercion
109+ # makes common) a bare == yields NA cells, and to_numpy() then produces
110+ # an OBJECT-dtype array that later explodes at rows[keep] (IndexError:
111+ # not int/bool). Null == val filters out on the scan path, so fill
112+ # False is parity-exact.
113+ if self ._engine in (Engine .POLARS , Engine .POLARS_GPU ):
114+ col_mask = cast (ArrayLike , (sub == val ).fill_null (False ).to_numpy ())
115+ elif self ._engine == Engine .CUDF :
116+ col_mask = cast (ArrayLike , (sub == val ).fillna (False ).values )
117+ else :
118+ col_mask = cast (
119+ ArrayLike , (sub == val ).fillna (False ).to_numpy (dtype = bool ))
120+ mask = col_mask if mask is None else cast (ArrayLike , cast (Any , mask ) & cast (Any , col_mask ))
121+ return mask
122+ except Exception : # pragma: no cover - defensive parity guard
123+ return None
124+
125+
126+ def _gather_series (series : Any , rows : ArrayLike , engine : Engine ) -> Any :
127+ """Positionally gather ``rows`` out of a single column. O(len(rows))."""
128+ if engine in (Engine .POLARS , Engine .POLARS_GPU ):
129+ import numpy as np
130+
131+ return series .gather (np .asarray (rows ))
132+ # pandas / cudf: positional take accepts numpy (pandas) or cupy (cudf) int arrays
133+ return series .take (rows )
134+
135+
136+ def _build_edge_row_filter (
137+ edges : DataFrameT , edge_match : EdgeMatch , engine : Engine
138+ ) -> Optional [_EdgeMatchRowFilter ]:
139+ """Validate a simple-equality ``edge_match`` against the edge schema and return a
140+ per-row evaluator, or ``None`` when the shape isn't covered (caller falls back to
141+ the scan rather than risk a divergence).
142+
143+ All checks here are schema-level (O(1) in E); no predicate is evaluated yet.
87144 """
88145 try :
89146 if not is_simple_equality_edge_match (edge_match ):
@@ -92,41 +149,30 @@ def _build_edge_keep_mask(
92149 _is_numeric_dtype_safe , _is_string_dtype_safe ,
93150 )
94151 n_edges = int (edges .shape [0 ])
95- mask : Optional [ArrayLike ] = None
152+ series : dict = {}
153+ items : list = []
96154 for col , val in edge_match .items ():
97155 if col not in edges .columns :
98156 return None
99157 if engine in (Engine .POLARS , Engine .POLARS_GPU ):
100- series = edges .get_column (col )
158+ col_series = edges .get_column (col )
101159 else :
102- series = edges [col ]
160+ col_series = edges [col ]
103161 # Obvious dtype mismatch (numeric col vs str val, string col vs numeric
104162 # val): the scan raises GFQLSchemaError E302 where a naive == is silently
105163 # all-False. Decline -> caller falls back to the scan, which raises the
106164 # SAME error (parity-exact; mirrors filter_by_dict's exact two checks,
107165 # skipped like the scan on empty frames).
108166 if n_edges > 0 :
109- dt = series .dtype
167+ dt = col_series .dtype
110168 if _is_numeric_dtype_safe (dt ) and isinstance (val , str ):
111169 return None
112170 if (_is_string_dtype_safe (dt )
113171 and isinstance (val , (int , float )) and not isinstance (val , bool )):
114172 return None
115- # Null-safe materialization: on null-carrying columns (pandas nullable
116- # Int64/boolean/string, polars nulls — which the NaN->null coercion makes
117- # common) a bare == yields NA cells, and to_numpy() then produces an
118- # OBJECT-dtype array that later explodes at rows[edge_keep[rows]]
119- # (IndexError: not int/bool). Null == val filters out on the scan path,
120- # so fill False is parity-exact.
121- if engine in (Engine .POLARS , Engine .POLARS_GPU ):
122- col_mask = cast (ArrayLike , (series == val ).fill_null (False ).to_numpy ())
123- elif engine == Engine .CUDF :
124- col_mask = cast (ArrayLike , (series == val ).fillna (False ).values )
125- else :
126- col_mask = cast (
127- ArrayLike , (series == val ).fillna (False ).to_numpy (dtype = bool ))
128- mask = col_mask if mask is None else cast (ArrayLike , cast (Any , mask ) & cast (Any , col_mask ))
129- return mask
173+ series [col ] = col_series
174+ items .append ((col , val ))
175+ return _EdgeMatchRowFilter (series , items , engine )
130176 except Exception : # pragma: no cover - defensive parity guard
131177 return None
132178
@@ -165,14 +211,16 @@ def index_seeded_hop(
165211
166212 xp , _backend = array_namespace (engine )
167213
168- # Typed-edge (edge_match) support: a boolean mask over ORIGINAL edge rows that
169- # pass the match predicate, applied to the CSR-matched rows each hop. Gated to
170- # simple scalar equality + the wavefront path by the coverability check upstream
171- # (maybe_index_hop); an unsupported shape returns None here => scan (parity-safe).
172- edge_keep : Optional [ArrayLike ] = None
214+ # Typed-edge (edge_match) support: the match predicate is evaluated on the
215+ # CSR-matched rows of each hop, so it costs O(edges visited) rather than O(E).
216+ # Gated to simple scalar equality + the wavefront path by the coverability check
217+ # upstream (maybe_index_hop); an unsupported shape returns None here => scan
218+ # (parity-safe). Schema validation happens now, up front, so an uncovered
219+ # edge_match still declines before any traversal work.
220+ edge_filter : Optional [_EdgeMatchRowFilter ] = None
173221 if edge_match :
174- edge_keep = _build_edge_keep_mask (edges , edge_match , engine , xp )
175- if edge_keep is None :
222+ edge_filter = _build_edge_row_filter (edges , edge_match , engine )
223+ if edge_filter is None :
176224 return None
177225
178226 # Do NOT narrow the seed to the index key dtype (a node-id int64 seed cast to
@@ -198,11 +246,17 @@ def index_seeded_hop(
198246 neigh_parts : List [ArrayLike ] = []
199247 for ix in indices :
200248 rows , matched = lookup_edge_rows (ix , frontier , xp )
201- if edge_keep is not None :
249+ if edge_filter is not None :
202250 # Keep only CSR-matched rows whose edge passes edge_match. Wavefront-
203251 # only (coverability gate), so the `matched`/first-hop `visited`
204252 # bookkeeping below — which edge_match does NOT filter — is never read.
205- rows = rows [edge_keep [rows ]]
253+ keep = edge_filter .mask_for (rows )
254+ if keep is None :
255+ # Evaluation failed on this candidate batch: abandon the indexed
256+ # path entirely so the caller re-runs the hop on the scan. Nothing
257+ # observable has been mutated, so this stays parity-safe.
258+ return None
259+ rows = rows [keep ]
206260 edge_rows_parts .append (rows )
207261 neigh_parts .append (ix .other_values [rows ])
208262 matched_parts .append (matched )
0 commit comments