Skip to content

Commit a76ab12

Browse files
lmeyerovclaude
andcommitted
feat(gfql): native polars undirected var-length bindings (-[*1..k]-) for IC11/IC6 cross-alias WHERE
binding_rows_polars now materializes the bounded UNDIRECTED variable-length bindings table for min_hops==1 (the LDBC IC11/IC6 -[*1..k]- shape), unblocking the cross-alias same-path `WHERE NOT a = b` / `a <> b` clause on polars (the WHERE lowering itself was already native; the residual was the undirected `rows(binding_ops=...)` materialization declining). Exact port of the pandas oracle `_gfql_multihop_binding_rows` (avoid_immediate_backtrack=True): a `__prev__` marker (seeded null, dtype-matched to the id column) drops immediate backtracks each hop, and the step-pair set reproduces pandas' edge multiplicity (each non-loop edge each directed orientation x2; self-loops (u,u) x2 only). Scoped to min_hops==1 where the raw-edge reconstruction provably matches pandas; min_hops 0 and >=2 decline with an honest NotImplementedError (never silent-wrong). Differential fuzz vs pandas over ~2500 random graphs (self-loops, parallel + antiparallel edges, *1..2..*1..5, all WHERE/RETURN variants): 0 disagreements; out-of-scope windows decline. Tests: parity + multiplicity/backtrack/self-loop/ string-id pins + *0..2/*2..3/*2..2 decline pins. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
1 parent 60ecaea commit a76ab12

3 files changed

Lines changed: 173 additions & 22 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 the LDBC IC11/IC6 undirected variable-length bindings table (`-[*1..k]-`), unblocking the cross-alias `WHERE NOT(person=friend)` clause**: a bounded UNDIRECTED variable-length MATCH that materializes a bindings row table (`rows(binding_ops=...)`, emitted whenever a downstream clause — e.g. a cross-alias same-path `WHERE NOT a = b` / `a <> b` — references two node aliases on the path) previously declined on polars (`binding_rows_polars` hard-NIE'd every undirected multihop), while pandas handled it. This was the single residual blocking the official LDBC IC11/IC6 queries on polars (the cross-alias WHERE lowering itself was already native — directed var-length + `WHERE NOT a = b` already matched pandas). `binding_rows_polars` now supports undirected var-length with **`min_hops == 1`** via a doubled-pair join with immediate-backtrack avoidance, an exact port of the pandas oracle `_gfql_multihop_binding_rows` (`avoid_immediate_backtrack=True`): a `__prev__` marker (seeded null, dtype-matched to the id column) drops immediate backtracks each hop (Kleene mask: null prev kept), and the step-pair set reproduces pandas' edge multiplicity exactly — each non-loop edge contributes each directed orientation TWICE (so a length-1 pair appears x2, length-2 x4) while self-loops contribute `(u,u)` x2 only (not double-counted). The multiplicity rule was derived by instrumenting pandas' `step_pairs` (which flow from the var-length `edge_op.execute` hop + `orient_edges`), NOT by reading code alone. Scoped to `min_hops == 1` because that is where the raw-edge reconstruction provably matches pandas: every edge is trivially a length-1 path so the var-length hop's backward pruning removes nothing; `min_hops == 0` (zero-hop, undoubled) and `min_hops >= 2` (backward-pruned / long-walk divergence) still **decline with an honest `NotImplementedError`** rather than risk silent-wrong multiplicities. Differential fuzz vs the pandas oracle over ~2500 random graphs (self-loops, parallel + antiparallel edges, `*1..2`…`*1..5`, all WHERE/RETURN variants): 0 disagreements; the out-of-scope windows decline (never diverge). Tests in `test_engine_polars_binding_rows.py` (parity + multiplicity/backtrack/self-loop/string-id pins + `*0..2`/`*2..3`/`*2..2` decline pins).
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).

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

Lines changed: 83 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,17 +1032,31 @@ def _names(lf: pl.LazyFrame) -> List[str]:
10321032
sem = EdgeSemantics.from_edge(op)
10331033
if sem.is_multihop:
10341034
# Bounded directed var-length (`-[*1..k]->`, graph-bench q3) is
1035-
# supported via iterative pair joins; everything else declines:
1036-
# unbounded (`[*]`, needs fixed-point + termination error),
1037-
# undirected multihop (immediate-backtrack avoidance not ported),
1038-
# and aliased var-length edges (pandas rejects those outright).
1035+
# supported via iterative pair joins. Bounded UNDIRECTED var-length
1036+
# with min_hops == 1 (`-[*1..k]-`, the LDBC IC11/IC6 shape) is now
1037+
# also supported via a doubled-pair join with immediate-backtrack
1038+
# avoidance (see the execution branch below). Everything else declines:
1039+
# unbounded (`[*]`, needs fixed-point + termination error), aliased
1040+
# var-length edges (pandas rejects those outright), and undirected
1041+
# var-length with min_hops != 1 (`-[*0..k]-` / `-[*2..k]-`): pandas'
1042+
# step_pairs come from the var-length `edge_op.execute` hop, whose
1043+
# backward hop-window pruning / zero-hop handling changes the edge
1044+
# multiplicity in a way this raw-edge reconstruction only reproduces
1045+
# for min_hops == 1 (every edge is trivially a length-1 path, so no
1046+
# pruning occurs) — fuzz-verified vs the pandas oracle. Decline the
1047+
# rest honestly rather than risk silent-wrong multiplicities.
10391048
if (
1040-
op.direction == "undirected"
1041-
or bool(op.to_fixed_point)
1049+
bool(op.to_fixed_point)
10421050
or (op.max_hops is None and op.hops is None)
10431051
or isinstance(op._name, str)
10441052
):
10451053
return None
1054+
if op.direction == "undirected":
1055+
_resolved_min = op.min_hops if op.min_hops is not None else (
1056+
op.hops if op.hops is not None else 1
1057+
)
1058+
if _resolved_min != 1:
1059+
return None
10461060
if op.direction not in ("forward", "reverse", "undirected"):
10471061
return None
10481062
if any(
@@ -1149,23 +1163,71 @@ def _names(lf: pl.LazyFrame) -> List[str]:
11491163
return None
11501164
min_hops = int(min_hops_value)
11511165
max_hops = int(max_hops_value)
1152-
pairs = oriented.select(["__from__", "__to__"])
11531166
state_cols = _names(state)
1154-
reachable = [state] if min_hops == 0 else []
1155-
current = state
1156-
# Lazy: build all max_hops iterations (no eager .height early-break —
1157-
# empty intermediates lazily join to empty, so the result is
1158-
# identical; the pandas break is an optimization, not semantics).
1159-
for _hop in range(1, max_hops + 1):
1160-
current = (
1161-
current.join(pairs, left_on="__current__", right_on="__from__", how="inner")
1162-
.drop("__current__")
1163-
.rename({"__to__": "__current__"})
1164-
.select(state_cols)
1167+
if sem.is_undirected:
1168+
# Bounded UNDIRECTED var-length, min_hops == 1 (gated above): the
1169+
# LDBC IC11/IC6 `-[*1..k]-` shape. Mirror the pandas oracle
1170+
# (`_gfql_multihop_binding_rows`, avoid_immediate_backtrack=True)
1171+
# EXACTLY, including its edge multiplicity: pandas' `step_pairs`
1172+
# come from the undirected var-length hop + `orient_edges`, which
1173+
# emits each NON-loop edge as (u,v)x2 AND (v,u)x2, and each
1174+
# SELF-loop as (u,u)x2 (loops are not double-counted). Reconstruct
1175+
# that here: `exec_rows` = both directions of non-loops + one row
1176+
# per self-loop; the final `pairs` doubles `exec_rows`
1177+
# (fuzz-verified vs pandas over random graphs incl. self-loops,
1178+
# parallel + antiparallel edges). A `__prev__` column (seeded null)
1179+
# carries the just-left node so each hop can drop immediate
1180+
# backtracks (`__to__ == __prev__`), matching pandas' Kleene mask
1181+
# (null prev -> kept).
1182+
normal = edges_f.filter(pl.col(src) != pl.col(dst))
1183+
loops = edges_f.filter(pl.col(src) == pl.col(dst))
1184+
fwd = normal.select([pl.col(src).alias("__from__"), pl.col(dst).alias("__to__")])
1185+
rev = normal.select([pl.col(dst).alias("__from__"), pl.col(src).alias("__to__")])
1186+
loop = loops.select([pl.col(src).alias("__from__"), pl.col(dst).alias("__to__")])
1187+
exec_rows = pl.concat([fwd, rev, loop], how="vertical")
1188+
pairs = pl.concat([exec_rows, exec_rows], how="vertical")
1189+
prev_col = "__prev__"
1190+
reachable = [state.select(state_cols)] if min_hops == 0 else []
1191+
# Seed the backtrack marker with the SAME dtype as __current__ so a
1192+
# non-Int64 node id (e.g. string ids) compares/concats cleanly.
1193+
current = state.with_columns(
1194+
pl.lit(None).cast(state.collect_schema()["__current__"]).alias(prev_col)
11651195
)
1166-
if _hop >= min_hops:
1167-
reachable.append(current)
1168-
state = pl.concat(reachable, how="vertical") if reachable else state.limit(0)
1196+
for _hop in range(1, max_hops + 1):
1197+
joined = current.join(
1198+
pairs, left_on="__current__", right_on="__from__", how="inner"
1199+
)
1200+
joined = joined.filter(
1201+
pl.col(prev_col).is_null() | (pl.col("__to__") != pl.col(prev_col))
1202+
)
1203+
# new prev = the node we are leaving (old __current__); new
1204+
# __current__ = __to__. Set prev BEFORE dropping __current__.
1205+
joined = (
1206+
joined.with_columns(pl.col("__current__").alias(prev_col))
1207+
.drop("__current__")
1208+
.rename({"__to__": "__current__"})
1209+
)
1210+
current = joined.select(state_cols + [prev_col])
1211+
if _hop >= min_hops:
1212+
reachable.append(current.select(state_cols))
1213+
state = pl.concat(reachable, how="vertical") if reachable else state.limit(0)
1214+
else:
1215+
pairs = oriented.select(["__from__", "__to__"])
1216+
reachable = [state] if min_hops == 0 else []
1217+
current = state
1218+
# Lazy: build all max_hops iterations (no eager .height early-break —
1219+
# empty intermediates lazily join to empty, so the result is
1220+
# identical; the pandas break is an optimization, not semantics).
1221+
for _hop in range(1, max_hops + 1):
1222+
current = (
1223+
current.join(pairs, left_on="__current__", right_on="__from__", how="inner")
1224+
.drop("__current__")
1225+
.rename({"__to__": "__current__"})
1226+
.select(state_cols)
1227+
)
1228+
if _hop >= min_hops:
1229+
reachable.append(current)
1230+
state = pl.concat(reachable, how="vertical") if reachable else state.limit(0)
11691231
else:
11701232
state = (
11711233
state.join(oriented, left_on="__current__", right_on="__from__", how="inner")

0 commit comments

Comments
 (0)