From b1903892aa2cbf1229c09a8560ee4ad3fb75b6db Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Mon, 6 Jul 2026 18:15:03 -0700 Subject: [PATCH 01/10] Fail fast on GRAPH residual predicates --- docs/source/gfql/cypher.rst | 7 +++++++ graphistry/compute/gfql/cypher/lowering.py | 12 ++++++++++++ .../tests/compute/gfql/cypher/test_lowering.py | 17 +++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/docs/source/gfql/cypher.rst b/docs/source/gfql/cypher.rst index 9bbd28229b..f5b3fc821d 100644 --- a/docs/source/gfql/cypher.rst +++ b/docs/source/gfql/cypher.rst @@ -92,6 +92,13 @@ state instead of a row table: subgraph._nodes subgraph._edges +``GRAPH { MATCH ... WHERE ... }`` currently accepts native graph-state +predicates that lower to chain node/edge filters. Row-only residual predicates +such as disjunctions, ``searchAny(...)``, and pattern predicates are rejected +inside ``GRAPH { }`` until the first-class independent node/edge subgraph +projection form is implemented. Use row-returning ``MATCH ... RETURN`` queries +for those predicates when you need row results. + Use ``GRAPH g = GRAPH { ... }`` to bind a named graph, then ``USE g`` to query it: diff --git a/graphistry/compute/gfql/cypher/lowering.py b/graphistry/compute/gfql/cypher/lowering.py index 3623e4d992..0ade3196b9 100644 --- a/graphistry/compute/gfql/cypher/lowering.py +++ b/graphistry/compute/gfql/cypher/lowering.py @@ -7182,6 +7182,18 @@ def _compile_graph_constructor( reentry_unwinds=(), ) lowered = lower_match_query(synthetic, params=params) + if lowered.row_pre_filters or lowered.row_where is not None: + residual = lowered.row_where.text if lowered.row_where is not None else "row_pre_filters" + raise _unsupported( + "Cypher GRAPH constructors currently support only native graph-state predicates; " + "residual row predicates such as OR, searchAny, or pattern predicates are not " + "yet supported inside GRAPH { } because they cannot be applied to graph-state " + "Chain output without first-class subgraph projection semantics", + field="graph_constructor", + value=residual, + line=constructor.span.line, + column=constructor.span.column, + ) constructor_bound_ir = FrontendBinder().bind(synthetic, PlanContext(), strict_name_resolution=True) ( constructor_logical_plan, diff --git a/graphistry/tests/compute/gfql/cypher/test_lowering.py b/graphistry/tests/compute/gfql/cypher/test_lowering.py index e1f8e75561..4b571f8244 100644 --- a/graphistry/tests/compute/gfql/cypher/test_lowering.py +++ b/graphistry/tests/compute/gfql/cypher/test_lowering.py @@ -2519,6 +2519,23 @@ def test_graph_constructor_empty_match_returns_empty_graph() -> None: assert len(result._edges) == 0 +def test_graph_constructor_rejects_residual_row_predicates() -> None: + nodes = pd.DataFrame({ + "id": ["a", "b", "c"], + "score": [0.3, 0.1, None], + "name": ["alpha", "beta", "gamma"], + }) + edges = pd.DataFrame({"s": ["a", "b"], "d": ["b", "c"], "weight": [7, 9]}) + g = _mk_graph(nodes, edges) + queries = [ + "GRAPH { MATCH (a)-[r]->(b) WHERE (a.score > 0.25 OR a.score IS NULL) }", + "GRAPH { MATCH (a)-[r]->(b) WHERE searchAny(a, 'alpha') }", + ] + for query in queries: + with pytest.raises(GFQLValidationError, match="GRAPH constructors currently support only native graph-state predicates"): + g.gfql(query) + + def test_standalone_graph_constructor_preserves_columns() -> None: nodes = pd.DataFrame({"id": ["a", "b", "c"], "score": [10, 5, 1]}) edges = pd.DataFrame({"s": ["a", "b"], "d": ["b", "c"], "weight": [7, 9]}) From 8f852715ae82ff4fb3d476bb96449251ca7aacac Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Mon, 6 Jul 2026 22:29:50 -0700 Subject: [PATCH 02/10] Apply GRAPH residual predicates as masks --- docs/source/gfql/cypher.rst | 12 +- graphistry/compute/gfql/cypher/lowering.py | 115 +++++++++++++++-- graphistry/compute/gfql_unified.py | 103 ++++++++++++++- .../compute/gfql/cypher/test_lowering.py | 122 ++++++++++++++++-- 4 files changed, 321 insertions(+), 31 deletions(-) diff --git a/docs/source/gfql/cypher.rst b/docs/source/gfql/cypher.rst index f5b3fc821d..d3a83a8b0e 100644 --- a/docs/source/gfql/cypher.rst +++ b/docs/source/gfql/cypher.rst @@ -92,12 +92,12 @@ state instead of a row table: subgraph._nodes subgraph._edges -``GRAPH { MATCH ... WHERE ... }`` currently accepts native graph-state -predicates that lower to chain node/edge filters. Row-only residual predicates -such as disjunctions, ``searchAny(...)``, and pattern predicates are rejected -inside ``GRAPH { }`` until the first-class independent node/edge subgraph -projection form is implemented. Use row-returning ``MATCH ... RETURN`` queries -for those predicates when you need row results. +``GRAPH { MATCH ... WHERE ... }`` accepts native graph-state predicates +that lower to chain node/edge filters. Single node- or edge-alias residual +predicates such as disjunctions and ``searchAny(...)`` are applied as GRAPH +subgraph masks before the match chain runs. Pattern-predicate and multi-alias +residuals remain unsupported inside ``GRAPH { }`` until first-class independent +node/edge subgraph projection covers their semantics. Use ``GRAPH g = GRAPH { ... }`` to bind a named graph, then ``USE g`` to query it: diff --git a/graphistry/compute/gfql/cypher/lowering.py b/graphistry/compute/gfql/cypher/lowering.py index 0ade3196b9..5ae3666547 100644 --- a/graphistry/compute/gfql/cypher/lowering.py +++ b/graphistry/compute/gfql/cypher/lowering.py @@ -176,6 +176,14 @@ class CompiledCypherExecutionExtras: logical_plan_defer_code: Optional[str] = None +@dataclass(frozen=True) +class CompiledGraphResidualFilter: + alias: str + kind: Literal["node", "edge"] + expr: str + pre_filters: Tuple[ASTCall, ...] = () + + @dataclass(frozen=True) class CompiledCypherQuery: chain: Chain @@ -185,6 +193,7 @@ class CompiledCypherQuery: execution_extras: Optional[CompiledCypherExecutionExtras] = None graph_bindings: Tuple["CompiledGraphBinding", ...] = () use_ref: Optional[str] = None + graph_residual_filters: Tuple[CompiledGraphResidualFilter, ...] = () @property def result_projection(self) -> Optional["ResultProjectionPlan"]: @@ -242,6 +251,7 @@ class CompiledGraphBinding: use_ref: Optional[str] = None logical_plan: Optional[LogicalPlan] = None logical_plan_defer_reason: Optional[str] = None + graph_residual_filters: Tuple[CompiledGraphResidualFilter, ...] = () @dataclass(frozen=True) class CompiledCypherGraphQuery: @@ -252,6 +262,7 @@ class CompiledCypherGraphQuery: use_ref: Optional[str] = None logical_plan: Optional[LogicalPlan] = None logical_plan_defer_reason: Optional[str] = None + graph_residual_filters: Tuple[CompiledGraphResidualFilter, ...] = () @dataclass(frozen=True) class OptionalNullFillPlan: @@ -7119,6 +7130,87 @@ def _compile_call_query( ) +def _compile_graph_residual_filters( + lowered: LoweredCypherMatch, + *, + alias_targets: Mapping[str, ASTObject], + params: Optional[Mapping[str, Any]], + line: int, + column: int, +) -> Tuple[CompiledGraphResidualFilter, ...]: + if lowered.row_where is None and not lowered.row_pre_filters: + return () + + unsupported_pre_filters = [op.function for op in lowered.row_pre_filters if op.function != "search_any"] + if unsupported_pre_filters or lowered.row_where is None: + raise _unsupported( + "Cypher GRAPH constructors do not yet support pattern-predicate residuals inside GRAPH { }", + field="graph_constructor", + value=unsupported_pre_filters or "row_pre_filters", + line=line, + column=column, + ) + + expr = lowered.row_where + expr_aliases = _expr_non_aggregate_match_aliases( + expr.text, + alias_targets=alias_targets, + params=params, + field="graph_constructor", + line=expr.span.line, + column=expr.span.column, + ) + pre_filter_aliases = { + cast(str, op.params.get("alias")) + for op in lowered.row_pre_filters + if isinstance(op.params.get("alias"), str) + } + aliases = expr_aliases | pre_filter_aliases + if len(aliases) != 1: + raise _unsupported( + "Cypher GRAPH residual predicates must reference exactly one node or edge alias to be applied as graph masks", + field="graph_constructor", + value=expr.text, + line=expr.span.line, + column=expr.span.column, + ) + + alias = next(iter(aliases)) + target = alias_targets.get(alias) + if isinstance(target, ASTNode): + kind: Literal["node", "edge"] = "node" + elif isinstance(target, ASTEdge): + kind = "edge" + else: + raise _unsupported( + "Cypher GRAPH residual predicates must target a node or edge alias", + field="graph_constructor", + value=alias, + line=expr.span.line, + column=expr.span.column, + ) + + _validate_row_expr_scope( + expr.text, + alias_targets=alias_targets, + active_match_alias=alias, + allowed_match_aliases={alias}, + unwind_aliases=(), + params=params, + field="graph_constructor", + line=expr.span.line, + column=expr.span.column, + ) + return ( + CompiledGraphResidualFilter( + alias=alias, + kind=kind, + expr=_row_expr_arg(expr, params=params, alias_targets=alias_targets, field="graph_constructor"), + pre_filters=tuple(lowered.row_pre_filters), + ), + ) + + def _compile_graph_constructor( constructor: GraphConstructor, *, @@ -7182,18 +7274,14 @@ def _compile_graph_constructor( reentry_unwinds=(), ) lowered = lower_match_query(synthetic, params=params) - if lowered.row_pre_filters or lowered.row_where is not None: - residual = lowered.row_where.text if lowered.row_where is not None else "row_pre_filters" - raise _unsupported( - "Cypher GRAPH constructors currently support only native graph-state predicates; " - "residual row predicates such as OR, searchAny, or pattern predicates are not " - "yet supported inside GRAPH { } because they cannot be applied to graph-state " - "Chain output without first-class subgraph projection semantics", - field="graph_constructor", - value=residual, - line=constructor.span.line, - column=constructor.span.column, - ) + alias_targets = _alias_target(lowered.query) + graph_residual_filters = _compile_graph_residual_filters( + lowered, + alias_targets=alias_targets, + params=params, + line=constructor.span.line, + column=constructor.span.column, + ) constructor_bound_ir = FrontendBinder().bind(synthetic, PlanContext(), strict_name_resolution=True) ( constructor_logical_plan, @@ -7215,6 +7303,7 @@ def _compile_graph_constructor( logical_plan_defer_code=constructor_logical_plan_defer_code, ) ), + graph_residual_filters=graph_residual_filters, ) @@ -7235,6 +7324,7 @@ def _compile_graph_bindings( use_ref=use_ref, logical_plan=compiled_query.logical_plan, logical_plan_defer_reason=compiled_query.logical_plan_defer_reason, + graph_residual_filters=compiled_query.graph_residual_filters, )) return tuple(compiled) @@ -7965,6 +8055,7 @@ def compile_cypher_query( use_ref=use_ref, logical_plan=compiled_constructor.logical_plan, logical_plan_defer_reason=compiled_constructor.logical_plan_defer_reason, + graph_residual_filters=compiled_constructor.graph_residual_filters, ) if isinstance(query, CypherUnionQuery): branch_output_names: Optional[Tuple[str, ...]] = None diff --git a/graphistry/compute/gfql_unified.py b/graphistry/compute/gfql_unified.py index 8ae58f0000..14f8cb6689 100644 --- a/graphistry/compute/gfql_unified.py +++ b/graphistry/compute/gfql_unified.py @@ -32,6 +32,7 @@ CompiledCypherGraphQuery, CompiledCypherQuery, CompiledCypherUnionQuery, + CompiledGraphResidualFilter, ConnectedOptionalMatchPlan, ) from graphistry.compute.gfql.cypher.reentry.execution import ( @@ -65,7 +66,8 @@ from graphistry.compute.gfql.ir.logical_plan import LogicalPlan from graphistry.compute.gfql.physical_planner import PhysicalPlanner from graphistry.compute.gfql.passes import DEFAULT_LOGICAL_PASSES, DEFAULT_TIER2_PASSES, PassManager -from graphistry.compute.gfql.row.pipeline import is_row_pipeline_call +from graphistry.compute.gfql.row.pipeline import _RowPipelineAdapter, is_row_pipeline_call +from graphistry.compute.gfql.search_any import search_any_mask from graphistry.compute.typing import DataFrameT, SeriesT from graphistry.compute.util.generate_safe_column_name import generate_safe_column_name from graphistry.compute.validate.validate_schema import validate_chain_schema @@ -537,11 +539,103 @@ def _apply_connected_match_join( return _chain_dispatch(joined_plottable, plan.post_join_chain, dispatch_engine, policy, context) +def _graph_residual_eval_frame(df: DataFrameT, alias: str) -> DataFrameT: + if alias in df.columns: + return df.copy() + return cast(DataFrameT, df.assign(**{alias: True})) + + +def _evaluate_graph_residual_mask( + graph: Plottable, + df: DataFrameT, + residual: CompiledGraphResidualFilter, +) -> Any: + eval_df = _graph_residual_eval_frame(df, residual.alias) + for pre_filter in residual.pre_filters: + if pre_filter.function != "search_any": + raise GFQLValidationError( + ErrorCode.E108, + "Cypher GRAPH residual pre-filter is not supported as a graph mask", + field="graph_constructor", + value=pre_filter.function, + language="cypher", + ) + params = pre_filter.params + marker_col = cast(str, params.get("out_col")) + search_df = eval_df[[ + col for col in eval_df.columns + if not str(col).startswith("__gfql_") and col != residual.alias + ]] + marker_mask = search_any_mask( + cast(DataFrameT, search_df), + cast(str, params.get("term")), + case_sensitive=bool(params.get("case_sensitive", False)), + regex=bool(params.get("regex", False)), + columns=cast(Optional[List[str]], params.get("columns")), + ) + if marker_mask is None: + raise GFQLValidationError( + ErrorCode.E108, + "searchAny columns= includes a column absent from the searched table", + field="columns", + value=params.get("columns"), + suggestion="List only columns present on the searched entity.", + language="cypher", + ) + eval_df = cast(DataFrameT, eval_df.assign(**{marker_col: marker_mask})) + + adapter = _RowPipelineAdapter(graph) + value = adapter._gfql_eval_string_expr(eval_df, residual.expr) + return adapter._gfql_bool_mask(eval_df, value) + + +def _apply_graph_residual_filters( + base_graph: Plottable, + residual_filters: Tuple[CompiledGraphResidualFilter, ...], + *, + engine: Union[EngineAbstract, str], +) -> Plottable: + if not residual_filters: + return base_graph + concrete_engine = resolve_engine(cast(Any, engine), base_graph) + if concrete_engine in POLARS_ENGINES: + raise GFQLValidationError( + ErrorCode.E108, + "Cypher GRAPH residual predicates are not yet supported on polars graph execution", + field="graph_constructor", + value=[residual.expr for residual in residual_filters], + suggestion="Use engine='pandas' or engine='cudf' for GRAPH residual predicates.", + language="cypher", + ) + + graph = base_graph + for residual in residual_filters: + if residual.kind == "node": + graph_with_nodes = graph if graph._nodes is not None else graph.materialize_nodes(engine=EngineAbstract(concrete_engine.value)) + nodes_df = cast(DataFrameT, graph_with_nodes._nodes) + node_mask = _evaluate_graph_residual_mask(graph_with_nodes, nodes_df, residual) + filtered_nodes = cast(DataFrameT, nodes_df.loc[node_mask]) + graph = graph_with_nodes.nodes(filtered_nodes) + if graph._edges is not None and graph._node is not None and graph._source is not None and graph._destination is not None: + node_ids = filtered_nodes[graph._node] + edges_df = cast(DataFrameT, graph._edges) + edge_mask = edges_df[graph._source].isin(node_ids) & edges_df[graph._destination].isin(node_ids) + graph = graph.edges(cast(DataFrameT, edges_df.loc[edge_mask])) + else: + if graph._edges is None: + continue + edges_df = cast(DataFrameT, graph._edges) + edge_mask = _evaluate_graph_residual_mask(graph, edges_df, residual) + graph = graph.edges(cast(DataFrameT, edges_df.loc[edge_mask])) + return graph + + def _execute_graph_constructor_compiled( base_graph: Plottable, chain: Chain, *, procedure_call: Any = None, + graph_residual_filters: Tuple[CompiledGraphResidualFilter, ...] = (), engine: Union[EngineAbstract, str], policy: Optional[PolicyDict], context: ExecutionContext, @@ -549,7 +643,10 @@ def _execute_graph_constructor_compiled( """Execute a compiled graph constructor (MATCH-based or CALL-based).""" if procedure_call is not None: return execute_cypher_call(base_graph, procedure_call) - return _chain_dispatch(base_graph, chain, engine, policy, context) + filtered_graph = _apply_graph_residual_filters( + base_graph, graph_residual_filters, engine=engine + ) + return _chain_dispatch(filtered_graph, chain, engine, policy, context) def _resolve_graph_bindings( @@ -578,6 +675,7 @@ def _resolve_graph_bindings( result = _execute_graph_constructor_compiled( target_graph, binding.chain, procedure_call=binding.procedure_call, + graph_residual_filters=binding.graph_residual_filters, engine=engine, policy=policy, context=context, ) scope[binding.name.lower()] = result @@ -604,6 +702,7 @@ def _execute_graph_query( return _execute_graph_constructor_compiled( target_graph, compiled.chain, procedure_call=compiled.procedure_call, + graph_residual_filters=compiled.graph_residual_filters, engine=engine, policy=policy, context=context, ) diff --git a/graphistry/tests/compute/gfql/cypher/test_lowering.py b/graphistry/tests/compute/gfql/cypher/test_lowering.py index 4b571f8244..516e9e0a90 100644 --- a/graphistry/tests/compute/gfql/cypher/test_lowering.py +++ b/graphistry/tests/compute/gfql/cypher/test_lowering.py @@ -2519,21 +2519,121 @@ def test_graph_constructor_empty_match_returns_empty_graph() -> None: assert len(result._edges) == 0 -def test_graph_constructor_rejects_residual_row_predicates() -> None: +def test_graph_constructor_applies_node_residual_row_predicate_as_graph_mask() -> None: nodes = pd.DataFrame({ - "id": ["a", "b", "c"], - "score": [0.3, 0.1, None], - "name": ["alpha", "beta", "gamma"], + "id": ["a", "b", "c", "d"], + "score": [0.3, 0.1, None, 0.5], + "name": ["alpha", "beta", "gamma", "delta"], }) - edges = pd.DataFrame({"s": ["a", "b"], "d": ["b", "c"], "weight": [7, 9]}) - g = _mk_graph(nodes, edges) - queries = [ + edges = pd.DataFrame({ + "s": ["a", "b", "c", "d"], + "d": ["b", "c", "d", "a"], + "weight": [7, 9, 11, 13], + }) + + result = _mk_graph(nodes, edges).gfql( + "GRAPH { MATCH (a)-[r]->(b) WHERE (a.score > 0.25 OR a.score IS NULL) }" + ) + + assert set(_to_pandas_df(result._nodes)["id"].tolist()) == {"a", "c", "d"} + assert _to_pandas_df(result._edges)[["s", "d", "weight"]].to_dict(orient="records") == [ + {"s": "c", "d": "d", "weight": 11}, + {"s": "d", "d": "a", "weight": 13}, + ] + + +def test_graph_constructor_applies_node_residual_row_predicate_as_graph_mask_cudf() -> None: + _require_cudf_runtime() + nodes = pd.DataFrame({ + "id": ["a", "b", "c", "d"], + "score": [0.3, 0.1, None, 0.5], + }) + edges = pd.DataFrame({ + "s": ["a", "b", "c", "d"], + "d": ["b", "c", "d", "a"], + "weight": [7, 9, 11, 13], + }) + + result = _mk_cudf_graph(nodes, edges).gfql( "GRAPH { MATCH (a)-[r]->(b) WHERE (a.score > 0.25 OR a.score IS NULL) }", - "GRAPH { MATCH (a)-[r]->(b) WHERE searchAny(a, 'alpha') }", + engine="cudf", + ) + + assert set(_to_pandas_df(result._nodes)["id"].tolist()) == {"a", "c", "d"} + assert _to_pandas_df(result._edges)[["s", "d", "weight"]].to_dict(orient="records") == [ + {"s": "c", "d": "d", "weight": 11}, + {"s": "d", "d": "a", "weight": 13}, + ] + + +def test_graph_constructor_applies_search_any_residual_as_graph_mask() -> None: + nodes = pd.DataFrame({ + "id": ["a", "b", "c", "d"], + "name": ["alpha", "beta", "gamma", "delta"], + }) + edges = pd.DataFrame({ + "s": ["a", "b", "c", "d"], + "d": ["b", "c", "d", "a"], + "weight": [7, 9, 11, 13], + }) + + result = _mk_graph(nodes, edges).gfql( + "GRAPH { MATCH (a)-[r]->(b) WHERE searchAny(a, 'l') }" + ) + + assert set(_to_pandas_df(result._nodes)["id"].tolist()) == {"a", "d"} + assert _to_pandas_df(result._edges)[["s", "d", "weight"]].to_dict(orient="records") == [ + {"s": "d", "d": "a", "weight": 13}, ] - for query in queries: - with pytest.raises(GFQLValidationError, match="GRAPH constructors currently support only native graph-state predicates"): - g.gfql(query) + + +def test_graph_constructor_applies_edge_residual_row_predicate_as_graph_mask() -> None: + nodes = pd.DataFrame({"id": ["a", "b", "c", "d"]}) + edges = pd.DataFrame({ + "s": ["a", "b", "c", "d"], + "d": ["b", "c", "d", "a"], + "weight": [7, 9, 11, 13], + }) + + result = _mk_graph(nodes, edges).gfql( + "GRAPH { MATCH (a)-[r]->(b) WHERE (r.weight > 8 OR r.weight IS NULL) }" + ) + + assert set(_to_pandas_df(result._nodes)["id"].tolist()) == {"a", "b", "c", "d"} + assert _to_pandas_df(result._edges)[["s", "d", "weight"]].to_dict(orient="records") == [ + {"s": "b", "d": "c", "weight": 9}, + {"s": "c", "d": "d", "weight": 11}, + {"s": "d", "d": "a", "weight": 13}, + ] + + +def test_graph_binding_applies_residual_mask_before_use() -> None: + nodes = pd.DataFrame({ + "id": ["a", "b", "c", "d"], + "score": [0.3, 0.1, None, 0.5], + }) + edges = pd.DataFrame({ + "s": ["a", "b", "c", "d"], + "d": ["b", "c", "d", "a"], + }) + + result = _mk_graph(nodes, edges).gfql( + "GRAPH g = GRAPH { " + "MATCH (a)-[r]->(b) WHERE (a.score > 0.25 OR a.score IS NULL) " + "} USE g MATCH (n) RETURN n.id AS id ORDER BY id" + ) + + assert _to_pandas_df(result._nodes)["id"].tolist() == ["a", "c", "d"] + + +def test_graph_constructor_rejects_pattern_residual_predicates() -> None: + nodes = pd.DataFrame({"id": ["a", "b", "c"]}) + edges = pd.DataFrame({"s": ["a", "b"], "d": ["b", "c"], "type": ["R", "S"]}) + + with pytest.raises(GFQLValidationError, match="pattern-predicate residuals"): + _mk_graph(nodes, edges).gfql( + "GRAPH { MATCH (a)-[r]->(b) WHERE (a)-[:R]->() }" + ) def test_standalone_graph_constructor_preserves_columns() -> None: From 0e1e425c8b1972f3f65246d5dc4cc6e2ab653e7e Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Mon, 6 Jul 2026 22:33:50 -0700 Subject: [PATCH 03/10] Update Cypher surface guard baseline --- bin/ci_cypher_surface_guard_baseline.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bin/ci_cypher_surface_guard_baseline.json b/bin/ci_cypher_surface_guard_baseline.json index aecf5dbfc7..8cb4466b74 100644 --- a/bin/ci_cypher_surface_guard_baseline.json +++ b/bin/ci_cypher_surface_guard_baseline.json @@ -1,17 +1,17 @@ { "compiled_surfaces": { "CompiledCypherGraphQuery": { - "max_fields": 6, + "max_fields": 7, "max_properties": 0 }, "CompiledCypherQuery": { - "max_fields": 7, + "max_fields": 8, "max_properties": 10 }, "CompiledGraphBinding": { - "max_fields": 6, + "max_fields": 7, "max_properties": 0 } }, - "lowering_py_max_lines": 9045 + "lowering_py_max_lines": 8457 } From a41b700224ae25c17c6fe139a474385e5f2983a2 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Tue, 7 Jul 2026 11:35:11 -0700 Subject: [PATCH 04/10] Document GRAPH residual Polars limitation in tests --- .../compute/gfql/cypher/test_lowering.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/graphistry/tests/compute/gfql/cypher/test_lowering.py b/graphistry/tests/compute/gfql/cypher/test_lowering.py index 516e9e0a90..a71a950279 100644 --- a/graphistry/tests/compute/gfql/cypher/test_lowering.py +++ b/graphistry/tests/compute/gfql/cypher/test_lowering.py @@ -2566,6 +2566,25 @@ def test_graph_constructor_applies_node_residual_row_predicate_as_graph_mask_cud ] +def test_graph_constructor_residual_row_predicate_declines_on_polars() -> None: + pl = pytest.importorskip("polars") + nodes = pl.DataFrame({ + "id": ["a", "b", "c", "d"], + "score": [0.3, 0.1, None, 0.5], + }) + edges = pl.DataFrame({ + "s": ["a", "b", "c", "d"], + "d": ["b", "c", "d", "a"], + "weight": [7, 9, 11, 13], + }) + + with pytest.raises(GFQLValidationError, match="not yet supported on polars"): + _CypherTestGraph().nodes(nodes, "id").edges(edges, "s", "d").gfql( + "GRAPH { MATCH (a)-[r]->(b) WHERE (a.score > 0.25 OR a.score IS NULL) }", + engine="polars", + ) + + def test_graph_constructor_applies_search_any_residual_as_graph_mask() -> None: nodes = pd.DataFrame({ "id": ["a", "b", "c", "d"], From b5773873325f4bebd563de1921b421f5fe61a078 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Tue, 7 Jul 2026 16:39:22 -0700 Subject: [PATCH 05/10] Fix GRAPH residual alias column collisions --- graphistry/compute/gfql_unified.py | 2 - .../compute/gfql/cypher/test_lowering.py | 43 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/graphistry/compute/gfql_unified.py b/graphistry/compute/gfql_unified.py index 14f8cb6689..bb0e7ac841 100644 --- a/graphistry/compute/gfql_unified.py +++ b/graphistry/compute/gfql_unified.py @@ -540,8 +540,6 @@ def _apply_connected_match_join( def _graph_residual_eval_frame(df: DataFrameT, alias: str) -> DataFrameT: - if alias in df.columns: - return df.copy() return cast(DataFrameT, df.assign(**{alias: True})) diff --git a/graphistry/tests/compute/gfql/cypher/test_lowering.py b/graphistry/tests/compute/gfql/cypher/test_lowering.py index a71a950279..3687a88fa9 100644 --- a/graphistry/tests/compute/gfql/cypher/test_lowering.py +++ b/graphistry/tests/compute/gfql/cypher/test_lowering.py @@ -2542,6 +2542,29 @@ def test_graph_constructor_applies_node_residual_row_predicate_as_graph_mask() - ] +def test_graph_constructor_residual_alias_marker_overrides_property_column_collision() -> None: + nodes = pd.DataFrame({ + "id": ["a", "b", "c", "d"], + "a": ["shadow-a", "shadow-b", "shadow-c", "shadow-d"], + "score": [0.3, 0.1, None, 0.5], + }) + edges = pd.DataFrame({ + "s": ["a", "b", "c", "d"], + "d": ["b", "c", "d", "a"], + "weight": [7, 9, 11, 13], + }) + + result = _mk_graph(nodes, edges).gfql( + "GRAPH { MATCH (a)-[r]->(b) WHERE (a.score > 0.25 OR a.score IS NULL) }" + ) + + assert set(_to_pandas_df(result._nodes)["id"].tolist()) == {"a", "c", "d"} + assert _to_pandas_df(result._edges)[["s", "d", "weight"]].to_dict(orient="records") == [ + {"s": "c", "d": "d", "weight": 11}, + {"s": "d", "d": "a", "weight": 13}, + ] + + def test_graph_constructor_applies_node_residual_row_predicate_as_graph_mask_cudf() -> None: _require_cudf_runtime() nodes = pd.DataFrame({ @@ -2626,6 +2649,26 @@ def test_graph_constructor_applies_edge_residual_row_predicate_as_graph_mask() - ] +def test_graph_constructor_edge_residual_alias_marker_overrides_property_column_collision() -> None: + nodes = pd.DataFrame({"id": ["a", "b", "c", "d"]}) + edges = pd.DataFrame({ + "s": ["a", "b", "c", "d"], + "d": ["b", "c", "d", "a"], + "r": ["shadow-r0", "shadow-r1", "shadow-r2", "shadow-r3"], + "weight": [7, 9, 11, 13], + }) + + result = _mk_graph(nodes, edges).gfql( + "GRAPH { MATCH (a)-[r]->(b) WHERE (r.weight > 8 OR r.weight IS NULL) }" + ) + + assert _to_pandas_df(result._edges)[["s", "d", "weight"]].to_dict(orient="records") == [ + {"s": "b", "d": "c", "weight": 9}, + {"s": "c", "d": "d", "weight": 11}, + {"s": "d", "d": "a", "weight": 13}, + ] + + def test_graph_binding_applies_residual_mask_before_use() -> None: nodes = pd.DataFrame({ "id": ["a", "b", "c", "d"], From 8126daf3361c7af4ac98581ae1a2dee8c9b1fe96 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Wed, 8 Jul 2026 01:27:47 -0700 Subject: [PATCH 06/10] fix(gfql): reject unsafe graph node residual masks Node residual GRAPH masks were applied as global node masks before the graph-state chain, which changes alias-specific MATCH semantics for multi-alias patterns. Keep single-node node residual masks and edge residual masks, but reject multi-alias node residual masks loudly. --- bin/ci_cypher_surface_guard_baseline.json | 2 +- graphistry/compute/gfql/cypher/lowering.py | 12 ++++++ .../compute/gfql/cypher/test_lowering.py | 40 ++++++------------- 3 files changed, 25 insertions(+), 29 deletions(-) diff --git a/bin/ci_cypher_surface_guard_baseline.json b/bin/ci_cypher_surface_guard_baseline.json index 8cb4466b74..704d9018f8 100644 --- a/bin/ci_cypher_surface_guard_baseline.json +++ b/bin/ci_cypher_surface_guard_baseline.json @@ -13,5 +13,5 @@ "max_properties": 0 } }, - "lowering_py_max_lines": 8457 + "lowering_py_max_lines": 8469 } diff --git a/graphistry/compute/gfql/cypher/lowering.py b/graphistry/compute/gfql/cypher/lowering.py index 5ae3666547..a784b3f77e 100644 --- a/graphistry/compute/gfql/cypher/lowering.py +++ b/graphistry/compute/gfql/cypher/lowering.py @@ -7179,6 +7179,18 @@ def _compile_graph_residual_filters( target = alias_targets.get(alias) if isinstance(target, ASTNode): kind: Literal["node", "edge"] = "node" + if not ( + len(lowered.query) == 1 + and isinstance(lowered.query[0], ASTNode) + and lowered.query[0]._name == alias + ): + raise _unsupported( + "Cypher GRAPH node residual predicates are only supported for single-node GRAPH MATCH masks", + field="graph_constructor", + value=expr.text, + line=expr.span.line, + column=expr.span.column, + ) elif isinstance(target, ASTEdge): kind = "edge" else: diff --git a/graphistry/tests/compute/gfql/cypher/test_lowering.py b/graphistry/tests/compute/gfql/cypher/test_lowering.py index 3687a88fa9..1517c5ff53 100644 --- a/graphistry/tests/compute/gfql/cypher/test_lowering.py +++ b/graphistry/tests/compute/gfql/cypher/test_lowering.py @@ -2519,7 +2519,7 @@ def test_graph_constructor_empty_match_returns_empty_graph() -> None: assert len(result._edges) == 0 -def test_graph_constructor_applies_node_residual_row_predicate_as_graph_mask() -> None: +def test_graph_constructor_rejects_node_residual_on_multi_alias_pattern() -> None: nodes = pd.DataFrame({ "id": ["a", "b", "c", "d"], "score": [0.3, 0.1, None, 0.5], @@ -2531,18 +2531,13 @@ def test_graph_constructor_applies_node_residual_row_predicate_as_graph_mask() - "weight": [7, 9, 11, 13], }) - result = _mk_graph(nodes, edges).gfql( - "GRAPH { MATCH (a)-[r]->(b) WHERE (a.score > 0.25 OR a.score IS NULL) }" - ) - - assert set(_to_pandas_df(result._nodes)["id"].tolist()) == {"a", "c", "d"} - assert _to_pandas_df(result._edges)[["s", "d", "weight"]].to_dict(orient="records") == [ - {"s": "c", "d": "d", "weight": 11}, - {"s": "d", "d": "a", "weight": 13}, - ] + with pytest.raises(GFQLValidationError, match="single-node GRAPH MATCH masks"): + _mk_graph(nodes, edges).gfql( + "GRAPH { MATCH (a)-[r]->(b) WHERE (a.score > 0.25 OR a.score IS NULL) }" + ) -def test_graph_constructor_residual_alias_marker_overrides_property_column_collision() -> None: +def test_graph_constructor_single_node_residual_alias_marker_overrides_property_column_collision() -> None: nodes = pd.DataFrame({ "id": ["a", "b", "c", "d"], "a": ["shadow-a", "shadow-b", "shadow-c", "shadow-d"], @@ -2555,17 +2550,13 @@ def test_graph_constructor_residual_alias_marker_overrides_property_column_colli }) result = _mk_graph(nodes, edges).gfql( - "GRAPH { MATCH (a)-[r]->(b) WHERE (a.score > 0.25 OR a.score IS NULL) }" + "GRAPH { MATCH (a) WHERE (a.score > 0.25 OR a.score IS NULL) }" ) assert set(_to_pandas_df(result._nodes)["id"].tolist()) == {"a", "c", "d"} - assert _to_pandas_df(result._edges)[["s", "d", "weight"]].to_dict(orient="records") == [ - {"s": "c", "d": "d", "weight": 11}, - {"s": "d", "d": "a", "weight": 13}, - ] -def test_graph_constructor_applies_node_residual_row_predicate_as_graph_mask_cudf() -> None: +def test_graph_constructor_applies_single_node_residual_row_predicate_as_graph_mask_cudf() -> None: _require_cudf_runtime() nodes = pd.DataFrame({ "id": ["a", "b", "c", "d"], @@ -2578,15 +2569,11 @@ def test_graph_constructor_applies_node_residual_row_predicate_as_graph_mask_cud }) result = _mk_cudf_graph(nodes, edges).gfql( - "GRAPH { MATCH (a)-[r]->(b) WHERE (a.score > 0.25 OR a.score IS NULL) }", + "GRAPH { MATCH (a) WHERE (a.score > 0.25 OR a.score IS NULL) }", engine="cudf", ) assert set(_to_pandas_df(result._nodes)["id"].tolist()) == {"a", "c", "d"} - assert _to_pandas_df(result._edges)[["s", "d", "weight"]].to_dict(orient="records") == [ - {"s": "c", "d": "d", "weight": 11}, - {"s": "d", "d": "a", "weight": 13}, - ] def test_graph_constructor_residual_row_predicate_declines_on_polars() -> None: @@ -2603,7 +2590,7 @@ def test_graph_constructor_residual_row_predicate_declines_on_polars() -> None: with pytest.raises(GFQLValidationError, match="not yet supported on polars"): _CypherTestGraph().nodes(nodes, "id").edges(edges, "s", "d").gfql( - "GRAPH { MATCH (a)-[r]->(b) WHERE (a.score > 0.25 OR a.score IS NULL) }", + "GRAPH { MATCH (a) WHERE (a.score > 0.25 OR a.score IS NULL) }", engine="polars", ) @@ -2620,13 +2607,10 @@ def test_graph_constructor_applies_search_any_residual_as_graph_mask() -> None: }) result = _mk_graph(nodes, edges).gfql( - "GRAPH { MATCH (a)-[r]->(b) WHERE searchAny(a, 'l') }" + "GRAPH { MATCH (a) WHERE searchAny(a, 'l') }" ) assert set(_to_pandas_df(result._nodes)["id"].tolist()) == {"a", "d"} - assert _to_pandas_df(result._edges)[["s", "d", "weight"]].to_dict(orient="records") == [ - {"s": "d", "d": "a", "weight": 13}, - ] def test_graph_constructor_applies_edge_residual_row_predicate_as_graph_mask() -> None: @@ -2681,7 +2665,7 @@ def test_graph_binding_applies_residual_mask_before_use() -> None: result = _mk_graph(nodes, edges).gfql( "GRAPH g = GRAPH { " - "MATCH (a)-[r]->(b) WHERE (a.score > 0.25 OR a.score IS NULL) " + "MATCH (a) WHERE (a.score > 0.25 OR a.score IS NULL) " "} USE g MATCH (n) RETURN n.id AS id ORDER BY id" ) From 99826de9602a331c1ca598a150e3c0baaa282fa6 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Wed, 8 Jul 2026 08:45:09 -0700 Subject: [PATCH 07/10] docs(gfql): clarify graph constructor filters --- docs/source/gfql/cypher.rst | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/source/gfql/cypher.rst b/docs/source/gfql/cypher.rst index d3a83a8b0e..574f3297b0 100644 --- a/docs/source/gfql/cypher.rst +++ b/docs/source/gfql/cypher.rst @@ -92,12 +92,26 @@ state instead of a row table: subgraph._nodes subgraph._edges -``GRAPH { MATCH ... WHERE ... }`` accepts native graph-state predicates -that lower to chain node/edge filters. Single node- or edge-alias residual -predicates such as disjunctions and ``searchAny(...)`` are applied as GRAPH -subgraph masks before the match chain runs. Pattern-predicate and multi-alias -residuals remain unsupported inside ``GRAPH { }`` until first-class independent -node/edge subgraph projection covers their semantics. +Use ``WHERE`` inside ``GRAPH { }`` to reduce the graph that flows into the +next stage. The result still stays graph-shaped: matching nodes and edges remain +in ``_nodes`` and ``_edges``, so a later ``USE``, ``GRAPH { ... }``, or +``CALL graphistry.*.write()`` can continue without first turning the result into +a row table. + +The current implementation supports filters that can be decided from one node +alias or one edge alias at a time, for example ``seed.degree >= 10``, +``reach.weight > 5``, ``seed.score > 0.25 OR seed.score IS NULL``, and +``searchAny(seed, 'alice')``. GFQL applies those filters while building the +subgraph, so fewer nodes and edges move forward. When a node filter removes a +node, edges attached to removed nodes are removed too. + +Filters that need the joined match rows are intentionally rejected inside +``GRAPH { }`` for now, because GFQL cannot yet turn every such row condition +back into one unambiguous node-and-edge graph to pass to the next stage. That +includes pattern-existence checks such as ``WHERE (a)-[:R]->()`` or +``EXISTS { ... }``, and expressions that combine multiple aliases in one +condition. Use a row query such as ``MATCH ... RETURN ...`` for those cases, or +split the work into smaller graph stages. Use ``GRAPH g = GRAPH { ... }`` to bind a named graph, then ``USE g`` to query it: From 53d0e0ccc61298f336d816265d9d64954cbcf156 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Wed, 8 Jul 2026 08:46:25 -0700 Subject: [PATCH 08/10] docs(gfql): explain graph residual filters --- graphistry/compute/gfql/cypher/lowering.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/graphistry/compute/gfql/cypher/lowering.py b/graphistry/compute/gfql/cypher/lowering.py index a784b3f77e..0c528aa698 100644 --- a/graphistry/compute/gfql/cypher/lowering.py +++ b/graphistry/compute/gfql/cypher/lowering.py @@ -178,6 +178,13 @@ class CompiledCypherExecutionExtras: @dataclass(frozen=True) class CompiledGraphResidualFilter: + """One remaining GRAPH WHERE filter applied to a node or edge table. + + Most GRAPH WHERE predicates lower directly into chain filters. This carrier is + for the leftover single-alias cases, such as OR/searchAny, that still have a + clear graph result: filter one node table or one edge table before returning + graph state. + """ alias: str kind: Literal["node", "edge"] expr: str @@ -7130,6 +7137,9 @@ def _compile_call_query( ) +# Convert GRAPH WHERE leftovers from row lowering into graph-state filters. +# Multi-alias and pattern predicates stay rejected here because they do not yet +# have an unambiguous node/edge subgraph result. def _compile_graph_residual_filters( lowered: LoweredCypherMatch, *, From 40a3770d694043e1cbc476b195018ec8644d30fe Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Wed, 8 Jul 2026 08:49:55 -0700 Subject: [PATCH 09/10] docs(gfql): tighten graph constructor filter wording --- docs/source/gfql/cypher.rst | 31 ++++++++-------------- graphistry/compute/gfql/cypher/lowering.py | 15 +++++------ 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/docs/source/gfql/cypher.rst b/docs/source/gfql/cypher.rst index 574f3297b0..8e75ef2063 100644 --- a/docs/source/gfql/cypher.rst +++ b/docs/source/gfql/cypher.rst @@ -92,26 +92,17 @@ state instead of a row table: subgraph._nodes subgraph._edges -Use ``WHERE`` inside ``GRAPH { }`` to reduce the graph that flows into the -next stage. The result still stays graph-shaped: matching nodes and edges remain -in ``_nodes`` and ``_edges``, so a later ``USE``, ``GRAPH { ... }``, or -``CALL graphistry.*.write()`` can continue without first turning the result into -a row table. - -The current implementation supports filters that can be decided from one node -alias or one edge alias at a time, for example ``seed.degree >= 10``, -``reach.weight > 5``, ``seed.score > 0.25 OR seed.score IS NULL``, and -``searchAny(seed, 'alice')``. GFQL applies those filters while building the -subgraph, so fewer nodes and edges move forward. When a node filter removes a -node, edges attached to removed nodes are removed too. - -Filters that need the joined match rows are intentionally rejected inside -``GRAPH { }`` for now, because GFQL cannot yet turn every such row condition -back into one unambiguous node-and-edge graph to pass to the next stage. That -includes pattern-existence checks such as ``WHERE (a)-[:R]->()`` or -``EXISTS { ... }``, and expressions that combine multiple aliases in one -condition. Use a row query such as ``MATCH ... RETURN ...`` for those cases, or -split the work into smaller graph stages. +Use ``WHERE`` inside ``GRAPH { }`` to reduce the graph passed to the next +stage while staying graph-shaped in ``_nodes`` and ``_edges``. Today this path is +strict: GFQL supports filters it can apply to one node or one edge table without +building joined rows, such as ``seed.degree >= 10``, ``reach.weight > 5``, +``seed.score > 0.25 OR seed.score IS NULL``, and ``searchAny(seed, 'alice')``. + +Predicates that require joined match rows, such as ``WHERE (a)-[:R]->()``, +``EXISTS { ... }``, or conditions spanning multiple aliases, are rejected for +now instead of triggering hidden materialization. Future support should make +that eager boundary explicit, for example through an inspectable plan or warning +mode, and then project the rows back to graph state. Use ``GRAPH g = GRAPH { ... }`` to bind a named graph, then ``USE g`` to query it: diff --git a/graphistry/compute/gfql/cypher/lowering.py b/graphistry/compute/gfql/cypher/lowering.py index 0c528aa698..51abd25cce 100644 --- a/graphistry/compute/gfql/cypher/lowering.py +++ b/graphistry/compute/gfql/cypher/lowering.py @@ -178,12 +178,12 @@ class CompiledCypherExecutionExtras: @dataclass(frozen=True) class CompiledGraphResidualFilter: - """One remaining GRAPH WHERE filter applied to a node or edge table. + """GRAPH WHERE expression left after normal chain-filter lowering. - Most GRAPH WHERE predicates lower directly into chain filters. This carrier is - for the leftover single-alias cases, such as OR/searchAny, that still have a - clear graph result: filter one node table or one edge table before returning - graph state. + Example: ``GRAPH { MATCH (n) WHERE n.score > 1 OR n.score IS NULL }``. + The OR cannot fit the Chain's simple node-filter map, but it still reads + only node alias ``n``, so execution can mask the node table and stay in + graph state without materializing joined match rows. """ alias: str kind: Literal["node", "edge"] @@ -7137,9 +7137,8 @@ def _compile_call_query( ) -# Convert GRAPH WHERE leftovers from row lowering into graph-state filters. -# Multi-alias and pattern predicates stay rejected here because they do not yet -# have an unambiguous node/edge subgraph result. +# Current strict path: accept only residuals that filter one node/edge table. +# Row-shaped cases need an explicit eager boundary, not hidden materialization. def _compile_graph_residual_filters( lowered: LoweredCypherMatch, *, From d35cfc54be51e062845d519770e1f68a861b3a10 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Wed, 8 Jul 2026 08:55:13 -0700 Subject: [PATCH 10/10] docs(gfql): add graph residual changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba37fa7c3c..4bc2bcb91f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - **GFQL Cypher parser: Earley → LALR(1) (~70× faster parse, same AST)**: `parse_cypher` now uses a single LALR(1) parser (contextual lexer) instead of Earley (dynamic lexer) — ~0.25ms vs ~17ms per query on representative queries. The WHERE grammar was unified to one `where_clause: "WHERE" expr` rule (the former `where_predicates | expr` dual rule was a genuine reduce/reduce ambiguity that forced Earley), and the structured flat-AND predicate form is recovered by a post-parse lift that re-parses the WHERE body with a LALR sub-parser (`start="where_predicate_chain"`). The full WHERE language is preserved — OR/XOR/NOT, parentheses, arithmetic, IN, pattern predicates — nothing is restricted to a subset. The grammar was then **purified to (near-)unambiguous**: WHERE binds to its preceding clause *in the grammar* (bundled into `match_clause`; the WITH..WHERE attachment ambiguity is eliminated, not tie-broken), and name-rooted dot chains derive only via `qualified_name` (the `property_access` redundancy is gone) — dropping the redundant `label_predicate_expr` rule (`(n:Admin)` parses via `grouped_expr`), and excluding a top-level `IN` from list-literal elements (`[x IN xs ...` is comprehension syntax; allowing a bare `IN` list element made it overlap). Net: the LALR conflict profile goes from 8 shift/reduce to **ZERO conflicts** — the grammar is now **provably unambiguous LALR(1)** and builds under Lark's `strict=True` (a build-time proof, machine-checked in CI as zero conflicts, plus a strict-mode build test where the optional `interegular` dep is present). Every input has a single derivation. **Machine-checked invariants** (`test_grammar_invariants.py`): (1) **zero LALR conflicts** (dependency-free, always-on in CI) plus a `strict=True` build test (skipped where the optional `interegular` dep is absent) — a grammar edit that introduces any ambiguity fails CI; (2) semantic ambiguity is ZERO — every corpus query has exactly one Earley derivation and it transforms to a single AST, no exceptions; (3) a rule-coverage gate (`test_every_grammar_rule_is_exercised_by_the_corpus`) forces every new grammar rule through the invariants; (4) differential tests run the production pipeline under both parsers (byte-identical ASTs, identical rejections). **Full-repo differentials** (1,850+ scraped queries, LALR-vs-Earley and old-vs-new production): **zero AST divergences**, and a small set of deliberate language fixes — accept-by-accident shapes with ill-defined semantics, now honest syntax errors and pinned as tests (`DELIBERATE_LANGUAGE_FIXES`): `RETURN DISTINCT` with no items (Earley re-lexed DISTINCT as a variable name), double WHERE (the old positional attachment kept *both* predicates in different AST fields), double post-WITH WHERE, WHERE after UNWIND, WHERE before MATCH in a graph constructor, and the invalid "list of IN-booleans" (`[x IN xs, y]`; use `[(x IN xs), y]` for a genuine bool list). The lift stays an internal optimization: only a flat `AND` chain over present columns lifts to `filter_dict`; parens / OR / XOR / NOT and any absent-column case stay on `where_rows` (a correctness boundary, since `where_rows` treats an absent property as null while `filter_dict` would raise). Full cypher suite (1,681 tests) passes. Downstream execution (`filter_dict` vs `where_rows` routing) is unchanged. A pre-existing `<>`-over-null 3VL divergence between the two execution paths (surfaced by #1653's metamorphic test) is tracked separately in #1683. ### Fixed +- **GFQL Cypher `GRAPH { }` residual predicates now fail safely or apply as graph masks**: `GRAPH { MATCH ... WHERE ... }` no longer silently drops predicates that the graph-state path cannot apply. Safe one-node/one-edge residual filters, including disjunctions and `searchAny(...)`, are applied before graph-state matching; unsupported pattern-predicate, multi-alias, and Polars graph-residual cases now raise clear validation errors instead of returning an over-broad subgraph. - **GFQL Cypher `=~` / scalar-fn cross-engine hardening (review wave, dgx-verified)**: (1) composed `=~` (`WHERE … =~ … OR …`, `RETURN`-expression position) now works on `engine='cudf'` — the series evaluator used raw `.str.fullmatch`, which cuDF lacks, and the resulting `AttributeError` was masked as "unsupported predicate op" (it now routes through the `Fullmatch` predicate's engine workarounds, and honest `NotImplementedError` declines pass through instead of being re-labeled); (2) the cuDF fullmatch emulation anchors alternations as a whole (`^(ab|cd)$` — bare `^ab|cd$` silently matched `'abXXX'`); (3) the cuDF case-insensitive `(?i)` lowercase-folding workaround now declines the fold-unsound shapes — uppercase escape classes (`.lower()` turns `\D` into `\d`, silently inverting the predicate), case-crossing character ranges (`(?i)[A-z]` silently narrowed; `[X-b]` folded to an invalid range), and non-ASCII patterns — while lowercase escapes (`\d`, `\.`) keep folding as before; lookaround, backreferences, and named-group refs decline up front (libcudf rejects them at kernel-compile time); (4) polars `Match`/`Fullmatch` lowering applies the same Rust-regex guard as `Contains` (lookaround/backrefs decline instead of a non-NIE `ComputeError` at collect); (5) `toLower`/`toUpper` on a non-string column decline like neo4j's type error instead of broadcasting the stringified Series repr (pandas/cuDF) or raising a non-NIE `SchemaError` (polars-gpu); (6) polars `floor`/`ceil`/`round` cast to `Float64` so integer columns return Float like neo4j and the pandas engine; (7) invalid regex patterns on the composed path raise a clear "invalid regex pattern" error instead of "unsupported predicate op". - **GFQL Cypher `round()` hardening (review wave, dgx-verified)**: (1) the `polars` extra's floor is now `polars>=1.29` — `Expr.round(mode=)` shipped in py-1.29.0 (pola-rs/polars#22248), not 1.5 as previously pinned, so 1.5–1.28 installs crashed with a raw `TypeError` on `round(x, p>0)` under `engine='polars'`; (2) `round(x, p>308)` is the identity on both engines (a float64 has no digits there) instead of pandas raising through an unclear decline while polars returned identity — parity restored, `10.0**p` overflow guarded; (3) polars `round(x, p>0)` normalizes `-0.0` to `+0.0` like the pandas kernel (`round(-0.04, 1)` was `0.0` on pandas vs `-0.0` on polars — invisible to value equality, pinned by a sign-bit test); (4) documented the precision>0 decimal-string deviation vs neo4j (`round(2.675, 2)` = `2.67` binary-double here vs `2.68` BigDecimal there) and added deterministic tie/hazard matrix cases so ties actually reach cuDF/polars-gpu (the fixture's normal-distribution floats never tied). - **GFQL `materialize_nodes()` on an edges-only graph under `engine='polars'`**: crashed with `AttributeError: 'Series' object has no attribute 'drop_duplicates'` — the node-id derivation used pandas-only `.drop_duplicates()` / `.reset_index()` / `.rename(columns=)` on a polars Series/DataFrame. Now polars-aware (`.unique(maintain_order=True)`, `.select().rename({...})`), matching the pandas oracle. Surfaced by any `let()` DAG on an edges-only graph under Polars (the DAG pre-materializes nodes). pandas/cuDF paths unchanged.