deepdiff-rs 0.11.2__tar.gz → 0.13.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 (93) hide show
  1. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/Cargo.lock +4 -4
  2. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/Cargo.toml +1 -1
  3. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/PKG-INFO +54 -30
  4. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/README.md +53 -29
  5. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-arrow/examples/row_diff_profile.rs +1 -1
  6. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-arrow/examples/row_diff_rss.rs +31 -1
  7. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-arrow/examples/shared/gen_shapes.rs +171 -1
  8. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/lib.rs +25 -95
  9. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/row_diff.rs +2256 -593
  10. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-arrow/tests/profile_passes.rs +14 -11
  11. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/datetime.rs +22 -99
  12. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/diff/array.rs +8 -10
  13. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/diff/dispatch.rs +65 -89
  14. deepdiff_rs-0.13.0/crates/onix-core/src/diff/mod.rs +34 -0
  15. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/diff/object.rs +58 -21
  16. deepdiff_rs-0.13.0/crates/onix-core/src/diff/options.rs +223 -0
  17. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/diff/tests.rs +432 -14
  18. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/error.rs +4 -24
  19. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/ignore_order/distance.rs +53 -14
  20. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/ignore_order/fxhash.rs +9 -3
  21. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/ignore_order/hash.rs +54 -23
  22. deepdiff_rs-0.13.0/crates/onix-core/src/ignore_order/memo.rs +235 -0
  23. deepdiff_rs-0.13.0/crates/onix-core/src/ignore_order/mod.rs +149 -0
  24. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/ignore_order/pairing.rs +2 -1
  25. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/ignore_order/tests.rs +491 -4
  26. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/lcs.rs +8 -8
  27. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/lcs_tests.rs +3 -3
  28. deepdiff_rs-0.13.0/crates/onix-core/src/lib.rs +68 -0
  29. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/path.rs +53 -3
  30. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/report.rs +109 -72
  31. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/report_tests.rs +7 -1
  32. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/test_support.rs +31 -0
  33. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/value.rs +498 -130
  34. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/value_tests.rs +1 -1
  35. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/tests/golden.rs +54 -0
  36. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/tests/proptest_diff.rs +9 -11
  37. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/deepdiff_rs.pyi +11 -2
  38. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/src/convert.rs +969 -51
  39. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/src/deepdiff.rs +78 -11
  40. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/src/fast_path.rs +3 -2
  41. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/src/guard.rs +20 -15
  42. deepdiff_rs-0.13.0/crates/onix-py/tests/test_conversions.py +1859 -0
  43. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_depth_guard.py +45 -3
  44. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_differential_fuzz.py +214 -6
  45. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/pyproject.toml +3 -1
  46. deepdiff_rs-0.11.2/crates/onix-core/src/diff/mod.rs +0 -234
  47. deepdiff_rs-0.11.2/crates/onix-core/src/diff/options.rs +0 -256
  48. deepdiff_rs-0.11.2/crates/onix-core/src/ignore_order/memo.rs +0 -364
  49. deepdiff_rs-0.11.2/crates/onix-core/src/ignore_order/mod.rs +0 -316
  50. deepdiff_rs-0.11.2/crates/onix-core/src/lib.rs +0 -123
  51. deepdiff_rs-0.11.2/crates/onix-py/tests/test_conversions.py +0 -720
  52. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-arrow/Cargo.toml +0 -0
  53. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-arrow/examples/type_stack_cost.rs +0 -0
  54. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/error.rs +0 -0
  55. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/json_rows.rs +0 -0
  56. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/options.rs +0 -0
  57. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/profile.rs +0 -0
  58. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/schema.rs +0 -0
  59. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/spool.rs +0 -0
  60. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/table_diff.rs +0 -0
  61. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/Cargo.toml +0 -0
  62. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/examples/stack_frame_cost.rs +0 -0
  63. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/datetime_tests.rs +0 -0
  64. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/diff/scalar.rs +0 -0
  65. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/diff/set.rs +0 -0
  66. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/unified_diff.rs +0 -0
  67. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/src/unified_diff_tests.rs +0 -0
  68. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/tests/ignore_order_memory.rs +0 -0
  69. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/tests/memory_footprint.rs +0 -0
  70. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-core/tests/proptest_ignore_order.rs +0 -0
  71. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/.python-version +0 -0
  72. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/Cargo.toml +0 -0
  73. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/benchmarks/bench_bindings.py +0 -0
  74. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/src/arrow.rs +0 -0
  75. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/src/errors.rs +0 -0
  76. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/src/lib.rs +0 -0
  77. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/conftest.py +0 -0
  78. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_bindings_memory.py +0 -0
  79. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_datetimes.py +0 -0
  80. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_golden_parity.py +0 -0
  81. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_non_finite.py +0 -0
  82. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_sets.py +0 -0
  83. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_signed_zero.py +0 -0
  84. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_smoke.py +0 -0
  85. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_stub_mypy.py +0 -0
  86. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_stub_signatures.py +0 -0
  87. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_suite_hygiene.py +0 -0
  88. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_table_diff.py +0 -0
  89. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_table_row_diff.py +0 -0
  90. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_timedeltas.py +0 -0
  91. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_times.py +0 -0
  92. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_tuples.py +0 -0
  93. {deepdiff_rs-0.11.2 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_wheel_contents.py +0 -0
@@ -629,7 +629,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
629
629
 
630
630
  [[package]]
631
631
  name = "onix-arrow"
632
- version = "0.11.2"
632
+ version = "0.13.0"
633
633
  dependencies = [
634
634
  "arrow-array",
635
635
  "arrow-buffer",
@@ -648,7 +648,7 @@ dependencies = [
648
648
 
649
649
  [[package]]
650
650
  name = "onix-cli"
651
- version = "0.11.2"
651
+ version = "0.13.0"
652
652
  dependencies = [
653
653
  "onix-core",
654
654
  "serde_json",
@@ -656,7 +656,7 @@ dependencies = [
656
656
 
657
657
  [[package]]
658
658
  name = "onix-core"
659
- version = "0.11.2"
659
+ version = "0.13.0"
660
660
  dependencies = [
661
661
  "num-bigint",
662
662
  "num-traits",
@@ -669,7 +669,7 @@ dependencies = [
669
669
 
670
670
  [[package]]
671
671
  name = "onix-py"
672
- version = "0.11.2"
672
+ version = "0.13.0"
673
673
  dependencies = [
674
674
  "arrow-array",
675
675
  "arrow-schema",
@@ -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.11.2"
6
+ version = "0.13.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.11.2
3
+ Version: 0.13.0
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -91,6 +91,24 @@ if diff:
91
91
  {'values_changed': {"root['a']": {'new_value': 2, 'old_value': 1}}}
92
92
  ```
93
93
 
94
+ A custom object diffs by its attributes:
95
+
96
+ ```python
97
+ from dataclasses import dataclass
98
+ from deepdiff_rs import DeepDiff
99
+
100
+ @dataclass
101
+ class Point:
102
+ x: int
103
+ y: int
104
+
105
+ print(DeepDiff(Point(1, 2), Point(1, 3)).to_json())
106
+ ```
107
+
108
+ ```
109
+ {"values_changed":{"root.y":{"new_value":3,"old_value":2}}}
110
+ ```
111
+
94
112
  `diff_json`, the fast path when you already have JSON text (it parses, diffs, and serializes entirely in Rust, with no Python-object conversion):
95
113
 
96
114
  ```python
@@ -151,31 +169,31 @@ Beyond a join-based diff, `diff_tables` reports duplicate and null keys rather t
151
169
 
152
170
  Two committed, regenerable reports back the numbers below; every figure here is copied verbatim from them.
153
171
 
154
- The Python bindings against real `deepdiff` on **live Python objects**, the number a real caller pays (source: [`crates/onix-py/benchmarks/bench_bindings.py`](crates/onix-py/benchmarks/bench_bindings.py), macOS 26.5.1, Apple M5 Max, median of 11 isolated subprocess runs per side, run on 2026-09-04):
172
+ The Python bindings against real `deepdiff` on **live Python objects**, the number a real caller pays (source: [`crates/onix-py/benchmarks/bench_bindings.py`](crates/onix-py/benchmarks/bench_bindings.py), macOS 26.5.1, Apple M5 Max, median of 11 isolated subprocess runs per side, run on 2026-09-23):
155
173
 
156
174
  | Shape | deepdiff | deepdiff_rs | Speedup |
157
175
  | --- | --- | --- | --- |
158
- | `ignore_order`, 10k shuffled ints, ~5% mutated (live objects) | 3111.56ms | 71.68ms | **43.41x** |
159
- |   peak RSS | 228.5 MB | 93.2 MB | **2.45x** |
160
- |   CPU seconds | 3.110 s | 0.072 s | **43.42x** |
161
- | Heterogeneous API-payload records, n=20,000 (live objects) | 3439.96ms | 153.83ms | **22.36x** |
162
- |   peak RSS | 118.1 MB | 147.8 MB | **0.80x** |
163
- |   CPU seconds | 3.439 s | 0.154 s | **22.36x** |
164
- | Typed records (datetime/tuple/set fields), n=10,000 (live objects) | 795.17ms | 48.48ms | **16.40x** |
165
- |   peak RSS | 60.2 MB | 62.2 MB | **0.97x** |
166
- |   CPU seconds | 0.795 s | 0.048 s | **16.40x** |
167
- | Same typed-records shape, `ignore_order` (live objects) | 60506.65ms | 775.38ms | **78.03x** |
168
- |   peak RSS | 110.3 MB | 121.6 MB | **0.91x** |
169
- |   CPU seconds | 60.471 s | 0.774 s | **78.10x** |
170
- | Same `ignore_order` shape, via `diff_json` (JSON-string path) | 3116.22ms | 73.85ms | **42.20x** |
171
- |   peak RSS | 228.8 MB | 93.8 MB | **2.44x** |
172
- |   CPU seconds | 3.114 s | 0.074 s | **42.20x** |
173
- | Same API-payload shape, via `diff_json` (JSON-string path) | 4559.90ms | 87.02ms | **52.40x** |
174
- |   peak RSS | 139.5 MB | 140.9 MB | **0.99x** |
175
- |   CPU seconds | 4.558 s | 0.087 s | **52.58x** |
176
- | Same API-payload shape, both tools reading two JSON files from disk | 4555.37ms | 85.62ms | **53.20x** |
177
- |   peak RSS | 139.5 MB | 141.0 MB | **0.99x** |
178
- |   CPU seconds | 4.553 s | 0.086 s | **53.21x** |
176
+ | `ignore_order`, 10k shuffled ints, ~5% mutated (live objects) | 699.68ms | 66.23ms | **10.56x** |
177
+ |   peak RSS | 401.8 MB | 203.4 MB | **1.98x** |
178
+ |   CPU seconds | 0.699 s | 0.066 s | **10.56x** |
179
+ | Heterogeneous API-payload records, n=20,000 (live objects) | 3486.50ms | 141.42ms | **24.65x** |
180
+ |   peak RSS | 228.1 MB | 274.6 MB | **0.83x** |
181
+ |   CPU seconds | 3.484 s | 0.141 s | **24.64x** |
182
+ | Typed records (datetime/tuple/set fields), n=10,000 (live objects) | 795.03ms | 46.45ms | **17.11x** |
183
+ |   peak RSS | 169.8 MB | 175.6 MB | **0.97x** |
184
+ |   CPU seconds | 0.794 s | 0.046 s | **17.11x** |
185
+ | Same typed-records shape, `ignore_order` (live objects) | 60834.60ms | 864.82ms | **70.34x** |
186
+ |   peak RSS | 225.6 MB | 257.6 MB | **0.88x** |
187
+ |   CPU seconds | 60.786 s | 0.863 s | **70.47x** |
188
+ | Same `ignore_order` shape, via `diff_json` (JSON-string path) | 731.31ms | 74.54ms | **9.81x** |
189
+ |   peak RSS | 402.3 MB | 203.9 MB | **1.97x** |
190
+ |   CPU seconds | 0.730 s | 0.074 s | **9.81x** |
191
+ | Same API-payload shape, via `diff_json` (JSON-string path) | 5125.41ms | 95.33ms | **53.76x** |
192
+ |   peak RSS | 249.1 MB | 269.7 MB | **0.92x** |
193
+ |   CPU seconds | 4.971 s | 0.095 s | **52.22x** |
194
+ | Same API-payload shape, both tools reading two JSON files from disk | 4774.33ms | 97.33ms | **49.05x** |
195
+ |   peak RSS | 249.0 MB | 269.7 MB | **0.92x** |
196
+ |   CPU seconds | 4.771 s | 0.097 s | **49.12x** |
179
197
 
180
198
  The engine's own diff-only time and peak resident memory against pinned `deepdiff` 9.1.0 (source: [`perf/RESULTS.md`](perf/RESULTS.md), same machine, median over tier-appropriate runs, diff time excluding process startup and JSON parsing on both sides):
181
199
 
@@ -192,13 +210,13 @@ The engine's own diff-only time and peak resident memory against pinned `deepdif
192
210
  | `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 | ✅ |
193
211
  | `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 | ✅ |
194
212
 
195
- 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 and rendered, now spills each side's changed rows by key-hash partition and renders them across every core, cutting its 5 GB-per-side wall time 6.14x (149.674 -> 24.375 s; about 3.8x DuckDB and 6.2x polars) and its peak RSS 2.02x (about 67 GB -> 33.1 GB), see the linked results for both; regenerated with `perf/arrow/bench_tables.py`.
213
+ 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 and rendered, spills each side's changed rows by key-hash partition and renders them across every core, which took its 5 GB-per-side wall from 149.674 to 24.375 s and its peak RSS from about 67 GB to 33.1 GB; and since 0.13.0 the parallel diff decodes the right input once and the left twice instead of each three times, taking the 5 GB pairs from 5.727 to 3.879 s narrow (1.71x DuckDB, 1.93x polars) and from 21.294 to 14.968 s wide (2.90x DuckDB, 5.47x polars) in a rotated, interleaved run, with the wide pair's peak RSS down from 33.2 GB to 28.8 GB; see the linked results for both; regenerated with `perf/arrow/bench_tables.py`.
196
214
 
197
215
  ## Reference
198
216
 
199
217
  **Python API.** The public surface is `DeepDiff`, `diff_json`, `diff_tables` (returning a `TableDiff`), `MaxDepthError`, `MAX_DEPTH_CEILING`, and `__version__` (a `str` matching the installed `deepdiff-rs` distribution version).
200
218
 
201
- - `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)]`).
219
+ - `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`, plus any custom object (diffed by its attributes; see [Known limitations](#known-limitations) for the exact restrictions and exclusions, including which types a `dict` key may be, and the types that raise `TypeError` instead); `.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 (a custom object, though, comes back as a plain `dict` of its attributes, since onix cannot reconstruct the instance — see [Known limitations](#known-limitations)) — 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)]`).
202
220
  - `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.
203
221
  - `MaxDepthError` (a `ValueError` subclass) is raised when input exceeds `max_depth`; `MAX_DEPTH_CEILING` (20,000) is the hard upper bound on `max_depth`.
204
222
  - `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.
@@ -225,6 +243,7 @@ crates/onix-core # the diff engine (library, no I/O)
225
243
  crates/onix-cli # the `onix` binary (thin CLI over the core)
226
244
  crates/onix-arrow # Arrow table diffing (schema diff and keyed row diff)
227
245
  crates/onix-py # PyO3 bindings, published as `deepdiff-rs`
246
+ docs/design/ # algorithm/invariant reference pages (list-diff, ignore-order, value-model, depth-budget)
228
247
  scripts/ # gen_goldens.py: regenerates tests/golden/ from real DeepDiff
229
248
  tests/golden # DeepDiff-generated expected outputs (the compatibility corpus)
230
249
  perf/ # cross-language benchmark harness and RESULTS.md
@@ -233,22 +252,27 @@ perf/ # cross-language benchmark harness and RESULTS.md
233
252
  ## Known limitations
234
253
 
235
254
  - Only the core diff is implemented: `exclude_paths`, `significant_digits`, custom operators, `verbose_level != 2`, and delta/patch are not (yet) supported.
236
- - 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).
255
+ - 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 is diffed by its attributes (see the **Custom objects** bullet below). 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).
256
+ - **Custom objects** diff by their attributes, matching DeepDiff's `_diff_obj` (`attribute_added`/`attribute_removed`, `root.attr` paths, `type_changes` between two different class objects), and an `Enum` member by its `name` and `value`, matching `_diff_enum`. A type DeepDiff routes to a handler onix lacks — `bytes`, `bytearray`, a generator or any other iterable, `complex`, `Decimal`, `Fraction`, a `numpy` scalar, `uuid`, `ipaddress`, a class object, a module, or a bare `object()` — raises `TypeError` at the root; below it, such a value is equal only to the same object, as DeepDiff's `t1 is t2` check makes it, and raises `TypeError` naming its path wherever the report would have to show it. Two identical Python objects at one position are never walked, a class attribute converts only when a report compares it with a shadowing value, and a class attribute is left out of a whole object in a report, as in DeepDiff (see [`tests/golden/README.md`](tests/golden/README.md#types-deepdiff-routes-to-a-handler-onix-lacks)). A `pydantic` model raises `TypeError` where DeepDiff diffs it (see [`tests/golden/README.md`](tests/golden/README.md#pydantic-models)). A custom non-`dict` `Mapping`, and a `re.Pattern` pair whose named groups differ, raise `TypeError` where DeepDiff diffs them (see [`tests/golden/README.md`](tests/golden/README.md#refused-mappings)). A `@property` that raises `AttributeError`, or an unset slot beside a `__dict__`, raises `TypeError` where DeepDiff reports the object as `unprocessed` (see [`tests/golden/README.md`](tests/golden/README.md#a-property-that-raises)). A child that points back at an object on its path reports nothing when it is on the first side; on the second side only it is compared as the object it points back at, as DeepDiff's `parents_ids` tracks only the first side's ancestry (see [`tests/golden/README.md`](tests/golden/README.md#a-recursive-object)). Three attribute-view divergences (`ignore_order` hashing, and the whole-object values in `to_json()` and `to_dict()`) are tracked in [#99](https://github.com/ksco92/onix/issues/99), see [`tests/golden/README.md`](tests/golden/README.md#custom-objects-where-onix-is-deliberately-different).
237
257
  - 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).
238
258
  - **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.
239
259
  - **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.
240
- - **Non-finite floats** (`NaN`, `Infinity`, `-Infinity`) compare and hash like real Python: two `NaN`s are never equal, `Infinity == Infinity`, `to_json()` renders the same bare `NaN`/`Infinity`/`-Infinity` tokens Python's `json.dumps` does, and `ignore_order` matching treats every `NaN` as one shared item, matching `DeepHash`. The one divergence, always deterministic: this crate's value model carries no Python object identity, so two independently-obtained `NaN`s always compare unequal here, where DeepDiff sometimes reports no difference (`t1 is t2`) or lets one collapse into another (a `set` member, an ordered-list match) when the two objects, or their containers, happen to be the same one. See [`tests/golden/README.md`](tests/golden/README.md)'s "Non-finite floats" section.
260
+ - **Non-finite floats** (`NaN`, `Infinity`, `-Infinity`) compare and hash like real Python: two `NaN`s are never equal, `Infinity == Infinity`, `to_json()` renders the same bare `NaN`/`Infinity`/`-Infinity` tokens Python's `json.dumps` does, and `ignore_order` matching treats every `NaN` as one shared item, matching `DeepHash`. The one divergence, always deterministic: this crate's value model carries Python object identity for custom objects only, so two `NaN`s outside one shared custom object always compare unequal here, where DeepDiff sometimes reports no difference (`t1 is t2`) or lets one collapse into another (a `set` member, an ordered-list match) when the two objects, or their containers, happen to be the same one. See [`tests/golden/README.md`](tests/golden/README.md)'s "Non-finite floats" section.
241
261
  - A `str` (or `dict` key) containing a lone (unpaired) surrogate code point (e.g. `'\udc80'`, legal in Python but not encodable as UTF-8) is compared and reported exactly like any other `str`, matching DeepDiff's plain `==`; `to_json()` renders it with `json.dumps`'s own single-backslash `\uXXXX` escape. The one accepted divergence, always deterministic: hashing one — a `set`/`frozenset` member, or *any* value at all once `ignore_order=True` (`DeepHash` hashes every value there, not just a set's members) — crashes real DeepDiff with an unhandled `UnicodeEncodeError`, where onix hashes by code point and reports normally. See [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
242
262
  - A `str` inside a `tuple` or `frozenset` set item is escaped exactly as Python's `repr()` escapes it, against Unicode 16.0.0; on a Python older than 3.14 (an older `unicodedata` table), a code point assigned to Unicode after that Python's own version is escaped by DeepDiff and rendered literally by onix. See [`tests/golden/README.md`](tests/golden/README.md)'s "Pinned versions" section.
243
263
  - Adversarially deep input raises `MaxDepthError` instead of crashing: the default `max_depth` is 512 and the hard ceiling is `MAX_DEPTH_CEILING` (20,000). See [`crates/onix-py/src/guard.rs`](crates/onix-py/src/guard.rs).
244
- - `ignore_order` pairing is `O(N^2)` in unpaired elements per side and carries a polynomial cost in both time and memory with input depth; it has no `max_passes`/`max_diffs` cutoff, so bound the size and depth of untrusted input yourself. See [`crates/onix-core/src/ignore_order/mod.rs`](crates/onix-core/src/ignore_order/mod.rs).
264
+ - `ignore_order` pairing is `O(N^2)` in unpaired elements per side and carries a polynomial cost in both time and memory with input depth (about 7.5 s and 146 MB at depth 400 on a few-KB input, growing roughly cubically with depth, all under the default `max_depth`); it has no `max_passes`/`max_diffs` cutoff, so bound the size and depth of untrusted input yourself. See [`docs/design/ignore-order.md`](docs/design/ignore-order.md)'s "Bounds" section.
245
265
  - A `values_changed` between two multi-line strings runs a `difflib`-style `O(N*M)` line diff on the default path with no opt-out, worst when changes are spread evenly through the text (about 35 s for a heavily edited 1 MB string and growing quadratically, so a few megabytes is minutes), so bound the size of untrusted strings yourself. See [`crates/onix-core/src/unified_diff.rs`](crates/onix-core/src/unified_diff.rs).
246
266
  - Ordered sequences (`list` or `tuple`) of scalars (null, bool, number, string, datetime, date, time, timedelta) run a `difflib`-style `O(N*M)` matcher on the default path with its popular-element (autojunk) purge disabled for `DeepDiff` parity, worst for sequences of a few repeated values with dense edits (about 620 s for two 8,000-element lists of two repeated values with every other element changed, growing faster than quadratically), so bound the size of untrusted sequences yourself. See [`crates/onix-core/src/lcs.rs`](crates/onix-core/src/lcs.rs).
247
267
  - `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).
248
268
  - 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.
249
269
  - `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.
250
- - `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 spills each side's changed value rows — every common value column of every changed row, changed or not — to anonymous per-key-hash-partition Arrow IPC files and compares and renders one partition at a time across the workers. Its resident cost is the spilled changed value rows (the changed-row count times the total width of the common value columns, both sides — resident where written temp pages count, e.g. macOS or a RAM-backed `tmpfs`) plus about twice the `cells_changed` output (its one out-of-place reorder) plus the per-row hash vectors; the spill term dominates for wide rows with few changed cells, the output term for many changed cells. Measured (`row_diff_rss`, 18 threads): a 1 KB `string` cell changed on every row (output-dominated) peaks at 0.92 GB at 100k rows/side, 1.31 GB at 200k, and 4.81 GB at 1M (the single-threaded path, holding both sides, is 8.30 GB); eight 512 B value columns with only one differing (spill-dominated: 150k changed cells, ~160 MB output) peaks at about 2.09 GB at 150k rows/side, versus 0.84 GB with one such column (medians over 5 runs); these are at 18 threads, the low point of the range -- the same two shapes peak higher at the ends (spill-dominated 3.03 GB at 2 threads and 2.09 GB at 64; output-dominated, 200k, 1.98 GB at 2 and 1.49 GB at 64) as fewer partitions enlarge the resident chunk and the 64-way framing adds a little; two narrow `int64` columns with every row changed peak at 0.38 GB at 1M; the full wide benchmark fixture (34 columns, nearly every cell changed) measures about 33 GB whole-process at 16.875M rows/side at 18 threads, down from about 67 GB before the streaming cell pass. The two Arrow types whose `take` retains data beyond the selected rows are decoded before the spill -- byte-view columns (`Utf8View`/`BinaryView`) cast to their large i64-offset non-view type (`LargeUtf8`/`LargeBinary`, since one batch's retained view buffers can exceed the i32 offset type's ~2 GB ceiling) and dictionaries (what polars and DuckDB emit for strings) decoded to their value type -- so the spill stays compact and independent of the partition count; without the decode it would grow with the partition count (about 97 GB at 64 threads for the wide pair vs about 34 GB with it), 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), and the cell pass additionally spills both sides' changed value rows to anonymous per-partition files (byte-view columns cast to their non-view type and dictionaries decoded to their value type first, so the spill is compact and does not grow with the thread count); the input spools and the partition spill are resident together, and for the full wide pair the whole process (input spool + spill + working set) peaks at about 33 GB at 18 threads, 45 GB at 2, and 34 GB at 64 (on Linux the spill 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; for the cell pass, the total width of the common value columns — every one is spilled in full for each changed row, changed or not — plus the rendered changed cells; and any column's width for the size-gate peek, which buffers decoded cells whether or not they change), the producer's batch size, and the thread count (the cell pass's peak is lowest at the default and higher at 2 threads, where 2 partitions hold half the rows, and slightly higher at 64, the partition cap), 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).
251
- - `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).
270
+ - `diff_tables` row diff: 32 B hash per row per side (left only in parallel): 75 MB at 1M, 660 MB at 10M (single-threaded); 600/618 MB at 8M, 2.7/2.5 GB at 37M (1/18 threads); wall time `N·log N` across workers. Duplicate-key report, 18 threads: 39 MB (16 B keys) to 0.83 GB (1 KB keys) at 200k. Cell pass (both sides' spilled rows × column width, plus 2x `cells_changed`), 18 threads: 0.96/4.62 GB at 200k/1M, 1.56 GB spill-dominated (150k rows, eight 512 B columns, one differing). Right-side duplicate keys, 18 threads: +0.95 GB per 500k repeated keys (2 KB of value columns, 1M rows/side); +85 MB per 1M keys the left lacks (8 B `int64` keys; the entry is 32 B at any key width). Kept rows pin their input batch, per side: the whole batch when over half is kept, its view data otherwise (~2.0 GB, 1 and 18 threads, for 100 removed rows over a 1M-row side with two 1 KB `Utf8View` columns). [RESULTS.md, Fused reads](perf/arrow/RESULTS.md#fused-reads-issue-90-1).
271
+ - Temp disk, same call: each input spools to an anonymous file (`tempfile`: unlinked, mode 0600, no name). Changed rows spill to anonymous per-partition files, byte-view columns cast to a large-offset type (i32 caps at ~2 GB) and dictionaries decoded, independent of the partition count (~97 GB at 64 threads undecoded vs ~32 GB, wide pair); in parallel also one row per right key the left holds once that a later batch repeats (1.03 GB per 500k such keys, 2 KB of value columns, 1M rows/side, 2 and 64 threads). All resident until the cell pass ends: ~23.8 GB, wide pair (2/18/64 threads; Linux spill may be a RAM-backed `tmpfs`). Whole-process peaks, wide pair: ~29 GB at 18 threads, 40 GB at 2, 31 GB at 64 (33/46/34 GB on 0.11.2). A full temp filesystem raises `ValueError` naming `TMPDIR`.
272
+ - The size gate peeks up to 50,000 rows or 64 MB decoded per side, holding one whole producer batch per side beyond it: a 49,999-row/side, 8 KB-cell, 100-row-batch pair peaks ~65 MB at 18 threads, 12 MB single-threaded, ~1.6 GB single-threaded (1.2 GB at 18 threads) for one whole-side batch.
273
+ - None of the row-diff memory, temp-disk or size-gate-peek costs above has a built-in cap. Bound: row count; changed fraction; right-side duplicate keys (on the parallel path, one full-width row per repeated key, spilled or held); column widths (key-column width, for duplicate-heavy data; for the cell pass, the total width of all common value columns, since every one spills in full per changed row whether it changed or not, plus the rendered changed cells; any column's width for the size-gate peek, which buffers decoded cells whether or not they change); the producer's batch size; thread count, lowest at 18 threads (measured default), higher at 2 threads (two partitions each holding half the rows), slightly higher at 64: spill-dominated 2.39 GB at 2 threads, 1.96 GB at 64; output-dominated at 200k rows, 1.45 GB at 2, 1.25 GB at 64.
274
+ - 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 batch from `ROW_DIFF_BATCH`), macOS on an Apple M-series laptop, 2026-09-24, same method as [Performance](#performance); the single-threaded hash-term figures carry run-to-run slack up to 2.7-3.8 GB at 37M rows/side, the other cell-pass and thread-count figures are medians over 3 runs, and the two 150k spill/output shapes over 5. See [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md) for the size-gate peek table and the decode-vs-undecoded partition figures, and [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs) for the implementation.
275
+ - `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 first row-diff 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).
252
276
  - `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`.
253
277
  - 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.
254
278
 
@@ -73,6 +73,24 @@ if diff:
73
73
  {'values_changed': {"root['a']": {'new_value': 2, 'old_value': 1}}}
74
74
  ```
75
75
 
76
+ A custom object diffs by its attributes:
77
+
78
+ ```python
79
+ from dataclasses import dataclass
80
+ from deepdiff_rs import DeepDiff
81
+
82
+ @dataclass
83
+ class Point:
84
+ x: int
85
+ y: int
86
+
87
+ print(DeepDiff(Point(1, 2), Point(1, 3)).to_json())
88
+ ```
89
+
90
+ ```
91
+ {"values_changed":{"root.y":{"new_value":3,"old_value":2}}}
92
+ ```
93
+
76
94
  `diff_json`, the fast path when you already have JSON text (it parses, diffs, and serializes entirely in Rust, with no Python-object conversion):
77
95
 
78
96
  ```python
@@ -133,31 +151,31 @@ Beyond a join-based diff, `diff_tables` reports duplicate and null keys rather t
133
151
 
134
152
  Two committed, regenerable reports back the numbers below; every figure here is copied verbatim from them.
135
153
 
136
- The Python bindings against real `deepdiff` on **live Python objects**, the number a real caller pays (source: [`crates/onix-py/benchmarks/bench_bindings.py`](crates/onix-py/benchmarks/bench_bindings.py), macOS 26.5.1, Apple M5 Max, median of 11 isolated subprocess runs per side, run on 2026-09-04):
154
+ The Python bindings against real `deepdiff` on **live Python objects**, the number a real caller pays (source: [`crates/onix-py/benchmarks/bench_bindings.py`](crates/onix-py/benchmarks/bench_bindings.py), macOS 26.5.1, Apple M5 Max, median of 11 isolated subprocess runs per side, run on 2026-09-23):
137
155
 
138
156
  | Shape | deepdiff | deepdiff_rs | Speedup |
139
157
  | --- | --- | --- | --- |
140
- | `ignore_order`, 10k shuffled ints, ~5% mutated (live objects) | 3111.56ms | 71.68ms | **43.41x** |
141
- |   peak RSS | 228.5 MB | 93.2 MB | **2.45x** |
142
- |   CPU seconds | 3.110 s | 0.072 s | **43.42x** |
143
- | Heterogeneous API-payload records, n=20,000 (live objects) | 3439.96ms | 153.83ms | **22.36x** |
144
- |   peak RSS | 118.1 MB | 147.8 MB | **0.80x** |
145
- |   CPU seconds | 3.439 s | 0.154 s | **22.36x** |
146
- | Typed records (datetime/tuple/set fields), n=10,000 (live objects) | 795.17ms | 48.48ms | **16.40x** |
147
- |   peak RSS | 60.2 MB | 62.2 MB | **0.97x** |
148
- |   CPU seconds | 0.795 s | 0.048 s | **16.40x** |
149
- | Same typed-records shape, `ignore_order` (live objects) | 60506.65ms | 775.38ms | **78.03x** |
150
- |   peak RSS | 110.3 MB | 121.6 MB | **0.91x** |
151
- |   CPU seconds | 60.471 s | 0.774 s | **78.10x** |
152
- | Same `ignore_order` shape, via `diff_json` (JSON-string path) | 3116.22ms | 73.85ms | **42.20x** |
153
- |   peak RSS | 228.8 MB | 93.8 MB | **2.44x** |
154
- |   CPU seconds | 3.114 s | 0.074 s | **42.20x** |
155
- | Same API-payload shape, via `diff_json` (JSON-string path) | 4559.90ms | 87.02ms | **52.40x** |
156
- |   peak RSS | 139.5 MB | 140.9 MB | **0.99x** |
157
- |   CPU seconds | 4.558 s | 0.087 s | **52.58x** |
158
- | Same API-payload shape, both tools reading two JSON files from disk | 4555.37ms | 85.62ms | **53.20x** |
159
- |   peak RSS | 139.5 MB | 141.0 MB | **0.99x** |
160
- |   CPU seconds | 4.553 s | 0.086 s | **53.21x** |
158
+ | `ignore_order`, 10k shuffled ints, ~5% mutated (live objects) | 699.68ms | 66.23ms | **10.56x** |
159
+ |   peak RSS | 401.8 MB | 203.4 MB | **1.98x** |
160
+ |   CPU seconds | 0.699 s | 0.066 s | **10.56x** |
161
+ | Heterogeneous API-payload records, n=20,000 (live objects) | 3486.50ms | 141.42ms | **24.65x** |
162
+ |   peak RSS | 228.1 MB | 274.6 MB | **0.83x** |
163
+ |   CPU seconds | 3.484 s | 0.141 s | **24.64x** |
164
+ | Typed records (datetime/tuple/set fields), n=10,000 (live objects) | 795.03ms | 46.45ms | **17.11x** |
165
+ |   peak RSS | 169.8 MB | 175.6 MB | **0.97x** |
166
+ |   CPU seconds | 0.794 s | 0.046 s | **17.11x** |
167
+ | Same typed-records shape, `ignore_order` (live objects) | 60834.60ms | 864.82ms | **70.34x** |
168
+ |   peak RSS | 225.6 MB | 257.6 MB | **0.88x** |
169
+ |   CPU seconds | 60.786 s | 0.863 s | **70.47x** |
170
+ | Same `ignore_order` shape, via `diff_json` (JSON-string path) | 731.31ms | 74.54ms | **9.81x** |
171
+ |   peak RSS | 402.3 MB | 203.9 MB | **1.97x** |
172
+ |   CPU seconds | 0.730 s | 0.074 s | **9.81x** |
173
+ | Same API-payload shape, via `diff_json` (JSON-string path) | 5125.41ms | 95.33ms | **53.76x** |
174
+ |   peak RSS | 249.1 MB | 269.7 MB | **0.92x** |
175
+ |   CPU seconds | 4.971 s | 0.095 s | **52.22x** |
176
+ | Same API-payload shape, both tools reading two JSON files from disk | 4774.33ms | 97.33ms | **49.05x** |
177
+ |   peak RSS | 249.0 MB | 269.7 MB | **0.92x** |
178
+ |   CPU seconds | 4.771 s | 0.097 s | **49.12x** |
161
179
 
162
180
  The engine's own diff-only time and peak resident memory against pinned `deepdiff` 9.1.0 (source: [`perf/RESULTS.md`](perf/RESULTS.md), same machine, median over tier-appropriate runs, diff time excluding process startup and JSON parsing on both sides):
163
181
 
@@ -174,13 +192,13 @@ The engine's own diff-only time and peak resident memory against pinned `deepdif
174
192
  | `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 | ✅ |
175
193
  | `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 | ✅ |
176
194
 
177
- 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 and rendered, now spills each side's changed rows by key-hash partition and renders them across every core, cutting its 5 GB-per-side wall time 6.14x (149.674 -> 24.375 s; about 3.8x DuckDB and 6.2x polars) and its peak RSS 2.02x (about 67 GB -> 33.1 GB), see the linked results for both; regenerated with `perf/arrow/bench_tables.py`.
195
+ 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 and rendered, spills each side's changed rows by key-hash partition and renders them across every core, which took its 5 GB-per-side wall from 149.674 to 24.375 s and its peak RSS from about 67 GB to 33.1 GB; and since 0.13.0 the parallel diff decodes the right input once and the left twice instead of each three times, taking the 5 GB pairs from 5.727 to 3.879 s narrow (1.71x DuckDB, 1.93x polars) and from 21.294 to 14.968 s wide (2.90x DuckDB, 5.47x polars) in a rotated, interleaved run, with the wide pair's peak RSS down from 33.2 GB to 28.8 GB; see the linked results for both; regenerated with `perf/arrow/bench_tables.py`.
178
196
 
179
197
  ## Reference
180
198
 
181
199
  **Python API.** The public surface is `DeepDiff`, `diff_json`, `diff_tables` (returning a `TableDiff`), `MaxDepthError`, `MAX_DEPTH_CEILING`, and `__version__` (a `str` matching the installed `deepdiff-rs` distribution version).
182
200
 
183
- - `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)]`).
201
+ - `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`, plus any custom object (diffed by its attributes; see [Known limitations](#known-limitations) for the exact restrictions and exclusions, including which types a `dict` key may be, and the types that raise `TypeError` instead); `.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 (a custom object, though, comes back as a plain `dict` of its attributes, since onix cannot reconstruct the instance — see [Known limitations](#known-limitations)) — 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)]`).
184
202
  - `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.
185
203
  - `MaxDepthError` (a `ValueError` subclass) is raised when input exceeds `max_depth`; `MAX_DEPTH_CEILING` (20,000) is the hard upper bound on `max_depth`.
186
204
  - `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.
@@ -207,6 +225,7 @@ crates/onix-core # the diff engine (library, no I/O)
207
225
  crates/onix-cli # the `onix` binary (thin CLI over the core)
208
226
  crates/onix-arrow # Arrow table diffing (schema diff and keyed row diff)
209
227
  crates/onix-py # PyO3 bindings, published as `deepdiff-rs`
228
+ docs/design/ # algorithm/invariant reference pages (list-diff, ignore-order, value-model, depth-budget)
210
229
  scripts/ # gen_goldens.py: regenerates tests/golden/ from real DeepDiff
211
230
  tests/golden # DeepDiff-generated expected outputs (the compatibility corpus)
212
231
  perf/ # cross-language benchmark harness and RESULTS.md
@@ -215,22 +234,27 @@ perf/ # cross-language benchmark harness and RESULTS.md
215
234
  ## Known limitations
216
235
 
217
236
  - Only the core diff is implemented: `exclude_paths`, `significant_digits`, custom operators, `verbose_level != 2`, and delta/patch are not (yet) supported.
218
- - 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).
237
+ - 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 is diffed by its attributes (see the **Custom objects** bullet below). 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).
238
+ - **Custom objects** diff by their attributes, matching DeepDiff's `_diff_obj` (`attribute_added`/`attribute_removed`, `root.attr` paths, `type_changes` between two different class objects), and an `Enum` member by its `name` and `value`, matching `_diff_enum`. A type DeepDiff routes to a handler onix lacks — `bytes`, `bytearray`, a generator or any other iterable, `complex`, `Decimal`, `Fraction`, a `numpy` scalar, `uuid`, `ipaddress`, a class object, a module, or a bare `object()` — raises `TypeError` at the root; below it, such a value is equal only to the same object, as DeepDiff's `t1 is t2` check makes it, and raises `TypeError` naming its path wherever the report would have to show it. Two identical Python objects at one position are never walked, a class attribute converts only when a report compares it with a shadowing value, and a class attribute is left out of a whole object in a report, as in DeepDiff (see [`tests/golden/README.md`](tests/golden/README.md#types-deepdiff-routes-to-a-handler-onix-lacks)). A `pydantic` model raises `TypeError` where DeepDiff diffs it (see [`tests/golden/README.md`](tests/golden/README.md#pydantic-models)). A custom non-`dict` `Mapping`, and a `re.Pattern` pair whose named groups differ, raise `TypeError` where DeepDiff diffs them (see [`tests/golden/README.md`](tests/golden/README.md#refused-mappings)). A `@property` that raises `AttributeError`, or an unset slot beside a `__dict__`, raises `TypeError` where DeepDiff reports the object as `unprocessed` (see [`tests/golden/README.md`](tests/golden/README.md#a-property-that-raises)). A child that points back at an object on its path reports nothing when it is on the first side; on the second side only it is compared as the object it points back at, as DeepDiff's `parents_ids` tracks only the first side's ancestry (see [`tests/golden/README.md`](tests/golden/README.md#a-recursive-object)). Three attribute-view divergences (`ignore_order` hashing, and the whole-object values in `to_json()` and `to_dict()`) are tracked in [#99](https://github.com/ksco92/onix/issues/99), see [`tests/golden/README.md`](tests/golden/README.md#custom-objects-where-onix-is-deliberately-different).
219
239
  - 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).
220
240
  - **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.
221
241
  - **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.
222
- - **Non-finite floats** (`NaN`, `Infinity`, `-Infinity`) compare and hash like real Python: two `NaN`s are never equal, `Infinity == Infinity`, `to_json()` renders the same bare `NaN`/`Infinity`/`-Infinity` tokens Python's `json.dumps` does, and `ignore_order` matching treats every `NaN` as one shared item, matching `DeepHash`. The one divergence, always deterministic: this crate's value model carries no Python object identity, so two independently-obtained `NaN`s always compare unequal here, where DeepDiff sometimes reports no difference (`t1 is t2`) or lets one collapse into another (a `set` member, an ordered-list match) when the two objects, or their containers, happen to be the same one. See [`tests/golden/README.md`](tests/golden/README.md)'s "Non-finite floats" section.
242
+ - **Non-finite floats** (`NaN`, `Infinity`, `-Infinity`) compare and hash like real Python: two `NaN`s are never equal, `Infinity == Infinity`, `to_json()` renders the same bare `NaN`/`Infinity`/`-Infinity` tokens Python's `json.dumps` does, and `ignore_order` matching treats every `NaN` as one shared item, matching `DeepHash`. The one divergence, always deterministic: this crate's value model carries Python object identity for custom objects only, so two `NaN`s outside one shared custom object always compare unequal here, where DeepDiff sometimes reports no difference (`t1 is t2`) or lets one collapse into another (a `set` member, an ordered-list match) when the two objects, or their containers, happen to be the same one. See [`tests/golden/README.md`](tests/golden/README.md)'s "Non-finite floats" section.
223
243
  - A `str` (or `dict` key) containing a lone (unpaired) surrogate code point (e.g. `'\udc80'`, legal in Python but not encodable as UTF-8) is compared and reported exactly like any other `str`, matching DeepDiff's plain `==`; `to_json()` renders it with `json.dumps`'s own single-backslash `\uXXXX` escape. The one accepted divergence, always deterministic: hashing one — a `set`/`frozenset` member, or *any* value at all once `ignore_order=True` (`DeepHash` hashes every value there, not just a set's members) — crashes real DeepDiff with an unhandled `UnicodeEncodeError`, where onix hashes by code point and reports normally. See [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
224
244
  - A `str` inside a `tuple` or `frozenset` set item is escaped exactly as Python's `repr()` escapes it, against Unicode 16.0.0; on a Python older than 3.14 (an older `unicodedata` table), a code point assigned to Unicode after that Python's own version is escaped by DeepDiff and rendered literally by onix. See [`tests/golden/README.md`](tests/golden/README.md)'s "Pinned versions" section.
225
245
  - Adversarially deep input raises `MaxDepthError` instead of crashing: the default `max_depth` is 512 and the hard ceiling is `MAX_DEPTH_CEILING` (20,000). See [`crates/onix-py/src/guard.rs`](crates/onix-py/src/guard.rs).
226
- - `ignore_order` pairing is `O(N^2)` in unpaired elements per side and carries a polynomial cost in both time and memory with input depth; it has no `max_passes`/`max_diffs` cutoff, so bound the size and depth of untrusted input yourself. See [`crates/onix-core/src/ignore_order/mod.rs`](crates/onix-core/src/ignore_order/mod.rs).
246
+ - `ignore_order` pairing is `O(N^2)` in unpaired elements per side and carries a polynomial cost in both time and memory with input depth (about 7.5 s and 146 MB at depth 400 on a few-KB input, growing roughly cubically with depth, all under the default `max_depth`); it has no `max_passes`/`max_diffs` cutoff, so bound the size and depth of untrusted input yourself. See [`docs/design/ignore-order.md`](docs/design/ignore-order.md)'s "Bounds" section.
227
247
  - A `values_changed` between two multi-line strings runs a `difflib`-style `O(N*M)` line diff on the default path with no opt-out, worst when changes are spread evenly through the text (about 35 s for a heavily edited 1 MB string and growing quadratically, so a few megabytes is minutes), so bound the size of untrusted strings yourself. See [`crates/onix-core/src/unified_diff.rs`](crates/onix-core/src/unified_diff.rs).
228
248
  - Ordered sequences (`list` or `tuple`) of scalars (null, bool, number, string, datetime, date, time, timedelta) run a `difflib`-style `O(N*M)` matcher on the default path with its popular-element (autojunk) purge disabled for `DeepDiff` parity, worst for sequences of a few repeated values with dense edits (about 620 s for two 8,000-element lists of two repeated values with every other element changed, growing faster than quadratically), so bound the size of untrusted sequences yourself. See [`crates/onix-core/src/lcs.rs`](crates/onix-core/src/lcs.rs).
229
249
  - `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).
230
250
  - 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.
231
251
  - `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.
232
- - `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 spills each side's changed value rows — every common value column of every changed row, changed or not — to anonymous per-key-hash-partition Arrow IPC files and compares and renders one partition at a time across the workers. Its resident cost is the spilled changed value rows (the changed-row count times the total width of the common value columns, both sides — resident where written temp pages count, e.g. macOS or a RAM-backed `tmpfs`) plus about twice the `cells_changed` output (its one out-of-place reorder) plus the per-row hash vectors; the spill term dominates for wide rows with few changed cells, the output term for many changed cells. Measured (`row_diff_rss`, 18 threads): a 1 KB `string` cell changed on every row (output-dominated) peaks at 0.92 GB at 100k rows/side, 1.31 GB at 200k, and 4.81 GB at 1M (the single-threaded path, holding both sides, is 8.30 GB); eight 512 B value columns with only one differing (spill-dominated: 150k changed cells, ~160 MB output) peaks at about 2.09 GB at 150k rows/side, versus 0.84 GB with one such column (medians over 5 runs); these are at 18 threads, the low point of the range -- the same two shapes peak higher at the ends (spill-dominated 3.03 GB at 2 threads and 2.09 GB at 64; output-dominated, 200k, 1.98 GB at 2 and 1.49 GB at 64) as fewer partitions enlarge the resident chunk and the 64-way framing adds a little; two narrow `int64` columns with every row changed peak at 0.38 GB at 1M; the full wide benchmark fixture (34 columns, nearly every cell changed) measures about 33 GB whole-process at 16.875M rows/side at 18 threads, down from about 67 GB before the streaming cell pass. The two Arrow types whose `take` retains data beyond the selected rows are decoded before the spill -- byte-view columns (`Utf8View`/`BinaryView`) cast to their large i64-offset non-view type (`LargeUtf8`/`LargeBinary`, since one batch's retained view buffers can exceed the i32 offset type's ~2 GB ceiling) and dictionaries (what polars and DuckDB emit for strings) decoded to their value type -- so the spill stays compact and independent of the partition count; without the decode it would grow with the partition count (about 97 GB at 64 threads for the wide pair vs about 34 GB with it), 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), and the cell pass additionally spills both sides' changed value rows to anonymous per-partition files (byte-view columns cast to their non-view type and dictionaries decoded to their value type first, so the spill is compact and does not grow with the thread count); the input spools and the partition spill are resident together, and for the full wide pair the whole process (input spool + spill + working set) peaks at about 33 GB at 18 threads, 45 GB at 2, and 34 GB at 64 (on Linux the spill 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; for the cell pass, the total width of the common value columns — every one is spilled in full for each changed row, changed or not — plus the rendered changed cells; and any column's width for the size-gate peek, which buffers decoded cells whether or not they change), the producer's batch size, and the thread count (the cell pass's peak is lowest at the default and higher at 2 threads, where 2 partitions hold half the rows, and slightly higher at 64, the partition cap), 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).
233
- - `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).
252
+ - `diff_tables` row diff: 32 B hash per row per side (left only in parallel): 75 MB at 1M, 660 MB at 10M (single-threaded); 600/618 MB at 8M, 2.7/2.5 GB at 37M (1/18 threads); wall time `N·log N` across workers. Duplicate-key report, 18 threads: 39 MB (16 B keys) to 0.83 GB (1 KB keys) at 200k. Cell pass (both sides' spilled rows × column width, plus 2x `cells_changed`), 18 threads: 0.96/4.62 GB at 200k/1M, 1.56 GB spill-dominated (150k rows, eight 512 B columns, one differing). Right-side duplicate keys, 18 threads: +0.95 GB per 500k repeated keys (2 KB of value columns, 1M rows/side); +85 MB per 1M keys the left lacks (8 B `int64` keys; the entry is 32 B at any key width). Kept rows pin their input batch, per side: the whole batch when over half is kept, its view data otherwise (~2.0 GB, 1 and 18 threads, for 100 removed rows over a 1M-row side with two 1 KB `Utf8View` columns). [RESULTS.md, Fused reads](perf/arrow/RESULTS.md#fused-reads-issue-90-1).
253
+ - Temp disk, same call: each input spools to an anonymous file (`tempfile`: unlinked, mode 0600, no name). Changed rows spill to anonymous per-partition files, byte-view columns cast to a large-offset type (i32 caps at ~2 GB) and dictionaries decoded, independent of the partition count (~97 GB at 64 threads undecoded vs ~32 GB, wide pair); in parallel also one row per right key the left holds once that a later batch repeats (1.03 GB per 500k such keys, 2 KB of value columns, 1M rows/side, 2 and 64 threads). All resident until the cell pass ends: ~23.8 GB, wide pair (2/18/64 threads; Linux spill may be a RAM-backed `tmpfs`). Whole-process peaks, wide pair: ~29 GB at 18 threads, 40 GB at 2, 31 GB at 64 (33/46/34 GB on 0.11.2). A full temp filesystem raises `ValueError` naming `TMPDIR`.
254
+ - The size gate peeks up to 50,000 rows or 64 MB decoded per side, holding one whole producer batch per side beyond it: a 49,999-row/side, 8 KB-cell, 100-row-batch pair peaks ~65 MB at 18 threads, 12 MB single-threaded, ~1.6 GB single-threaded (1.2 GB at 18 threads) for one whole-side batch.
255
+ - None of the row-diff memory, temp-disk or size-gate-peek costs above has a built-in cap. Bound: row count; changed fraction; right-side duplicate keys (on the parallel path, one full-width row per repeated key, spilled or held); column widths (key-column width, for duplicate-heavy data; for the cell pass, the total width of all common value columns, since every one spills in full per changed row whether it changed or not, plus the rendered changed cells; any column's width for the size-gate peek, which buffers decoded cells whether or not they change); the producer's batch size; thread count, lowest at 18 threads (measured default), higher at 2 threads (two partitions each holding half the rows), slightly higher at 64: spill-dominated 2.39 GB at 2 threads, 1.96 GB at 64; output-dominated at 200k rows, 1.45 GB at 2, 1.25 GB at 64.
256
+ - 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 batch from `ROW_DIFF_BATCH`), macOS on an Apple M-series laptop, 2026-09-24, same method as [Performance](#performance); the single-threaded hash-term figures carry run-to-run slack up to 2.7-3.8 GB at 37M rows/side, the other cell-pass and thread-count figures are medians over 3 runs, and the two 150k spill/output shapes over 5. See [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md) for the size-gate peek table and the decode-vs-undecoded partition figures, and [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs) for the implementation.
257
+ - `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 first row-diff 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).
234
258
  - `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`.
235
259
  - 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.
236
260
 
@@ -9,7 +9,7 @@
9
9
  //! over that net wall. Peak RSS is the process's, third diff in the process.
10
10
  //!
11
11
  //! - **file**: reads each side from an uncompressed Arrow IPC file, re-opened
12
- //! and re-decoded by every pass. Convert a parquet fixture once with
12
+ //! by each pass that reads it. Convert a parquet fixture once with
13
13
  //! `python -c "import pyarrow.parquet as p, pyarrow.feather as f;
14
14
  //! f.write_feather(p.read_table('a.parquet'), 'a.arrow', compression='uncompressed')"`.
15
15
  //! - **generated**: spools both sides of a deterministic proxy shape to anonymous
@@ -18,6 +18,19 @@
18
18
  //! /usr/bin/time -l target/release/examples/row_diff_rss 200000 wide 1024
19
19
  //! # wide rows, few changed cells: id + 8 512-byte columns, only one differing
20
20
  //! ROW_DIFF_THREADS=18 /usr/bin/time -l target/release/examples/row_diff_rss 150000 manycols 8 512
21
+ //! # two 1 KB `Utf8View` columns: every row removed, every row added, every
22
+ //! # 10,000th row removed; the right repeating left keys, repeating keys the
23
+ //! # left lacks, and repeating one key the left lacks once per 10,000 rows
24
+ //! /usr/bin/time -l target/release/examples/row_diff_rss 1000000 viewremoved 1024
25
+ //! /usr/bin/time -l target/release/examples/row_diff_rss 1000000 viewadded 1024
26
+ //! /usr/bin/time -l target/release/examples/row_diff_rss 1000000 viewaddedbyvalue 1024
27
+ //! /usr/bin/time -l target/release/examples/row_diff_rss 1000000 viewaddedrepeat 1024
28
+ //! /usr/bin/time -l target/release/examples/row_diff_rss 1000000 viewsparse 1024 10000
29
+ //! /usr/bin/time -l target/release/examples/row_diff_rss 1000000 duprightonce 1024
30
+ //! /usr/bin/time -l target/release/examples/row_diff_rss 1000000 duprightabsent 1024
31
+ //! /usr/bin/time -l target/release/examples/row_diff_rss 1000000 repeatabsent 1024 10000
32
+ //! # a right side of keys the left lacks, each batch repeating the last one's
33
+ //! ROW_DIFF_BATCH=16 target/release/examples/row_diff_rss 1000000 chain 64
21
34
  //! # duplicate-heavy shape: every key duplicated, wide string key
22
35
  //! /usr/bin/time -l target/release/examples/row_diff_rss 1000000 dup 16
23
36
  //! /usr/bin/time -l target/release/examples/row_diff_rss 200000 dup 1024
@@ -98,9 +111,26 @@ fn main() {
98
111
  )
99
112
  }
100
113
  "dup" => (Case::Dup(width), format!(" (dup, key_width={width})")),
114
+ "viewremoved" | "viewadded" | "viewaddedbyvalue" | "viewaddedrepeat" | "viewsparse"
115
+ | "duprightonce" | "duprightabsent" | "repeatabsent" | "chain" => {
116
+ let width: usize = args.get(3).and_then(|a| a.parse().ok()).unwrap_or(1024);
117
+ let every: i64 = args.get(4).and_then(|a| a.parse().ok()).unwrap_or(10_000);
118
+ let case = match mode {
119
+ "viewremoved" => Case::ViewRemoved(width),
120
+ "viewadded" => Case::ViewAdded(width),
121
+ "viewaddedbyvalue" => Case::ViewAddedByValue(width),
122
+ "viewaddedrepeat" => Case::ViewAddedRepeat(width),
123
+ "viewsparse" => Case::ViewSparse { width, every },
124
+ "duprightonce" => Case::DupRightOnce(width),
125
+ "duprightabsent" => Case::DupRightAbsent(width),
126
+ "chain" => Case::Chain(width),
127
+ _ => Case::RepeatAbsent { width, every },
128
+ };
129
+ (case, format!(" ({mode}, width={width}, every={every})"))
130
+ }
101
131
  other => {
102
132
  eprintln!(
103
- "unknown mode {other:?}; expected linear (the default), nochange, allchange, wide, widesame, manycols or dup"
133
+ "unknown mode {other:?}; expected linear (the default), nochange, allchange, wide, widesame, manycols, dup, viewremoved, viewadded, viewaddedbyvalue, viewaddedrepeat, viewsparse, duprightonce, duprightabsent, repeatabsent or chain"
104
134
  );
105
135
  std::process::exit(2);
106
136
  }