Skip to content

Commit 4bd5496

Browse files
lmeyerovclaude
andcommitted
feat(gfql): native polars WITH...MATCH re-traversal reentry (IC6/IC11 core)
A MATCH re-traversing from a preceding WITH's projected/aggregated bindings previously declined on polars ("could not recover carried node identities"). Fixes two pandas-only gaps: the native projector now emits the _cypher_entity_projection_meta side-channel the bounded-reentry executor reads to re-seed; the executor id-handling + seeded binding pipeline are engine-aware (polars is_not_null/filter/order-preserving join; semi-join to carried ids). RETURN entity/property/count/count(*)/DISTINCT over WITH->MATCH now native, pandas-parity. Differential fuzz ~3500 graphs: 0 disagreements, 0 crashes. Scalar-column carry + duplicate-id / cartesian-under-seed stay honest NIEs (were crashes/silent-wrong). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
1 parent 60ecaea commit 4bd5496

7 files changed

Lines changed: 437 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1212
- **GFQL engine-selection docs (pandas / polars / cuDF / polars-gpu)**: New :doc:`Choosing a GFQL Engine <gfql/engines>` page — a numbers-first, persona-tested guide to the four interchangeable engines. Adds the one-keyword `engine='polars'` speedup (up to ~38× over pandas on real graphs, no GPU), a motivating warm-median comparison table on real public graphs (LiveJournal 35M / Orkut 117M), a decision matrix (workload shape × size × hardware → engine, with the measured ~10K-edge CPU crossover, GPU-work-bound rule, polars-gpu memory-pressure caveat, and GPU-or-error contract), a cuDF-vs-polars-gpu disambiguation (eager-op vs fused-lazy; cuDF is not deprecated), an honest "when *not* to use Polars" section, the differential-parity guarantee, and a methodology + reproducer-script disclosure. Rewrote the top of `gfql/performance.rst` to lead with the engine comparison (de-marketed the prose), wired the new page into the GFQL toctree + recommended paths, and added Polars/polars-gpu to the engine examples in `gfql/quick.rst` and `gfql/about.rst` (previously only pandas/cuDF were documented). Driven by 4-persona doc user-testing (pandas DS, RAPIDS user, perf engineer, skeptical evaluator).
1313

1414
### Fixed
15+
- **GFQL polars engine natively runs `WITH ... MATCH ...` re-traversal (the IC6/IC11 core)**: a MATCH that re-traverses from a preceding `WITH`'s projected/aggregated bindings — `MATCH (p {id:X})-[:KNOWS]-(friend) WITH DISTINCT friend MATCH (friend)-[:HAS_CREATOR]-(post) RETURN count(post)` — previously declined on polars (`Cypher MATCH after WITH could not recover carried node identities from the prefix stage`). Two pandas-only gaps fixed: (1) the native polars projector now emits the `_cypher_entity_projection_meta` side-channel (carried alias `id`/`ids`) that the bounded-reentry executor reads to re-seed the next MATCH; (2) the reentry executor's id-handling and the seeded binding pipeline are made engine-aware (polars `is_not_null`/`filter`/order-preserving left-join; a semi-join of the first alias to the carried ids). `RETURN entity / property / count / count(*) / DISTINCT` over WITH→MATCH now run natively with pandas parity. Differential fuzz vs pandas over ~3500 random graphs × WITH→MATCH shapes (DISTINCT/filtered/ORDER-BY prefixes × fwd/rev/undirected re-MATCH × entity/property/count/distinct RETURN): **0 disagreements, 0 crashes** (declines are honest NIEs). Still honest-NIE (clean decline, was a crash): a WITH that also carries a **scalar column** into the trailing MATCH (hidden-payload threading is still pandas-only), and duplicate carried ids / node-cartesian trailing patterns under a seed (preserve pandas path-multiplicity / avoid silent-wrong). New `test_engine_polars_with_match_reentry.py`.
1516
- **GFQL polars engine natively runs multi-source (node-cartesian) MATCH (#1273)**: comma-separated disconnected aliases — `g.gfql("MATCH (a {..}), (b {..}) RETURN a.id, b.id")` — previously declined on polars (the `rows(binding_ops)` `node_cartesian` branch hard-NIE'd) while pandas handled them. New `_cartesian_node_bindings_polars` mirrors the pandas oracle `_gfql_cartesian_node_bindings_row_table`: each alias is independently filtered, projected into the per-alias lookup schema (bare `alias` id, `alias.id`, `alias.<prop>`, and pandas' leaked `alias.alias=True` flag incl. property-shadowing), then left-major cross-joined for row-order parity. Scalar/aliased/`count(*)` projections over the cartesian match pandas exactly (verified end-to-end); whole-entity `RETURN a, b` stays an honest NIE (separate projection surface). Two parity-safe declines mirror shapes where pandas itself errors (proven on master, so both engines fail identically, never diverge): an anonymous node op, and ≥4 named aliases (pandas' bare-id merge residue collides). Differential fuzz vs pandas over ~10k lowered cases: 0 disagreements. Tests in `test_engine_polars_binding_rows.py`.
1617
- **GFQL polars chain — variable-length node aliases are hop-distance gated; the chain is now silent-wrong-free (#1741)**: a node named after a variable-length edge (`g.gfql("MATCH (a)-[*1..2]-(b) RETURN b")`) previously carried its alias regardless of hop distance, so an undirected walk that backtracked into the seed wrongly returned it (pandas correctly excludes it — trail semantics). The polars chain now auto-injects the #1741 hop-distance label (name resolved against the user's node columns via the `reserved_columns` registry, so a user column named like the internal one is never clobbered) and applies pandas' alias `[min_hop, max_hop]` window in `_apply_node_names`. The one shape the gate can't yet cover — a node alias after a **forward/reverse `min_hops>1`** edge, whose labels need pandas' layered backward walk (not ported) — now **declines with an honest `NotImplementedError` (#1748)** instead of returning nodes outside the window; the decline is precise (differential vs pandas: 30/30 named shapes NIE, 30/30 unnamed run and match, 0 over-decline). 4-engine A/B on the stacked build: pandas/cudf 144/144, polars/polars-gpu 112 agree / **0 disagree** / 32 honest-NIE — zero silent-wrong shapes remain on the polars chain. Adapts the mechanism from the retired #1742 (declined undirected varlen+alias, now natively gated). Tests: `TestVarlenAliasHopGate` (pandas-parity across directions, the `*2..3` decline + unnamed-still-runs pins, a column-collision test).
1718
- **GFQL native polars `label_node_hops` on the plain BFS — correct, direction-dependent hop labels (#1741 groundwork)**: the polars eager hop now emits pandas-parity node hop-distance labels instead of declining `label_node_hops`. The labeling rule is DIRECTION-DEPENDENT (the subtle part, derived by differential fuzz vs the pandas oracle, not by reading pandas alone): forward/reverse label EVERY destination of a hop first-wins (matching pandas `hop.py` `new_node_ids`), so a seed re-entered at hop 1 IS labeled; undirected labels destinations MINUS everything already visited and pre-seeds the seen-set with the seeds, so a seed re-reached by a backtracking walk stays NULL (its shortest-path distance). Two correctness fixes over the first cut: (1) the undirected seed pre-seed must NOT depend on `return_as_wave_front` — a seed filtered out of the frontier by `source_node_match` or suppressed in wave-front mode was being re-labeled (A/B vs pandas over direction × hops × `return_as_wave_front` × `source_node_match` × `destination_node_match` × 10 graphs: was 456/24, now 480/480); (2) a requested `label_node_hops` name that collides with an existing column is redirected to `<name>_1` like pandas' `resolve_label_col`, not the polars left-join auto-suffix `<name>_right`/`DuplicateError`. `label_edge_hops` and `min_hops>1` labels stay honest NIEs. Amplified `test_engine_polars_hop.py` (`TestHopLabelsDifferential` over the seed-filter / wave-front / multi-seed axes, an explicit direction-asymmetry contrast pin, and a collision test).

bin/test-polars.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ POLARS_TEST_FILES=(
1818
graphistry/tests/compute/gfql/test_engine_polars_chain.py
1919
graphistry/tests/compute/gfql/test_engine_polars_row_pipeline.py
2020
graphistry/tests/compute/gfql/test_engine_polars_binding_rows.py
21+
graphistry/tests/compute/gfql/test_engine_polars_with_match_reentry.py
2122
graphistry/tests/compute/gfql/test_engine_polars_cypher_conformance.py
2223
graphistry/tests/compute/gfql/test_engine_polars_conformance_matrix.py
2324
graphistry/tests/compute/gfql/test_polars_string_predicate_nonstring.py

graphistry/compute/gfql/cypher/reentry/execution.py

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,51 @@
2323
from graphistry.Engine import is_polars_df as _is_polars_df
2424

2525

26+
def _series_is_series_like(s: Any) -> bool:
27+
"""True for a pandas/cuDF Series (``.dropna``) or a polars Series (module check).
28+
29+
The reentry executor recovers carried node identities from a projection-meta ``ids`` Series
30+
that is pandas under ``engine='pandas'`` and polars under ``engine='polars'``; both are valid."""
31+
return hasattr(s, "dropna") or _is_polars_df(s)
32+
33+
34+
def _series_not_null_mask(s: Any) -> Any:
35+
"""Non-null boolean mask, engine-aware (polars ``is_not_null`` vs pandas ``notna``)."""
36+
if _is_polars_df(s):
37+
return s.is_not_null()
38+
return s.notna()
39+
40+
41+
def _series_filter(s: Any, mask: Any) -> Any:
42+
"""Filter a Series by a boolean mask, engine-aware, dropping the old index (pandas)."""
43+
if _is_polars_df(s):
44+
return s.filter(mask)
45+
return s[mask].reset_index(drop=True)
46+
47+
48+
def _frame_filter(df: Any, mask: Any) -> Any:
49+
"""Filter a DataFrame's rows by a boolean mask, engine-aware, dropping the old index."""
50+
if _is_polars_df(df):
51+
return df.filter(mask)
52+
return df.loc[mask].reset_index(drop=True)
53+
54+
55+
def _ordered_left_join(left: Any, right: Any, *, on: str) -> Any:
56+
"""Left join preserving ``left`` row order, engine-aware. Polars ``.merge`` does not exist;
57+
``safe_merge`` (pandas/cuDF ``.merge``) cannot run on polars frames, so branch to
58+
``.join(..., maintain_order='left')`` which pins the WITH-row ordering the reentry seed needs.
59+
60+
Under ``engine='polars'`` the carried ids come from the natively-projected (polars) prefix
61+
while the base node table can still be pandas (the base graph is converted lazily), so align
62+
``right`` onto ``left``'s engine before the polars join."""
63+
if _is_polars_df(left):
64+
from graphistry.Engine import Engine, df_to_engine
65+
if not _is_polars_df(right):
66+
right = df_to_engine(right, Engine.POLARS)
67+
return left.join(right, on=on, how="left", maintain_order="left")
68+
return safe_merge(left, right, on=on, how="left")
69+
70+
2671
def _reentry_row(prefix_rows: Any, row_index: int) -> Any:
2772
"""One prefix row as a col->scalar mapping, engine-aware (``row[col]`` works for
2873
both the pandas Series and the polars named-row dict)."""
@@ -274,7 +319,7 @@ def compiled_query_reentry_state(
274319
)
275320
ids = meta["ids"]
276321
id_column = meta["id_column"]
277-
if not hasattr(ids, "dropna"):
322+
if not _series_is_series_like(ids):
278323
raise reentry_validation_error(
279324
"Cypher MATCH after WITH could not recover carried node identities from the prefix stage",
280325
value=output_name,
@@ -306,6 +351,17 @@ def compiled_query_reentry_state(
306351
value=output_name,
307352
suggestion=REENTRY_SCALAR_SUGGESTION,
308353
)
354+
if _is_polars_df(carried_ids) or _is_polars_df(aligned_prefix_rows) or _is_polars_df(base_nodes):
355+
# Whole-row re-entry that ALSO carries scalar WITH columns (e.g. `WITH a, a.val AS av
356+
# MATCH (a)-...`) threads hidden ``__cypher_reentry_*`` payload columns through the
357+
# binding pipeline; that carry path is pandas-only so far. The seed-only whole-row case
358+
# (no carried scalars) returned above. Decline honestly rather than run the pandas-only
359+
# payload merge on polars frames (parity-or-NIE; no silent bridge).
360+
raise NotImplementedError(
361+
"polars engine does not yet natively support Cypher WITH -> MATCH re-entry that carries "
362+
"scalar WITH columns into the trailing MATCH; use engine='pandas' for this query "
363+
"(no pandas fallback; parity-or-error by design)"
364+
)
309365
duplicate_mask = carried_ids.duplicated()
310366
if bool(duplicate_mask.any()) if hasattr(duplicate_mask, "any") else False:
311367
raise reentry_validation_error(
@@ -488,18 +544,18 @@ def aligned_reentry_rows(
488544
value=output_name,
489545
suggestion="Retry with a direct whole-row carry through WITH or inspect intermediate row-shaping before MATCH re-entry.",
490546
)
491-
if not hasattr(ids, "notna"):
547+
if not _series_is_series_like(ids):
492548
raise reentry_validation_error(
493549
"Cypher MATCH after WITH could not align carried node identities from the prefix stage",
494550
value=output_name,
495551
suggestion=REENTRY_WHOLE_ROW_SUGGESTION,
496552
)
497553

498-
non_null_mask = cast(SeriesT, ids.notna())
499-
carried_ids = cast(SeriesT, ids[non_null_mask].reset_index(drop=True))
554+
non_null_mask = _series_not_null_mask(ids)
555+
carried_ids = cast(SeriesT, _series_filter(ids, non_null_mask))
500556
if prefix_rows is None:
501557
return carried_ids, None
502-
return carried_ids, cast(DataFrameT, prefix_rows.loc[non_null_mask].reset_index(drop=True))
558+
return carried_ids, cast(DataFrameT, _frame_filter(prefix_rows, non_null_mask))
503559

504560

505561
def reentry_carry_payload(
@@ -533,4 +589,4 @@ def ordered_reentry_start_nodes(
533589
id_column: str,
534590
) -> DataFrameT:
535591
# MATCH re-entry must preserve the WITH row order, not the base node-table order.
536-
return cast(DataFrameT, safe_merge(carried_node_ids, node_rows, on=id_column, how="left"))
592+
return cast(DataFrameT, _ordered_left_join(carried_node_ids, node_rows, on=id_column))

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

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,43 @@ def _flat_entity_exprs_polars(rows_df: pl.DataFrame, projection: ResultProjectio
132132
return out
133133

134134

135+
def _record_entity_meta(
136+
entity_meta: dict,
137+
rows_df: pl.DataFrame,
138+
projection: ResultProjectionPlan,
139+
source_alias: str,
140+
output_name: str,
141+
id_column: Optional[str],
142+
) -> None:
143+
"""Record whole-entity projection metadata for one column, mirroring the pandas projector.
144+
145+
``_try_native_projection`` reaches this only in the single-entity branch (flat exprs and the
146+
entity-text path both decline multi-entity prefixed columns), so ``rows_df`` is the aligned
147+
source frame and ``rows_df[id_column]`` is the carried alias's id column, row-aligned with the
148+
projected output. Snapshot (``.clone()``) the id column so downstream reentry recovery never
149+
aliases a later-mutated working frame (see #1356)."""
150+
if id_column is None or id_column not in rows_df.columns: # pragma: no cover - defensive: node re-entry always carries the id column
151+
return
152+
entity_meta[output_name] = {
153+
"table": projection.table,
154+
"alias": source_alias,
155+
"id_column": id_column,
156+
"ids": rows_df.get_column(id_column).clone(),
157+
}
158+
159+
135160
def _try_native_projection(result: Plottable, rows_df: pl.DataFrame, projection: ResultProjectionPlan, structured: bool) -> Optional[Plottable]:
136161
"""Native projection for property/expr columns already in the polars row table + structured-
137162
flat or entity-text whole-entity returns; None → caller raises NIE."""
138163
import polars as pl
139164

140165
exprs = []
166+
# Whole-entity projection metadata side-channel (#1273 WITH->MATCH re-entry): mirror the
167+
# pandas projector (result_postprocess._apply_result_projection_pandas), which records the
168+
# carried alias's id column so the bounded-reentry executor can recover carried node
169+
# identities. Without it a WITH-projected node alias feeding a trailing MATCH declines.
170+
entity_meta: dict = {}
171+
id_column = result._node
141172
for column in projection.columns:
142173
if column.kind == "whole_row":
143174
if projection.table != "nodes":
@@ -146,15 +177,16 @@ def _try_native_projection(result: Plottable, rows_df: pl.DataFrame, projection:
146177
if structured:
147178
# #1650 default: flatten to {output}.{field} (near-free, any dtype);
148179
# text fallback only for synthesized-absent rows.
149-
id_column = result._node
150180
flat = _flat_entity_exprs_polars(rows_df, projection, source_alias, column.output_name, id_column)
151181
if flat is not None:
152182
exprs.extend(flat)
183+
_record_entity_meta(entity_meta, rows_df, projection, source_alias, column.output_name, id_column)
153184
continue
154185
ent = _native_node_entity_text_expr(rows_df, source_alias, projection.exclude_columns)
155186
if ent is None:
156187
return None
157188
exprs.append(ent.alias(column.output_name))
189+
_record_entity_meta(entity_meta, rows_df, projection, source_alias, column.output_name, id_column)
158190
continue
159191
src = column.source_name
160192
if src is None or src not in rows_df.columns:
@@ -172,6 +204,8 @@ def _try_native_projection(result: Plottable, rows_df: pl.DataFrame, projection:
172204
return None
173205
out = result.bind()
174206
out._nodes = rows_df.select(exprs)
207+
if entity_meta:
208+
setattr(out, "_cypher_entity_projection_meta", entity_meta)
175209
edges_df = result._edges
176210
if edges_df is not None:
177211
out._edges = edges_df.clear() if _is_polars_frame(edges_df) else edges_df[:0]

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

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,15 +1008,37 @@ def _names(lf: pl.LazyFrame) -> List[str]:
10081008
dst = g._destination
10091009
if nodes is None or edges is None or node_id is None or src is None or dst is None:
10101010
return None
1011-
if getattr(g, "_gfql_start_nodes", None) is not None:
1012-
# Bounded re-entry seeds the first alias from carried rows — pandas-only.
1013-
return None
1011+
seed_ids_lf: Optional[Any] = None # LazyFrame; Any avoids the union-typed seed_nodes.join mismatch
1012+
start_nodes = getattr(g, "_gfql_start_nodes", None)
1013+
if start_nodes is not None:
1014+
# Bounded WITH->MATCH re-entry (#1273): the carried WITH rows seed the first
1015+
# alias. Constrain the first node alias to the carried ids via a semi-join —
1016+
# the native twin of the pandas wavefront seed. Support only UNIQUE carried
1017+
# ids: then the semi-join contributes each seed node exactly once, matching the
1018+
# pandas seed row-for-row; duplicate carried ids could change path multiplicity,
1019+
# so decline (return None -> honest NIE), never risk a silent-wrong count.
1020+
from graphistry.Engine import Engine as _Engine, df_to_engine as _df_to_engine, is_polars_df as _is_polars
1021+
sn = start_nodes.collect() if isinstance(start_nodes, pl.LazyFrame) else start_nodes
1022+
if not _is_polars(sn):
1023+
sn = _df_to_engine(sn, _Engine.POLARS)
1024+
if node_id not in sn.columns:
1025+
return None
1026+
seed_ids = sn.select(pl.col(node_id)).drop_nulls()
1027+
if seed_ids.height != seed_ids.unique().height:
1028+
return None
1029+
seed_ids_lf = seed_ids.lazy()
10141030

10151031
ops: List[ASTObject] = [ast_from_json(op_json, validate=False) for op_json in binding_ops]
10161032
# Shared validation (engine-agnostic): raises the canonical GFQLValidationError
10171033
# for malformed op sequences / duplicate aliases — same error as pandas.
10181034
RowPipelineMixin._gfql_validate_binding_ops(ops)
10191035
if RowPipelineMixin._gfql_binding_ops_mode(ops) == "node_cartesian":
1036+
if seed_ids_lf is not None:
1037+
# A WITH->MATCH seed constrains the FIRST alias, but the cartesian builder
1038+
# below does not thread it; running it would silently ignore the seed
1039+
# (wrong cross-product). Decline honestly (the alternating-path seed is
1040+
# applied at seed_nodes; node-cartesian re-entry stays pandas-only).
1041+
return None
10201042
# MATCH (a), (b), ... disconnected node aliases: native cross-product (#1273).
10211043
return _cartesian_node_bindings_polars(g, ops, node_id)
10221044
if RowPipelineMixin._gfql_is_shortest_path_scalar_binding_ops(ops):
@@ -1076,6 +1098,9 @@ def _names(lf: pl.LazyFrame) -> List[str]:
10761098
if not isinstance(first_op, ASTNode):
10771099
return None
10781100
seed_nodes = filter_by_dict_polars(nodes_lf, first_op.filter_dict)
1101+
if seed_ids_lf is not None:
1102+
# WITH->MATCH re-entry seed: constrain the first alias to the carried ids.
1103+
seed_nodes = seed_nodes.join(seed_ids_lf, on=node_id, how="semi")
10791104
state = seed_nodes.select(pl.col(node_id).alias("__current__"))
10801105
alias_frames: Dict[str, pl.LazyFrame] = {}
10811106
node_aliases: List[str] = []

0 commit comments

Comments
 (0)