Skip to content

Commit c394ae2

Browse files
lmeyerovclaude
andcommitted
review: share the bounded var-length expansion; type the polars path bag lazily
- extract `_directed_varlen_reachable_polars` so the unbounded fixed-point arm and the bounded `-[*1..k]->` arm run literally the same loop instead of two copies kept in sync by a comment. - pin the generic bindings builder's path bag as a LazyFrame. It always was one; mypy inferred eager because `filter_by_dict_polars` declared the eager type. - make `filter_by_dict_polars` frame-polymorphic via a constrained TypeVar (DataFrame | LazyFrame) rather than declaring one flavour and being called with both -- the eager viz lane and the lazy chain lane take the same `.filter(expr)` path, and the TypeVar keeps the caller's flavour on the way out. Removes the mypy noise this file was carrying, so the new code lands at delta 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxxTFpKiqA4FQa9Pjy8yR1
1 parent 134af67 commit c394ae2

3 files changed

Lines changed: 78 additions & 42 deletions

File tree

graphistry/compute/gfql/lazy/engine/polars/predicates.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import operator
1212
import re
13-
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
13+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, TypeVar, Union
1414

1515
from graphistry.compute.predicates.ASTPredicate import ASTPredicate
1616
from graphistry.compute.predicates.str import Contains, Endswith, Fullmatch, Match, Startswith
@@ -21,6 +21,12 @@
2121
import datetime
2222
import polars as pl
2323

24+
# These helpers are frame-polymorphic: the eager (viz/crossfilter) lane hands them
25+
# DataFrames, the lazy chain/bindings lane hands them LazyFrames, and both take the
26+
# SAME `.filter(expr)` path. A constrained TypeVar says exactly that and keeps the
27+
# caller's flavour on the way out (a plain union would not).
28+
PolarsFrameT = TypeVar("PolarsFrameT", "pl.DataFrame", "pl.LazyFrame")
29+
2430
# Comparison-predicate RHS: genuinely dynamic (Cypher properties are dynamically typed) — a
2531
# python scalar or a GFQL/py temporal matched structurally by type(val).__name__
2632
# (DateValue/TemporalValue/…, never imported here).
@@ -282,7 +288,7 @@ def _is_membership(value: Any) -> bool:
282288
return isinstance(value, (list, tuple, set, frozenset))
283289

284290

285-
def _is_cross_type_predicate(df: "pl.DataFrame", col: str, pred: ASTPredicate) -> bool:
291+
def _is_cross_type_predicate(df: "Union[pl.DataFrame, pl.LazyFrame]", col: str, pred: ASTPredicate) -> bool:
286292
"""True iff the predicate compares a numeric column to a string value (or vice versa):
287293
polars raises `cannot compare string with numeric type` (an uncatchable Rust panic when
288294
nested); pandas/cypher return a value/null. Recurses into AllOf (fold of x>a AND x<b) and
@@ -307,15 +313,15 @@ def _mismatch(v: Any) -> bool:
307313
return _mismatch(val)
308314

309315

310-
def filter_by_dict_polars(df: "pl.DataFrame", filter_dict: "Optional[Dict[str, Any]]") -> "pl.DataFrame":
316+
def filter_by_dict_polars(df: "PolarsFrameT", filter_dict: "Optional[Dict[str, Any]]") -> "PolarsFrameT":
311317
"""Return rows of polars ``df`` matching all entries in ``filter_dict`` via one filter."""
312318
combined = filter_expr_by_dict_polars(df, filter_dict)
313319
if combined is None:
314320
return df
315321
return df.filter(combined)
316322

317323

318-
def filter_expr_by_dict_polars(df: "pl.DataFrame", filter_dict: "Optional[Dict[str, Any]]") -> "Optional[pl.Expr]":
324+
def filter_expr_by_dict_polars(df: "Union[pl.DataFrame, pl.LazyFrame]", filter_dict: "Optional[Dict[str, Any]]") -> "Optional[pl.Expr]":
319325
"""Build the combined boolean ``pl.Expr`` filter_by_dict_polars would apply, or None
320326
for an empty/absent filter dict. ``df`` supplies the schema for column/dtype
321327
resolution only — callers may apply the expr to a LazyFrame over the same schema

graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py

Lines changed: 52 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,6 +1094,41 @@ def _cartesian_node_bindings_polars(
10941094
return _rewrap(g, out_df)
10951095

10961096

1097+
def _directed_varlen_reachable_polars(
1098+
state: "pl.LazyFrame",
1099+
pairs: "pl.LazyFrame",
1100+
state_cols: List[str],
1101+
*,
1102+
min_hops: int,
1103+
max_hops: int,
1104+
) -> "pl.LazyFrame":
1105+
"""Bounded DIRECTED variable-length expansion of a bindings path bag.
1106+
1107+
One row per distinct edge SEQUENCE: ``pairs`` is NOT deduped, so parallel edges
1108+
multiply per hop, matching pandas' ``_gfql_multihop_binding_rows`` merge. Zero-hop
1109+
rows (``min_hops == 0``) keep the seed row (endpoint == start) and come first, then
1110+
hop 1, 2, ... — the same ``reachable`` concat order pandas builds.
1111+
1112+
Stays fully lazy: all ``max_hops`` iterations are built without an eager
1113+
``.height`` early-break, because an empty intermediate lazily joins to empty and
1114+
yields the identical result (pandas' break is an optimization, not semantics).
1115+
"""
1116+
import polars as pl
1117+
1118+
reachable: List["pl.LazyFrame"] = [state] if min_hops == 0 else []
1119+
current = state
1120+
for hop in range(1, max_hops + 1):
1121+
current = (
1122+
current.join(pairs, left_on="__current__", right_on="__from__", how="inner")
1123+
.drop("__current__")
1124+
.rename({"__to__": "__current__"})
1125+
.select(state_cols)
1126+
)
1127+
if hop >= min_hops:
1128+
reachable.append(current)
1129+
return pl.concat(reachable, how="vertical") if reachable else state.limit(0)
1130+
1131+
10971132
def _directed_fixed_point_binding_rows_polars(
10981133
state: "pl.LazyFrame",
10991134
pairs: "pl.LazyFrame",
@@ -1169,19 +1204,10 @@ def _directed_fixed_point_binding_rows_polars(
11691204
"Cypher multi-alias row bindings currently require terminating variable-length segments"
11701205
)
11711206

1172-
# (b) bounded path expansion, identical to the `-[*1..k]->` arm with max_hops=depth.
1173-
reachable: List["pl.LazyFrame"] = [state] if min_hops == 0 else []
1174-
current = state
1175-
for hop in range(1, depth + 1):
1176-
current = (
1177-
current.join(pairs_lf, left_on="__current__", right_on="__from__", how="inner")
1178-
.drop("__current__")
1179-
.rename({"__to__": "__current__"})
1180-
.select(state_cols)
1181-
)
1182-
if hop >= min_hops:
1183-
reachable.append(current)
1184-
return pl.concat(reachable, how="vertical") if reachable else state.limit(0)
1207+
# (b) the SAME bounded expansion the `-[*1..k]->` arm runs, with max_hops = depth.
1208+
return _directed_varlen_reachable_polars(
1209+
state, pairs_lf, state_cols, min_hops=min_hops, max_hops=depth
1210+
)
11851211

11861212

11871213
def binding_rows_polars(
@@ -1397,7 +1423,11 @@ def _names(lf: pl.LazyFrame) -> List[str]:
13971423
if seed_ids_lf is not None:
13981424
# WITH->MATCH re-entry seed: constrain the first alias to the carried ids.
13991425
seed_nodes = seed_nodes.join(seed_ids_lf, on=node_id, how="semi")
1400-
state = seed_nodes.select(pl.col(node_id).alias("__current__"))
1426+
# The whole generic builder works in LazyFrames (`nodes_lf` / `edges_lf` above);
1427+
# `filter_by_dict_polars` is frame-polymorphic at runtime but declares the eager
1428+
# type, so pin the path bag lazy here instead of leaving every downstream lazy
1429+
# op to fight an eager inference.
1430+
state: pl.LazyFrame = seed_nodes.select(pl.col(node_id).alias("__current__")) # type: ignore[assignment]
14011431
alias_frames: Dict[str, pl.LazyFrame] = {}
14021432
node_aliases: List[str] = []
14031433
first_alias = first_op._name
@@ -1528,23 +1558,14 @@ def _names(lf: pl.LazyFrame) -> List[str]:
15281558
reachable.append(current.select(state_cols))
15291559
state = pl.concat(reachable, how="vertical") if reachable else state.limit(0)
15301560
else:
1531-
max_hops = int(max_hops_value)
1532-
pairs = oriented.select(["__from__", "__to__"])
1533-
reachable = [state] if min_hops == 0 else []
1534-
current = state
1535-
# Lazy: build all max_hops iterations (no eager .height early-break —
1536-
# empty intermediates lazily join to empty, so the result is
1537-
# identical; the pandas break is an optimization, not semantics).
1538-
for _hop in range(1, max_hops + 1):
1539-
current = (
1540-
current.join(pairs, left_on="__current__", right_on="__from__", how="inner")
1541-
.drop("__current__")
1542-
.rename({"__to__": "__current__"})
1543-
.select(state_cols)
1544-
)
1545-
if _hop >= min_hops:
1546-
reachable.append(current)
1547-
state = pl.concat(reachable, how="vertical") if reachable else state.limit(0)
1561+
# Bounded directed var-length (`-[*1..k]->`, graph-bench q3).
1562+
state = _directed_varlen_reachable_polars(
1563+
state,
1564+
oriented.select(["__from__", "__to__"]),
1565+
state_cols,
1566+
min_hops=min_hops,
1567+
max_hops=int(max_hops_value),
1568+
)
15481569
else:
15491570
state = (
15501571
state.join(oriented, left_on="__current__", right_on="__from__", how="inner")

graphistry/tests/compute/gfql/test_engine_polars_row_pipeline.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -630,15 +630,24 @@ def test_run_calls_polars_binding_ops_native():
630630

631631

632632
def test_run_calls_polars_binding_ops_unbounded_multihop_defers():
633-
"""UNBOUNDED variable-length binding patterns stay outside the native subset
634-
(bounded `-[*1..k]->` is native, #1709) -> NotImplementedError (NO pandas
635-
bridge, see plan.md NO-CHEATING)."""
633+
"""Unbounded variable-length binding patterns: DIRECTED fixed point is native
634+
(#1709, LDBC IS6); the rest stay outside the subset -> NotImplementedError (NO
635+
pandas bridge, see plan.md NO-CHEATING)."""
636636
from graphistry.compute.gfql.lazy.engine.polars.chain import _run_calls_polars
637-
from graphistry.compute.ast import call, n, e_forward
637+
from graphistry.compute.ast import call, n, e_forward, e_undirected
638638
g = _polars_graph()
639-
middle = [n(name="a"), e_forward(to_fixed_point=True), n(name="b")]
640-
with pytest.raises(NotImplementedError):
641-
_run_calls_polars(g, [call("rows", {})], None, g, middle)
639+
native = _run_calls_polars(
640+
g, [call("rows", {})], None, g, [n(name="a"), e_forward(to_fixed_point=True), n(name="b")]
641+
)
642+
assert "polars" in type(native._nodes).__module__
643+
for middle in [
644+
# undirected unbounded: multiplicity + backtrack-aware termination unmodeled
645+
[n(name="a"), e_undirected(to_fixed_point=True), n(name="b")],
646+
# aliased variable-length relationship: pandas rejects it outright
647+
[n(name="a"), e_forward(to_fixed_point=True, name="r"), n(name="b")],
648+
]:
649+
with pytest.raises(NotImplementedError):
650+
_run_calls_polars(g, [call("rows", {})], None, g, middle)
642651

643652

644653
def test_frame_ops_polars_rows_empty_table():

0 commit comments

Comments
 (0)