deepdiff-rs 0.9.3__tar.gz → 0.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/Cargo.lock +7 -4
  2. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/Cargo.toml +1 -1
  3. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/PKG-INFO +5 -5
  4. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/README.md +4 -4
  5. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-arrow/examples/row_diff_rss.rs +47 -6
  6. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-arrow/src/error.rs +47 -0
  7. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-arrow/src/lib.rs +12 -1
  8. deepdiff_rs-0.11.0/crates/onix-arrow/src/options.rs +135 -0
  9. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-arrow/src/row_diff.rs +1400 -75
  10. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/Cargo.toml +5 -0
  11. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/diff/mod.rs +1 -1
  12. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/diff/scalar.rs +5 -16
  13. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/diff/tests.rs +1 -1
  14. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/ignore_order/distance.rs +3 -1
  15. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/ignore_order/fxhash.rs +11 -0
  16. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/ignore_order/hash.rs +31 -35
  17. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/ignore_order/memo.rs +14 -3
  18. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/ignore_order/tests.rs +34 -0
  19. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/lcs.rs +30 -1
  20. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/lcs_tests.rs +45 -0
  21. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/path.rs +5 -2
  22. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/value.rs +139 -47
  23. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/value_tests.rs +130 -0
  24. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/tests/golden.rs +105 -3
  25. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/Cargo.toml +3 -0
  26. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/deepdiff_rs.pyi +11 -2
  27. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/src/arrow.rs +45 -5
  28. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/src/convert.rs +74 -16
  29. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/src/guard.rs +36 -24
  30. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/conftest.py +7 -0
  31. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_conversions.py +165 -9
  32. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_datetimes.py +4 -1
  33. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_depth_guard.py +2 -1
  34. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_differential_fuzz.py +68 -23
  35. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_golden_parity.py +59 -2
  36. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_non_finite.py +5 -0
  37. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_sets.py +13 -2
  38. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_signed_zero.py +4 -0
  39. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_stub_signatures.py +7 -0
  40. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_table_diff.py +2 -0
  41. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_table_row_diff.py +67 -0
  42. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_timedeltas.py +4 -1
  43. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_times.py +4 -1
  44. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_tuples.py +4 -0
  45. deepdiff_rs-0.9.3/crates/onix-arrow/src/options.rs +0 -51
  46. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-arrow/Cargo.toml +0 -0
  47. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-arrow/examples/type_stack_cost.rs +0 -0
  48. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-arrow/src/json_rows.rs +0 -0
  49. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-arrow/src/schema.rs +0 -0
  50. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-arrow/src/table_diff.rs +0 -0
  51. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/examples/stack_frame_cost.rs +0 -0
  52. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/datetime.rs +0 -0
  53. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/datetime_tests.rs +0 -0
  54. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/diff/array.rs +0 -0
  55. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/diff/dispatch.rs +0 -0
  56. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/diff/object.rs +0 -0
  57. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/diff/options.rs +0 -0
  58. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/diff/set.rs +0 -0
  59. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/error.rs +0 -0
  60. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/ignore_order/mod.rs +0 -0
  61. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/ignore_order/pairing.rs +0 -0
  62. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/lib.rs +0 -0
  63. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/report.rs +0 -0
  64. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/report_tests.rs +0 -0
  65. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/test_support.rs +0 -0
  66. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/unified_diff.rs +0 -0
  67. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/src/unified_diff_tests.rs +0 -0
  68. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/tests/ignore_order_memory.rs +0 -0
  69. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/tests/memory_footprint.rs +0 -0
  70. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/tests/proptest_diff.rs +0 -0
  71. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-core/tests/proptest_ignore_order.rs +0 -0
  72. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/.python-version +0 -0
  73. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/benchmarks/bench_bindings.py +0 -0
  74. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/src/deepdiff.rs +0 -0
  75. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/src/errors.rs +0 -0
  76. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/src/fast_path.rs +0 -0
  77. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/src/lib.rs +0 -0
  78. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_bindings_memory.py +0 -0
  79. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_smoke.py +0 -0
  80. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_stub_mypy.py +0 -0
  81. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_suite_hygiene.py +0 -0
  82. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/crates/onix-py/tests/test_wheel_contents.py +0 -0
  83. {deepdiff_rs-0.9.3 → deepdiff_rs-0.11.0}/pyproject.toml +0 -0
@@ -629,7 +629,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
629
629
 
630
630
  [[package]]
631
631
  name = "onix-arrow"
632
- version = "0.9.3"
632
+ version = "0.11.0"
633
633
  dependencies = [
634
634
  "arrow-array",
635
635
  "arrow-buffer",
@@ -646,7 +646,7 @@ dependencies = [
646
646
 
647
647
  [[package]]
648
648
  name = "onix-cli"
649
- version = "0.9.3"
649
+ version = "0.11.0"
650
650
  dependencies = [
651
651
  "onix-core",
652
652
  "serde_json",
@@ -654,8 +654,10 @@ dependencies = [
654
654
 
655
655
  [[package]]
656
656
  name = "onix-core"
657
- version = "0.9.3"
657
+ version = "0.11.0"
658
658
  dependencies = [
659
+ "num-bigint",
660
+ "num-traits",
659
661
  "proptest",
660
662
  "serde",
661
663
  "serde_json",
@@ -665,11 +667,12 @@ dependencies = [
665
667
 
666
668
  [[package]]
667
669
  name = "onix-py"
668
- version = "0.9.3"
670
+ version = "0.11.0"
669
671
  dependencies = [
670
672
  "arrow-array",
671
673
  "arrow-ipc",
672
674
  "arrow-schema",
675
+ "num-bigint",
673
676
  "onix-arrow",
674
677
  "onix-core",
675
678
  "pyo3",
@@ -3,7 +3,7 @@ resolver = "3"
3
3
  members = ["crates/onix-core", "crates/onix-py", "crates/onix-arrow"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.9.3"
6
+ version = "0.11.0"
7
7
  edition = "2024"
8
8
  license = "MIT"
9
9
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepdiff-rs
3
- Version: 0.9.3
3
+ Version: 0.11.0
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -195,7 +195,7 @@ The engine's own diff-only time and peak resident memory against pinned `deepdif
195
195
  | `ignore_order_10k` | 73.475 ms (71.672 ms-73.940 ms) | 12.976 s (12.900 s-13.005 s) | 176.60x | 60.11 MB | 345.19 MB | 5.74x | ✅ |
196
196
  | `identical_1m` | 9.254 ms (6.726 ms-9.875 ms) | 15.790 s (15.660 s-15.989 s) | 1706.35x | 315.41 MB | 503.19 MB | 1.60x | ✅ |
197
197
 
198
- Both reports carry their full methodology, fairness rules, and the reproduce command. `perf/RESULTS.md` is an upper bound (JSON parsed straight into the engine, no Python-object conversion); the bindings table is the product-surface number. Regenerate them with `perf/run_bench.sh` and `crates/onix-py/benchmarks/bench_bindings.py` (see [CONTRIBUTING.md](CONTRIBUTING.md)). The Arrow table diff's cost against two hand-rolled baselines — a DuckDB SQL join and a polars anti-join/inner-join diff, on a seeded 5 GB parquet pair and its 1M-row subset — is in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md): onix's single-threaded row diff trails both parallelized baselines at both sizes; see the linked results for the figures; regenerated with `perf/arrow/bench_tables.py`.
198
+ Both reports carry their full methodology, fairness rules, and the reproduce command. `perf/RESULTS.md` is an upper bound (JSON parsed straight into the engine, no Python-object conversion); the bindings table is the product-surface number. Regenerate them with `perf/run_bench.sh` and `crates/onix-py/benchmarks/bench_bindings.py` (see [CONTRIBUTING.md](CONTRIBUTING.md)). The Arrow table diff's cost against two hand-rolled baselines — a DuckDB SQL join and a polars anti-join/inner-join diff, on two seeded ~5 GB parquet pairs and their own 1M-row subsets (a five-column narrow fixture, and a wide fixture covering every scalar Arrow type) — is in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md): the row diff now hashes and classifies across every core, so at the 5 GB size its wall time is a small multiple of the two parallelized baselines' for the narrow fixture (down from the several-fold single-threaded gap); the wide fixture, where nearly every row's 34 columns are compared, still trails by a much larger multiple, see the linked results for both; regenerated with `perf/arrow/bench_tables.py`.
199
199
 
200
200
  ## Reference
201
201
 
@@ -204,7 +204,7 @@ Both reports carry their full methodology, fairness rules, and the reproduce com
204
204
  - `DeepDiff(t1, t2, ignore_order=False, max_depth=None)`: diffs two live Python objects of supported value types — `None`, `bool`, `int`, `float`, `str`, `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, and `datetime.timedelta` (see [Known limitations](#known-limitations) for the exact restrictions and exclusions, including which types a `dict` key may be); `.to_json()` returns the DeepDiff-compatible JSON string, `.to_dict()` the same report as a dict — with Python types preserved, so a value the diff found in a `tuple`, `set` or `frozenset` comes back as one and a `datetime`/`date`/`time`/`timedelta` comes back as a real one of those — and the instance is falsy when there is no difference. The `set_item_added`/`set_item_removed` categories are lists of path strings, each ending in the item itself (`root['a'][2]`, `root['x']`, `root[(1, 2)]`).
205
205
  - `diff_json(a, b, ignore_order=False, max_depth=None) -> str`: diffs two JSON strings entirely in Rust and returns the report as a JSON string.
206
206
  - `MaxDepthError` (a `ValueError` subclass) is raised when input exceeds `max_depth`; `MAX_DEPTH_CEILING` (20,000) is the hard upper bound on `max_depth`.
207
- - `diff_tables(left, right, key=[...]) -> TableDiff`: diffs two Arrow tables (see [Diffing tables](#diffing-tables)). `TableDiff.schema` is the list of changed columns, `.schema_arrow` the same as an Arrow table, `.summary()` the schema and row change counts, `.to_json()` the full diff (schema, summary, and every row-level member, capped at 10,000 embedded rows); `.rows_added()`, `.rows_removed()`, `.cells_changed()`, and `.duplicate_keys()` return Arrow tables.
207
+ - `diff_tables(left, right, key=[...], threads=None) -> TableDiff`: diffs two Arrow tables (see [Diffing tables](#diffing-tables)). `threads` sets the row diff's worker count — `None` uses the machine's available parallelism, `1` runs single-threaded, and a value below 1 or above 1024 raises `ValueError` (the diff spawns one worker per thread); the result is byte-identical at any value. `TableDiff.schema` is the list of changed columns, `.schema_arrow` the same as an Arrow table, `.summary()` the schema and row change counts, `.to_json()` the full diff (schema, summary, and every row-level member, capped at 10,000 embedded rows); `.rows_added()`, `.rows_removed()`, `.cells_changed()`, and `.duplicate_keys()` return Arrow tables.
208
208
 
209
209
  **CLI.** `onix diff <a.json> <b.json> [--max-depth N] [--ignore-order] [--timing]` reads both files as JSON and prints a compact, single-line DeepDiff-compatible report to stdout (`{}` when there is no difference).
210
210
 
@@ -236,7 +236,7 @@ perf/ # cross-language benchmark harness and RESULTS.md
236
236
  ## Known limitations
237
237
 
238
238
  - Only the core diff is implemented: `exclude_paths`, `significant_digits`, custom operators, `verbose_level != 2`, and delta/patch are not (yet) supported.
239
- - Supported value types are `None`, `bool`, `int`, `float` (`NaN`/`Infinity`/`-Infinity` included), `str`, `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, and `datetime.timedelta`; a `set`/`frozenset` member may be any of these except a `list`, `dict` or `set`, matching Python's own hashability rule, transitively through whatever the member nests, and a `dict` key may be `str`, `None`, `bool`, `int`, `float`, `datetime.datetime`, `datetime.date`, or a `tuple` of those (never nested), or a `tuple`/`datetime`/`date` subclass key (`namedtuple` included), accepted and matched against its base-type value the same way — see [Known limitations](#known-limitations)'s subclass bullet. `int`s must fit in `i64`/`u64`, and anything else — a custom object or an arbitrary-precision `int` — raises `TypeError`/`ValueError` naming the exact path it was found at. A non-`str` key's path renders via Python's own `repr()`, except a `tuple` key, which splits into one bracket group per element (`root[1][2]` for `(1, 2)`, never `root[(1, 2)]`, with one deliberate exception for a real DeepDiff bug on the empty tuple, and another on a non-finite-float key — see [`tests/golden/README.md`](tests/golden/README.md)). A nested dict value's own `bool`/`None`/`int`/`float` key stringifies the way `json.dumps` does, but its `datetime`/`date`/`tuple` key renders that same `repr()` text where DeepDiff's own `to_json()` raises `TypeError` on one — a superset, not a difference in the findings (see [`tests/golden/README.md`](tests/golden/README.md)). `1`/`1.0`/`True` match as the same key between two dicts (Python `dict`/`set` equality). The **Datetimes**, **Sets** and **Non-finite floats** bullets below cover the deliberate divergences for those types. See [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs) and [`tests/golden/README.md`](tests/golden/README.md).
239
+ - Supported value types are `None`, `bool`, `int`, `float` (`NaN`/`Infinity`/`-Infinity` included), `str`, `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, and `datetime.timedelta`; a `set`/`frozenset` member may be any of these except a `list`, `dict` or `set`, matching Python's own hashability rule, transitively through whatever the member nests, and a `dict` key may be `str`, `None`, `bool`, `int`, `float`, `datetime.datetime`, `datetime.date`, or a `tuple` of those (never nested), or a `tuple`/`datetime`/`date` subclass key (`namedtuple` included), accepted and matched against its base-type value the same way — see [Known limitations](#known-limitations)'s subclass bullet. An `int` of any magnitude converts and keeps its exact value (an arbitrary-precision integer beyond `i64`/`u64` is compared, ordered, hashed and rendered by its full digits), while a custom object raises `TypeError` naming the exact path it was found at. Under `ignore_order`, comparing an integer beyond `f64::MAX` (about `2**1024`) reports the change deterministically where real DeepDiff raises `OverflowError` (its distance function calls `float()` on it unguarded), a documented crash-class divergence like the datetime one below (see [`tests/golden/README.md`](tests/golden/README.md)). Reading JSON *text* (`diff_json` and the CLI, not the Python-object path), an integer beyond `i64`/`u64` parses as the nearest `float`, so two documents whose integers differ only past that range compare **equal** and diff to `{}` — a limitation of the JSON number reader, not the value model, tracked in [#92](https://github.com/ksco92/onix/issues/92). A non-`str` key's path renders via Python's own `repr()`, except a `tuple` key, which splits into one bracket group per element (`root[1][2]` for `(1, 2)`, never `root[(1, 2)]`, with one deliberate exception for a real DeepDiff bug on the empty tuple, and another on a non-finite-float key — see [`tests/golden/README.md`](tests/golden/README.md)). A nested dict value's own `bool`/`None`/`int`/`float` key stringifies the way `json.dumps` does, but its `datetime`/`date`/`tuple` key renders that same `repr()` text where DeepDiff's own `to_json()` raises `TypeError` on one — a superset, not a difference in the findings (see [`tests/golden/README.md`](tests/golden/README.md)). `1`/`1.0`/`True` match as the same key between two dicts (Python `dict`/`set` equality). The **Datetimes**, **Sets** and **Non-finite floats** bullets below cover the deliberate divergences for those types. See [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs) and [`tests/golden/README.md`](tests/golden/README.md).
240
240
  - A subclass of a supported `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, or `datetime.timedelta` (including `namedtuple` and pandas' `Timestamp`) converts and compares as its base type but carries its own class name into a `type_changes` entry, matching DeepDiff's `type(obj).__name__` reporting, with three exceptions: a calendar-type (`datetime`/`date`/`time`/`timedelta`) subclass held as a `set`/`frozenset` member compares as its base with no `type_changes`, matching DeepDiff, since set membership has no pairwise comparison to report one against; a `tuple`/`frozenset` subclass, including a `namedtuple`, is not accepted as a `set`/`frozenset` member at all and raises `TypeError` where DeepDiff accepts and compares it by value (a documented divergence); and `namedtuple` diffs positionally rather than by field (also documented). A `type_changes` entry's `old_type`/`new_type` are type *names* in `to_dict()`, where DeepDiff returns the type objects. See [`tests/golden/README.md`](tests/golden/README.md).
241
241
  - **Datetimes** compare by instant, with a naive value read as UTC, matching DeepDiff. A changed pair is reported normalized to UTC (`to_json()` renders `...+00:00`, `to_dict()` returns UTC-aware `datetime`s); everywhere else a datetime keeps its raw value. Three deliberate departures: `to_json()` renders a `date` as `YYYY-MM-DD` where DeepDiff's own `to_json()` raises `TypeError` (a documented superset); a `zoneinfo`/`pytz` tzinfo comes back from `to_dict()` as a fixed-offset `datetime.timezone` carrying the offset it was in force at, not the original zone object; and a set holding both a naive and an aware value at one instant reports both as members, where DeepDiff's own digest cache can report only one (see [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs)). Comparing two datetimes whose UTC form would leave year 1..=9999 raises `ValueError` naming the path, where DeepDiff raises `OverflowError`; under `ignore_order` DeepDiff's hasher normalizes every datetime and so raises for such a value even when it is only added, removed, or shuffled, where onix hashes by instant and reports it normally (see [`tests/golden/README.md`](tests/golden/README.md)). `truncate_datetime` is not supported; the normalized-versus-raw split is documented there too. A `time`/`timedelta`, unlike a datetime, is never normalized for report (DeepDiff compares `time`/`date`/`timedelta` with a plain `!=`), and a naive `time` is never equal to an aware one; `to_json()` renders a `time` as `time.isoformat()`'s bytes and a `timedelta` as `str(timedelta)`'s, both supersets. Under `ignore_order`, `DeepHash` hashes a `time` by whole seconds-of-day only — dropping the microsecond and any offset, a confirmed upstream quirk — while a `timedelta` hashes exactly; see [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
242
242
  - **Sets** are diffed deterministically, where DeepDiff's own answers depend on the order the running process happens to iterate a set in (hash order, and `PYTHONHASHSEED`-dependent for `str` members) or on how its digest cache/computation handles a tuple, frozenset, or calendar member independently of Python's own `==`. Each consequence — entry order, which member of an equality class is reported, set-versus-sequence coercion, and a tuple/frozenset member's own (positional, not order-/repetition-insensitive) matching rule — is shown with both tools' output in [`tests/golden/README.md`](tests/golden/README.md)'s "Set iteration order" section. A report holding a `frozenset` value also serializes to JSON here, where DeepDiff's own `to_json()` raises `TypeError` — a superset, not a difference in the findings.
@@ -250,7 +250,7 @@ perf/ # cross-language benchmark harness and RESULTS.md
250
250
  - `diff_tables` inherits some Arrow-interchange quirks: a column name with an embedded NUL byte (`\0`) arrives truncated at the NUL through the C Data Interface (the report shows the truncated name; rare in practice); a list of structs named exactly `key`/`value` with a nullable key is not distinguished from a real map, so a migration between the two is not reported as a type change (polars exports both as the same Arrow type — see `map_entries` in [`crates/onix-arrow/src/schema.rs`](crates/onix-arrow/src/schema.rs)); and a polars all-null (`Null`-typed) column fails at Arrow C import with a `ValueError` (`the datatype "Null" doesn't expect buffer at index 0`), so give such a column a concrete type first (a pyarrow all-`None` column, inferred as `null`, works and compares as all-null).
251
251
  - In `diff_tables`, DuckDB labels a `TIMESTAMP WITH TIME ZONE` column with the connection's *session* time zone when it exports to Arrow (a UTC session as `Timestamp(µs, "UTC")`, an `America/New_York` session as `Timestamp(µs, "America/New_York")`), so on a non-UTC machine such a column can be reported as a type change against a UTC column from another library. Run `SET TimeZone='UTC'` on the DuckDB connection first for a deterministic, machine-independent result.
252
252
  - `diff_tables` refuses a column whose Arrow type is nested deeper than `MAX_NESTING_DEPTH` (128) with a `MaxDepthError`, because comparing arbitrarily deep nesting would overflow the native stack; 128 is far beyond any real schema. Importing a schema nested many thousands of levels deep is also slow regardless, a cost of the Arrow C Data Interface itself.
253
- - `diff_tables`'s row diff costs, per call: RAM for a 32-byte hash per row per side (sorted), so peak memory is linear in row count (about 75 MB at 1M rows/side, 660 MB at 10M) and time is `N·log N` (about 2 s for a 10M-row pair), plus a second term for the duplicate-key report, which holds the key values of every *distinct duplicated* key — an all-duplicate 200k-rows/side table (100k distinct duplicated keys) peaks at about 37 MB with 16-byte keys and 1.05 GB with 1 KB keys, 1M rows/side (500k distinct) at about 165 MB with 16-byte keys; a third term for the per-cell diff, which re-reads both inputs, holds the changed rows of both sides, and renders every changed cell to a string held once in the output — so its cost is the number of changed cells times the cell width, not the changed-row count alone (every value is rendered in full). Measured, same method: with two narrow `int64` columns, 85 MB at 1M rows/side with 2% changed and 629 MB with every row changed; with a 1 KB `string` cell changed on every row, 1.18 GB at 100k rows/side and 2.34 GB at 200k (about 11.6 KB per changed row, linear, so on the order of 11.6 GB at 1M). Then temp disk, because each input is re-read several times and so is spooled to an anonymous file (`tempfile`: unlinked at once, mode 0600, no predictable name — nothing is left on disk even on abnormal exit), both spools resident at once, peaking at the decoded size of both inputs (about 315 MB — 161 + 154 MB uncompressed Arrow IPC — for the 1M-row fixture pair; on the order of 10 GB for the full 5 GB-per-side fixture pair, which on Linux may be a RAM-backed `tmpfs`), a full temp filesystem raising `ValueError` naming `TMPDIR`. None of these has a built-in cap: for untrusted input, bound the row count, the changed fraction, and the column widths (key columns for duplicate-heavy data, changed cells — rendered in full — for wide value columns). Figures are the peak resident set of `cargo run -p onix-arrow --release --example row_diff_rss`, single run, macOS on an Apple M-series laptop, 2026-09-05, same method as [Performance](#performance). See [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs).
253
+ - `diff_tables`'s row diff costs, per call: RAM for a 32-byte hash per row per side (sorted), so peak memory is linear in row count (about 75 MB at 1M rows/side, 660 MB at 10M); the diff hashes and classifies across all cores by default, running single-threaded only when both sides stay under 50,000 rows and under 64 MB of decoded data (either bound reached first runs the parallel path), and appends the per-row hashes into shared per-partition buffers so there is no separate combined copy; the parallel path adds only the in-flight batches (worker count times batch size) plus the shared buffers' reallocation slack, measured at the linear shape as a peak of about 629 MB single-threaded and 857 MB at 18 threads at 8M rows/side (+228 MB) and about 2.9 GB and 3.6 GB at 37M rows/side (+685 MB) — roughly 10-15 bytes per row per side, under one extra copy of the 32-byte hash vectors, and within the run-to-run slack the single-threaded peak itself shows (2.9-4.2 GB at 37M); `threads=1` runs the sequential path; the size gate peeks each side only up to 50,000 rows or 64 MB of decoded data before choosing, reading the left side first; the peek holds at most 64 MB plus one producer batch per side — a streamed 49,999-row/side pair of 8 KB cells in 100-row batches peaks at about 78 MB at 18 threads and 14 MB single-threaded, rising to about 1.6 GB when the whole side arrives as one batch (see the size-gate peek table in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md)); wall time is `N·log N` work spread across the workers, plus a second term for the duplicate-key report, which holds the key values of every *distinct duplicated* key — an all-duplicate 200k-rows/side table (100k distinct duplicated keys) peaks at about 37 MB with 16-byte keys and 1.05 GB with 1 KB keys, 1M rows/side (500k distinct) at about 165 MB with 16-byte keys; a third term for the per-cell diff, which re-reads both inputs, holds the changed rows of both sides, and renders every changed cell to a string held once in the output — so its cost is the number of changed cells times the cell width, not the changed-row count alone (every value is rendered in full). Measured, same method: with two narrow `int64` columns, 85 MB at 1M rows/side with 2% changed and 629 MB with every row changed; with a 1 KB `string` cell changed on every row, 1.18 GB at 100k rows/side and 2.34 GB at 200k (about 11.6 KB per changed row, linear, so on the order of 11.6 GB at 1M); the full wide benchmark fixture (34 columns, nearly every cell changed) measures about 67 GB at 16.875M rows/side, see [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md). Then temp disk, because each input is re-read several times and so is spooled to an anonymous file (`tempfile`: unlinked at once, mode 0600, no predictable name — nothing is left on disk even on abnormal exit), both spools resident at once, peaking at the decoded size of both inputs (about 315 MB — 161 + 154 MB uncompressed Arrow IPC — for the 1M-row fixture pair; on the order of 10 GB for the full 5 GB-per-side fixture pair, which on Linux may be a RAM-backed `tmpfs`), a full temp filesystem raising `ValueError` naming `TMPDIR`. None of these has a built-in cap: for untrusted input, bound the row count, the changed fraction, the column widths (key columns for duplicate-heavy data, changed cells — rendered in full — for wide value columns, and any column's width for the size-gate peek, which buffers decoded cells whether or not they change), and the producer's batch size, since the peek holds up to one whole batch per side beyond the 64 MB bound. Figures are the peak resident set of `cargo run -p onix-arrow --release --example row_diff_rss` (worker count from `ROW_DIFF_THREADS`, rows per generated batch from `ROW_DIFF_BATCH`), single run, macOS on an Apple M-series laptop, 2026-09-06, same method as [Performance](#performance). See [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs).
254
254
  - `TableDiff.to_json()` is the one member with a built-in cap: it embeds `rows_added`, `rows_removed`, `cells_changed`, and `duplicate_keys` in full, one JSON object per row, so it refuses with `ValueError` — naming the row count and the cap — once those four together hold more than 10,000 rows. The cap bounds row count only, the same way the row-diff cost bullet above states its per-cell term as changed cells times cell width, not column count or cell width — so a table under the row cap but with wide or large cells can still be large; use the Arrow-returning accessors instead. See [`crates/onix-arrow/src/json_rows.rs`](crates/onix-arrow/src/json_rows.rs).
255
255
  - `diff_tables` compares scalar columns by value (hashed: null, booleans, every integer, float and decimal width, strings and binary in every encoding, timestamps, dates, times, durations, intervals, and dictionaries of these; refused with `ValueError`: run-end encoded columns and any type-and-unit combination Arrow itself cannot build; nested non-key columns skipped, nested key columns refused), with the exact enumeration in the module doc of [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs). It also refuses a key column whose type differs across the two inputs after encoding normalization (the conservative choice: a primary key that changed type is refused rather than guessed, not coerced). A row whose only difference is a lossless type change — an `Int32` widened to `Int64`, a timestamp unit change at the same instant — hashes equal on both sides, so it is in neither `rows_changed` nor `cells_changed`; the column's type change is still reported in `schema`.
256
256
  - Output is byte-identical to DeepDiff except for the cases listed above and two path-rendering quirks; [`tests/golden/README.md`](tests/golden/README.md) enumerates every accepted exception, including integers past `2^53` (the limit of exact `f64` representation) inside ordered scalar lists and `ignore_order` pairing among naive datetimes, which DeepDiff ranks using the *process's local timezone* while onix reads a naive value as UTC everywhere.
@@ -177,7 +177,7 @@ The engine's own diff-only time and peak resident memory against pinned `deepdif
177
177
  | `ignore_order_10k` | 73.475 ms (71.672 ms-73.940 ms) | 12.976 s (12.900 s-13.005 s) | 176.60x | 60.11 MB | 345.19 MB | 5.74x | ✅ |
178
178
  | `identical_1m` | 9.254 ms (6.726 ms-9.875 ms) | 15.790 s (15.660 s-15.989 s) | 1706.35x | 315.41 MB | 503.19 MB | 1.60x | ✅ |
179
179
 
180
- Both reports carry their full methodology, fairness rules, and the reproduce command. `perf/RESULTS.md` is an upper bound (JSON parsed straight into the engine, no Python-object conversion); the bindings table is the product-surface number. Regenerate them with `perf/run_bench.sh` and `crates/onix-py/benchmarks/bench_bindings.py` (see [CONTRIBUTING.md](CONTRIBUTING.md)). The Arrow table diff's cost against two hand-rolled baselines — a DuckDB SQL join and a polars anti-join/inner-join diff, on a seeded 5 GB parquet pair and its 1M-row subset — is in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md): onix's single-threaded row diff trails both parallelized baselines at both sizes; see the linked results for the figures; regenerated with `perf/arrow/bench_tables.py`.
180
+ Both reports carry their full methodology, fairness rules, and the reproduce command. `perf/RESULTS.md` is an upper bound (JSON parsed straight into the engine, no Python-object conversion); the bindings table is the product-surface number. Regenerate them with `perf/run_bench.sh` and `crates/onix-py/benchmarks/bench_bindings.py` (see [CONTRIBUTING.md](CONTRIBUTING.md)). The Arrow table diff's cost against two hand-rolled baselines — a DuckDB SQL join and a polars anti-join/inner-join diff, on two seeded ~5 GB parquet pairs and their own 1M-row subsets (a five-column narrow fixture, and a wide fixture covering every scalar Arrow type) — is in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md): the row diff now hashes and classifies across every core, so at the 5 GB size its wall time is a small multiple of the two parallelized baselines' for the narrow fixture (down from the several-fold single-threaded gap); the wide fixture, where nearly every row's 34 columns are compared, still trails by a much larger multiple, see the linked results for both; regenerated with `perf/arrow/bench_tables.py`.
181
181
 
182
182
  ## Reference
183
183
 
@@ -186,7 +186,7 @@ Both reports carry their full methodology, fairness rules, and the reproduce com
186
186
  - `DeepDiff(t1, t2, ignore_order=False, max_depth=None)`: diffs two live Python objects of supported value types — `None`, `bool`, `int`, `float`, `str`, `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, and `datetime.timedelta` (see [Known limitations](#known-limitations) for the exact restrictions and exclusions, including which types a `dict` key may be); `.to_json()` returns the DeepDiff-compatible JSON string, `.to_dict()` the same report as a dict — with Python types preserved, so a value the diff found in a `tuple`, `set` or `frozenset` comes back as one and a `datetime`/`date`/`time`/`timedelta` comes back as a real one of those — and the instance is falsy when there is no difference. The `set_item_added`/`set_item_removed` categories are lists of path strings, each ending in the item itself (`root['a'][2]`, `root['x']`, `root[(1, 2)]`).
187
187
  - `diff_json(a, b, ignore_order=False, max_depth=None) -> str`: diffs two JSON strings entirely in Rust and returns the report as a JSON string.
188
188
  - `MaxDepthError` (a `ValueError` subclass) is raised when input exceeds `max_depth`; `MAX_DEPTH_CEILING` (20,000) is the hard upper bound on `max_depth`.
189
- - `diff_tables(left, right, key=[...]) -> TableDiff`: diffs two Arrow tables (see [Diffing tables](#diffing-tables)). `TableDiff.schema` is the list of changed columns, `.schema_arrow` the same as an Arrow table, `.summary()` the schema and row change counts, `.to_json()` the full diff (schema, summary, and every row-level member, capped at 10,000 embedded rows); `.rows_added()`, `.rows_removed()`, `.cells_changed()`, and `.duplicate_keys()` return Arrow tables.
189
+ - `diff_tables(left, right, key=[...], threads=None) -> TableDiff`: diffs two Arrow tables (see [Diffing tables](#diffing-tables)). `threads` sets the row diff's worker count — `None` uses the machine's available parallelism, `1` runs single-threaded, and a value below 1 or above 1024 raises `ValueError` (the diff spawns one worker per thread); the result is byte-identical at any value. `TableDiff.schema` is the list of changed columns, `.schema_arrow` the same as an Arrow table, `.summary()` the schema and row change counts, `.to_json()` the full diff (schema, summary, and every row-level member, capped at 10,000 embedded rows); `.rows_added()`, `.rows_removed()`, `.cells_changed()`, and `.duplicate_keys()` return Arrow tables.
190
190
 
191
191
  **CLI.** `onix diff <a.json> <b.json> [--max-depth N] [--ignore-order] [--timing]` reads both files as JSON and prints a compact, single-line DeepDiff-compatible report to stdout (`{}` when there is no difference).
192
192
 
@@ -218,7 +218,7 @@ perf/ # cross-language benchmark harness and RESULTS.md
218
218
  ## Known limitations
219
219
 
220
220
  - Only the core diff is implemented: `exclude_paths`, `significant_digits`, custom operators, `verbose_level != 2`, and delta/patch are not (yet) supported.
221
- - Supported value types are `None`, `bool`, `int`, `float` (`NaN`/`Infinity`/`-Infinity` included), `str`, `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, and `datetime.timedelta`; a `set`/`frozenset` member may be any of these except a `list`, `dict` or `set`, matching Python's own hashability rule, transitively through whatever the member nests, and a `dict` key may be `str`, `None`, `bool`, `int`, `float`, `datetime.datetime`, `datetime.date`, or a `tuple` of those (never nested), or a `tuple`/`datetime`/`date` subclass key (`namedtuple` included), accepted and matched against its base-type value the same way — see [Known limitations](#known-limitations)'s subclass bullet. `int`s must fit in `i64`/`u64`, and anything else — a custom object or an arbitrary-precision `int` — raises `TypeError`/`ValueError` naming the exact path it was found at. A non-`str` key's path renders via Python's own `repr()`, except a `tuple` key, which splits into one bracket group per element (`root[1][2]` for `(1, 2)`, never `root[(1, 2)]`, with one deliberate exception for a real DeepDiff bug on the empty tuple, and another on a non-finite-float key — see [`tests/golden/README.md`](tests/golden/README.md)). A nested dict value's own `bool`/`None`/`int`/`float` key stringifies the way `json.dumps` does, but its `datetime`/`date`/`tuple` key renders that same `repr()` text where DeepDiff's own `to_json()` raises `TypeError` on one — a superset, not a difference in the findings (see [`tests/golden/README.md`](tests/golden/README.md)). `1`/`1.0`/`True` match as the same key between two dicts (Python `dict`/`set` equality). The **Datetimes**, **Sets** and **Non-finite floats** bullets below cover the deliberate divergences for those types. See [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs) and [`tests/golden/README.md`](tests/golden/README.md).
221
+ - Supported value types are `None`, `bool`, `int`, `float` (`NaN`/`Infinity`/`-Infinity` included), `str`, `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, and `datetime.timedelta`; a `set`/`frozenset` member may be any of these except a `list`, `dict` or `set`, matching Python's own hashability rule, transitively through whatever the member nests, and a `dict` key may be `str`, `None`, `bool`, `int`, `float`, `datetime.datetime`, `datetime.date`, or a `tuple` of those (never nested), or a `tuple`/`datetime`/`date` subclass key (`namedtuple` included), accepted and matched against its base-type value the same way — see [Known limitations](#known-limitations)'s subclass bullet. An `int` of any magnitude converts and keeps its exact value (an arbitrary-precision integer beyond `i64`/`u64` is compared, ordered, hashed and rendered by its full digits), while a custom object raises `TypeError` naming the exact path it was found at. Under `ignore_order`, comparing an integer beyond `f64::MAX` (about `2**1024`) reports the change deterministically where real DeepDiff raises `OverflowError` (its distance function calls `float()` on it unguarded), a documented crash-class divergence like the datetime one below (see [`tests/golden/README.md`](tests/golden/README.md)). Reading JSON *text* (`diff_json` and the CLI, not the Python-object path), an integer beyond `i64`/`u64` parses as the nearest `float`, so two documents whose integers differ only past that range compare **equal** and diff to `{}` — a limitation of the JSON number reader, not the value model, tracked in [#92](https://github.com/ksco92/onix/issues/92). A non-`str` key's path renders via Python's own `repr()`, except a `tuple` key, which splits into one bracket group per element (`root[1][2]` for `(1, 2)`, never `root[(1, 2)]`, with one deliberate exception for a real DeepDiff bug on the empty tuple, and another on a non-finite-float key — see [`tests/golden/README.md`](tests/golden/README.md)). A nested dict value's own `bool`/`None`/`int`/`float` key stringifies the way `json.dumps` does, but its `datetime`/`date`/`tuple` key renders that same `repr()` text where DeepDiff's own `to_json()` raises `TypeError` on one — a superset, not a difference in the findings (see [`tests/golden/README.md`](tests/golden/README.md)). `1`/`1.0`/`True` match as the same key between two dicts (Python `dict`/`set` equality). The **Datetimes**, **Sets** and **Non-finite floats** bullets below cover the deliberate divergences for those types. See [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs) and [`tests/golden/README.md`](tests/golden/README.md).
222
222
  - A subclass of a supported `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, or `datetime.timedelta` (including `namedtuple` and pandas' `Timestamp`) converts and compares as its base type but carries its own class name into a `type_changes` entry, matching DeepDiff's `type(obj).__name__` reporting, with three exceptions: a calendar-type (`datetime`/`date`/`time`/`timedelta`) subclass held as a `set`/`frozenset` member compares as its base with no `type_changes`, matching DeepDiff, since set membership has no pairwise comparison to report one against; a `tuple`/`frozenset` subclass, including a `namedtuple`, is not accepted as a `set`/`frozenset` member at all and raises `TypeError` where DeepDiff accepts and compares it by value (a documented divergence); and `namedtuple` diffs positionally rather than by field (also documented). A `type_changes` entry's `old_type`/`new_type` are type *names* in `to_dict()`, where DeepDiff returns the type objects. See [`tests/golden/README.md`](tests/golden/README.md).
223
223
  - **Datetimes** compare by instant, with a naive value read as UTC, matching DeepDiff. A changed pair is reported normalized to UTC (`to_json()` renders `...+00:00`, `to_dict()` returns UTC-aware `datetime`s); everywhere else a datetime keeps its raw value. Three deliberate departures: `to_json()` renders a `date` as `YYYY-MM-DD` where DeepDiff's own `to_json()` raises `TypeError` (a documented superset); a `zoneinfo`/`pytz` tzinfo comes back from `to_dict()` as a fixed-offset `datetime.timezone` carrying the offset it was in force at, not the original zone object; and a set holding both a naive and an aware value at one instant reports both as members, where DeepDiff's own digest cache can report only one (see [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs)). Comparing two datetimes whose UTC form would leave year 1..=9999 raises `ValueError` naming the path, where DeepDiff raises `OverflowError`; under `ignore_order` DeepDiff's hasher normalizes every datetime and so raises for such a value even when it is only added, removed, or shuffled, where onix hashes by instant and reports it normally (see [`tests/golden/README.md`](tests/golden/README.md)). `truncate_datetime` is not supported; the normalized-versus-raw split is documented there too. A `time`/`timedelta`, unlike a datetime, is never normalized for report (DeepDiff compares `time`/`date`/`timedelta` with a plain `!=`), and a naive `time` is never equal to an aware one; `to_json()` renders a `time` as `time.isoformat()`'s bytes and a `timedelta` as `str(timedelta)`'s, both supersets. Under `ignore_order`, `DeepHash` hashes a `time` by whole seconds-of-day only — dropping the microsecond and any offset, a confirmed upstream quirk — while a `timedelta` hashes exactly; see [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
224
224
  - **Sets** are diffed deterministically, where DeepDiff's own answers depend on the order the running process happens to iterate a set in (hash order, and `PYTHONHASHSEED`-dependent for `str` members) or on how its digest cache/computation handles a tuple, frozenset, or calendar member independently of Python's own `==`. Each consequence — entry order, which member of an equality class is reported, set-versus-sequence coercion, and a tuple/frozenset member's own (positional, not order-/repetition-insensitive) matching rule — is shown with both tools' output in [`tests/golden/README.md`](tests/golden/README.md)'s "Set iteration order" section. A report holding a `frozenset` value also serializes to JSON here, where DeepDiff's own `to_json()` raises `TypeError` — a superset, not a difference in the findings.
@@ -232,7 +232,7 @@ perf/ # cross-language benchmark harness and RESULTS.md
232
232
  - `diff_tables` inherits some Arrow-interchange quirks: a column name with an embedded NUL byte (`\0`) arrives truncated at the NUL through the C Data Interface (the report shows the truncated name; rare in practice); a list of structs named exactly `key`/`value` with a nullable key is not distinguished from a real map, so a migration between the two is not reported as a type change (polars exports both as the same Arrow type — see `map_entries` in [`crates/onix-arrow/src/schema.rs`](crates/onix-arrow/src/schema.rs)); and a polars all-null (`Null`-typed) column fails at Arrow C import with a `ValueError` (`the datatype "Null" doesn't expect buffer at index 0`), so give such a column a concrete type first (a pyarrow all-`None` column, inferred as `null`, works and compares as all-null).
233
233
  - In `diff_tables`, DuckDB labels a `TIMESTAMP WITH TIME ZONE` column with the connection's *session* time zone when it exports to Arrow (a UTC session as `Timestamp(µs, "UTC")`, an `America/New_York` session as `Timestamp(µs, "America/New_York")`), so on a non-UTC machine such a column can be reported as a type change against a UTC column from another library. Run `SET TimeZone='UTC'` on the DuckDB connection first for a deterministic, machine-independent result.
234
234
  - `diff_tables` refuses a column whose Arrow type is nested deeper than `MAX_NESTING_DEPTH` (128) with a `MaxDepthError`, because comparing arbitrarily deep nesting would overflow the native stack; 128 is far beyond any real schema. Importing a schema nested many thousands of levels deep is also slow regardless, a cost of the Arrow C Data Interface itself.
235
- - `diff_tables`'s row diff costs, per call: RAM for a 32-byte hash per row per side (sorted), so peak memory is linear in row count (about 75 MB at 1M rows/side, 660 MB at 10M) and time is `N·log N` (about 2 s for a 10M-row pair), plus a second term for the duplicate-key report, which holds the key values of every *distinct duplicated* key — an all-duplicate 200k-rows/side table (100k distinct duplicated keys) peaks at about 37 MB with 16-byte keys and 1.05 GB with 1 KB keys, 1M rows/side (500k distinct) at about 165 MB with 16-byte keys; a third term for the per-cell diff, which re-reads both inputs, holds the changed rows of both sides, and renders every changed cell to a string held once in the output — so its cost is the number of changed cells times the cell width, not the changed-row count alone (every value is rendered in full). Measured, same method: with two narrow `int64` columns, 85 MB at 1M rows/side with 2% changed and 629 MB with every row changed; with a 1 KB `string` cell changed on every row, 1.18 GB at 100k rows/side and 2.34 GB at 200k (about 11.6 KB per changed row, linear, so on the order of 11.6 GB at 1M). Then temp disk, because each input is re-read several times and so is spooled to an anonymous file (`tempfile`: unlinked at once, mode 0600, no predictable name — nothing is left on disk even on abnormal exit), both spools resident at once, peaking at the decoded size of both inputs (about 315 MB — 161 + 154 MB uncompressed Arrow IPC — for the 1M-row fixture pair; on the order of 10 GB for the full 5 GB-per-side fixture pair, which on Linux may be a RAM-backed `tmpfs`), a full temp filesystem raising `ValueError` naming `TMPDIR`. None of these has a built-in cap: for untrusted input, bound the row count, the changed fraction, and the column widths (key columns for duplicate-heavy data, changed cells — rendered in full — for wide value columns). Figures are the peak resident set of `cargo run -p onix-arrow --release --example row_diff_rss`, single run, macOS on an Apple M-series laptop, 2026-09-05, same method as [Performance](#performance). See [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs).
235
+ - `diff_tables`'s row diff costs, per call: RAM for a 32-byte hash per row per side (sorted), so peak memory is linear in row count (about 75 MB at 1M rows/side, 660 MB at 10M); the diff hashes and classifies across all cores by default, running single-threaded only when both sides stay under 50,000 rows and under 64 MB of decoded data (either bound reached first runs the parallel path), and appends the per-row hashes into shared per-partition buffers so there is no separate combined copy; the parallel path adds only the in-flight batches (worker count times batch size) plus the shared buffers' reallocation slack, measured at the linear shape as a peak of about 629 MB single-threaded and 857 MB at 18 threads at 8M rows/side (+228 MB) and about 2.9 GB and 3.6 GB at 37M rows/side (+685 MB) — roughly 10-15 bytes per row per side, under one extra copy of the 32-byte hash vectors, and within the run-to-run slack the single-threaded peak itself shows (2.9-4.2 GB at 37M); `threads=1` runs the sequential path; the size gate peeks each side only up to 50,000 rows or 64 MB of decoded data before choosing, reading the left side first; the peek holds at most 64 MB plus one producer batch per side — a streamed 49,999-row/side pair of 8 KB cells in 100-row batches peaks at about 78 MB at 18 threads and 14 MB single-threaded, rising to about 1.6 GB when the whole side arrives as one batch (see the size-gate peek table in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md)); wall time is `N·log N` work spread across the workers, plus a second term for the duplicate-key report, which holds the key values of every *distinct duplicated* key — an all-duplicate 200k-rows/side table (100k distinct duplicated keys) peaks at about 37 MB with 16-byte keys and 1.05 GB with 1 KB keys, 1M rows/side (500k distinct) at about 165 MB with 16-byte keys; a third term for the per-cell diff, which re-reads both inputs, holds the changed rows of both sides, and renders every changed cell to a string held once in the output — so its cost is the number of changed cells times the cell width, not the changed-row count alone (every value is rendered in full). Measured, same method: with two narrow `int64` columns, 85 MB at 1M rows/side with 2% changed and 629 MB with every row changed; with a 1 KB `string` cell changed on every row, 1.18 GB at 100k rows/side and 2.34 GB at 200k (about 11.6 KB per changed row, linear, so on the order of 11.6 GB at 1M); the full wide benchmark fixture (34 columns, nearly every cell changed) measures about 67 GB at 16.875M rows/side, see [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md). Then temp disk, because each input is re-read several times and so is spooled to an anonymous file (`tempfile`: unlinked at once, mode 0600, no predictable name — nothing is left on disk even on abnormal exit), both spools resident at once, peaking at the decoded size of both inputs (about 315 MB — 161 + 154 MB uncompressed Arrow IPC — for the 1M-row fixture pair; on the order of 10 GB for the full 5 GB-per-side fixture pair, which on Linux may be a RAM-backed `tmpfs`), a full temp filesystem raising `ValueError` naming `TMPDIR`. None of these has a built-in cap: for untrusted input, bound the row count, the changed fraction, the column widths (key columns for duplicate-heavy data, changed cells — rendered in full — for wide value columns, and any column's width for the size-gate peek, which buffers decoded cells whether or not they change), and the producer's batch size, since the peek holds up to one whole batch per side beyond the 64 MB bound. Figures are the peak resident set of `cargo run -p onix-arrow --release --example row_diff_rss` (worker count from `ROW_DIFF_THREADS`, rows per generated batch from `ROW_DIFF_BATCH`), single run, macOS on an Apple M-series laptop, 2026-09-06, same method as [Performance](#performance). See [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs).
236
236
  - `TableDiff.to_json()` is the one member with a built-in cap: it embeds `rows_added`, `rows_removed`, `cells_changed`, and `duplicate_keys` in full, one JSON object per row, so it refuses with `ValueError` — naming the row count and the cap — once those four together hold more than 10,000 rows. The cap bounds row count only, the same way the row-diff cost bullet above states its per-cell term as changed cells times cell width, not column count or cell width — so a table under the row cap but with wide or large cells can still be large; use the Arrow-returning accessors instead. See [`crates/onix-arrow/src/json_rows.rs`](crates/onix-arrow/src/json_rows.rs).
237
237
  - `diff_tables` compares scalar columns by value (hashed: null, booleans, every integer, float and decimal width, strings and binary in every encoding, timestamps, dates, times, durations, intervals, and dictionaries of these; refused with `ValueError`: run-end encoded columns and any type-and-unit combination Arrow itself cannot build; nested non-key columns skipped, nested key columns refused), with the exact enumeration in the module doc of [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs). It also refuses a key column whose type differs across the two inputs after encoding normalization (the conservative choice: a primary key that changed type is refused rather than guessed, not coerced). A row whose only difference is a lossless type change — an `Int32` widened to `Int64`, a timestamp unit change at the same instant — hashes equal on both sides, so it is in neither `rows_changed` nor `cells_changed`; the column's type change is still reported in `schema`.
238
238
  - Output is byte-identical to DeepDiff except for the cases listed above and two path-rendering quirks; [`tests/golden/README.md`](tests/golden/README.md) enumerates every accepted exception, including integers past `2^53` (the limit of exact `f64` representation) inside ordered scalar lists and `ignore_order` pairing among naive datetimes, which DeepDiff ranks using the *process's local timezone* while onix reads a naive value as UTC everywhere.
@@ -19,6 +19,9 @@
19
19
  //! # duplicate-heavy shape: every key duplicated, wide string key
20
20
  //! /usr/bin/time -l target/release/examples/row_diff_rss 1000000 dup 16
21
21
  //! /usr/bin/time -l target/release/examples/row_diff_rss 200000 dup 1024
22
+ //! # size-gate peek: identical wide-cell sides (zero changes); ROW_DIFF_BATCH
23
+ //! # sets the producer's batch size, ROW_DIFF_THREADS the worker count
24
+ //! ROW_DIFF_BATCH=100 ROW_DIFF_THREADS=18 /usr/bin/time -l target/release/examples/row_diff_rss 49999 widesame 8192
22
25
  //! ```
23
26
  //!
24
27
  //! Each side is generated on the fly, batch by batch, and nothing is retained
@@ -46,8 +49,19 @@ use arrow_array::{ArrayRef, Int64Array, RecordBatch, RecordBatchReader, StringAr
46
49
  use arrow_schema::{ArrowError, DataType, Field, Schema, SchemaRef};
47
50
  use onix_arrow::{TableDiffError, TableDiffOptions, TableInput, diff_tables};
48
51
 
52
+ /// The default rows per generated batch; override with `ROW_DIFF_BATCH` to
53
+ /// simulate a streamed input of many small batches.
49
54
  const BATCH: i64 = 65_536;
50
55
 
56
+ /// The rows-per-batch used by the generator, from `ROW_DIFF_BATCH` or [`BATCH`].
57
+ fn batch_rows() -> i64 {
58
+ std::env::var("ROW_DIFF_BATCH")
59
+ .ok()
60
+ .and_then(|v| v.parse().ok())
61
+ .filter(|&n| n > 0)
62
+ .unwrap_or(BATCH)
63
+ }
64
+
51
65
  /// The generated table shape.
52
66
  #[derive(Clone, Copy)]
53
67
  enum Shape {
@@ -69,6 +83,7 @@ struct Generated {
69
83
  schema: SchemaRef,
70
84
  rows: i64,
71
85
  shape: Shape,
86
+ batch: i64,
72
87
  }
73
88
 
74
89
  impl TableInput for Generated {
@@ -81,6 +96,7 @@ impl TableInput for Generated {
81
96
  schema: self.schema.clone(),
82
97
  rows: self.rows,
83
98
  shape: self.shape,
99
+ batch: self.batch,
84
100
  next: 0,
85
101
  }))
86
102
  }
@@ -90,6 +106,7 @@ struct GenReader {
90
106
  schema: SchemaRef,
91
107
  rows: i64,
92
108
  shape: Shape,
109
+ batch: i64,
93
110
  next: i64,
94
111
  }
95
112
 
@@ -100,7 +117,7 @@ impl Iterator for GenReader {
100
117
  if self.next >= self.rows {
101
118
  return None;
102
119
  }
103
- let end = (self.next + BATCH).min(self.rows);
120
+ let end = (self.next + self.batch).min(self.rows);
104
121
  let columns: Vec<ArrayRef> = match self.shape {
105
122
  Shape::Linear {
106
123
  id_offset,
@@ -144,6 +161,23 @@ impl RecordBatchReader for GenReader {
144
161
  }
145
162
  }
146
163
 
164
+ /// Options for the diff, honoring a `ROW_DIFF_THREADS` override so the parallel
165
+ /// path's peak RSS can be compared against the single-threaded baseline; unset
166
+ /// uses the default (available parallelism).
167
+ fn options_from_env(key: &str) -> TableDiffOptions {
168
+ let mut options = TableDiffOptions::new(vec![key.to_string()]);
169
+ if let Some(threads) = std::env::var("ROW_DIFF_THREADS")
170
+ .ok()
171
+ .and_then(|v| v.parse().ok())
172
+ .and_then(std::num::NonZeroUsize::new)
173
+ {
174
+ options = options
175
+ .with_threads(threads)
176
+ .expect("ROW_DIFF_THREADS within MAX_THREADS");
177
+ }
178
+ options
179
+ }
180
+
147
181
  fn main() {
148
182
  let args: Vec<String> = std::env::args().collect();
149
183
  let rows: i64 = args
@@ -173,11 +207,14 @@ fn main() {
173
207
  format!(" (dup, key_width={width})"),
174
208
  )
175
209
  }
176
- "wide" => {
210
+ "wide" | "widesame" => {
177
211
  let schema = Arc::new(Schema::new(vec![
178
212
  Field::new("id", DataType::Int64, false),
179
213
  Field::new("value", DataType::Utf8, false),
180
214
  ]));
215
+ // `widesame` fills both sides identically (zero changes), isolating
216
+ // the size gate's peek buffer; `wide` differs, changing every row.
217
+ let right_fill = if mode == "widesame" { b'a' } else { b'b' };
181
218
  (
182
219
  schema,
183
220
  Shape::Wide {
@@ -186,10 +223,10 @@ fn main() {
186
223
  },
187
224
  Shape::Wide {
188
225
  value_width: width,
189
- fill: b'b',
226
+ fill: right_fill,
190
227
  },
191
228
  "id",
192
- format!(" (wide, value_width={width}, all changed)"),
229
+ format!(" ({mode}, value_width={width})"),
193
230
  )
194
231
  }
195
232
  _ => {
@@ -222,24 +259,28 @@ fn main() {
222
259
  }
223
260
  };
224
261
 
262
+ let batch = batch_rows();
225
263
  let left = Generated {
226
264
  schema: schema.clone(),
227
265
  rows,
228
266
  shape: left_shape,
267
+ batch,
229
268
  };
230
269
  let right = Generated {
231
270
  schema,
232
271
  rows,
233
272
  shape: right_shape,
273
+ batch,
234
274
  };
235
275
 
276
+ let options = options_from_env(key);
236
277
  let start = std::time::Instant::now();
237
- let diff = diff_tables(&left, &right, &TableDiffOptions::new(vec![key.to_string()]))
238
- .expect("diff succeeds");
278
+ let diff = diff_tables(&left, &right, &options).expect("diff succeeds");
239
279
  let elapsed = start.elapsed();
240
280
  let summary = diff.summary();
241
281
 
242
282
  println!("rows per side: {rows}{label}");
283
+ println!("threads: {}", options.threads());
243
284
  println!("wall: {:.2}s", elapsed.as_secs_f64());
244
285
  println!(
245
286
  "rows_added={} rows_removed={} rows_changed={} duplicate_keys={} cells_changed={}",
@@ -140,6 +140,24 @@ pub enum TableDiffError {
140
140
  /// The underlying `serde_json` error's message.
141
141
  message: String,
142
142
  },
143
+ /// A worker thread in the parallel row diff panicked. The panic is caught
144
+ /// and surfaced as this typed error rather than allowed to abort the
145
+ /// process, so a bug or an unexpected failure inside one worker fails the
146
+ /// diff recoverably.
147
+ WorkerPanicked {
148
+ /// The panic payload, when it was a string.
149
+ message: String,
150
+ },
151
+ /// The requested worker-thread count exceeds [`crate::MAX_THREADS`]. The
152
+ /// row diff spawns one worker per requested thread, so an unbounded count
153
+ /// is refused before any thread is spawned rather than risking thread
154
+ /// exhaustion.
155
+ ThreadCountTooLarge {
156
+ /// The requested thread count.
157
+ threads: usize,
158
+ /// The maximum accepted, [`crate::MAX_THREADS`].
159
+ max: usize,
160
+ },
143
161
  }
144
162
 
145
163
  impl fmt::Display for TableDiffError {
@@ -198,6 +216,14 @@ impl fmt::Display for TableDiffError {
198
216
  TableDiffError::Json { message } => {
199
217
  write!(f, "failed to serialize the table diff to JSON: {message}")
200
218
  }
219
+ TableDiffError::WorkerPanicked { message } => {
220
+ write!(f, "a parallel row-diff worker thread panicked: {message}")
221
+ }
222
+ TableDiffError::ThreadCountTooLarge { threads, max } => write!(
223
+ f,
224
+ "the requested thread count {threads} exceeds the maximum of {max}; \
225
+ pass a smaller `threads` value"
226
+ ),
201
227
  }
202
228
  }
203
229
  }
@@ -265,6 +291,16 @@ mod tests {
265
291
  assert!(message.contains("RunEndEncoded"));
266
292
  }
267
293
 
294
+ #[test]
295
+ fn worker_panicked_message_carries_the_payload() {
296
+ let error = TableDiffError::WorkerPanicked {
297
+ message: "kaboom".to_string(),
298
+ };
299
+ let message = error.to_string();
300
+ assert!(message.contains("worker thread panicked"));
301
+ assert!(message.contains("kaboom"));
302
+ }
303
+
268
304
  #[test]
269
305
  fn key_type_mismatch_message_names_the_column() {
270
306
  let error = TableDiffError::KeyTypeMismatch {
@@ -275,6 +311,17 @@ mod tests {
275
311
  assert!(message.contains("same type on both sides"));
276
312
  }
277
313
 
314
+ #[test]
315
+ fn thread_count_too_large_message_names_count_and_max() {
316
+ let error = TableDiffError::ThreadCountTooLarge {
317
+ threads: 5000,
318
+ max: 1024,
319
+ };
320
+ let message = error.to_string();
321
+ assert!(message.contains("5000"));
322
+ assert!(message.contains("1024"));
323
+ }
324
+
278
325
  #[test]
279
326
  fn render_message_names_the_column() {
280
327
  let error = TableDiffError::Render {
@@ -144,6 +144,10 @@ pub use table_diff::{TableDiff, TableDiffSummary};
144
144
  /// backstop.
145
145
  pub const MAX_NESTING_DEPTH: usize = 128;
146
146
 
147
+ /// The maximum worker-thread count for the row diff. A larger `threads` is
148
+ /// refused with [`TableDiffError::ThreadCountTooLarge`] before any thread spawns.
149
+ pub const MAX_THREADS: usize = 1024;
150
+
147
151
  /// Diffs two tables presented as re-openable [`TableInput`]s.
148
152
  ///
149
153
  /// See the [crate-level docs](crate) for the type-comparison rules, the
@@ -224,7 +228,14 @@ pub fn diff_tables(
224
228
  }
225
229
  }
226
230
 
227
- let rows = row_diff::diff_rows(left, right, &left_schema, &right_schema, options.key())?;
231
+ let rows = row_diff::diff_rows(
232
+ left,
233
+ right,
234
+ &left_schema,
235
+ &right_schema,
236
+ options.key(),
237
+ options.threads(),
238
+ )?;
228
239
 
229
240
  Ok(TableDiff::new(changes, rows))
230
241
  }
@@ -0,0 +1,135 @@
1
+ //! Options controlling a table diff.
2
+
3
+ use std::num::NonZeroUsize;
4
+
5
+ use crate::error::TableDiffError;
6
+
7
+ /// Options for [`crate::diff_tables`].
8
+ ///
9
+ /// The key columns are the table's primary key: rows are matched across the
10
+ /// two inputs by their values (in the later row-diff versions), and the key
11
+ /// must be non-empty. Later versions add more fields (value-comparison
12
+ /// tolerances and the like); construct this through [`TableDiffOptions::new`]
13
+ /// rather than a struct literal so those additions stay backward compatible.
14
+ #[derive(Debug, Clone, PartialEq, Eq)]
15
+ pub struct TableDiffOptions {
16
+ /// The key columns, in the order given. Required and non-empty; an empty
17
+ /// key makes [`crate::diff_tables`] return
18
+ /// [`crate::TableDiffError::EmptyKey`].
19
+ key: Vec<String>,
20
+ /// Worker threads the row diff hashes and classifies rows with. Defaults to
21
+ /// the machine's available parallelism; `1` runs the single-threaded path.
22
+ /// The result is byte-identical at any value.
23
+ threads: NonZeroUsize,
24
+ }
25
+
26
+ impl TableDiffOptions {
27
+ /// Creates options keyed on `key`, with [`threads`](Self::threads)
28
+ /// defaulting to the machine's available parallelism.
29
+ ///
30
+ /// No validation happens here — an empty `key` is reported by
31
+ /// [`crate::diff_tables`] so every misuse surfaces through one error
32
+ /// channel.
33
+ #[must_use]
34
+ pub fn new(key: Vec<String>) -> Self {
35
+ Self {
36
+ key,
37
+ threads: default_threads(),
38
+ }
39
+ }
40
+
41
+ /// Sets the number of worker threads the row diff uses. `1` selects the
42
+ /// single-threaded path; higher values partition the hash and classify
43
+ /// work across that many threads. The diff's output is byte-identical at
44
+ /// any value.
45
+ ///
46
+ /// # Errors
47
+ ///
48
+ /// [`TableDiffError::ThreadCountTooLarge`] if `threads` exceeds
49
+ /// [`crate::MAX_THREADS`] — the row diff spawns one worker per thread, so
50
+ /// the count is bounded here, before any thread or buffer is allocated.
51
+ pub fn with_threads(mut self, threads: NonZeroUsize) -> Result<Self, TableDiffError> {
52
+ if threads.get() > crate::MAX_THREADS {
53
+ return Err(TableDiffError::ThreadCountTooLarge {
54
+ threads: threads.get(),
55
+ max: crate::MAX_THREADS,
56
+ });
57
+ }
58
+ self.threads = threads;
59
+ Ok(self)
60
+ }
61
+
62
+ /// The key columns, in the order supplied.
63
+ #[must_use]
64
+ pub fn key(&self) -> &[String] {
65
+ &self.key
66
+ }
67
+
68
+ /// The number of worker threads the row diff uses.
69
+ #[must_use]
70
+ pub fn threads(&self) -> NonZeroUsize {
71
+ self.threads
72
+ }
73
+ }
74
+
75
+ /// The machine's available parallelism, or `1` when it cannot be queried.
76
+ fn default_threads() -> NonZeroUsize {
77
+ std::thread::available_parallelism().unwrap_or(NonZeroUsize::MIN)
78
+ }
79
+
80
+ #[cfg(test)]
81
+ mod tests {
82
+ use super::{TableDiffOptions, default_threads};
83
+ use std::num::NonZeroUsize;
84
+
85
+ #[test]
86
+ fn new_preserves_key_order() {
87
+ let options = TableDiffOptions::new(vec!["b".to_string(), "a".to_string()]);
88
+ assert_eq!(options.key(), &["b".to_string(), "a".to_string()]);
89
+ }
90
+
91
+ #[test]
92
+ fn empty_key_is_accepted_by_the_constructor() {
93
+ let options = TableDiffOptions::new(Vec::new());
94
+ assert!(options.key().is_empty());
95
+ }
96
+
97
+ #[test]
98
+ fn threads_defaults_to_available_parallelism() {
99
+ let options = TableDiffOptions::new(vec!["id".to_string()]);
100
+ assert_eq!(options.threads(), default_threads());
101
+ }
102
+
103
+ #[test]
104
+ fn default_threads_is_the_machine_parallelism() {
105
+ let expected = std::thread::available_parallelism().map_or(1, NonZeroUsize::get);
106
+ assert_eq!(default_threads().get(), expected);
107
+ }
108
+
109
+ #[test]
110
+ fn with_threads_overrides_the_default() {
111
+ let options = TableDiffOptions::new(vec!["id".to_string()])
112
+ .with_threads(NonZeroUsize::new(4).unwrap())
113
+ .unwrap();
114
+ assert_eq!(options.threads().get(), 4);
115
+ }
116
+
117
+ #[test]
118
+ fn with_threads_at_the_ceiling_is_accepted() {
119
+ let options = TableDiffOptions::new(vec!["id".to_string()])
120
+ .with_threads(NonZeroUsize::new(crate::MAX_THREADS).unwrap())
121
+ .unwrap();
122
+ assert_eq!(options.threads().get(), crate::MAX_THREADS);
123
+ }
124
+
125
+ #[test]
126
+ fn with_threads_above_the_ceiling_errors() {
127
+ let result = TableDiffOptions::new(vec!["id".to_string()])
128
+ .with_threads(NonZeroUsize::new(crate::MAX_THREADS + 1).unwrap());
129
+ assert!(matches!(
130
+ result,
131
+ Err(crate::error::TableDiffError::ThreadCountTooLarge { threads, max })
132
+ if threads == crate::MAX_THREADS + 1 && max == crate::MAX_THREADS
133
+ ));
134
+ }
135
+ }