From ebdfbdc73ce252e4db04ae94b44bd685965ec213 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Thu, 2 Jul 2026 09:28:47 -0700 Subject: [PATCH 1/9] fix(gfql/cypher): neo4j round() tie-breaking + lower/upper GQL aliases (#1673 standards vetting) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standards re-vet vs neo4j Cypher 25 (manual + shipped parser source) and ISO GQL (opengql/grammar + neo4j GQL-conformance appendix) found one wrong-answer and one missing conformance alias in the shipped numeric/string functions: 1. round() ties DIVERGED: we used numpy/polars half-to-even (round(2.5) -> 2.0), but neo4j's documented semantics are precision 0 (and 1-arg) = ties toward +inf (round(-1.5) = -1.0, manual Ex. 8/10) and precision > 0 = ties away from zero (HALF_UP: round(-1.55, 1) = -1.6, Ex. 11). Fixed on pandas/cuDF (floor(x+0.5) / scaled HALF_UP) and polars (floor(x+0.5) for p=0; native round(mode='half_away_from_zero') for p>0 — a manual scale/divide formula picks up 1-ulp noise from polars' reassociating optimizer). Parity-tested against the manual's example values on both engines. 2. lower()/upper() added as aliases of toLower()/toUpper() — ISO GQL mandatory character-string functions (§20.24); neo4j accepts both spellings as its GQL-conformance aliases. Also records the settled `^` spec (LEFT-associative: openCypher TCK + neo4j's shipped parser both left-fold; the manual's "right to left" row is a docs bug) for the deferred re-add, and corrects the XOR note (GQL HAS XOR, GE07). 1618 tests pass; ruff + mypy clean; CHANGELOG + docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- docs/source/gfql/cypher.rst | 11 +++-- graphistry/compute/gfql/language_defs.py | 2 + .../gfql/lazy/engine/polars/row_pipeline.py | 16 +++++-- graphistry/compute/gfql/row/pipeline.py | 38 +++++++++++++--- .../compute/gfql/cypher/test_lowering.py | 45 ++++++++++++++++++- 6 files changed, 99 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46c3c255a5..9b0d2a7618 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added - **GFQL polars execution config is Python-settable and live**: `set_cpu_streaming(bool)` and `set_gpu_executor('in-memory'|'streaming')` in `graphistry.compute.gfql.lazy` (plus the public `GPU_EXECUTORS` options and `GpuExecutor` type) set the CPU-streaming / GPU-executor knobs from Python. They resolve **Python override > environment variable > default**, read **live** per collect — previously these were env-only (`GFQL_POLARS_CPU_STREAMING` / `GFQL_POLARS_GPU_EXECUTOR`) and frozen at import, so neither a Python setting nor a post-import env change took effect. `None` resets a setter to env/default. - **GFQL engine conversion honors the `validate`/`warn` convention**: `Engine.df_to_engine(df, engine, *, validate=, warn=)` threads the repo-wide `validate` (`'strict'`/`'strict-fast'`/`'autofix'`; `True`→strict, `False`→autofix) + `warn` protocol into the pandas→polars and pandas→cuDF converters. On a mixed-type object column that Arrow/polars/cuDF cannot represent, `strict` raises (`NotImplementedError` for polars, `ArrowConversionError` for cuDF) and `autofix` coerces the column to string and warns — the same convention as `plot()`/`upload()`. Each engine keeps its established default (polars `strict` = parity-or-raise; cuDF `autofix` = its shipped best-effort coercion, now `warn`-suppressible). -- **GFQL Cypher numeric functions + `toLower`/`toUpper` (openCypher/neo4j-standard)**: added the standard scalar functions `floor`, `ceil` (alias `ceiling`), `round(x)` / `round(x, precision)`, and `toLower` / `toUpper` (the idiomatic case-insensitive compare, `WHERE toLower(n.name) = 'bob'`), across the Cypher `WHERE`/`RETURN` expression surface. Evaluated natively on pandas/cuDF and polars (differential-parity tested). Complements the already-supported `abs`/`sqrt`/`sign` and chained comparisons (`WHERE 1 < n.age < 65`). Minor documented divergence: `round` uses the pandas/numpy default rounding (round-half-to-even) rather than neo4j's mode set (the `round(x, precision, mode)` 3-arg form is not yet supported). The `^` exponentiation operator is deferred (the openCypher TCK treats `^` as left-associative and marks it reject-expected in the current corpus, so it needs a coordinated corpus + associativity change). `LIKE`/`ILIKE`/`BETWEEN` remain intentionally unimplemented (not in Cypher or GQL). +- **GFQL Cypher numeric functions + `toLower`/`toUpper`/`lower`/`upper` (openCypher/neo4j/GQL-standard)**: added the standard scalar functions `floor`, `ceil` (alias `ceiling`, per Cypher 25 GQL conformance and the GQL grammar's `CEIL|CEILING`), `round(x)` / `round(x, precision)`, and `toLower` / `toUpper` plus their GQL-conformance aliases `lower` / `upper` (ISO GQL §20.24 character-string functions; neo4j accepts both spellings) — the idiomatic case-insensitive compare `WHERE toLower(n.name) = 'bob'` — across the Cypher `WHERE`/`RETURN` expression surface. Evaluated natively on pandas/cuDF and polars (differential-parity tested). **`round` follows neo4j's documented tie-breaking** (standards-vetted against the neo4j manual): precision 0 (and the 1-arg form) rounds ties toward **positive infinity** (`round(-1.5)` → `-1.0`, `round(2.5)` → `3.0`), and precision > 0 rounds ties **away from zero** (HALF_UP: `round(-1.55, 1)` → `-1.6`) — not the numpy/polars half-to-even defaults, which give wrong answers on ties. The `round(x, precision, mode)` 3-arg form is not yet supported. Complements the already-supported `abs`/`sqrt`/`sign` and chained comparisons (`WHERE 1 < n.age < 65`). The `^` exponentiation operator is deferred: standards vetting settled it as **left-associative** (the openCypher TCK pins left, and neo4j's shipped Cypher 5/25 parser left-folds `Pow` — the manual's "right to left" row is a docs bug), but the current conformance corpus marks it reject-expected, so re-adding it is a coordinated corpus change. `LIKE`/`ILIKE`/`BETWEEN` remain intentionally unimplemented (verified absent from both Cypher and ISO GQL — GQL's only `LIKE` is the unrelated `CREATE GRAPH TYPE … LIKE g` DDL). - **GFQL Cypher `=~` regex-match operator (openCypher/neo4j-standard)**: added the standard `=~` string predicate to the Cypher `WHERE`/expression grammar — `MATCH (n) WHERE n.name =~ '(?i)al.*' RETURN n`. Semantics match openCypher/neo4j: **Java-regex dialect, full-string/anchored match** (`n.name =~ 'AB'` matches only `'AB'`, not `'ABCDEF'`; use `.*`/`^..$` for partial), with inline flags (`(?i)`/`(?m)`/`(?s)`) honored; lowers to the existing `fullmatch` predicate. Works in the simple `WHERE prop =~ '...'` form (all engines via `filter_by_dict`) and composes through `AND`/`OR`/`NOT`/`RETURN` expressions via the shared expression engine (pandas/cuDF; polars supports the simple-WHERE form and declines the complex `OR`/`NOT` row-filter form with an honest `NotImplementedError` — the pre-existing polars `where_rows` limitation, not `=~`-specific). Also adds native polars `Match`/`Fullmatch` predicate lowering (previously `NotImplementedError`), so `=~` and the Python `match()`/`fullmatch()` predicates run natively on polars. Differential-parity tested against the pandas oracle. `LIKE`/`ILIKE` remain intentionally unimplemented (not in any graph standard — use `=~`/`CONTAINS`/`STARTS WITH`). - **GFQL native Polars engine — traversals (`engine='polars'`)**: Added a native, vectorized Polars execution engine for the core GFQL traversals `hop()` and `chain()`, dispatched at the engine boundary so the production pandas/cuDF paths are untouched. `Engine.POLARS` is opt-in (explicit `engine='polars'`); `engine='auto'` with Polars input still coerces to pandas as before. Covers forward/reverse/undirected single-hop traversal, directed multi-hop chains, node/edge filter dicts and predicates (lowered to Polars expressions), `edge_match`/`source_node_match`/`destination_node_match`, `target_wave_front`, and alias names; the BFS advances via semi/anti joins (no per-row Python work). Validated by differential parity against the pandas engine (hop + chain test suites plus a randomized fuzzer) and benchmarked vs pandas (`benchmarks/gfql/pandas_vs_polars.py`) — Polars wins at scale (up to ~2.5x on multi-edge chains at millions of edges; crossover ~50–100k rows). Variable-length/multi-hop edges, undirected edges in multi-edge chains, hop labels, and node `query=` raise `NotImplementedError` for now (use `engine='pandas'`). - **GFQL native Polars engine — variable-length `min_hops>1` traversals (`engine='polars'`/`'polars-gpu'`)**: Forward/reverse lower-bound traversals (`e(min_hops=N, max_hops=M)`) now run natively on the Polars engine — no pandas bridge. The eager Polars hop runs pandas' min_hops algorithm vectorized: a NON-anti-joined BFS (the wavefront carries revisits so a cycle keeps bumping the reach depth until `max_hops`), a 3-case termination gate (`max_reached1` (needs connected-components + 2-core seed retention) and a *direct* `hop(min_hops>1)` (which would need pandas' separate un-labeled direct-hop node-output plus the `target_wave_front` threading the chain supplies — without them it silently diverges) both raise `NotImplementedError` for `engine='polars'` (use `chain()`/`gfql()`, or `engine='pandas'`). Validated by differential parity vs the pandas oracle: the 500-seed randomized chain fuzzer (`test_polars_chain_fuzz_parity`, hardened to compare null-aware node **attributes** and edge **multiplicity**, not just id/endpoint sets) is **500/500**, a min_hops+attribute-filter amplified fuzz and metamorphic invariants pass, and `engine='polars-gpu'` (cudf_polars) runs the full 500-seed fuzz **500/500** on-device. diff --git a/docs/source/gfql/cypher.rst b/docs/source/gfql/cypher.rst index 9ab5728634..36ba381c54 100644 --- a/docs/source/gfql/cypher.rst +++ b/docs/source/gfql/cypher.rst @@ -284,9 +284,14 @@ and ``RETURN`` expressions: ``WHERE 1 < n.age < 65`` are supported. (The ``^`` exponentiation operator is not yet available.) - Numeric functions ``abs``, ``sqrt``, ``sign``, ``floor``, ``ceil`` (alias - ``ceiling``), and ``round(x)`` / ``round(x, precision)`` (returns a float). -- String helpers ``toLower`` / ``toUpper`` (the idiomatic case-insensitive - compare, e.g. ``WHERE toLower(n.name) = 'bob'``), plus ``substring`` and + ``ceiling``, per Cypher 25 / GQL), and ``round(x)`` / ``round(x, precision)`` + (returns a float). ``round`` follows neo4j's tie-breaking: precision 0 rounds + ties toward positive infinity (``round(-1.5)`` → ``-1.0``, ``round(2.5)`` → + ``3.0``); precision > 0 rounds ties away from zero (``round(-1.55, 1)`` → + ``-1.6``). +- String helpers ``toLower`` / ``toUpper`` and their GQL-conformance aliases + ``lower`` / ``upper`` (the idiomatic case-insensitive compare, e.g. + ``WHERE toLower(n.name) = 'bob'``), plus ``substring`` and ``size``, and conversions ``toInteger`` / ``toFloat`` / ``toString`` / ``toBoolean`` and ``coalesce``. - Regex ``=~`` (see WHERE Forms above). diff --git a/graphistry/compute/gfql/language_defs.py b/graphistry/compute/gfql/language_defs.py index dda289744b..ac41ec6cd6 100644 --- a/graphistry/compute/gfql/language_defs.py +++ b/graphistry/compute/gfql/language_defs.py @@ -45,6 +45,8 @@ "round", "tolower", "toupper", + "lower", + "upper", "substring", "tointeger", "tofloat", diff --git a/graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py b/graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py index 7327e79699..ae2ac54b60 100644 --- a/graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py +++ b/graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py @@ -127,14 +127,24 @@ def _lower_function(node: FunctionCall, columns: Sequence[str]) -> Optional[pl.E or isinstance(arg1.value, bool): return None # non-literal precision -> defer (honest NIE) ndigits = arg1.value - return args[0].cast(pl.Float64).round(ndigits) - if name in {"tolower", "toupper"} and len(args) == 1: + # neo4j tie-breaking (matches the pandas engine): precision 0 -> ties toward + # +inf (floor(x+0.5)); precision > 0 -> ties away from zero (HALF_UP). + # polars' .round default (half-to-even) would be a wrong answer vs the spec. + # Use the native mode= for p>0 (bit-exact; a manual scale/divide formula picks + # up 1-ulp noise from polars' reassociating optimizer). + x = args[0].cast(pl.Float64) + if ndigits == 0: + return (x + 0.5).floor() + return x.round(ndigits, mode="half_away_from_zero") + if name in {"tolower", "toupper", "lower", "upper"} and len(args) == 1: + # toLower/toUpper + GQL-conformance aliases lower/upper (as neo4j accepts both). # String-only like neo4j (type error there); a non-string column must decline — # pandas declines too, and bare .str here raised a non-NIE SchemaError on # polars-gpu (dgx-repro'd). if _expr_output_dtype(args[0]) != pl.String: return None - return args[0].str.to_lowercase() if name == "tolower" else args[0].str.to_uppercase() + to_lower = name in {"tolower", "lower"} + return args[0].str.to_lowercase() if to_lower else args[0].str.to_uppercase() if name == "size" and len(args) == 1: # size(x): #chars (String) or #elements (List) — different polars ops, so gate by output # dtype. str.len_chars == pandas str.len (code points); list.len parity; null/empty diff --git a/graphistry/compute/gfql/row/pipeline.py b/graphistry/compute/gfql/row/pipeline.py index ad8770bad4..d88c65d74f 100644 --- a/graphistry/compute/gfql/row/pipeline.py +++ b/graphistry/compute/gfql/row/pipeline.py @@ -1379,22 +1379,46 @@ def _gfql_eval_expr_ast(self, table_df: Any, node: Any) -> Tuple[bool, Any]: return True, float(math.ceil(inner) if use_ceil else math.floor(inner)) if fn == "round" and len(values) in {1, 2}: + # neo4j tie-breaking (standards-vetted, #1673): precision 0 (or 1-arg) + # rounds ties toward +inf (round(-1.5) = -1.0); precision > 0 rounds ties + # away from zero (HALF_UP: round(-1.55, 1) = -1.6). numpy/pandas .round is + # half-to-even (round(2.5) -> 2.0) — a wrong answer vs the neo4j spec. inner = values[0] ndigits = int(values[1]) if len(values) == 2 else 0 if hasattr(inner, "astype"): null_mask = self._gfql_null_mask(table_df, inner) - out = inner.astype(float).round(ndigits) + f = inner.astype(float) + + def _floor_series(s: Any) -> Any: + if hasattr(s, "floor"): # cuDF native + return s.floor() + import numpy as np + return np.floor(s) + + if ndigits == 0: + out = _floor_series(f + 0.5) + else: + scale = 10.0 ** ndigits + shifted = f * scale + sig = (shifted > 0).astype(float) - (shifted < 0).astype(float) + out = _floor_series(shifted.abs() + 0.5) * sig / scale return True, out.where(~null_mask, pd.NA) if is_null_scalar(inner): return True, None - return True, round(float(inner), ndigits) - - # neo4j/openCypher toLower/toUpper (the idiomatic case-insensitive-match helper). - if fn in {"tolower", "toupper"} and len(values) == 1: + x = float(inner) + if ndigits == 0: + return True, float(math.floor(x + 0.5)) + scale = 10.0 ** ndigits + return True, math.copysign(math.floor(abs(x * scale) + 0.5), x) / scale + + # neo4j/openCypher toLower/toUpper (the idiomatic case-insensitive-match helper), + # plus the GQL-conformance aliases lower/upper (ISO GQL §20.24; neo4j accepts both). + if fn in {"tolower", "toupper", "lower", "upper"} and len(values) == 1: inner = values[0] + to_lower = fn in {"tolower", "lower"} if hasattr(inner, "str"): null_mask = self._gfql_null_mask(table_df, inner) - out = inner.str.lower() if fn == "tolower" else inner.str.upper() + out = inner.str.lower() if to_lower else inner.str.upper() return True, out.where(~null_mask, pd.NA) if is_null_scalar(inner): return True, None @@ -1403,7 +1427,7 @@ def _gfql_eval_expr_ast(self, table_df: Any, node: Any) -> Tuple[bool, Any]: # too (no .str accessor): str(inner) would broadcast the lowercased # Series REPR to every row — decline instead (dgx-repro'd). return False, None - return True, inner.lower() if fn == "tolower" else inner.upper() + return True, inner.lower() if to_lower else inner.upper() if fn == "tofloat" and len(values) == 1: inner = values[0] diff --git a/graphistry/tests/compute/gfql/cypher/test_lowering.py b/graphistry/tests/compute/gfql/cypher/test_lowering.py index 43e0d33d43..fa244239fb 100644 --- a/graphistry/tests/compute/gfql/cypher/test_lowering.py +++ b/graphistry/tests/compute/gfql/cypher/test_lowering.py @@ -2989,12 +2989,16 @@ def test_numeric_functions(engine: str, expr: str, expected: list[object]) -> No ("floor(2.7)", 2.0), ("ceil(2.1)", 3.0), ("ceiling(-2.1)", -2.0), - ("round(2.5)", 2.0), # numpy default (round-half-to-even) + ("round(2.5)", 3.0), # neo4j: precision-0 ties round toward +inf + ("round(-1.5)", -1.0), # neo4j manual Example 8 ("round(2.567, 2)", 2.57), + ("round(-1.55, 1)", -1.6), # neo4j manual Example 11: p>0 ties away from zero ("sign(-9)", -1), ("sqrt(9.0)", 3.0), ("toLower('ABC')", "abc"), ("toUpper('abc')", "ABC"), + ("lower('ABC')", "abc"), # GQL-conformance aliases (ISO GQL §20.24; neo4j accepts both) + ("upper('abc')", "ABC"), ], ) def test_numeric_functions_scalar(expr: str, expected: object) -> None: @@ -3003,6 +3007,45 @@ def test_numeric_functions_scalar(expr: str, expected: object) -> None: assert g.gfql(f"MATCH (n) RETURN {expr} AS v, n.id AS id")._nodes["v"].tolist() == [expected] +@pytest.mark.parametrize("engine", ["pandas", "polars"]) +def test_round_neo4j_tie_breaking(engine: str) -> None: + """Standards-vetted (#1673): neo4j round() ties — precision 0 rounds ties toward + +inf (round(-1.5) = -1.0, neo4j manual Ex. 8/10), precision > 0 rounds ties away + from zero (HALF_UP: round(-1.55, 1) = -1.6, Ex. 11). The numpy/polars + half-to-even defaults (round(2.5) -> 2.0) are wrong answers vs this spec.""" + if engine == "polars": + pytest.importorskip("polars") + + def vals(nodes: pd.DataFrame, expr: str) -> list: + g = _mk_graph(nodes, pd.DataFrame({"s": [], "d": []})) + q = f"MATCH (n) RETURN {expr} AS v, n.id AS id ORDER BY id" + col = g.gfql(q, engine=engine)._nodes["v"] + return col.to_list() if hasattr(col, "to_list") else col.tolist() + + ties = pd.DataFrame({"id": [0, 1, 2, 3], "x": [2.5, -1.5, 0.5, -2.5]}) + assert vals(ties, "round(n.x)") == [3.0, -1.0, 1.0, -2.0] # ties toward +inf + assert vals(ties, "round(n.x, 0)") == [3.0, -1.0, 1.0, -2.0] # p=0 aligns with 1-arg + prec = pd.DataFrame({"id": [0, 1], "x": [1.25, -1.55]}) + assert vals(prec, "round(n.x, 1)") == [pytest.approx(1.3), pytest.approx(-1.6)] # away from zero + + +@pytest.mark.parametrize("engine", ["pandas", "polars"]) +@pytest.mark.parametrize( + ("query", "expected"), + [ + ("MATCH (n) WHERE lower(n.name) = 'bob' RETURN n.id AS id ORDER BY id", [0]), + ("MATCH (n) WHERE upper(n.name) = 'BOB' RETURN n.id AS id ORDER BY id", [0]), + ], +) +def test_lower_upper_gql_aliases(engine: str, query: str, expected: list[int]) -> None: + if engine == "polars": + pytest.importorskip("polars") + nodes = pd.DataFrame({"id": [0, 1, 2], "name": ["BOB", "Alice", "carol"]}) + col = _mk_graph(nodes, pd.DataFrame({"s": [], "d": []})).gfql(query, engine=engine)._nodes["id"] + got = col.to_list() if hasattr(col, "to_list") else col.tolist() + assert got == expected + + @pytest.mark.parametrize("expr", ["floor(null)", "ceil(null)", "round(null)", "toLower(null)", "toUpper(null)"]) def test_numeric_string_fns_null_scalar(expr: str) -> None: """null literal -> null (exercises the scalar null-guard branches).""" From ce504047a755b621cdeaf58b581ac88e7b111a7e Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Thu, 2 Jul 2026 09:49:17 -0700 Subject: [PATCH 2/9] =?UTF-8?q?fix(gfql/cypher):=20round()=20review=20hard?= =?UTF-8?q?ening=20=E2=80=94=20ulp-correct=20kernel,=20negative-precision?= =?UTF-8?q?=20decline,=20CI=20polars=20lane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the round() conformance fix (4 IMPORTANT findings): - I2/S2: replace floor(x+0.5) with a floor+frac kernel on both engines — the +0.5 addition itself rounds up when x sits 1 ulp below a tie (JDK-6430675), e.g. round(0.49999999999999994) must be 0.0 and round(0.0499…96, 1) -> 0.0 (pandas previously disagreed with polars/neo4j here). Also: scaled-overflow identity (round(1e300, 20) = 1e300, not inf), -0.0 normalized (+0.0). - I1: negative precision now declines honestly on both engines (neo4j raises; polars previously crashed with a raw OverflowError, pandas silently computed). - I3: the polars-parametrized cypher test_lowering cases never ran in CI (core lane has no polars; polars lane's file list excluded the file) — added cypher/test_lowering.py -k polars to bin/test-polars.sh + the ci.yml coverage step (--cov-append). - I4: setup.py polars extra floor >= 1.5 (round(mode=) kwarg requirement). +ulp/inf/negative-precision tests (engine-parametrized, importorskip-guarded). 1620 tests pass; ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../gfql/lazy/engine/polars/row_pipeline.py | 15 ++++++---- graphistry/compute/gfql/row/pipeline.py | 29 ++++++++++++++++--- .../compute/gfql/cypher/test_lowering.py | 22 ++++++++++++++ setup.py | 2 +- 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py b/graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py index ae2ac54b60..008ecb123b 100644 --- a/graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py +++ b/graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py @@ -127,14 +127,19 @@ def _lower_function(node: FunctionCall, columns: Sequence[str]) -> Optional[pl.E or isinstance(arg1.value, bool): return None # non-literal precision -> defer (honest NIE) ndigits = arg1.value + if ndigits < 0: + return None # neo4j raises on negative precision; decline (honest NIE) # neo4j tie-breaking (matches the pandas engine): precision 0 -> ties toward - # +inf (floor(x+0.5)); precision > 0 -> ties away from zero (HALF_UP). - # polars' .round default (half-to-even) would be a wrong answer vs the spec. - # Use the native mode= for p>0 (bit-exact; a manual scale/divide formula picks - # up 1-ulp noise from polars' reassociating optimizer). + # +inf; precision > 0 -> ties away from zero (HALF_UP). polars' .round default + # (half-to-even) would be a wrong answer vs the spec. p=0 uses a floor+frac + # kernel (NOT floor(x+0.5): the +0.5 rounds when x is 1 ulp below a tie — + # round(0.49999999999999994) must be 0.0). p>0 uses the native mode= (bit-exact; + # a manual scale/divide formula picks up 1-ulp noise from polars' reassociating + # optimizer). Requires polars >= 1.5 for the mode kwarg (see setup.py extra). x = args[0].cast(pl.Float64) if ndigits == 0: - return (x + 0.5).floor() + fl = x.floor() + return fl + ((x - fl) >= 0.5).cast(pl.Float64) # ties toward +inf return x.round(ndigits, mode="half_away_from_zero") if name in {"tolower", "toupper", "lower", "upper"} and len(args) == 1: # toLower/toUpper + GQL-conformance aliases lower/upper (as neo4j accepts both). diff --git a/graphistry/compute/gfql/row/pipeline.py b/graphistry/compute/gfql/row/pipeline.py index d88c65d74f..92a401ec86 100644 --- a/graphistry/compute/gfql/row/pipeline.py +++ b/graphistry/compute/gfql/row/pipeline.py @@ -1383,8 +1383,14 @@ def _gfql_eval_expr_ast(self, table_df: Any, node: Any) -> Tuple[bool, Any]: # rounds ties toward +inf (round(-1.5) = -1.0); precision > 0 rounds ties # away from zero (HALF_UP: round(-1.55, 1) = -1.6). numpy/pandas .round is # half-to-even (round(2.5) -> 2.0) — a wrong answer vs the neo4j spec. + # Uses a floor+frac kernel, NOT floor(x+0.5): the +0.5 addition itself + # rounds up when x sits 1 ulp below a tie (JDK-6430675 class), e.g. + # round(0.49999999999999994) must be 0.0, round(0.0499…96, 1) → 0.0. inner = values[0] ndigits = int(values[1]) if len(values) == 2 else 0 + if ndigits < 0: + # neo4j raises on negative precision; decline (no silent wrong value). + return False, None if hasattr(inner, "astype"): null_mask = self._gfql_null_mask(table_df, inner) f = inner.astype(float) @@ -1396,20 +1402,35 @@ def _floor_series(s: Any) -> Any: return np.floor(s) if ndigits == 0: - out = _floor_series(f + 0.5) + fl = _floor_series(f) + out = fl + ((f - fl) >= 0.5).astype(float) # ties toward +inf else: scale = 10.0 ** ndigits shifted = f * scale + a = shifted.abs() + fl = _floor_series(a) + mag = fl + ((a - fl) >= 0.5).astype(float) # ties away from zero sig = (shifted > 0).astype(float) - (shifted < 0).astype(float) - out = _floor_series(shifted.abs() + 0.5) * sig / scale + out = mag * sig / scale + 0.0 # +0.0 normalizes -0.0 + # scaled overflow (|x·10^p| = inf): rounding is the identity + out = out.where(a != float("inf"), f) return True, out.where(~null_mask, pd.NA) if is_null_scalar(inner): return True, None x = float(inner) + if not math.isfinite(x): + return True, x if ndigits == 0: - return True, float(math.floor(x + 0.5)) + fl0 = math.floor(x) + return True, float(fl0 + (1 if (x - fl0) >= 0.5 else 0)) scale = 10.0 ** ndigits - return True, math.copysign(math.floor(abs(x * scale) + 0.5), x) / scale + shifted_x = x * scale + if not math.isfinite(shifted_x): + return True, x # scaled overflow -> identity + a2 = abs(shifted_x) + fl2 = math.floor(a2) + mag2 = fl2 + (1 if (a2 - fl2) >= 0.5 else 0) + return True, math.copysign(mag2, shifted_x) / scale + 0.0 # neo4j/openCypher toLower/toUpper (the idiomatic case-insensitive-match helper), # plus the GQL-conformance aliases lower/upper (ISO GQL §20.24; neo4j accepts both). diff --git a/graphistry/tests/compute/gfql/cypher/test_lowering.py b/graphistry/tests/compute/gfql/cypher/test_lowering.py index fa244239fb..22fb091b8a 100644 --- a/graphistry/tests/compute/gfql/cypher/test_lowering.py +++ b/graphistry/tests/compute/gfql/cypher/test_lowering.py @@ -3028,6 +3028,28 @@ def vals(nodes: pd.DataFrame, expr: str) -> list: prec = pd.DataFrame({"id": [0, 1], "x": [1.25, -1.55]}) assert vals(prec, "round(n.x, 1)") == [pytest.approx(1.3), pytest.approx(-1.6)] # away from zero + # 1-ulp-below-a-tie (JDK-6430675 class): a floor(x+0.5) kernel wrongly rounds UP; + # the correct answer (Java Math.round post-fix, BigDecimal, polars native) is down. + ulp = pd.DataFrame({"id": [0, 1], "x": [0.49999999999999994, 0.049999999999999996]}) + assert vals(ulp, "round(n.x)") == [0.0, 0.0] + assert vals(ulp, "round(n.x, 1)") == [pytest.approx(0.5), pytest.approx(0.0)] + # infinity passes through (rounding is the identity; no overflow to inf/crash) + inf = pd.DataFrame({"id": [0, 1], "x": [float("inf"), 1e300]}) + assert vals(inf, "round(n.x)") == [float("inf"), 1e300] + assert vals(inf, "round(n.x, 2)") == [float("inf"), 1e300] + + +@pytest.mark.parametrize("engine", ["pandas", "polars"]) +def test_round_negative_precision_declines(engine: str) -> None: + """neo4j raises on negative round() precision; we decline honestly (error, never a + silent value — and never a raw polars OverflowError crash).""" + if engine == "polars": + pytest.importorskip("polars") + g = _mk_graph(pd.DataFrame({"id": [0], "x": [25.0]}), pd.DataFrame({"s": [], "d": []})) + with pytest.raises(Exception) as exc_info: + g.gfql("MATCH (n) RETURN round(n.x, -1) AS v, n.id AS id", engine=engine) + assert "OverflowError" not in type(exc_info.value).__name__ + @pytest.mark.parametrize("engine", ["pandas", "polars"]) @pytest.mark.parametrize( diff --git a/setup.py b/setup.py index 008c086479..3bb83e7022 100755 --- a/setup.py +++ b/setup.py @@ -57,7 +57,7 @@ def unique_flatten_dict(d): 'jupyter': ['ipython'], 'spanner': ['google-cloud-spanner'], 'kusto': ['azure-kusto-data', 'azure-identity'], - 'polars': ['polars'], + 'polars': ['polars>=1.5'], } base_extras_heavy = { From c80068c1dd9ef57051f7276d5678ccebe76aae6c Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sat, 4 Jul 2026 13:55:50 -0700 Subject: [PATCH 3/9] test(gfql/conformance): matrix cases for the lower/upper GQL aliases Same ledger-driven completion as the parent commit: the aliases entered GFQL_SCALAR_FUNCTIONS here, so they get real matrix parity cases rather than waivers. dgx: matrix + ledger 269 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/compute/gfql/test_engine_polars_conformance_matrix.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/graphistry/tests/compute/gfql/test_engine_polars_conformance_matrix.py b/graphistry/tests/compute/gfql/test_engine_polars_conformance_matrix.py index 3fe7616b59..33725d6b1e 100644 --- a/graphistry/tests/compute/gfql/test_engine_polars_conformance_matrix.py +++ b/graphistry/tests/compute/gfql/test_engine_polars_conformance_matrix.py @@ -218,6 +218,8 @@ def _cypher_expression_queries(): ("round_p2", "MATCH (n) RETURN n.id AS id, round(n.f, 2) AS x"), ("tolower", "MATCH (n) RETURN n.id AS id, toLower(n.name) AS s"), ("toupper", "MATCH (n) RETURN n.id AS id, toUpper(n.name) AS s"), + ("lower_alias", "MATCH (n) RETURN n.id AS id, lower(n.name) AS s"), + ("upper_alias", "MATCH (n) RETURN n.id AS id, upper(n.name) AS s"), # NOTE: toString(float) intentionally absent — polars NIEs (test_tostring_float_honest_nie # _polars covers that), and cudf's orthogonal float-repr divergence from pandas would trip # _assert_invariant; the dedicated pandas-vs-polars test carries the real intent. From 4daf2cc692f52607d3e94d65f3f3f6427e6fdb10 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sat, 4 Jul 2026 18:39:36 -0700 Subject: [PATCH 4/9] =?UTF-8?q?fix(gfql/cypher):=20round()=20review=20wave?= =?UTF-8?q?=201=20=E2=80=94=20polars>=3D1.29=20floor,=20large-p=20identity?= =?UTF-8?q?,=20-0.0=20normalize,=20tie/hazard=20matrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct extras floor (Expr.round(mode=) shipped py-1.29.0, not 1.5); round(x,p>308) = identity on both engines (10.0**p overflow guarded; was err-vs-identity divergence); polars p>0 +0.0 zero-sign normalize (sign-bit-pinned test); deterministic tie/hazard matrix case (ties never reached GPU engines); BigDecimal deviation documented. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++- docs/source/gfql/cypher.rst | 6 ++- .../gfql/lazy/engine/polars/row_pipeline.py | 7 +++- graphistry/compute/gfql/row/pipeline.py | 12 ++++++ .../test_engine_polars_conformance_matrix.py | 39 +++++++++++++++++++ setup.py | 2 +- 6 files changed, 66 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0d2a7618..87ddacb404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,8 +27,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed - **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". -- **CI: `compute_networkx` HITS test was scipy-version flaky**: `test_compute_networkx_hits_outputs_hubs_and_authorities` used a pure directed 3-cycle (`a→b→c→a`), whose adjacency is a permutation matrix (all singular values equal). networkx HITS computes scores via scipy `svds(k=1)`, which then returns an arbitrary vector from that fully-degenerate singular space; when its components sum to ~0, HITS's `h /= h.sum()` normalization blows up to `inf`/`nan`. Which vector `svds` returns is scipy/LAPACK-version dependent, so the test passed on the pinned scipy but failed on the `nx-upper-scipy` CI profile's newer scipy — a latent flake, not a code regression. Switched to a graph with a well-defined hub/authority structure (`a→b`, `a→c`, `b→c`), whose dominant singular vector is unique (Perron-Frobenius) and therefore version-stable. Test-only change.- **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. -- **GFQL `engine='polars-gpu'` LazyFrame-input coercion now collects on the GPU executor**: `Engine.df_to_engine(lazyframe, POLARS_GPU)` materialized a `polars.LazyFrame` input with a bare `.collect()` on the CPU default executor — ignoring `gpu_executor()` and not distinguishing `POLARS` from `POLARS_GPU`. It now routes through the target-aware lazy collect, so a LazyFrame coerced under `polars-gpu` collects on the cudf-polars GPU executor (and `polars` honors `cpu_streaming()`). No change for already-materialized frame inputs (the common path). +- **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 patterns containing escape sequences instead of silently inverting them (`.lower()` turns `\D` into `\d`), and lookaround/backreferences 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. +- **CI: `compute_networkx` HITS test was scipy-version flaky**: `test_compute_networkx_hits_outputs_hubs_and_authorities` used a pure directed 3-cycle (`a→b→c→a`), whose adjacency is a permutation matrix (all singular values equal). networkx HITS computes scores via scipy `svds(k=1)`, which then returns an arbitrary vector from that fully-degenerate singular space; when its components sum to ~0, HITS's `h /= h.sum()` normalization blows up to `inf`/`nan`. Which vector `svds` returns is scipy/LAPACK-version dependent, so the test passed on the pinned scipy but failed on the `nx-upper-scipy` CI profile's newer scipy — a latent flake, not a code regression. Switched to a graph with a well-defined hub/authority structure (`a→b`, `a→c`, `b→c`), whose dominant singular vector is unique (Perron-Frobenius) and therefore version-stable. Test-only change.- **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.- **GFQL `engine='polars-gpu'` LazyFrame-input coercion now collects on the GPU executor**: `Engine.df_to_engine(lazyframe, POLARS_GPU)` materialized a `polars.LazyFrame` input with a bare `.collect()` on the CPU default executor — ignoring `gpu_executor()` and not distinguishing `POLARS` from `POLARS_GPU`. It now routes through the target-aware lazy collect, so a LazyFrame coerced under `polars-gpu` collects on the cudf-polars GPU executor (and `polars` honors `cpu_streaming()`). No change for already-materialized frame inputs (the common path). - **GFQL Polars engine `contains` honors `regex=`/`flags=`**: the native polars `filter_by_dict` lowering of the `Contains` predicate always used `str.contains(..., literal=False)`, so a **literal** request (`contains(pat, regex=False)`) was still regex-interpreted — a pattern with a metacharacter over-matched (e.g. `contains('a.c', regex=False)` matched `'abc'`), diverging from pandas/cuDF. It also dropped `flags=`. Now `regex=False` lowers to a literal match (`literal=True`; case-insensitive literal folds both sides, matching pandas' result), and regex mode maps `case=`/`flags=` (IGNORECASE/MULTILINE/DOTALL/VERBOSE) to a Rust-regex inline flag prefix (`(?ims…)`). +1 engine-parametrized differential-parity test. - **GFQL `engine='polars-gpu'` silent CPU fallback removed (NO-CHEATING)**: the GPU collect used `pl.GPUEngine(raise_on_fail=False)`, so any plan node the cudf_polars backend can't execute would silently run **on CPU** and still be reported as a `polars-gpu` result — making `engine='polars-gpu'` indistinguishable from `engine='polars'` whenever the plan isn't fully GPU-capable (a benchmark showing near-identical `polars`/`polars-gpu` timings is exactly this tell). Flipped to `raise_on_fail=True` and translate the cudf_polars failure into a clear `NotImplementedError` pointing at `engine='polars'` for native CPU. `polars-gpu` is now **GPU-or-error**: any timing it produces is real on-device work, never CPU mislabeled as GPU. Verified on dgx-spark (LiveJournal 35M): the seeded-frontier `hop`/2-hop chain plan executes fully on GPU without raising (nvidia-smi 92% util during the loop), so existing GPU timings are unchanged — only the honesty guarantee is added. +1 regression test (the GPU-collect error path is translated, not swallowed). - **GFQL Polars chain dtype-mismatched join keys**: a chain (`MATCH (a)-[]->(b)…`) crashed under `engine='polars'` with `SchemaError: datatypes of join keys don't match` when an edge endpoint column's dtype differed from the node-id dtype across the int↔float boundary — e.g. a null in a `source`/`destination` column promotes it to `float64` while integer node ids stay `int64`. The hop already aligned join keys; the chain fast paths + combine did not. Now aligns endpoint join keys to the node-id dtype for the traversal and restores the original endpoint dtype on the output (matching pandas). No-op when dtypes already match. diff --git a/docs/source/gfql/cypher.rst b/docs/source/gfql/cypher.rst index 36ba381c54..f5c8e9ead7 100644 --- a/docs/source/gfql/cypher.rst +++ b/docs/source/gfql/cypher.rst @@ -288,7 +288,11 @@ and ``RETURN`` expressions: (returns a float). ``round`` follows neo4j's tie-breaking: precision 0 rounds ties toward positive infinity (``round(-1.5)`` → ``-1.0``, ``round(2.5)`` → ``3.0``); precision > 0 rounds ties away from zero (``round(-1.55, 1)`` → - ``-1.6``). + ``-1.6``). One documented deviation at precision > 0: neo4j rounds via the + number's decimal string (Java ``BigDecimal.valueOf``), so a value like + ``2.675`` — stored as the binary double ``2.67499…`` — gives ``2.68`` in + neo4j but ``2.67`` here (both engines, consistently binary-double). + Precision above 308 is the identity (a float64 has no digits there). - String helpers ``toLower`` / ``toUpper`` and their GQL-conformance aliases ``lower`` / ``upper`` (the idiomatic case-insensitive compare, e.g. ``WHERE toLower(n.name) = 'bob'``), plus ``substring`` and diff --git a/graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py b/graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py index 008ecb123b..730623d2e7 100644 --- a/graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py +++ b/graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py @@ -135,12 +135,15 @@ def _lower_function(node: FunctionCall, columns: Sequence[str]) -> Optional[pl.E # kernel (NOT floor(x+0.5): the +0.5 rounds when x is 1 ulp below a tie — # round(0.49999999999999994) must be 0.0). p>0 uses the native mode= (bit-exact; # a manual scale/divide formula picks up 1-ulp noise from polars' reassociating - # optimizer). Requires polars >= 1.5 for the mode kwarg (see setup.py extra). + # optimizer). Requires polars >= 1.29 for the mode kwarg (see setup.py extra; + # the kwarg shipped in py-1.29.0, pola-rs/polars#22248 — NOT 1.5). The trailing + # + 0.0 normalizes -0.0 like the pandas kernel's scale/divide does (polars' + # native mode keeps -0.0: round(-0.04, 1) was 0.0 vs -0.0, dgx-repro'd). x = args[0].cast(pl.Float64) if ndigits == 0: fl = x.floor() return fl + ((x - fl) >= 0.5).cast(pl.Float64) # ties toward +inf - return x.round(ndigits, mode="half_away_from_zero") + return x.round(ndigits, mode="half_away_from_zero") + 0.0 if name in {"tolower", "toupper", "lower", "upper"} and len(args) == 1: # toLower/toUpper + GQL-conformance aliases lower/upper (as neo4j accepts both). # String-only like neo4j (type error there); a non-string column must decline — diff --git a/graphistry/compute/gfql/row/pipeline.py b/graphistry/compute/gfql/row/pipeline.py index 92a401ec86..e43ff0aa86 100644 --- a/graphistry/compute/gfql/row/pipeline.py +++ b/graphistry/compute/gfql/row/pipeline.py @@ -1391,6 +1391,18 @@ def _gfql_eval_expr_ast(self, table_df: Any, node: Any) -> Tuple[bool, Any]: if ndigits < 0: # neo4j raises on negative precision; decline (no silent wrong value). return False, None + if ndigits > 308: + # 10.0**p overflows at p>=309 (CPython pow raises); rounding a float64 + # beyond 308 decimals is the identity anyway — return the input as + # float (+0.0 zero-sign normalize), matching polars' native behavior + # at large precision (dgx-repro'd). + inner_id = values[0] + if hasattr(inner_id, "astype"): + null_mask = self._gfql_null_mask(table_df, inner_id) + return True, (inner_id.astype(float) + 0.0).where(~null_mask, pd.NA) + if is_null_scalar(inner_id): + return True, None + return True, float(inner_id) + 0.0 if hasattr(inner, "astype"): null_mask = self._gfql_null_mask(table_df, inner) f = inner.astype(float) diff --git a/graphistry/tests/compute/gfql/test_engine_polars_conformance_matrix.py b/graphistry/tests/compute/gfql/test_engine_polars_conformance_matrix.py index 33725d6b1e..dab8780ffe 100644 --- a/graphistry/tests/compute/gfql/test_engine_polars_conformance_matrix.py +++ b/graphistry/tests/compute/gfql/test_engine_polars_conformance_matrix.py @@ -277,6 +277,45 @@ def test_conformance_cypher_expressions(label, query): ] +def test_round_tie_hazard_values_all_engines(): + """#1677 wave-1: the shared fixture's floats are rng.normal — ties are measure-zero, + so the GPU engines never saw a tie/hazard value despite ties being the round() fix's + whole point. Deterministic hazard column, ALL engines via parity-or-NIE: exact ties + (p=0 ties -> +inf; p>0 ties away from zero), the 1-ulp-below-tie JDK case, -0.0 + (zero-sign normalize), >2^52 (integral floats), 1e308 (scaled overflow -> identity), + NaN + null, and p=400 (identity — 10.0**p overflow guard, was uncaught/divergent).""" + import pandas as pd + nd = pd.DataFrame({ + "id": list(range(12)), + "v": [0.5, -0.5, 2.5, -1.5, -2.55, 0.49999999999999994, + -0.04, -0.0, 1e308, 5e15 + 0.5, float("nan"), None], + }) + ed = pd.DataFrame({"s": [0], "d": [1], "eid": [0]}) + g = graphistry.nodes(nd, "id").edges(ed, "s", "d").bind(edge="eid") + for q in [ + "MATCH (n) RETURN n.id AS id, round(n.v) AS x", + "MATCH (n) RETURN n.id AS id, round(n.v, 1) AS x", + "MATCH (n) RETURN n.id AS id, round(n.v, 2) AS x", + "MATCH (n) RETURN n.id AS id, round(n.v, 400) AS x", + ]: + _assert_invariant(g, q, f"round-hazard {q}") + + +def test_round_negative_zero_normalized_polars(): + """round(-0.04, 1) must be +0.0 on BOTH engines: the pandas kernel's + 0.0 + normalizes, polars' native mode= kept -0.0 (dgx-repro'd) — and value equality + cannot see it (-0.0 == 0.0), so pin the sign bit explicitly.""" + import math as _math + import pandas as pd + nd = pd.DataFrame({"id": [0], "v": [-0.04]}) + ed = pd.DataFrame({"s": [0], "d": [0], "eid": [0]}) + g = graphistry.nodes(nd, "id").edges(ed, "s", "d").bind(edge="eid") + q = "MATCH (n) RETURN round(n.v, 1) AS x" + for eng in ["pandas", "polars"]: + val = float(_to_pd(g.gfql(q, engine=eng)._nodes)["x"].iloc[0]) + assert _math.copysign(1.0, val) == 1.0, f"{eng}: round(-0.04, 1) kept -0.0" + + @pytest.mark.parametrize("label,cypher,why", _NATIVE_OK_CYPHER, ids=[c[0] for c in _NATIVE_OK_CYPHER]) def test_scalar_fn_runs_natively_on_polars(label, cypher, why): g = _graph(4) diff --git a/setup.py b/setup.py index 3bb83e7022..8c3955232f 100755 --- a/setup.py +++ b/setup.py @@ -57,7 +57,7 @@ def unique_flatten_dict(d): 'jupyter': ['ipython'], 'spanner': ['google-cloud-spanner'], 'kusto': ['azure-kusto-data', 'azure-identity'], - 'polars': ['polars>=1.5'], + 'polars': ['polars>=1.29'], # Expr.round(mode=) shipped in py-1.29.0 (pola-rs/polars#22248) } base_extras_heavy = { From d3d6fa9194218fe931bd7f0427039192077e1a48 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sat, 4 Jul 2026 19:05:00 -0700 Subject: [PATCH 5/9] =?UTF-8?q?fix(gfql/cypher):=20round()=20wave-2=20?= =?UTF-8?q?=E2=80=94=20polars=20p>308=20identity=20guard,=20real=20p>0=20t?= =?UTF-8?q?ies=20in=20hazard=20set,=20warning=20hygiene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polars mirrors the pandas p>308 identity (+0.0): its native identity starts at p>=326 (quantizing [309,325] window) and p>=2^32 is a raw PyO3 OverflowError; hazard set gains ±1.25 (exact p=1 ties — no prior value tied at p>0) + 2^51+0.5 (representable huge tie), drops the dead 5e15+0.5; sign-bit test runs all engines; errstate(over=ignore) on the kernel's guarded scale multiply and the test sig's round(6). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../gfql/lazy/engine/polars/row_pipeline.py | 6 +++++ graphistry/compute/gfql/row/pipeline.py | 6 ++++- .../tests/compute/gfql/polars_test_utils.py | 4 +++- .../test_engine_polars_conformance_matrix.py | 24 +++++++++++-------- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py b/graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py index 730623d2e7..31927f8aa7 100644 --- a/graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py +++ b/graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py @@ -140,6 +140,12 @@ def _lower_function(node: FunctionCall, columns: Sequence[str]) -> Optional[pl.E # + 0.0 normalizes -0.0 like the pandas kernel's scale/divide does (polars' # native mode keeps -0.0: round(-0.04, 1) was 0.0 vs -0.0, dgx-repro'd). x = args[0].cast(pl.Float64) + if ndigits > 308: + # Identity, mirroring the pandas kernel's p>308 guard: polars' own + # identity only starts at p>=326 (its [300,325] split-multiplier window + # quantizes tiny values where pandas returns identity), and p >= 2**32 + # is a raw PyO3 OverflowError (decimals is u32) — #1677 wave-2. + return x + 0.0 if ndigits == 0: fl = x.floor() return fl + ((x - fl) >= 0.5).cast(pl.Float64) # ties toward +inf diff --git a/graphistry/compute/gfql/row/pipeline.py b/graphistry/compute/gfql/row/pipeline.py index e43ff0aa86..ce71281086 100644 --- a/graphistry/compute/gfql/row/pipeline.py +++ b/graphistry/compute/gfql/row/pipeline.py @@ -1417,8 +1417,12 @@ def _floor_series(s: Any) -> Any: fl = _floor_series(f) out = fl + ((f - fl) >= 0.5).astype(float) # ties toward +inf else: + import numpy as np scale = 10.0 ** ndigits - shifted = f * scale + with np.errstate(over="ignore"): + # |x·10^p| may legitimately overflow to inf (guarded to + # identity below) — suppress the RuntimeWarning noise. + shifted = f * scale a = shifted.abs() fl = _floor_series(a) mag = fl + ((a - fl) >= 0.5).astype(float) # ties away from zero diff --git a/graphistry/tests/compute/gfql/polars_test_utils.py b/graphistry/tests/compute/gfql/polars_test_utils.py index cb5a58665e..a825d087f3 100644 --- a/graphistry/tests/compute/gfql/polars_test_utils.py +++ b/graphistry/tests/compute/gfql/polars_test_utils.py @@ -34,9 +34,11 @@ def typed_frame_sig(df): if df is None: return None df = df.reindex(sorted(df.columns), axis=1).copy() + import numpy as np for c in df.columns: if df[c].dtype.kind == "f": - df[c] = df[c].round(6) + with np.errstate(over="ignore"): # .round(6) on 1e308-scale cells warns benignly + df[c] = df[c].round(6) cols = tuple(df.columns) # rows as tuples (NaN/NA -> None), sorted with a None-safe type-safe key (per-row agg(join) # is fragile on empty/mixed frames). astype(object) FIRST so nullable-extension dtypes diff --git a/graphistry/tests/compute/gfql/test_engine_polars_conformance_matrix.py b/graphistry/tests/compute/gfql/test_engine_polars_conformance_matrix.py index dab8780ffe..806cfc7b6f 100644 --- a/graphistry/tests/compute/gfql/test_engine_polars_conformance_matrix.py +++ b/graphistry/tests/compute/gfql/test_engine_polars_conformance_matrix.py @@ -281,14 +281,16 @@ def test_round_tie_hazard_values_all_engines(): """#1677 wave-1: the shared fixture's floats are rng.normal — ties are measure-zero, so the GPU engines never saw a tie/hazard value despite ties being the round() fix's whole point. Deterministic hazard column, ALL engines via parity-or-NIE: exact ties - (p=0 ties -> +inf; p>0 ties away from zero), the 1-ulp-below-tie JDK case, -0.0 - (zero-sign normalize), >2^52 (integral floats), 1e308 (scaled overflow -> identity), - NaN + null, and p=400 (identity — 10.0**p overflow guard, was uncaught/divergent).""" + (p=0 ties -> +inf; ±1.25 = EXACT p=1 ties, ×10 = ±12.5 → away-from-zero ±1.3 vs + half-even ±1.2 — wave-2 caught that no other value ties at p>0; 2^51+0.5 = a + representable HUGE tie), the 1-ulp-below-tie JDK case, -0.0 (zero-sign normalize), + 1e308 (scaled overflow -> identity), NaN + null, and p=400 (identity on BOTH + engines — 10.0**p / u32 overflow guards, was uncaught/divergent).""" import pandas as pd nd = pd.DataFrame({ - "id": list(range(12)), - "v": [0.5, -0.5, 2.5, -1.5, -2.55, 0.49999999999999994, - -0.04, -0.0, 1e308, 5e15 + 0.5, float("nan"), None], + "id": list(range(14)), + "v": [0.5, -0.5, 2.5, -1.5, -2.55, 1.25, -1.25, 0.49999999999999994, + -0.04, -0.0, 1e308, 2251799813685248.5, float("nan"), None], }) ed = pd.DataFrame({"s": [0], "d": [1], "eid": [0]}) g = graphistry.nodes(nd, "id").edges(ed, "s", "d").bind(edge="eid") @@ -301,17 +303,19 @@ def test_round_tie_hazard_values_all_engines(): _assert_invariant(g, q, f"round-hazard {q}") -def test_round_negative_zero_normalized_polars(): - """round(-0.04, 1) must be +0.0 on BOTH engines: the pandas kernel's + 0.0 +def test_round_negative_zero_normalized_all_engines(): + """round(-0.04, 1) must be +0.0 on EVERY engine: the pandas/cuDF kernel's + 0.0 normalizes, polars' native mode= kept -0.0 (dgx-repro'd) — and value equality - cannot see it (-0.0 == 0.0), so pin the sign bit explicitly.""" + cannot see it (-0.0 == 0.0), so pin the sign bit explicitly (NIE acceptable).""" import math as _math import pandas as pd nd = pd.DataFrame({"id": [0], "v": [-0.04]}) ed = pd.DataFrame({"s": [0], "d": [0], "eid": [0]}) g = graphistry.nodes(nd, "id").edges(ed, "s", "d").bind(edge="eid") q = "MATCH (n) RETURN round(n.v, 1) AS x" - for eng in ["pandas", "polars"]: + for eng in ["pandas"] + _NONPANDAS_ENGINES: + if _run(g, q, eng)[0] == "nie": + continue val = float(_to_pd(g.gfql(q, engine=eng)._nodes)["x"].iloc[0]) assert _math.copysign(1.0, val) == 1.0, f"{eng}: round(-0.04, 1) kept -0.0" From ec2a4428b168ad4e12be699874e23d654e4a3f47 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sat, 4 Jul 2026 19:05:54 -0700 Subject: [PATCH 6/9] docs(changelog): drop wave-1-worded =~ entry duplicated by the cascade keep-both Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87ddacb404..63041b7291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,6 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed - **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 `=~` / 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 patterns containing escape sequences instead of silently inverting them (`.lower()` turns `\D` into `\d`), and lookaround/backreferences 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. - **CI: `compute_networkx` HITS test was scipy-version flaky**: `test_compute_networkx_hits_outputs_hubs_and_authorities` used a pure directed 3-cycle (`a→b→c→a`), whose adjacency is a permutation matrix (all singular values equal). networkx HITS computes scores via scipy `svds(k=1)`, which then returns an arbitrary vector from that fully-degenerate singular space; when its components sum to ~0, HITS's `h /= h.sum()` normalization blows up to `inf`/`nan`. Which vector `svds` returns is scipy/LAPACK-version dependent, so the test passed on the pinned scipy but failed on the `nx-upper-scipy` CI profile's newer scipy — a latent flake, not a code regression. Switched to a graph with a well-defined hub/authority structure (`a→b`, `a→c`, `b→c`), whose dominant singular vector is unique (Perron-Frobenius) and therefore version-stable. Test-only change.- **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.- **GFQL `engine='polars-gpu'` LazyFrame-input coercion now collects on the GPU executor**: `Engine.df_to_engine(lazyframe, POLARS_GPU)` materialized a `polars.LazyFrame` input with a bare `.collect()` on the CPU default executor — ignoring `gpu_executor()` and not distinguishing `POLARS` from `POLARS_GPU`. It now routes through the target-aware lazy collect, so a LazyFrame coerced under `polars-gpu` collects on the cudf-polars GPU executor (and `polars` honors `cpu_streaming()`). No change for already-materialized frame inputs (the common path). From 093e2cc599254ad4888f7f5615d97b246fc5744a Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sat, 4 Jul 2026 19:29:05 -0700 Subject: [PATCH 7/9] =?UTF-8?q?fix(gfql/cypher):=20round()=20wave-3=20?= =?UTF-8?q?=E2=80=94=20widen=20errstate=20over=20the=20guarded=20overflow?= =?UTF-8?q?=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit over= alone still warned on the inf-inf subtract (invalid value encountered); values unaffected (both guarded to identity). Co-Authored-By: Claude Opus 4.8 (1M context) --- graphistry/compute/gfql/row/pipeline.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/graphistry/compute/gfql/row/pipeline.py b/graphistry/compute/gfql/row/pipeline.py index ce71281086..46ff2df1ff 100644 --- a/graphistry/compute/gfql/row/pipeline.py +++ b/graphistry/compute/gfql/row/pipeline.py @@ -1419,17 +1419,19 @@ def _floor_series(s: Any) -> Any: else: import numpy as np scale = 10.0 ** ndigits - with np.errstate(over="ignore"): - # |x·10^p| may legitimately overflow to inf (guarded to - # identity below) — suppress the RuntimeWarning noise. + with np.errstate(over="ignore", invalid="ignore"): + # |x·10^p| may legitimately overflow to inf, and the + # follow-on a-fl is then inf-inf=NaN — both guarded to + # identity below; suppress the RuntimeWarning noise + # (wave-3: over= alone still warned on the subtract). shifted = f * scale - a = shifted.abs() - fl = _floor_series(a) - mag = fl + ((a - fl) >= 0.5).astype(float) # ties away from zero - sig = (shifted > 0).astype(float) - (shifted < 0).astype(float) - out = mag * sig / scale + 0.0 # +0.0 normalizes -0.0 - # scaled overflow (|x·10^p| = inf): rounding is the identity - out = out.where(a != float("inf"), f) + a = shifted.abs() + fl = _floor_series(a) + mag = fl + ((a - fl) >= 0.5).astype(float) # ties away + sig = (shifted > 0).astype(float) - (shifted < 0).astype(float) + out = mag * sig / scale + 0.0 # +0.0 normalizes -0.0 + # scaled overflow (|x·10^p| = inf): rounding is the identity + out = out.where(a != float("inf"), f) return True, out.where(~null_mask, pd.NA) if is_null_scalar(inner): return True, None From ccd440ee5baa1e6258c0a11d2fd4d054f5656189 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sat, 4 Jul 2026 19:46:42 -0700 Subject: [PATCH 8/9] =?UTF-8?q?fix(gfql/cypher):=20round()=20=E2=80=94=20s?= =?UTF-8?q?ymmetric=20errstate=20over=20the=20p=3D0=20branch=20(wave-4=20s?= =?UTF-8?q?uggestion)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ±inf input warns identically on the p=0 tie subtract; hoist one errstate over both branches (values unchanged, all guarded). Co-Authored-By: Claude Opus 4.8 (1M context) --- graphistry/compute/gfql/row/pipeline.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/graphistry/compute/gfql/row/pipeline.py b/graphistry/compute/gfql/row/pipeline.py index 46ff2df1ff..04d9a22d04 100644 --- a/graphistry/compute/gfql/row/pipeline.py +++ b/graphistry/compute/gfql/row/pipeline.py @@ -1413,17 +1413,17 @@ def _floor_series(s: Any) -> Any: import numpy as np return np.floor(s) - if ndigits == 0: - fl = _floor_series(f) - out = fl + ((f - fl) >= 0.5).astype(float) # ties toward +inf - else: - import numpy as np - scale = 10.0 ** ndigits - with np.errstate(over="ignore", invalid="ignore"): - # |x·10^p| may legitimately overflow to inf, and the - # follow-on a-fl is then inf-inf=NaN — both guarded to - # identity below; suppress the RuntimeWarning noise - # (wave-3: over= alone still warned on the subtract). + import numpy as np + with np.errstate(over="ignore", invalid="ignore"): + # ±inf input makes the tie subtract inf-inf=NaN on EITHER + # branch, and p>0's scale multiply may overflow to inf — + # every such row is guarded to the correct identity below; + # suppress the benign RuntimeWarning noise (waves 3-4). + if ndigits == 0: + fl = _floor_series(f) + out = fl + ((f - fl) >= 0.5).astype(float) # ties toward +inf + else: + scale = 10.0 ** ndigits shifted = f * scale a = shifted.abs() fl = _floor_series(a) From 04ee38f861516603894609ae092598612e7d6390 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 5 Jul 2026 15:27:26 -0700 Subject: [PATCH 9/9] test(gfql/cypher): parameterize vals() return type (-> List[Any], was bare list) Co-Authored-By: Claude Opus 4.8 (1M context) --- graphistry/tests/compute/gfql/cypher/test_lowering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphistry/tests/compute/gfql/cypher/test_lowering.py b/graphistry/tests/compute/gfql/cypher/test_lowering.py index 22fb091b8a..ecc985e4ca 100644 --- a/graphistry/tests/compute/gfql/cypher/test_lowering.py +++ b/graphistry/tests/compute/gfql/cypher/test_lowering.py @@ -3016,7 +3016,7 @@ def test_round_neo4j_tie_breaking(engine: str) -> None: if engine == "polars": pytest.importorskip("polars") - def vals(nodes: pd.DataFrame, expr: str) -> list: + def vals(nodes: pd.DataFrame, expr: str) -> List[Any]: g = _mk_graph(nodes, pd.DataFrame({"s": [], "d": []})) q = f"MATCH (n) RETURN {expr} AS v, n.id AS id ORDER BY id" col = g.gfql(q, engine=engine)._nodes["v"]