deepdiff-rs 0.12.0__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 (91) hide show
  1. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/Cargo.lock +4 -4
  2. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/Cargo.toml +1 -1
  3. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/PKG-INFO +9 -8
  4. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/README.md +8 -7
  5. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-arrow/examples/row_diff_profile.rs +1 -1
  6. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-arrow/examples/row_diff_rss.rs +31 -1
  7. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-arrow/examples/shared/gen_shapes.rs +171 -1
  8. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/lib.rs +2 -2
  9. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/row_diff.rs +2256 -593
  10. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-arrow/tests/profile_passes.rs +14 -11
  11. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/datetime.rs +22 -99
  12. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/diff/array.rs +8 -10
  13. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/diff/dispatch.rs +16 -75
  14. deepdiff_rs-0.13.0/crates/onix-core/src/diff/mod.rs +34 -0
  15. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/diff/object.rs +5 -5
  16. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/diff/options.rs +16 -96
  17. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/diff/tests.rs +9 -10
  18. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/error.rs +4 -24
  19. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/ignore_order/distance.rs +7 -7
  20. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/ignore_order/fxhash.rs +3 -3
  21. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/ignore_order/hash.rs +15 -14
  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.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/ignore_order/pairing.rs +2 -1
  25. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/ignore_order/tests.rs +4 -3
  26. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/lcs.rs +8 -8
  27. {deepdiff_rs-0.12.0 → 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.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/report.rs +13 -69
  30. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/value.rs +45 -104
  31. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/value_tests.rs +1 -1
  32. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/tests/proptest_diff.rs +9 -11
  33. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/src/convert.rs +6 -6
  34. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/src/fast_path.rs +2 -1
  35. deepdiff_rs-0.12.0/crates/onix-core/src/diff/mod.rs +0 -237
  36. deepdiff_rs-0.12.0/crates/onix-core/src/ignore_order/memo.rs +0 -434
  37. deepdiff_rs-0.12.0/crates/onix-core/src/ignore_order/mod.rs +0 -316
  38. deepdiff_rs-0.12.0/crates/onix-core/src/lib.rs +0 -123
  39. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-arrow/Cargo.toml +0 -0
  40. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-arrow/examples/type_stack_cost.rs +0 -0
  41. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/error.rs +0 -0
  42. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/json_rows.rs +0 -0
  43. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/options.rs +0 -0
  44. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/profile.rs +0 -0
  45. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/schema.rs +0 -0
  46. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/spool.rs +0 -0
  47. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-arrow/src/table_diff.rs +0 -0
  48. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/Cargo.toml +0 -0
  49. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/examples/stack_frame_cost.rs +0 -0
  50. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/datetime_tests.rs +0 -0
  51. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/diff/scalar.rs +0 -0
  52. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/diff/set.rs +0 -0
  53. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/path.rs +0 -0
  54. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/report_tests.rs +0 -0
  55. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/test_support.rs +0 -0
  56. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/unified_diff.rs +0 -0
  57. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/src/unified_diff_tests.rs +0 -0
  58. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/tests/golden.rs +0 -0
  59. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/tests/ignore_order_memory.rs +0 -0
  60. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/tests/memory_footprint.rs +0 -0
  61. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-core/tests/proptest_ignore_order.rs +0 -0
  62. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/.python-version +0 -0
  63. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/Cargo.toml +0 -0
  64. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/benchmarks/bench_bindings.py +0 -0
  65. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/deepdiff_rs.pyi +0 -0
  66. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/src/arrow.rs +0 -0
  67. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/src/deepdiff.rs +0 -0
  68. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/src/errors.rs +0 -0
  69. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/src/guard.rs +0 -0
  70. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/src/lib.rs +0 -0
  71. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/conftest.py +0 -0
  72. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_bindings_memory.py +0 -0
  73. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_conversions.py +0 -0
  74. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_datetimes.py +0 -0
  75. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_depth_guard.py +0 -0
  76. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_differential_fuzz.py +0 -0
  77. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_golden_parity.py +0 -0
  78. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_non_finite.py +0 -0
  79. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_sets.py +0 -0
  80. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_signed_zero.py +0 -0
  81. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_smoke.py +0 -0
  82. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_stub_mypy.py +0 -0
  83. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_stub_signatures.py +0 -0
  84. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_suite_hygiene.py +0 -0
  85. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_table_diff.py +0 -0
  86. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_table_row_diff.py +0 -0
  87. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_timedeltas.py +0 -0
  88. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_times.py +0 -0
  89. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_tuples.py +0 -0
  90. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/crates/onix-py/tests/test_wheel_contents.py +0 -0
  91. {deepdiff_rs-0.12.0 → deepdiff_rs-0.13.0}/pyproject.toml +0 -0
@@ -629,7 +629,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
629
629
 
630
630
  [[package]]
631
631
  name = "onix-arrow"
632
- version = "0.12.0"
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.12.0"
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.12.0"
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.12.0"
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.12.0"
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.12.0
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
@@ -210,7 +210,7 @@ The engine's own diff-only time and peak resident memory against pinned `deepdif
210
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 | ✅ |
211
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 | ✅ |
212
212
 
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, 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`.
214
214
 
215
215
  ## Reference
216
216
 
@@ -243,6 +243,7 @@ crates/onix-core # the diff engine (library, no I/O)
243
243
  crates/onix-cli # the `onix` binary (thin CLI over the core)
244
244
  crates/onix-arrow # Arrow table diffing (schema diff and keyed row diff)
245
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)
246
247
  scripts/ # gen_goldens.py: regenerates tests/golden/ from real DeepDiff
247
248
  tests/golden # DeepDiff-generated expected outputs (the compatibility corpus)
248
249
  perf/ # cross-language benchmark harness and RESULTS.md
@@ -260,17 +261,17 @@ perf/ # cross-language benchmark harness and RESULTS.md
260
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.
261
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.
262
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).
263
- - `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.
264
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).
265
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).
266
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).
267
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.
268
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.
269
- - `diff_tables` row diff: per-row hash (32 B/row/side): 75 MB at 1M, 660 MB at 10M, linear in row count; wall time `N·log N` across workers. Parallel overhead ~10-15 B/row/side: 629/857 MB at 8M (1/18 threads), 2.9/3.6 GB at 37M; `threads=1` sequential. Duplicate-key report (every distinct duplicated key): 37 MB (16 B keys) to 1.05 GB (1 KB keys) at 200k (100k distinct), 165 MB at 1M (500k distinct, 16 B keys). Cell-pass resident (spilled rows-x-column-width, both sides + 2x `cells_changed` output + hash vectors) at 18 threads: 0.92/1.31/4.81 GB at 100k/200k/1M output-dominated (single-threaded 8.30 GB); eight 512 B columns, one differing (150k changed cells, ~160 MB output): 2.09 GB at 150k spill-dominated vs 0.84 GB one column; 0.38 GB at 1M two int64.
270
- - Temp disk, same call: each input is spooled to an anonymous file (`tempfile`: unlinked at once, mode 0600, no name, nothing left on abnormal exit). The cell pass spills both sides' changed rows to anonymous per-partition files: byte-view columns cast to a large-offset type (buffers can exceed i32's ~2 GB), dictionaries decoded to value type, keeping the spill compact, independent of partition count (~97 GB at 64 threads undecoded vs ~34 GB decoded, wide pair). Spool and spill resident together, whole-process peaks (34-column fixture, 16.875M rows/side): ~33 GB at 18 threads, 45 GB at 2, 34 GB at 64, down from ~67 GB pre-streaming (Linux spill may be a RAM-backed `tmpfs`). A full temp filesystem raises `ValueError` naming `TMPDIR`.
271
- - 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 ~78 MB at 18 threads, 14 MB single-threaded, ~1.6 GB for one whole-side batch.
272
- - None of the row-diff memory, temp-disk or size-gate-peek costs above has a built-in cap. Bound: row count; changed fraction; 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 3.03 GB at 2 threads, 2.09 GB at 64; output-dominated at 200k rows, 1.98 GB at 2, 1.49 GB at 64.
273
- - 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`), single run, macOS on an Apple M-series laptop, 2026-09-06, same method as [Performance](#performance); the single-threaded hash-term figures carry run-to-run slack up to 2.9-4.2 GB at 37M rows/side, and the two spill/output cell-pass shapes are medians over 5 runs. 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.
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.
274
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).
275
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`.
276
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.
@@ -192,7 +192,7 @@ The engine's own diff-only time and peak resident memory against pinned `deepdif
192
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 | ✅ |
193
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 | ✅ |
194
194
 
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`.
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`.
196
196
 
197
197
  ## Reference
198
198
 
@@ -225,6 +225,7 @@ crates/onix-core # the diff engine (library, no I/O)
225
225
  crates/onix-cli # the `onix` binary (thin CLI over the core)
226
226
  crates/onix-arrow # Arrow table diffing (schema diff and keyed row diff)
227
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)
228
229
  scripts/ # gen_goldens.py: regenerates tests/golden/ from real DeepDiff
229
230
  tests/golden # DeepDiff-generated expected outputs (the compatibility corpus)
230
231
  perf/ # cross-language benchmark harness and RESULTS.md
@@ -242,17 +243,17 @@ perf/ # cross-language benchmark harness and RESULTS.md
242
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.
243
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.
244
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).
245
- - `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.
246
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).
247
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).
248
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).
249
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.
250
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.
251
- - `diff_tables` row diff: per-row hash (32 B/row/side): 75 MB at 1M, 660 MB at 10M, linear in row count; wall time `N·log N` across workers. Parallel overhead ~10-15 B/row/side: 629/857 MB at 8M (1/18 threads), 2.9/3.6 GB at 37M; `threads=1` sequential. Duplicate-key report (every distinct duplicated key): 37 MB (16 B keys) to 1.05 GB (1 KB keys) at 200k (100k distinct), 165 MB at 1M (500k distinct, 16 B keys). Cell-pass resident (spilled rows-x-column-width, both sides + 2x `cells_changed` output + hash vectors) at 18 threads: 0.92/1.31/4.81 GB at 100k/200k/1M output-dominated (single-threaded 8.30 GB); eight 512 B columns, one differing (150k changed cells, ~160 MB output): 2.09 GB at 150k spill-dominated vs 0.84 GB one column; 0.38 GB at 1M two int64.
252
- - Temp disk, same call: each input is spooled to an anonymous file (`tempfile`: unlinked at once, mode 0600, no name, nothing left on abnormal exit). The cell pass spills both sides' changed rows to anonymous per-partition files: byte-view columns cast to a large-offset type (buffers can exceed i32's ~2 GB), dictionaries decoded to value type, keeping the spill compact, independent of partition count (~97 GB at 64 threads undecoded vs ~34 GB decoded, wide pair). Spool and spill resident together, whole-process peaks (34-column fixture, 16.875M rows/side): ~33 GB at 18 threads, 45 GB at 2, 34 GB at 64, down from ~67 GB pre-streaming (Linux spill may be a RAM-backed `tmpfs`). A full temp filesystem raises `ValueError` naming `TMPDIR`.
253
- - 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 ~78 MB at 18 threads, 14 MB single-threaded, ~1.6 GB for one whole-side batch.
254
- - None of the row-diff memory, temp-disk or size-gate-peek costs above has a built-in cap. Bound: row count; changed fraction; 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 3.03 GB at 2 threads, 2.09 GB at 64; output-dominated at 200k rows, 1.98 GB at 2, 1.49 GB at 64.
255
- - 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`), single run, macOS on an Apple M-series laptop, 2026-09-06, same method as [Performance](#performance); the single-threaded hash-term figures carry run-to-run slack up to 2.9-4.2 GB at 37M rows/side, and the two spill/output cell-pass shapes are medians over 5 runs. 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.
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.
256
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).
257
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`.
258
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.
@@ -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
  }
@@ -10,7 +10,9 @@
10
10
 
11
11
  use std::sync::Arc;
12
12
 
13
- use arrow_array::{ArrayRef, Int64Array, RecordBatch, RecordBatchReader, StringArray};
13
+ use arrow_array::{
14
+ ArrayRef, Int64Array, RecordBatch, RecordBatchReader, StringArray, StringViewArray,
15
+ };
14
16
  use arrow_schema::{ArrowError, DataType, Field, Schema, SchemaRef};
15
17
  use onix_arrow::{TableDiffError, TableInput};
16
18
 
@@ -50,6 +52,60 @@ pub enum Shape {
50
52
  width: usize,
51
53
  first_fill: u8,
52
54
  },
55
+ /// `(id, v0, v1)`, two `width`-byte `Utf8View` columns starting with `fill`;
56
+ /// `keys` maps row `i` to its id, or omits it.
57
+ View {
58
+ width: usize,
59
+ fill: u8,
60
+ keys: ViewKeys,
61
+ },
62
+ }
63
+
64
+ /// How a [`Shape::View`] side keys its rows.
65
+ #[derive(Clone, Copy)]
66
+ pub enum ViewKeys {
67
+ /// Id `i`, omitting every row when `omit_every` is 1, every
68
+ /// `omit_every`-th row when above 1, and none when 0.
69
+ Plain { omit_every: i64 },
70
+ /// Id `offset + i % period`, so each id repeats every `period` rows.
71
+ Wrap { offset: i64, period: i64 },
72
+ /// Id `i`, except every `every`-th row, whose id is `-1`.
73
+ RepeatAbsent { every: i64 },
74
+ /// In `batch`-row batches, the first half new ids from `offset` up and the
75
+ /// second half the previous batch's new ids.
76
+ Chain { offset: i64, batch: i64 },
77
+ /// Id `i`, except each batch's last row after the first, which repeats the
78
+ /// previous batch's first id.
79
+ RepeatPrevFirst { batch: i64 },
80
+ }
81
+
82
+ impl ViewKeys {
83
+ fn id(self, i: i64) -> Option<i64> {
84
+ match self {
85
+ ViewKeys::Plain { omit_every } => (omit_every == 0 || i % omit_every != 0).then_some(i),
86
+ ViewKeys::Wrap { offset, period } => Some(offset + i % period),
87
+ ViewKeys::RepeatAbsent { every } => Some(if i % every == 0 { -1 } else { i }),
88
+ ViewKeys::RepeatPrevFirst { batch } => {
89
+ let (b, j) = (i / batch, i % batch);
90
+ Some(if b > 0 && j == batch - 1 {
91
+ (b - 1) * batch
92
+ } else {
93
+ i
94
+ })
95
+ }
96
+ ViewKeys::Chain { offset, batch } => {
97
+ let (b, j, half) = (i / batch, i % batch, (batch / 2).max(1));
98
+ Some(
99
+ offset
100
+ + if j < half {
101
+ b * half + j
102
+ } else {
103
+ (b - 1) * half + j - half
104
+ },
105
+ )
106
+ }
107
+ }
108
+ }
53
109
  }
54
110
 
55
111
  /// A generated two-sided case, with its size parameters already defaulted by
@@ -70,9 +126,98 @@ pub enum Case {
70
126
  ManyCols { ncols: usize, width: usize },
71
127
  /// Every `key_width`-byte string key appearing twice on each side.
72
128
  Dup(usize),
129
+ /// Two `width`-byte view columns; every left row removed (right empty).
130
+ ViewRemoved(usize),
131
+ /// [`Case::ViewRemoved`] mirrored: every right row added (left empty).
132
+ ViewAdded(usize),
133
+ /// [`Case::ViewAdded`] keyed on the `width`-byte view column `v0`.
134
+ ViewAddedByValue(usize),
135
+ /// [`Case::ViewAdded`] with each right batch's last row repeating the
136
+ /// previous batch's first key, so every batch keeps a right-only duplicate.
137
+ ViewAddedRepeat(usize),
138
+ /// Two `width`-byte view columns, equal sides except every `every`-th left
139
+ /// row, which the right lacks.
140
+ ViewSparse { width: usize, every: i64 },
141
+ /// Left ids once; the right holds the first half of them twice each, half
142
+ /// a side apart and with different values, so each is a duplicate key.
143
+ DupRightOnce(usize),
144
+ /// Left ids once; the right holds ids the left lacks, each twice, half a
145
+ /// side apart.
146
+ DupRightAbsent(usize),
147
+ /// Equal sides except every `every`-th right row, keyed by one id the left
148
+ /// lacks.
149
+ RepeatAbsent { width: usize, every: i64 },
150
+ /// A right side of ids the left lacks, each batch repeating the previous
151
+ /// batch's new ids.
152
+ Chain(usize),
73
153
  }
74
154
 
75
155
  impl Case {
156
+ /// [`Case::build`] for the two-view-column shapes.
157
+ fn view(self, rows: i64) -> (SchemaRef, Shape, Shape, &'static str) {
158
+ let (Case::ViewRemoved(width)
159
+ | Case::ViewAdded(width)
160
+ | Case::ViewAddedByValue(width)
161
+ | Case::ViewAddedRepeat(width)
162
+ | Case::ViewSparse { width, .. }
163
+ | Case::DupRightOnce(width)
164
+ | Case::DupRightAbsent(width)
165
+ | Case::RepeatAbsent { width, .. }
166
+ | Case::Chain(width)) = self
167
+ else {
168
+ unreachable!("only view cases reach this method")
169
+ };
170
+ let schema = Arc::new(Schema::new(vec![
171
+ Field::new("id", DataType::Int64, false),
172
+ Field::new("v0", DataType::Utf8View, false),
173
+ Field::new("v1", DataType::Utf8View, false),
174
+ ]));
175
+ let view = |fill, keys| Shape::View { width, fill, keys };
176
+ let period = (rows / 2).max(1);
177
+ let all = ViewKeys::Plain { omit_every: 0 };
178
+ let (left, right) = match self {
179
+ Case::ViewRemoved(_) => (all, ViewKeys::Plain { omit_every: 1 }),
180
+ Case::ViewAdded(_) | Case::ViewAddedByValue(_) => {
181
+ (ViewKeys::Plain { omit_every: 1 }, all)
182
+ }
183
+ Case::ViewAddedRepeat(_) => (
184
+ ViewKeys::Plain { omit_every: 1 },
185
+ ViewKeys::RepeatPrevFirst {
186
+ batch: batch_rows(),
187
+ },
188
+ ),
189
+ Case::ViewSparse { every, .. } => (all, ViewKeys::Plain { omit_every: every }),
190
+ Case::DupRightOnce(_) => (all, ViewKeys::Wrap { offset: 0, period }),
191
+ Case::DupRightAbsent(_) => (
192
+ all,
193
+ ViewKeys::Wrap {
194
+ offset: rows,
195
+ period,
196
+ },
197
+ ),
198
+ Case::RepeatAbsent { every, .. } => (all, ViewKeys::RepeatAbsent { every }),
199
+ Case::Chain(_) => (
200
+ all,
201
+ ViewKeys::Chain {
202
+ offset: 2 * rows,
203
+ batch: batch_rows(),
204
+ },
205
+ ),
206
+ _ => unreachable!("only view cases reach this arm"),
207
+ };
208
+ let right_fill = if matches!(self, Case::DupRightOnce(_)) {
209
+ b'b'
210
+ } else {
211
+ b'a'
212
+ };
213
+ let key = if matches!(self, Case::ViewAddedByValue(_)) {
214
+ "v0"
215
+ } else {
216
+ "id"
217
+ };
218
+ (schema, view(b'a', left), view(right_fill, right), key)
219
+ }
220
+
76
221
  /// The schema, the left and right shapes, and the key column.
77
222
  pub fn build(self, rows: i64) -> (SchemaRef, Shape, Shape, &'static str) {
78
223
  let int_schema = || {
@@ -130,6 +275,15 @@ impl Case {
130
275
  "id",
131
276
  )
132
277
  }
278
+ Case::ViewRemoved(_)
279
+ | Case::ViewAdded(_)
280
+ | Case::ViewAddedByValue(_)
281
+ | Case::ViewAddedRepeat(_)
282
+ | Case::ViewSparse { .. }
283
+ | Case::DupRightOnce(_)
284
+ | Case::DupRightAbsent(_)
285
+ | Case::RepeatAbsent { .. }
286
+ | Case::Chain(_) => self.view(rows),
133
287
  Case::Dup(key_width) => {
134
288
  let schema = Arc::new(Schema::new(vec![
135
289
  Field::new("key", DataType::Utf8, false),
@@ -231,6 +385,22 @@ impl Iterator for GenReader {
231
385
  }
232
386
  columns
233
387
  }
388
+ Shape::View { width, fill, keys } => {
389
+ let (ids, rows): (Vec<i64>, Vec<i64>) = (self.next..end)
390
+ .filter_map(|i| keys.id(i).map(|id| (id, i)))
391
+ .unzip();
392
+ let cell = |column: u8| {
393
+ let cells: StringViewArray = rows
394
+ .iter()
395
+ .map(|&i| {
396
+ let pad = width.saturating_sub(2);
397
+ Some(format!("{}{column}{i:0>pad$}", char::from(fill)))
398
+ })
399
+ .collect();
400
+ Arc::new(cells) as ArrayRef
401
+ };
402
+ vec![Arc::new(Int64Array::from(ids)), cell(0), cell(1)]
403
+ }
234
404
  };
235
405
  self.next = end;
236
406
 
@@ -3,8 +3,8 @@
3
3
  //! [`diff_tables`] compares two tables presented as [`TableInput`]s and
4
4
  //! returns a [`TableDiff`] carrying the schema diff, the keyed row diff, and
5
5
  //! the per-cell diff. The two tables are matched on a required, non-empty
6
- //! set of key columns, carried in [`TableDiffOptions`]. The row diff reads
7
- //! each side more than once, so [`diff_tables`] takes a re-openable
6
+ //! set of key columns, carried in [`TableDiffOptions`]. The row diff may read
7
+ //! a side more than once, so [`diff_tables`] takes a re-openable
8
8
  //! [`TableInput`] rather than a single-use `RecordBatchReader`. In-memory
9
9
  //! tables use [`MemoryInput`]; a one-shot stream spools to a temporary file
10
10
  //! and implements [`TableInput`] over it, as the Python bindings do.