8585from graphistry .compute .gfql .passes import DEFAULT_LOGICAL_PASSES , DEFAULT_TIER2_PASSES , PassManager
8686from graphistry .compute .gfql .row .pipeline import _RowPipelineAdapter , is_row_pipeline_call
8787from graphistry .compute .gfql .search_any import search_any_mask
88- from graphistry .compute .typing import DataFrameT , SeriesT , NodeDtypes
88+ from graphistry .compute .typing import DataFrameT , DType , SeriesT , NodeDtypes
8989from graphistry .compute .util .generate_safe_column_name import generate_safe_column_name
9090from graphistry .compute .validate .validate_schema import validate_chain_schema
9191from graphistry .compute .gfql_validate import gfql_validate as gfql_preflight_validate
@@ -611,11 +611,20 @@ def _connected_join_two_star_split_residuals(
611611
612612
613613# The simple residual shapes the connected-join lowering emits for scalar predicates it
614- # cannot push into filter_dict (see #1729): case-insensitive equality and scalar
614+ # cannot push into filter_dict (see #1729): case-folded equality and scalar
615615# equality/range on a single aliased column. Anything else falls back to the where_rows
616616# chain evaluator. Literals: single-quoted strings (no embedded quotes) or numbers.
617- _RESIDUAL_TOLOWER_EQ = re .compile (
618- r"^\(tolower\((?P<alias>\w+)\.(?P<col>\w+)\) = tolower\('(?P<lit>[^']*)'\)\)$"
617+ #
618+ # ONE CANONICAL SHAPE. The lowering constant-folds literal-only sub-expressions
619+ # (``graphistry/compute/gfql/expr_const_fold.py``), so ``toLower(a.c) = toLower('LIT')``
620+ # arrives here already reduced to ``(tolower(a.c) = 'lit')``. This matcher therefore
621+ # learns the case-function-vs-LITERAL form only, and the literal is used AS WRITTEN --
622+ # the where_rows evaluator does not case-fold a bare literal, so ``toLower(x) = 'MALE'``
623+ # correctly matches nothing. (Case-folding the RHS here would be a silent wrong answer
624+ # that every all-lowercase benchmark literal would hide.)
625+ _RESIDUAL_CASE_FN_EQ = re .compile (
626+ r"^\((?P<fn>tolower|lower|toupper|upper)\((?P<alias>\w+)\.(?P<col>\w+)\) = "
627+ r"'(?P<lit>[^']*)'\)$"
619628)
620629_RESIDUAL_SCALAR_CMP = re .compile (
621630 r"^\((?P<alias>\w+)\.(?P<col>\w+) (?P<op>=|>=|<=|>|<) "
@@ -624,25 +633,30 @@ def _connected_join_two_star_split_residuals(
624633
625634
626635def _residual_polars_expr (
627- expr : str , alias : str , schema : Mapping [ str , Any ]
636+ expr : str , alias : str , schema : NodeDtypes
628637) -> Optional ['pl.Expr' ]:
629638 """Translate a simple residual to a native polars expression, or None to fall back.
630639
631640 ``expr`` is a *string* by contract: the #1729 connected-join lowering serializes
632641 residual predicates into ASTCall params as canonical predicate strings (e.g.
633- ``(tolower(a.col) = tolower( 'lit') )``), not typed AST terms — so string parsing here
642+ ``(tolower(a.col) = 'lit')``), not typed AST terms — so string parsing here
634643 is the honest interface; a typed term would require a lowering-level refactor.
635644
636- Covered (exactly the #1729 scalar-residual shapes): ``(tolower(a.col) = tolower('lit'))``
637- and ``(a.col <op> literal)`` for ``= >= <= > <``. Semantics match the where_rows
638- evaluator on these shapes: string compares are null-safe (null -> filtered out, since
639- polars comparisons on null yield null which ``filter`` drops, same as the evaluator's
640- null-propagating comparisons); toLower equality lowercases the column via polars
641- ``str.to_lowercase()`` and the literal via Python ``str.lower()`` (empirically equal
642- on the ASCII/latin shapes the lowering emits; a divergence would need a Rust-vs-Python
643- Unicode table drift). Float NaN ranking differs between polars and the evaluator, but
644- gfql ingest normalizes NaN->null (``_pl_nan_to_null``) so NaN never reaches this
645- filter through ``gfql()``. Declines (returns None, caller uses the chain fallback) on:
645+ Covered (exactly the #1729 scalar-residual shapes): ``(<case fn>(a.col) = 'lit')``
646+ for ``toLower``/``lower``/``toUpper``/``upper``, and ``(a.col <op> literal)`` for
647+ ``= >= <= > <``. Semantics match the where_rows evaluator on these shapes: string
648+ compares are null-safe (null -> filtered out, since polars comparisons on null yield
649+ null which ``filter`` drops, same as the evaluator's null-propagating comparisons);
650+ the case functions map to the SAME polars kernels the evaluator uses
651+ (``str.to_lowercase`` / ``str.to_uppercase``) so the column side is identical by
652+ construction, and the LITERAL side is used verbatim because the evaluator does not
653+ case-fold a bare literal. Nothing is case-folded in Python here — the lowering's
654+ constant-folding pass has already reduced ``= toLower('LIT')`` to ``= 'lit'`` where
655+ every engine provably agrees on that value (see ``expr_const_fold`` and #1802), and
656+ where it does not, the two-sided text arrives unfolded and DECLINES below.
657+ Float NaN ranking differs between polars and the evaluator, but gfql ingest
658+ normalizes NaN->null (``_pl_nan_to_null``) so NaN never reaches this filter through
659+ ``gfql()``. Declines (returns None, caller uses the chain fallback) on:
646660 any other shape, non-matching alias, a column absent from the schema, an ESCAPED
647661 string literal (``\\ `` — the renderer escapes ``' \\ \\ n`` etc. to ``\\ uXXXX`` which the
648662 evaluator unescapes; raw comparison would silently mismatch), and dtype-incompatible
@@ -652,23 +666,29 @@ def _residual_polars_expr(
652666 """
653667 import polars as pl
654668
655- def _is_string_dtype (dtype : Any ) -> bool :
669+ def _is_string_dtype (dtype : DType ) -> bool :
656670 return dtype == pl .Utf8 or dtype == pl .String
657671
658- def _is_numeric_dtype (dtype : Any ) -> bool :
672+ def _is_numeric_dtype (dtype : DType ) -> bool :
659673 return dtype .is_numeric () if hasattr (dtype , "is_numeric" ) else False
660674
661- m = _RESIDUAL_TOLOWER_EQ .match (expr )
675+ m = _RESIDUAL_CASE_FN_EQ .match (expr )
662676 if m is not None :
663677 col_name = m .group ("col" )
664- tolower_lit = m .group ("lit" )
678+ case_lit = m .group ("lit" )
665679 if m .group ("alias" ) != alias or col_name not in schema :
666680 return None
667- if "\\ " in tolower_lit :
681+ if "\\ " in case_lit :
668682 return None # escaped literal: let the evaluator unescape it
669683 if not _is_string_dtype (schema [col_name ]):
670- return None # tolower on non-string column: evaluator raises designed NIE
671- return pl .col (col_name ).str .to_lowercase () == tolower_lit .lower ()
684+ return None # case fn on non-string column: evaluator raises designed NIE
685+ col_str = pl .col (col_name ).str
686+ folded_col = (
687+ col_str .to_lowercase ()
688+ if m .group ("fn" ) in ("tolower" , "lower" )
689+ else col_str .to_uppercase ()
690+ )
691+ return folded_col == case_lit
672692 m = _RESIDUAL_SCALAR_CMP .match (expr )
673693 if m is not None :
674694 col_name = m .group ("col" )
@@ -712,8 +732,9 @@ def _connected_join_apply_node_residuals(
712732 """Filter a fast-path node frame by single-alias post-join residual expressions.
713733
714734 Fast lane (polars): the simple scalar shapes the #1729 lowering emits
715- (``tolower(a.col) = tolower('lit')``, ``a.col <op> literal``) translate directly to
716- native polars filters — no chain dispatch (the where_rows chain costs ~1.7ms/alias,
735+ (``tolower(a.col) = 'lit'`` and the other case functions, ``a.col <op> literal``;
736+ the lowering constant-folds ``= toLower('LIT')`` into that one shape) translate
737+ directly to native polars filters — no chain dispatch (the where_rows chain costs ~1.7ms/alias,
717738 the dominant cost of the residual OLAP fast path). Any expression outside those
718739 shapes falls back to the chain evaluator below, so semantics never diverge.
719740
0 commit comments