diff --git a/diffly/_conditions.py b/diffly/_conditions.py index a86cd97..c057372 100644 --- a/diffly/_conditions.py +++ b/diffly/_conditions.py @@ -9,9 +9,9 @@ from polars.datatypes import DataType, DataTypeClass from diffly._utils import ( - ABS_TOL_DEFAULT, + ABS_TOL_COMPARE_DEFAULT, ABS_TOL_TEMPORAL_DEFAULT, - REL_TOL_DEFAULT, + REL_TOL_COMPARE_DEFAULT, Side, ) @@ -51,8 +51,8 @@ def condition_equal_columns( dtype_left: pl.DataType, dtype_right: pl.DataType, max_list_length: int | None, - abs_tol: float = ABS_TOL_DEFAULT, - rel_tol: float = REL_TOL_DEFAULT, + abs_tol: float = ABS_TOL_COMPARE_DEFAULT, + rel_tol: float = REL_TOL_COMPARE_DEFAULT, abs_tol_temporal: dt.timedelta = ABS_TOL_TEMPORAL_DEFAULT, ) -> pl.Expr: """Build an expression whether two columns are equal, depending on the columns' data diff --git a/diffly/_utils.py b/diffly/_utils.py index a3f5392..d1d52fa 100644 --- a/diffly/_utils.py +++ b/diffly/_utils.py @@ -51,8 +51,15 @@ def capitalize_first(s: str) -> str: return s[0].upper() + s[1:] if s else s -ABS_TOL_DEFAULT = 1e-08 -REL_TOL_DEFAULT = 1e-05 +# Comparison defaults match `polars.Expr.is_close` and Python's `math.isclose`; a diff +# tool should bias toward flagging differences. +ABS_TOL_COMPARE_DEFAULT = 0.0 +REL_TOL_COMPARE_DEFAULT = 1e-09 + +# Testing defaults match `polars.testing.assert_frame_equal`. +ABS_TOL_TESTING_DEFAULT = 1e-08 +REL_TOL_TESTING_DEFAULT = 1e-05 + ABS_TOL_TEMPORAL_DEFAULT = dt.timedelta(0) diff --git a/diffly/cli.py b/diffly/cli.py index b1b7148..6094fc6 100644 --- a/diffly/cli.py +++ b/diffly/cli.py @@ -11,7 +11,11 @@ from diffly import compare_frames from ._compat import typer -from ._utils import ABS_TOL_DEFAULT, ABS_TOL_TEMPORAL_DEFAULT, REL_TOL_DEFAULT +from ._utils import ( + ABS_TOL_COMPARE_DEFAULT, + ABS_TOL_TEMPORAL_DEFAULT, + REL_TOL_COMPARE_DEFAULT, +) from .metrics.change import DEFAULT_CHANGE_METRICS from .metrics.data import DEFAULT_DATA_METRICS @@ -34,15 +38,15 @@ def main( abs_tol: Annotated[ float, typer.Option( - help="Absolute tolerance for numerical comparisons. Default is 1e-08." + help="Absolute tolerance for numerical comparisons. Default is 0.0." ), - ] = ABS_TOL_DEFAULT, + ] = ABS_TOL_COMPARE_DEFAULT, rel_tol: Annotated[ float, typer.Option( - help="Relative tolerance for numerical comparisons. Default is 1e-05." + help="Relative tolerance for numerical comparisons. Default is 1e-09." ), - ] = REL_TOL_DEFAULT, + ] = REL_TOL_COMPARE_DEFAULT, abs_tol_temporal: Annotated[ float, typer.Option( diff --git a/diffly/comparison.py b/diffly/comparison.py index 70d19b7..a27a77d 100644 --- a/diffly/comparison.py +++ b/diffly/comparison.py @@ -15,9 +15,9 @@ from ._conditions import condition_equal_columns, condition_equal_rows from ._exceptions import PrimaryKeyError from ._utils import ( - ABS_TOL_DEFAULT, + ABS_TOL_COMPARE_DEFAULT, ABS_TOL_TEMPORAL_DEFAULT, - REL_TOL_DEFAULT, + REL_TOL_COMPARE_DEFAULT, Side, get_select_columns, is_primary_key, @@ -40,8 +40,8 @@ def compare_frames( /, *, primary_key: str | Sequence[str] | None = None, - abs_tol: float | Mapping[str, float] = ABS_TOL_DEFAULT, - rel_tol: float | Mapping[str, float] = REL_TOL_DEFAULT, + abs_tol: float | Mapping[str, float] = ABS_TOL_COMPARE_DEFAULT, + rel_tol: float | Mapping[str, float] = REL_TOL_COMPARE_DEFAULT, abs_tol_temporal: dt.timedelta | Mapping[str, dt.timedelta] = ABS_TOL_TEMPORAL_DEFAULT, ) -> DataFrameComparison: diff --git a/diffly/testing.py b/diffly/testing.py index 8b8bf66..7b98393 100644 --- a/diffly/testing.py +++ b/diffly/testing.py @@ -10,9 +10,9 @@ import polars as pl from diffly._utils import ( - ABS_TOL_DEFAULT, ABS_TOL_TEMPORAL_DEFAULT, - REL_TOL_DEFAULT, + ABS_TOL_TESTING_DEFAULT, + REL_TOL_TESTING_DEFAULT, Side, ) from diffly.summary import WIDTH @@ -90,8 +90,8 @@ def assert_collection_equal( /, *, check_dtypes: bool = True, - abs_tol: float | Mapping[str, float] = ABS_TOL_DEFAULT, - rel_tol: float | Mapping[str, float] = REL_TOL_DEFAULT, + abs_tol: float | Mapping[str, float] = ABS_TOL_TESTING_DEFAULT, + rel_tol: float | Mapping[str, float] = REL_TOL_TESTING_DEFAULT, abs_tol_temporal: dt.timedelta | Mapping[str, dt.timedelta] = ABS_TOL_TEMPORAL_DEFAULT, show_perfect_column_matches: bool = False, @@ -237,8 +237,8 @@ def assert_frame_equal( *, primary_key: str | Sequence[str] | None = None, check_dtypes: bool = True, - abs_tol: float | Mapping[str, float] = ABS_TOL_DEFAULT, - rel_tol: float | Mapping[str, float] = REL_TOL_DEFAULT, + abs_tol: float | Mapping[str, float] = ABS_TOL_TESTING_DEFAULT, + rel_tol: float | Mapping[str, float] = REL_TOL_TESTING_DEFAULT, abs_tol_temporal: dt.timedelta | Mapping[str, dt.timedelta] = ABS_TOL_TEMPORAL_DEFAULT, show_perfect_column_matches: bool = False, diff --git a/docs/guides/features/testing.md b/docs/guides/features/testing.md index f0fc763..0d518a8 100644 --- a/docs/guides/features/testing.md +++ b/docs/guides/features/testing.md @@ -23,6 +23,7 @@ Unlike `polars.testing.assert_frame_equal`, `diffly`'s version: - Prints a comprehensive summary of all differences - Supports tolerance-based comparisons for floating point and temporal values - Allows mixing eager and lazy frames in the same comparison +- Uses the same default tolerances as `polars.testing.assert_frame_equal`, which are looser than those of `compare_frames` (see {doc}`tolerances`) ## Asserting equality of `dataframely` collections diff --git a/docs/guides/features/tolerances.ipynb b/docs/guides/features/tolerances.ipynb index 06e07e7..db6bc5c 100644 --- a/docs/guides/features/tolerances.ipynb +++ b/docs/guides/features/tolerances.ipynb @@ -34,7 +34,13 @@ "source": [ "## Default behavior\n", "\n", - "By default, `diffly` uses `abs_tol=1e-08` and `rel_tol=1e-05` for floating point comparisons, matching the defaults of `polars.testing.assert_frame_equal` (note these are looser than the defaults of `polars.Expr.is_close` and Python's `math.isclose`). Temporal types (dates, datetimes) are compared exactly (`abs_tol_temporal=0`). This means tiny floating point rounding is automatically ignored, but even a one-second timestamp difference will be flagged.\n", + "By default, `diffly` uses `abs_tol=0.0` and `rel_tol=1e-09` for floating point comparisons, matching `polars.Expr.is_close` and Python's `math.isclose`. This means tiny floating point rounding is ignored for nonzero values but flagged near zero. Temporal types (dates, datetimes) are compared exactly (`abs_tol_temporal=0`), so even one-second timestamp differences are flagged.\n", + "\n", + "```{note}\n", + "With `abs_tol=0.0`, two values that are both effectively zero but differ by cancellation noise (e.g., `0.0` and `1e-17`) are flagged as unequal. Following [PEP 485](https://peps.python.org/pep-0485/#absolute-tolerance-default), `diffly` does not guess a magnitude for you; set `abs_tol` to a value that is negligible at your data's scale.\n", + "```\n", + "\n", + "`diffly.testing` is an exception to these defaults: it uses the looser `abs_tol=1e-08` and `rel_tol=1e-05` to match `polars.testing.assert_frame_equal`. Consequently, `compare_frames(a, b).equal()` can be `False` where `assert_frame_equal(a, b)` passes.\n", "\n", "In our scenario, the `total` column has some values that differ only at the 10th decimal place due to how the totals were calculated in each system:" ] @@ -327,7 +333,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": null, "id": "cell-13", "metadata": {}, "outputs": [], @@ -339,7 +345,7 @@ " df_current,\n", " primary_key=\"transaction_id\",\n", " abs_tol=defaultdict(lambda: 0, {\"total\": 0.01}),\n", - " rel_tol=defaultdict(lambda: 1e-05, {\"total\": 0}),\n", + " rel_tol=defaultdict(lambda: 1e-09, {\"total\": 0}),\n", ")" ] }, diff --git a/tests/test_assert_frame_equal.py b/tests/test_assert_frame_equal.py index a34e0d0..e22d0be 100644 --- a/tests/test_assert_frame_equal.py +++ b/tests/test_assert_frame_equal.py @@ -66,6 +66,22 @@ def test_success_with_nan() -> None: assert_frame_equal(df, df, primary_key="id") +@pytest.mark.parametrize( + ("left_value", "right_value"), + [ + (1.0, 1.0 + 1e-7), # within 1e-05 rel_tol, outside 1e-09 + (0.0, 1e-17), # within 1e-08 abs_tol, outside 0.0 + ], +) +def test_compare_frames_flags_what_assert_frame_equal_tolerates( + left_value: float, right_value: float +) -> None: + left = pl.DataFrame({"id": [1], "value": [left_value]}) + right = pl.DataFrame({"id": [1], "value": [right_value]}) + assert not compare_frames(left, right, primary_key="id").equal() + assert_frame_equal(left, right, primary_key="id") # must not raise + + def test_error_exposes_comparison() -> None: # Arrange left = pl.DataFrame({"id": [1, 2], "value": [10.0, 20.0]}) diff --git a/tests/test_performance.py b/tests/test_performance.py index 621aa3c..6193adc 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -9,9 +9,9 @@ from diffly import compare_frames from diffly._conditions import condition_equal_columns from diffly._utils import ( - ABS_TOL_DEFAULT, + ABS_TOL_COMPARE_DEFAULT, ABS_TOL_TEMPORAL_DEFAULT, - REL_TOL_DEFAULT, + REL_TOL_COMPARE_DEFAULT, Side, ) @@ -115,8 +115,8 @@ def test_eq_missing_not_slower_than_element_wise_for_list_columns() -> None: dtype_left=df.schema[col_left], dtype_right=df.schema[col_right], max_list_length=list_len, - abs_tol=ABS_TOL_DEFAULT, - rel_tol=REL_TOL_DEFAULT, + abs_tol=ABS_TOL_COMPARE_DEFAULT, + rel_tol=REL_TOL_COMPARE_DEFAULT, abs_tol_temporal=ABS_TOL_TEMPORAL_DEFAULT, ) ).to_series()