Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CHANGELOG.md

Large diffs are not rendered by default.

15 changes: 12 additions & 3 deletions docs/source/gfql/cypher.rst
Original file line number Diff line number Diff line change
Expand Up @@ -284,9 +284,18 @@ 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``). 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
``size``, and conversions ``toInteger`` / ``toFloat`` / ``toString`` /
``toBoolean`` and ``coalesce``.
- Regex ``=~`` (see WHERE Forms above).
Expand Down
2 changes: 2 additions & 0 deletions graphistry/compute/gfql/language_defs.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
"round",
"tolower",
"toupper",
"lower",
"upper",
"substring",
"tointeger",
"tofloat",
Expand Down
30 changes: 27 additions & 3 deletions graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,38 @@ 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:
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; 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.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 > 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
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 —
# 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
Expand Down
77 changes: 70 additions & 7 deletions graphistry/compute/gfql/row/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -1379,22 +1379,85 @@ 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.
# 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 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)
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)

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)
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
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 not math.isfinite(x):
return True, x
if ndigits == 0:
fl0 = math.floor(x)
return True, float(fl0 + (1 if (x - fl0) >= 0.5 else 0))
scale = 10.0 ** ndigits
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).
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
Expand All @@ -1403,7 +1466,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]
Expand Down
67 changes: 66 additions & 1 deletion graphistry/tests/compute/gfql/cypher/test_lowering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -3003,6 +3007,67 @@ 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[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"]
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

# 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(
("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)."""
Expand Down
4 changes: 3 additions & 1 deletion graphistry/tests/compute/gfql/polars_test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -275,6 +277,49 @@ 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; ±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(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")
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_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 (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"] + _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"


@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)
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.29'], # Expr.round(mode=) shipped in py-1.29.0 (pola-rs/polars#22248)
}

base_extras_heavy = {
Expand Down
Loading