Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
<!-- Do Not Erase This Section - Used for tracking unreleased changes -->

### Added
- **Native polars `rows(binding_ops=...)` for UNBOUNDED directed variable-length patterns (`-[*]->` / `-[*0..]->`) (#1709)**: the Cypher multi-alias bindings table already lowered natively for fixed-length and *bounded* variable-length segments, but an unbounded fixed-point segment declined with `NotImplementedError: polars engine does not yet natively support cypher row op 'rows'`. This was the last shape blocking `engine='polars'` on LDBC SNB interactive-short-6 (`MATCH (m:Message)-[:REPLY_OF*0..]->(p:Post)<-[:CONTAINER_OF]-(f:Forum)-[:HAS_MODERATOR]->(mod)`), the one interactive-short query polars could not answer. It now runs natively: a dedup-by-node frontier walk finds the exhaustion depth, then the SAME lazy bounded pair-join loop the `-[*1..k]->` arm uses materializes one row per distinct edge SEQUENCE (Cypher path multiplicity, parallel edges included) — no pandas bridge, no `to_pandas()` round trip. A cycle reachable from the seed means infinitely many paths; that raises the same E108 "require terminating variable-length segments" error pandas raises — same exception class and same `.code` on both engines — and is detected before the path expansion blows up rather than after, bounded by the REACHABLE node count so an unreachable remainder of the graph costs nothing. Still declining honestly (NIE, never a silent answer): UNDIRECTED unbounded (`-[*]-`, needs the min_hops == 1 multiplicity reconstruction plus backtrack-aware termination — pandas rejects it outright), aliased variable-length relationships (pandas rejects those too), unbounded segments WITHOUT `to_fixed_point` (pandas silently truncates at a bound this lowering cannot reconstruct), unbounded segments with `min_hops >= 2` (`-[*2..]->`: pandas' step pairs are pruned by min_hops against a dedup-by-node eccentricity, which this raw-edge reconstruction cannot reproduce — serving it would return a different count with no error), and `to_fixed_point` combined with an explicit bound (declined on master too; it is not Cypher-reachable, since the parser only sets the flag for `*` / `*k..` where there is no maximum, but through the AST surface it hits the same reconstruction gap for `min_hops >= 3`). Cross-engine parity is the gate: differential fuzz vs the pandas oracle over random DAGs and cyclic graphs with self-loops and parallel edges, plus pinned IS6/zero-hop/multiplicity/cycle tests.

- **GFQL secondary node property indexes (`create_index('node_prop', column=...)` / `g.gfql_index_node_props([...])`)**: a seed predicate on a NON-key column — `MATCH (m {id: 42})` where the graph's node id binding is some other column — previously cost a full node scan, because the registry only indexed the node-id binding and the CSR adjacencies. A property index is the same pay-as-you-go sidecar as the existing kinds: sorted distinct values over node **row positions** (CSR, so duplicate values are indexable), never reorders `.nodes`, fingerprint-validated so a `.nodes()` rebind is treated as absent (safe miss, never a wrong answer), engine-polymorphic (numpy host / cupy on-device), and policy-gated (`off`/`use`/`auto`/`force`). The seeded fixed-hop planner picks the **most selective** indexed scalar predicate in the seed filter using a free CSR-offset estimate, gathers those candidates, and applies every remaining predicate to them — so results are identical whether the index is present, absent, stale, or cost-gated out. `show_indexes()` lists property indexes; `drop_index('node_prop', column=...)` drops one. Only integer columns are indexable today (float NaN ordering, strings on cupy, and nulls all decline to the scan); widening that is additive. **Perf (dgx-spark, official LDBC SNB SF1, 3.18M nodes, warm median, value-identical 19-row result):** interactive-short IS7 `71.6 ms -> 19.5 ms` (**3.7x**), with a one-time `112 ms` build — the seed lookup itself goes from a `51.2 ms` scan to `0.096 ms`.

### Performance
Expand All @@ -20,6 +22,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- **GFQL execution context is declared rather than attached at runtime**: the private `_gfql_*` per-execution fields (index policy and registry, row-pipeline base graph, carried seed nodes, edge aliases, shortest-path backend, and the indexed-bindings handoff) are now declared on `Plottable` with defaults on `PlotterBase`, instead of being set with `setattr` and read back with `getattr(..., default)`. No public API or behaviour change; internal call sites are typed, and hand-rolled `Plottable` stand-ins must now construct the full context.

### Fixed
- **Native polars variable-length bindings dropped zero-hop rows (`-[*0..k]->`)**: the polars `rows(binding_ops=...)` builder rebuilt the bindings table from the CHAIN OUTPUT, while the pandas oracle rebuilds from the pre-chain base graph. The traversal prunes to what IT considers matched, which is not the same set the bindings builder matches — a zero-hop variable-length segment binds a seed that has no matching outgoing edge, and the traversal drops that node entirely. So `MATCH (a)-[*0..2]->(b)` could silently return fewer rows than pandas (no error, just missing rows). The polars builder now sources its node/edge tables from the same pre-chain base graph pandas uses (which is also what the indexed builder was already handed). Found by differential fuzzing while adding unbounded support (#1709); pinned by a zero-hop regression test and by `-[*0..2]->` parity cases.

- **Indexed bypass could return every node for `rows()` over an unnamed pattern**: the boundary accepted a `rows()` call carrying no binding ops over a middle with no aliases. That call reads the traversal-narrowed node table, but the bypass hands it the full graph, so the query would have returned all nodes. The gate now requires that the rows call actually consumes the whole middle as binding ops — either it already carries them, or the named-middle rewrite installs exactly them.
- **Seeded property-RETURN dtype divergence on cuDF**: the lean projection applied the pandas rows-pivot artifact (int → float64, bool → object) on every engine, but cuDF's canonical pivot preserves the source dtypes — so the fast path returned `float64`/`object` where cuDF's own canonical path returns `int64`/`bool`. The cast rule is now engine-aware; the dtype-class decline guard is unchanged.

Expand Down
4 changes: 3 additions & 1 deletion graphistry/compute/gfql/index/bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ def _filter_frame(
filter_by_dict_polars,
)

return cast(DataFrameT, filter_by_dict_polars(frame, filter_dict)) # type: ignore[arg-type]
# frame is DataFrameT (engine-neutral); on this branch it IS a polars frame,
# which the helper's constrained TypeVar cannot see through the alias
return cast(DataFrameT, filter_by_dict_polars(frame, filter_dict)) # type: ignore[type-var]
from graphistry.compute.filter_by_dict import filter_by_dict

return filter_by_dict(frame, filter_dict, engine) # type: ignore[arg-type]
Expand Down
21 changes: 20 additions & 1 deletion graphistry/compute/gfql/lazy/engine/polars/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,8 +401,27 @@ def _run_calls_polars(g_cur, calls, start_nodes, base_graph, middle):
# is MECHANICAL (is_row_pipeline_call), not curated. (umap/hypergraph never reach here —
# the generic chain routes schema-changers straight to execute_call.)
from graphistry.compute.gfql.row.pipeline import is_row_pipeline_call
from graphistry.compute.exceptions import ErrorCode, GFQLTypeError, GFQLValidationError
for op in calls:
native = _try_native_row_op(g_cur, op)
try:
native = _try_native_row_op(g_cur, op)
except GFQLTypeError:
raise
except GFQLValidationError as validation_error:
# Same wrapping `execute_call` applies (gfql/call/executor.py): a kernel that
# raises a validation error surfaces as GFQLTypeError(E303) with this message
# shape. The native attempt runs BEFORE execute_call, so without this the SAME
# query carries a different class AND a different `.code` per engine — and this
# repo's control flow keys on `.code`. Scoped to GFQLValidationError, the one
# divergence actually observed (an E108 from the var-length cycle guard);
# other exception classes are left alone rather than blanket-normalized.
fn_name = getattr(op, "function", None)
raise GFQLTypeError(
ErrorCode.E303,
f"Error executing '{fn_name}': {validation_error}",
field="function",
value=fn_name,
) from validation_error
if native is not None:
g_cur = native
continue
Expand Down
14 changes: 10 additions & 4 deletions graphistry/compute/gfql/lazy/engine/polars/predicates.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import operator
import re
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, TypeVar, Union

from graphistry.compute.predicates.ASTPredicate import ASTPredicate
from graphistry.compute.predicates.str import Contains, Endswith, Fullmatch, Match, Startswith
Expand All @@ -21,6 +21,12 @@
import datetime
import polars as pl

# These helpers are frame-polymorphic: the eager (viz/crossfilter) lane hands them
# DataFrames, the lazy chain/bindings lane hands them LazyFrames, and both take the
# SAME `.filter(expr)` path. A constrained TypeVar says exactly that and keeps the
# caller's flavour on the way out (a plain union would not).
PolarsFrameT = TypeVar("PolarsFrameT", "pl.DataFrame", "pl.LazyFrame")

# Comparison-predicate RHS: genuinely dynamic (Cypher properties are dynamically typed) — a
# python scalar or a GFQL/py temporal matched structurally by type(val).__name__
# (DateValue/TemporalValue/…, never imported here).
Expand Down Expand Up @@ -282,7 +288,7 @@ def _is_membership(value: Any) -> bool:
return isinstance(value, (list, tuple, set, frozenset))


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


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


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