From ce67c022e5dc02d6445ad425a53bf228c7a9b77f Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 19 Jul 2026 23:47:33 -0700 Subject: [PATCH 1/3] perf(gfql): route engine=auto to native polars for polars-frame graphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_engine(AUTO) maps polars frames to PANDAS (it predates Engine.POLARS), so g.gfql(query) on a polars-frame graph silently bridged to the generic pandas path: ~3-13x slower on cypher point queries and pandas frames out. Route AUTO to the native polars engine; an honest NotImplementedError (unsupported shape) falls back to the legacy AUTO path — allowed because the user did not pin an engine. Frames in = frames out: AUTO results on polars graphs are now polars. Repro: 2k-node polars graph, seeded 1-hop cypher — AUTO 25.0ms/pandas out before, 9.3ms/polars out after (engine='polars' = 8.8ms); polars-NIE shapes (shortestPath) still answer via the pandas fallback. Fixes the q5 finding in plans/gfql-benchmark-numbers (inferred-engine 13x penalty). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL --- graphistry/compute/gfql_unified.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/graphistry/compute/gfql_unified.py b/graphistry/compute/gfql_unified.py index d0a6f0603e..2b1fbe49aa 100644 --- a/graphistry/compute/gfql_unified.py +++ b/graphistry/compute/gfql_unified.py @@ -1815,6 +1815,24 @@ def gfql(self: Plottable, :returns: Resulting Plottable :rtype: Plottable """ + # engine inference: resolve_engine(AUTO) maps polars frames to PANDAS (polars predates + # Engine.POLARS there), silently bridging polars-frame graphs onto the generic pandas path + # (~13x slower on cypher point queries, pandas frames out). Route AUTO to the native polars + # engine instead; an honest NIE (unsupported shape) falls back to the legacy AUTO path, which + # is allowed here because the user did not pin an engine. + if ( + (engine == EngineAbstract.AUTO or engine == EngineAbstract.AUTO.value) + and is_polars_df(self._edges) and (self._nodes is None or is_polars_df(self._nodes)) + ): + try: + return gfql( + self, query, engine=Engine.POLARS.value, output=output, policy=policy, + where=where, language=language, params=params, validate=validate, + shortest_path_backend=shortest_path_backend, + ) + except NotImplementedError: + logger.debug('AUTO polars-native attempt declined; falling back to generic path') + context = ExecutionContext() if policy and context.policy_depth >= 1: From f54f9e8f21d49f72a3e5ddbb39ad32dab1384609 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 19 Jul 2026 23:48:20 -0700 Subject: [PATCH 2/3] test(gfql): pin auto-engine native polars routing + NIE fallback Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL --- .../test_engine_polars_cypher_conformance.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/graphistry/tests/compute/gfql/test_engine_polars_cypher_conformance.py b/graphistry/tests/compute/gfql/test_engine_polars_cypher_conformance.py index d1a8c5433d..1940740065 100644 --- a/graphistry/tests/compute/gfql/test_engine_polars_cypher_conformance.py +++ b/graphistry/tests/compute/gfql/test_engine_polars_cypher_conformance.py @@ -496,3 +496,33 @@ def test_bool_modulo_declines_like_pandas(): # bool + int still computes in parity (not over-declined) got = g.gfql("MATCH (n) RETURN n.flag + 2 AS r", engine="polars")._nodes["r"].to_list() assert got == [3, 2, 3] + + +class TestAutoEngineRoutesPolarsNative: + """engine=auto on polars-frame graphs must run the native polars path (frames in = frames + out), not the silent pandas bridge (~13x on point queries); polars-NIE shapes still answer + via the legacy AUTO fallback since the user did not pin an engine.""" + + def _graph(self): + nodes = pl.DataFrame({"id": [0, 1, 2, 3], "label__Person": [True] * 4}) + edges = pl.DataFrame({"s": [0, 1, 2], "d": [1, 2, 3], "type": ["KNOWS"] * 3}) + return graphistry.nodes(nodes, "id").edges(edges, "s", "d") + + def test_auto_returns_polars_frames_and_matches_explicit(self): + g = self._graph() + q = "MATCH (a:Person {id: 0})-[:KNOWS]->(b) RETURN b.id AS bid ORDER BY bid" + r_auto = g.gfql(q)._nodes + r_expl = g.gfql(q, engine="polars")._nodes + assert "polars" in type(r_auto).__module__, "auto must not bridge polars graphs to pandas" + from polars.testing import assert_frame_equal + assert_frame_equal(r_auto, r_expl) + + def test_auto_falls_back_on_polars_nie(self): + g = self._graph() + q = ("MATCH p = shortestPath((a:Person {id: 0})-[:KNOWS*]-(b:Person {id: 3})) " + "RETURN length(p) AS l") + with pytest.raises(NotImplementedError): + g.gfql(q, engine="polars") # pinned engine: honest NIE + out = g.gfql(q)._nodes # auto: answers via fallback + rows = out.to_dict("records") if hasattr(out, "to_dict") else out.to_pandas().to_dict("records") + assert rows == [{"l": 3}] From 9af2c5e078006a172797b3b41af86deca5ff0058 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Mon, 20 Jul 2026 00:08:19 -0700 Subject: [PATCH 3/3] test(gfql): probe to_dicts before to_dict(orient=) for engine-agnostic records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dual-path guards checked hasattr(out, 'to_dict') first, but polars DataFrame HAS to_dict (without orient) — so the polars branch was unreachable and these tests only ever ran because auto silently returned pandas. With auto routing polars-frame graphs natively they now receive polars frames; probe to_dicts first. Assertions (record values) unchanged and still pass on both engines. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL --- graphistry/tests/compute/gfql/cypher/test_lowering.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/graphistry/tests/compute/gfql/cypher/test_lowering.py b/graphistry/tests/compute/gfql/cypher/test_lowering.py index 093c727fcc..9de105e86f 100644 --- a/graphistry/tests/compute/gfql/cypher/test_lowering.py +++ b/graphistry/tests/compute/gfql/cypher/test_lowering.py @@ -15997,7 +15997,7 @@ def test_connected_join_polars_nullable_columns_match_master(predicate: str, exp query = f"MATCH (p)-[]->(x), (p)-[]->(y) WHERE {predicate} RETURN count(p) AS n" out = g.gfql(query)._nodes - records = out.to_dict(orient="records") if hasattr(out, "to_dict") else out.to_dicts() + records = out.to_dicts() if hasattr(out, "to_dicts") else out.to_dict(orient="records") assert records == expected @@ -16027,7 +16027,7 @@ def test_arrow_table_nodes_match_master_results() -> None: def run(predicate: str) -> Any: query = f"MATCH (p)-[]->(x), (p)-[]->(y) WHERE {predicate} RETURN count(p) AS n" out = g.gfql(query)._nodes - return out.to_dict(orient="records") if hasattr(out, "to_dict") else out.to_dicts() + return out.to_dicts() if hasattr(out, "to_dicts") else out.to_dict(orient="records") assert run("p.name = 5") == [] assert run("p.name = 'ann'") == [{"n": 4}] @@ -16084,7 +16084,7 @@ def test_connected_join_polars_decimal_matches_master(predicate: str, expected: query = f"MATCH (p)-->(a), (p)-->(b) WHERE {predicate} RETURN count(p) AS n" result = g.gfql(query)._nodes - records = result.to_dict(orient="records") if hasattr(result, "to_dict") else result.to_dicts() + records = result.to_dicts() if hasattr(result, "to_dicts") else result.to_dict(orient="records") assert records == expected @@ -16099,7 +16099,7 @@ def test_connected_join_polars_backed_graph_matches_pandas_results() -> None: g_polars = graphistry.nodes(pl.from_pandas(nodes), "id").edges(pl.from_pandas(edges), "s", "d") result = g_polars.gfql(query)._nodes - records = result.to_dict(orient="records") if hasattr(result, "to_dict") else result.to_dicts() + records = result.to_dicts() if hasattr(result, "to_dicts") else result.to_dict(orient="records") assert records == []