From 50b7f6035e903ef87a76cc05af3be290971c5aa3 Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Sun, 4 Oct 2026 09:03:53 +0200 Subject: [PATCH] Round half-way values towards positive infinity in ROUND SPARQL 1.1 17.4.4.2 (fn:numeric-round): of two equally close integers, the one closest to positive infinity is returned, so round(-2.5) = -2. Polars only offers half-away-from-zero, so negative ties are moved one step up. Refs DataTreehouse/maplib#78. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/expressions/functions/round_.rs | 45 ++++++++++++++----- py_maplib/tests/test_round.py | 43 ++++++++++++++++++ 2 files changed, 76 insertions(+), 12 deletions(-) create mode 100644 py_maplib/tests/test_round.py diff --git a/lib/query_processing/src/expressions/functions/round_.rs b/lib/query_processing/src/expressions/functions/round_.rs index e950b637..eb942117 100644 --- a/lib/query_processing/src/expressions/functions/round_.rs +++ b/lib/query_processing/src/expressions/functions/round_.rs @@ -1,5 +1,6 @@ use crate::errors::QueryProcessingError; -use polars::prelude::{as_struct, col, lit, LiteralValue, RoundMode}; +use oxrdf::vocab::xsd; +use polars::prelude::{as_struct, col, lit, when, Expr, LiteralValue, RoundMode}; use representation::query_context::Context; use representation::solution_mapping::SolutionMappings; use representation::{BaseRDFNodeType, RDFNodeState}; @@ -21,11 +22,6 @@ pub fn round_( )); } let first_context = args_contexts.get(&0).unwrap(); - solution_mappings.mappings = solution_mappings.mappings.with_column( - col(first_context.as_str()) - .round(0, RoundMode::HalfAwayFromZero) - .alias(outer_context.as_str()), - ); let existing_type = solution_mappings .rdf_node_types .get(first_context.as_str()) @@ -36,17 +32,22 @@ pub fn round_( for t in existing_type.map.keys() { if t.is_numeric() { rounded_exprs.push( - col(first_context.as_str()) - .struct_() - .field_by_name(&t.field_col_name()) - .round(0, RoundMode::HalfAwayFromZero) - .alias(&t.field_col_name()), + sparql_round( + col(first_context.as_str()) + .struct_() + .field_by_name(&t.field_col_name()), + t, + ) + .alias(&t.field_col_name()), ); rounded_types.push(t.clone()); } } } else if existing_type.is_numeric() { - rounded_exprs.push(col(first_context.as_str()).round(0, RoundMode::HalfAwayFromZero)); + rounded_exprs.push(sparql_round( + col(first_context.as_str()), + existing_type.get_base_type().unwrap(), + )); rounded_types.push(existing_type.get_base_type().unwrap().clone()); } if rounded_exprs.is_empty() { @@ -86,3 +87,23 @@ pub fn round_( } Ok(solution_mappings) } + +/// SPARQL 1.1 17.4.4.2 (fn:numeric-round): returns the number with no fractional part +/// that is closest to the argument. If there are two such numbers, the one closest to +/// positive infinity is returned, e.g. round(-2.5) = -2. +/// +/// Polars has no such rounding mode, so we round half away from zero and move negative +/// ties one step up. For a tie, x - round(x) is exactly 0.5 (also for floats), so the +/// comparison is exact. +fn sparql_round(e: Expr, t: &BaseRDFNodeType) -> Expr { + let rounded = e.clone().round(0, RoundMode::HalfAwayFromZero); + if t.is_lit_type(xsd::DECIMAL) || t.is_lit_type(xsd::DOUBLE) || t.is_lit_type(xsd::FLOAT) { + let dt = t.default_input_polars_data_type(); + when((e - rounded.clone()).eq(lit(0.5).cast(dt.clone()))) + .then(rounded.clone() + lit(1).cast(dt)) + .otherwise(rounded) + } else { + // Integer types have no fractional part + rounded + } +} diff --git a/py_maplib/tests/test_round.py b/py_maplib/tests/test_round.py new file mode 100644 index 00000000..ee0cfa79 --- /dev/null +++ b/py_maplib/tests/test_round.py @@ -0,0 +1,43 @@ +import pytest + +from maplib import Model + +# SPARQL 1.1 17.4.4.2: "Returns the number with no fractional part that is closest to the +# argument. If there are two such numbers, then the one that is closest to positive +# infinity is returned." Examples: round(2.5) = 3.0, round(-2.5) = -2.0, round(2.4999) = 2.0 + + +@pytest.mark.parametrize( + "expression,expected", + [ + ("ROUND(2.5)", 3), + ("ROUND(-2.5)", -2), + ("ROUND(2.4999)", 2), + ("ROUND(-0.5)", 0), + ("ROUND(-36.5)", -36), + ("ROUND(-36.6)", -37), + ("ROUND(-36.4)", -36), + ("ROUND(2.5e0)", 3), + ("ROUND(-2.5e0)", -2), + ("ROUND(-2.6e0)", -3), + ("ROUND(-3)", -3), + ("ROUND(4)", 4), + ], +) +def test_round_ties_towards_positive_infinity(expression, expected): + m = Model() + df = m.query(f"SELECT ({expression} AS ?r) WHERE {{}}") + assert float(df["r"][0]) == expected + + +def test_round_on_data(): + m = Model() + m.insert( + """PREFIX ex: + CONSTRUCT { ex:a ex:v -36.5 . ex:b ex:v 36.5 . ex:c ex:v -36.51 . } WHERE {}""" + ) + df = m.query( + """PREFIX ex: + SELECT ?x (ROUND(?v) AS ?r) WHERE { ?x ex:v ?v } ORDER BY ?x""" + ) + assert [float(x) for x in df["r"].to_list()] == [-36, 37, -37]