deepdiff-rs 0.9.0__tar.gz → 0.9.1__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 (82) hide show
  1. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/Cargo.lock +8 -8
  2. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/Cargo.toml +1 -1
  3. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/PKG-INFO +3 -2
  4. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/README.md +2 -1
  5. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/diff/scalar.rs +7 -4
  6. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/ignore_order/distance.rs +1 -1
  7. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/ignore_order/fxhash.rs +8 -0
  8. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/ignore_order/hash.rs +42 -10
  9. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/ignore_order/memo.rs +8 -6
  10. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/ignore_order/tests.rs +48 -6
  11. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/lcs.rs +33 -0
  12. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/lcs_tests.rs +61 -0
  13. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/path.rs +13 -2
  14. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/test_support.rs +1 -1
  15. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/value.rs +112 -31
  16. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/value_tests.rs +83 -16
  17. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/deepdiff_rs.pyi +2 -1
  18. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/src/convert.rs +8 -12
  19. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/src/deepdiff.rs +3 -2
  20. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/src/guard.rs +107 -6
  21. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_conversions.py +13 -13
  22. deepdiff_rs-0.9.1/crates/onix-py/tests/test_non_finite.py +318 -0
  23. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-arrow/Cargo.toml +0 -0
  24. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-arrow/examples/row_diff_rss.rs +0 -0
  25. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-arrow/examples/type_stack_cost.rs +0 -0
  26. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-arrow/src/error.rs +0 -0
  27. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-arrow/src/json_rows.rs +0 -0
  28. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-arrow/src/lib.rs +0 -0
  29. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-arrow/src/options.rs +0 -0
  30. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-arrow/src/row_diff.rs +0 -0
  31. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-arrow/src/schema.rs +0 -0
  32. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-arrow/src/table_diff.rs +0 -0
  33. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/Cargo.toml +0 -0
  34. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/examples/stack_frame_cost.rs +0 -0
  35. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/datetime.rs +0 -0
  36. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/datetime_tests.rs +0 -0
  37. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/diff/array.rs +0 -0
  38. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/diff/dispatch.rs +0 -0
  39. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/diff/mod.rs +0 -0
  40. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/diff/object.rs +0 -0
  41. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/diff/options.rs +0 -0
  42. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/diff/set.rs +0 -0
  43. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/diff/tests.rs +0 -0
  44. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/error.rs +0 -0
  45. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/ignore_order/mod.rs +0 -0
  46. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/ignore_order/pairing.rs +0 -0
  47. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/lib.rs +0 -0
  48. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/report.rs +0 -0
  49. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/report_tests.rs +0 -0
  50. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/unified_diff.rs +0 -0
  51. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/src/unified_diff_tests.rs +0 -0
  52. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/tests/golden.rs +0 -0
  53. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/tests/ignore_order_memory.rs +0 -0
  54. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/tests/memory_footprint.rs +0 -0
  55. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/tests/proptest_diff.rs +0 -0
  56. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-core/tests/proptest_ignore_order.rs +0 -0
  57. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/.python-version +0 -0
  58. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/Cargo.toml +0 -0
  59. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/benchmarks/bench_bindings.py +0 -0
  60. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/src/arrow.rs +0 -0
  61. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/src/errors.rs +0 -0
  62. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/src/fast_path.rs +0 -0
  63. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/src/lib.rs +0 -0
  64. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/conftest.py +0 -0
  65. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_bindings_memory.py +0 -0
  66. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_datetimes.py +0 -0
  67. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_depth_guard.py +0 -0
  68. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_differential_fuzz.py +0 -0
  69. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_golden_parity.py +0 -0
  70. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_sets.py +0 -0
  71. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_signed_zero.py +0 -0
  72. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_smoke.py +0 -0
  73. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_stub_mypy.py +0 -0
  74. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_stub_signatures.py +0 -0
  75. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_suite_hygiene.py +0 -0
  76. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_table_diff.py +0 -0
  77. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_table_row_diff.py +0 -0
  78. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_timedeltas.py +0 -0
  79. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_times.py +0 -0
  80. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_tuples.py +0 -0
  81. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/crates/onix-py/tests/test_wheel_contents.py +0 -0
  82. {deepdiff_rs-0.9.0 → deepdiff_rs-0.9.1}/pyproject.toml +0 -0
@@ -629,7 +629,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
629
629
 
630
630
  [[package]]
631
631
  name = "onix-arrow"
632
- version = "0.9.0"
632
+ version = "0.9.1"
633
633
  dependencies = [
634
634
  "arrow-array",
635
635
  "arrow-buffer",
@@ -646,7 +646,7 @@ dependencies = [
646
646
 
647
647
  [[package]]
648
648
  name = "onix-cli"
649
- version = "0.9.0"
649
+ version = "0.9.1"
650
650
  dependencies = [
651
651
  "onix-core",
652
652
  "serde_json",
@@ -654,7 +654,7 @@ dependencies = [
654
654
 
655
655
  [[package]]
656
656
  name = "onix-core"
657
- version = "0.9.0"
657
+ version = "0.9.1"
658
658
  dependencies = [
659
659
  "proptest",
660
660
  "serde",
@@ -665,7 +665,7 @@ dependencies = [
665
665
 
666
666
  [[package]]
667
667
  name = "onix-py"
668
- version = "0.9.0"
668
+ version = "0.9.1"
669
669
  dependencies = [
670
670
  "arrow-array",
671
671
  "arrow-ipc",
@@ -997,7 +997,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
997
997
  dependencies = [
998
998
  "proc-macro2",
999
999
  "quote",
1000
- "syn 3.0.4",
1000
+ "syn 3.0.5",
1001
1001
  ]
1002
1002
 
1003
1003
  [[package]]
@@ -1050,9 +1050,9 @@ dependencies = [
1050
1050
 
1051
1051
  [[package]]
1052
1052
  name = "syn"
1053
- version = "3.0.4"
1053
+ version = "3.0.5"
1054
1054
  source = "registry+https://github.com/rust-lang/crates.io-index"
1055
- checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
1055
+ checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
1056
1056
  dependencies = [
1057
1057
  "proc-macro2",
1058
1058
  "quote",
@@ -1199,7 +1199,7 @@ dependencies = [
1199
1199
  "bumpalo",
1200
1200
  "proc-macro2",
1201
1201
  "quote",
1202
- "syn 3.0.4",
1202
+ "syn 3.0.5",
1203
1203
  "wasm-bindgen-shared",
1204
1204
  ]
1205
1205
 
@@ -3,7 +3,7 @@ resolver = "3"
3
3
  members = ["crates/onix-core", "crates/onix-py", "crates/onix-arrow"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.9.0"
6
+ version = "0.9.1"
7
7
  edition = "2024"
8
8
  license = "MIT"
9
9
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepdiff-rs
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -236,10 +236,11 @@ perf/ # cross-language benchmark harness and RESULTS.md
236
236
  ## Known limitations
237
237
 
238
238
  - Only the core diff is implemented: `exclude_paths`, `significant_digits`, custom operators, `verbose_level != 2`, and delta/patch are not (yet) supported.
239
- - Supported value types are `None`, `bool`, `int`, `float`, `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). `int`s must fit in `i64`/`u64`, `float`s must be finite, and anything else — a custom object, an arbitrary-precision `int`, or a non-finite `float` — raises `TypeError`/`ValueError` naming the exact path it was found at. A non-`str` key's path renders via Python's own `repr()`, except a `tuple` key, which splits into one bracket group per element (`root[1][2]` for `(1, 2)`, never `root[(1, 2)]`, with one deliberate exception for a real DeepDiff bug on the empty tuple — 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** and **Sets** bullets below cover the deliberate divergences for those types. See [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs) and [`tests/golden/README.md`](tests/golden/README.md).
239
+ - Supported value types are `None`, `bool`, `int`, `float` (`NaN`/`Infinity`/`-Infinity` included), `str`, `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, and `datetime.timedelta`; a `set`/`frozenset` member may be any of these except a `list`, `dict` or `set`, matching Python's own hashability rule, transitively through whatever the member nests, and a `dict` key may be `str`, `None`, `bool`, `int`, `float`, `datetime.datetime`, `datetime.date`, or a `tuple` of those (never nested). `int`s must fit in `i64`/`u64`, and anything else — a custom object or an arbitrary-precision `int` — raises `TypeError`/`ValueError` naming the exact path it was found at. A non-`str` key's path renders via Python's own `repr()`, except a `tuple` key, which splits into one bracket group per element (`root[1][2]` for `(1, 2)`, never `root[(1, 2)]`, with one deliberate exception for a real DeepDiff bug on the empty tuple, and another on a non-finite-float key — see [`tests/golden/README.md`](tests/golden/README.md)). A nested dict value's own `bool`/`None`/`int`/`float` key stringifies the way `json.dumps` does, but its `datetime`/`date`/`tuple` key renders that same `repr()` text where DeepDiff's own `to_json()` raises `TypeError` on one — a superset, not a difference in the findings (see [`tests/golden/README.md`](tests/golden/README.md)). `1`/`1.0`/`True` match as the same key between two dicts (Python `dict`/`set` equality). The **Datetimes**, **Sets** and **Non-finite floats** bullets below cover the deliberate divergences for those types. See [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs) and [`tests/golden/README.md`](tests/golden/README.md).
240
240
  - A subclass of a supported type (a `tuple`, `set` or `frozenset` subclass including `namedtuple`, a `datetime`/`date`/`time`/`timedelta` subclass such as pandas' `Timestamp`) raises `TypeError` rather than being diffed as its base type, because DeepDiff reports each value's own type name. A `type_changes` entry's `old_type`/`new_type` are type *names* in `to_dict()`, where DeepDiff returns the type objects. Both are described in [`tests/golden/README.md`](tests/golden/README.md).
241
241
  - **Datetimes** compare by instant, with a naive value read as UTC, matching DeepDiff. A changed pair is reported normalized to UTC (`to_json()` renders `...+00:00`, `to_dict()` returns UTC-aware `datetime`s); everywhere else a datetime keeps its raw value. Three deliberate departures: `to_json()` renders a `date` as `YYYY-MM-DD` where DeepDiff's own `to_json()` raises `TypeError` (a documented superset); a `zoneinfo`/`pytz` tzinfo comes back from `to_dict()` as a fixed-offset `datetime.timezone` carrying the offset it was in force at, not the original zone object; and a set holding both a naive and an aware value at one instant reports both as members, where DeepDiff's own digest cache can report only one (see [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs)). Comparing two datetimes whose UTC form would leave year 1..=9999 raises `ValueError` naming the path, where DeepDiff raises `OverflowError`; under `ignore_order` DeepDiff's hasher normalizes every datetime and so raises for such a value even when it is only added, removed, or shuffled, where onix hashes by instant and reports it normally (see [`tests/golden/README.md`](tests/golden/README.md)). `truncate_datetime` is not supported; the normalized-versus-raw split is documented there too. A `time`/`timedelta`, unlike a datetime, is never normalized for report (DeepDiff compares `time`/`date`/`timedelta` with a plain `!=`), and a naive `time` is never equal to an aware one; `to_json()` renders a `time` as `time.isoformat()`'s bytes and a `timedelta` as `str(timedelta)`'s, both supersets. Under `ignore_order`, `DeepHash` hashes a `time` by whole seconds-of-day only — dropping the microsecond and any offset, a confirmed upstream quirk — while a `timedelta` hashes exactly; see [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
242
242
  - **Sets** are diffed deterministically, where DeepDiff's own answers depend on the order the running process happens to iterate a set in (hash order, and `PYTHONHASHSEED`-dependent for `str` members) or on how its digest cache/computation handles a tuple, frozenset, or calendar member independently of Python's own `==`. Each consequence — entry order, which member of an equality class is reported, set-versus-sequence coercion, and a tuple/frozenset member's own (positional, not order-/repetition-insensitive) matching rule — is shown with both tools' output in [`tests/golden/README.md`](tests/golden/README.md)'s "Set iteration order" section. A report holding a `frozenset` value also serializes to JSON here, where DeepDiff's own `to_json()` raises `TypeError` — a superset, not a difference in the findings.
243
+ - **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.
243
244
  - A `str` containing a lone (unpaired) surrogate code point (e.g. `'\udc80'`, legal in Python but not encodable as UTF-8) raises `ValueError` naming the exact path on either side, before the two values are ever compared — including a pair DeepDiff would call equal and report as no change, since DeepDiff's scalar equality is plain Python `==` and never hits the encoding problem; DeepDiff does report a plain change for a *differing* pair, and crashes with an unhandled `UnicodeEncodeError` if such a string is ever hashed (a `set`/`frozenset` member). See [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
244
245
  - 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.
245
246
  - 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).
@@ -218,10 +218,11 @@ perf/ # cross-language benchmark harness and RESULTS.md
218
218
  ## Known limitations
219
219
 
220
220
  - Only the core diff is implemented: `exclude_paths`, `significant_digits`, custom operators, `verbose_level != 2`, and delta/patch are not (yet) supported.
221
- - Supported value types are `None`, `bool`, `int`, `float`, `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). `int`s must fit in `i64`/`u64`, `float`s must be finite, and anything else — a custom object, an arbitrary-precision `int`, or a non-finite `float` — raises `TypeError`/`ValueError` naming the exact path it was found at. A non-`str` key's path renders via Python's own `repr()`, except a `tuple` key, which splits into one bracket group per element (`root[1][2]` for `(1, 2)`, never `root[(1, 2)]`, with one deliberate exception for a real DeepDiff bug on the empty tuple — 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** and **Sets** bullets below cover the deliberate divergences for those types. See [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs) and [`tests/golden/README.md`](tests/golden/README.md).
221
+ - Supported value types are `None`, `bool`, `int`, `float` (`NaN`/`Infinity`/`-Infinity` included), `str`, `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, and `datetime.timedelta`; a `set`/`frozenset` member may be any of these except a `list`, `dict` or `set`, matching Python's own hashability rule, transitively through whatever the member nests, and a `dict` key may be `str`, `None`, `bool`, `int`, `float`, `datetime.datetime`, `datetime.date`, or a `tuple` of those (never nested). `int`s must fit in `i64`/`u64`, and anything else — a custom object or an arbitrary-precision `int` — raises `TypeError`/`ValueError` naming the exact path it was found at. A non-`str` key's path renders via Python's own `repr()`, except a `tuple` key, which splits into one bracket group per element (`root[1][2]` for `(1, 2)`, never `root[(1, 2)]`, with one deliberate exception for a real DeepDiff bug on the empty tuple, and another on a non-finite-float key — see [`tests/golden/README.md`](tests/golden/README.md)). A nested dict value's own `bool`/`None`/`int`/`float` key stringifies the way `json.dumps` does, but its `datetime`/`date`/`tuple` key renders that same `repr()` text where DeepDiff's own `to_json()` raises `TypeError` on one — a superset, not a difference in the findings (see [`tests/golden/README.md`](tests/golden/README.md)). `1`/`1.0`/`True` match as the same key between two dicts (Python `dict`/`set` equality). The **Datetimes**, **Sets** and **Non-finite floats** bullets below cover the deliberate divergences for those types. See [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs) and [`tests/golden/README.md`](tests/golden/README.md).
222
222
  - A subclass of a supported type (a `tuple`, `set` or `frozenset` subclass including `namedtuple`, a `datetime`/`date`/`time`/`timedelta` subclass such as pandas' `Timestamp`) raises `TypeError` rather than being diffed as its base type, because DeepDiff reports each value's own type name. A `type_changes` entry's `old_type`/`new_type` are type *names* in `to_dict()`, where DeepDiff returns the type objects. Both are described in [`tests/golden/README.md`](tests/golden/README.md).
223
223
  - **Datetimes** compare by instant, with a naive value read as UTC, matching DeepDiff. A changed pair is reported normalized to UTC (`to_json()` renders `...+00:00`, `to_dict()` returns UTC-aware `datetime`s); everywhere else a datetime keeps its raw value. Three deliberate departures: `to_json()` renders a `date` as `YYYY-MM-DD` where DeepDiff's own `to_json()` raises `TypeError` (a documented superset); a `zoneinfo`/`pytz` tzinfo comes back from `to_dict()` as a fixed-offset `datetime.timezone` carrying the offset it was in force at, not the original zone object; and a set holding both a naive and an aware value at one instant reports both as members, where DeepDiff's own digest cache can report only one (see [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs)). Comparing two datetimes whose UTC form would leave year 1..=9999 raises `ValueError` naming the path, where DeepDiff raises `OverflowError`; under `ignore_order` DeepDiff's hasher normalizes every datetime and so raises for such a value even when it is only added, removed, or shuffled, where onix hashes by instant and reports it normally (see [`tests/golden/README.md`](tests/golden/README.md)). `truncate_datetime` is not supported; the normalized-versus-raw split is documented there too. A `time`/`timedelta`, unlike a datetime, is never normalized for report (DeepDiff compares `time`/`date`/`timedelta` with a plain `!=`), and a naive `time` is never equal to an aware one; `to_json()` renders a `time` as `time.isoformat()`'s bytes and a `timedelta` as `str(timedelta)`'s, both supersets. Under `ignore_order`, `DeepHash` hashes a `time` by whole seconds-of-day only — dropping the microsecond and any offset, a confirmed upstream quirk — while a `timedelta` hashes exactly; see [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
224
224
  - **Sets** are diffed deterministically, where DeepDiff's own answers depend on the order the running process happens to iterate a set in (hash order, and `PYTHONHASHSEED`-dependent for `str` members) or on how its digest cache/computation handles a tuple, frozenset, or calendar member independently of Python's own `==`. Each consequence — entry order, which member of an equality class is reported, set-versus-sequence coercion, and a tuple/frozenset member's own (positional, not order-/repetition-insensitive) matching rule — is shown with both tools' output in [`tests/golden/README.md`](tests/golden/README.md)'s "Set iteration order" section. A report holding a `frozenset` value also serializes to JSON here, where DeepDiff's own `to_json()` raises `TypeError` — a superset, not a difference in the findings.
225
+ - **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.
225
226
  - A `str` containing a lone (unpaired) surrogate code point (e.g. `'\udc80'`, legal in Python but not encodable as UTF-8) raises `ValueError` naming the exact path on either side, before the two values are ever compared — including a pair DeepDiff would call equal and report as no change, since DeepDiff's scalar equality is plain Python `==` and never hits the encoding problem; DeepDiff does report a plain change for a *differing* pair, and crashes with an unhandled `UnicodeEncodeError` if such a string is ever hashed (a `set`/`frozenset` member). See [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
226
227
  - 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.
227
228
  - 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).
@@ -207,14 +207,17 @@ pub(crate) fn numbers_equal(old: &Number, new: &Number) -> bool {
207
207
  }
208
208
  /// Compares two floats for exact equality.
209
209
  ///
210
- /// This mirrors Python's `==` semantics for floats (including
211
- /// `0.0 == -0.0`), with no implicit epsilon; NaN/Infinity are out of scope
212
- /// for this JSON value model, so exact IEEE-754 equality is the correct
210
+ /// This mirrors Python's `==` semantics for floats: `0.0 == -0.0`, and —
211
+ /// with no special-casing needed — `NaN != NaN` (matching real `DeepDiff`
212
+ /// for two independently-obtained `NaN` values; see `tests/golden/README.md`'s
213
+ /// "Non-finite floats" section for the one case this crate cannot
214
+ /// reproduce, where `DeepDiff` short-circuits on `t1 is t2`), no implicit
215
+ /// epsilon, and `Infinity == Infinity`. Exact IEEE-754 `==` is the correct
213
216
  /// (and only) rule here.
214
217
  fn floats_equal(a: f64, b: f64) -> bool {
215
218
  #[allow(
216
219
  clippy::float_cmp,
217
- reason = "exact IEEE-754 equality is the intended rule (Python == semantics, including 0.0 == -0.0); NaN/Infinity are out of scope for this JSON value model"
220
+ reason = "exact IEEE-754 equality is the intended rule (Python == semantics, including 0.0 == -0.0 and NaN != NaN)"
218
221
  )]
219
222
  {
220
223
  a == b
@@ -519,7 +519,7 @@ fn coerce_for_type_change(old_value: &Value, new_value: &Value) -> Option<Value>
519
519
  match new_value {
520
520
  Value::Bool(_) => Some(Value::Bool(is_truthy(old_value))),
521
521
  Value::Number(n) if n.is_f64() => {
522
- coerce_to_f64(old_value).and_then(|f| Number::from_f64(f).map(Value::Number))
522
+ coerce_to_f64(old_value).map(|f| Value::Number(Number::from_f64(f)))
523
523
  }
524
524
  Value::Number(_) => coerce_to_i64(old_value).map(|i| Value::Number(Number::from_i64(i))),
525
525
  Value::Str(_) => coerce_to_python_str(old_value).map(|s| Value::Str(s.into_boxed_str())),
@@ -59,6 +59,14 @@ pub(crate) type HashSet<T> = std::collections::HashSet<T, BuildHasherDefault<FxH
59
59
  /// hence `mix_float_bits`, so the hazard does not reach the new key either — a
60
60
  /// *non-adversarial* run of integral/half-integer floats, whose raw bit
61
61
  /// patterns share ~50 trailing zeros, does not accidentally collide.
62
+ /// `ItemKey::Float` additionally maps *every* `NaN`, regardless of its own
63
+ /// bits, onto one fixed representative (`deephash_float_bits`, matching
64
+ /// `DeepHash`'s `str(obj)`-based digest — see that function's own doc): a
65
+ /// deliberate full key collapse, not merely a hash collision, so a run of
66
+ /// `NaN`s in one of these tables costs *less* than the equivalent run of
67
+ /// distinct floats would (they fold to a single entry), not more — this
68
+ /// changes which values these tables treat as the same item, not their
69
+ /// per-lookup cost.
62
70
  ///
63
71
  /// For those remaining `ignore_order`-only tables the trade is deliberate and
64
72
  /// measured. Re-keying them to `SipHash` (`RandomState`) added a material,
@@ -189,9 +189,11 @@ pub(crate) enum ItemKey {
189
189
  /// `serde_json::Number`'s non-float representation is always an `i64`
190
190
  /// or `u64`, both of which fit losslessly in `i128`.
191
191
  Int(i128),
192
- /// A JSON value `serde_json` parsed as a float, keyed by its exact bit
193
- /// pattern — kept as its own bucket even when whole-numbered (`5.0`
194
- /// never collides with `Int(5)`; see this type's own doc).
192
+ /// A float, keyed by [`deephash_float_bits`] — its exact bit pattern for
193
+ /// any finite value (kept as its own bucket even when whole-numbered:
194
+ /// `5.0` never collides with `Int(5)`; see this type's own doc), but one
195
+ /// fixed representative for *every* `NaN` regardless of its bits, since
196
+ /// `DeepHash` digests any `NaN` the same way (see that function's doc).
195
197
  Float(u64),
196
198
  Str(String),
197
199
  /// A `datetime`, keyed by its instant with a naive value read as UTC —
@@ -678,15 +680,44 @@ fn scalar_content_key(value: &Value) -> ItemKey {
678
680
  }
679
681
  }
680
682
 
683
+ /// The bit pattern [`ItemKey::Float`] keys a float by:
684
+ /// [`crate::value::fold_signed_zero`] for every finite value (agreeing with
685
+ /// [`crate::value::Number`]'s `PartialEq` and `canonical_cmp`'s
686
+ /// `number_cmp`), and one fixed representative for *every* `NaN`,
687
+ /// regardless of its own sign or payload bits.
688
+ ///
689
+ /// This is deliberately **coarser** than [`crate::value::fold_signed_zero`]
690
+ /// (which leaves a `NaN`'s bits alone) and coarser than
691
+ /// [`crate::value::Number`]'s own `PartialEq` (under which no two `NaN`s are
692
+ /// ever equal): `DeepHash._prep_number`
693
+ /// builds its digest from `str(obj)` when `significant_digits` is unset (the
694
+ /// only mode this crate reproduces), and `str()` of *any* `NaN` — whatever
695
+ /// its bits — is the same three characters, `"nan"`. Collapsing every `NaN`
696
+ /// onto one [`ItemKey`] is what makes `onix`'s `ignore_order` item/member
697
+ /// matching agree with that: two list elements, dict values, or set members
698
+ /// that are each independently a `NaN` hash-match here exactly as they do
699
+ /// under real `DeepHash`, confirmed against `deepdiff==9.1.0` (`[nan_a]` vs
700
+ /// `[nan_b]`, two *distinct* `NaN` objects, is `{}` under `ignore_order`).
701
+ /// `Infinity`/`-Infinity` need no such collapse: `str(inf)` and `str(-inf)`
702
+ /// already differ, matching their (already distinct) bit patterns.
703
+ fn deephash_float_bits(f: f64) -> u64 {
704
+ if f.is_nan() {
705
+ f64::NAN.to_bits()
706
+ } else {
707
+ crate::value::fold_signed_zero(f).to_bits()
708
+ }
709
+ }
710
+
681
711
  /// The type-distinct key for a bare number, mirroring [`ItemKey`]'s own
682
- /// number rule (see [`crate::value::fold_signed_zero`] for the signed-zero
683
- /// note).
712
+ /// number rule (see [`deephash_float_bits`] for the float rule, including
713
+ /// its `NaN` collapse, and [`crate::value::fold_signed_zero`] for the
714
+ /// signed-zero note).
684
715
  fn number_key(n: &crate::value::Number) -> ItemKey {
685
716
  if n.is_f64() {
686
717
  let f = n
687
718
  .as_f64()
688
719
  .expect("Number::is_f64 guarantees as_f64 succeeds");
689
- return ItemKey::Float(crate::value::fold_signed_zero(f).to_bits());
720
+ return ItemKey::Float(deephash_float_bits(f));
690
721
  }
691
722
  if let Some(i) = n.as_i64() {
692
723
  return ItemKey::Int(i128::from(i));
@@ -742,13 +773,14 @@ fn keyed(value: &Value, memo: &IgnoreOrderMemo, want_part: bool) -> (ItemKey, Op
742
773
  let f = n
743
774
  .as_f64()
744
775
  .expect("Number::is_f64 guarantees as_f64 succeeds");
745
- // See `crate::value::fold_signed_zero`: it is the identity on
746
- // every float but `-0.0`, so an integral float like `2.0`
776
+ // See `deephash_float_bits`: it is the identity on every
777
+ // finite float but `-0.0`, so an integral float like `2.0`
747
778
  // keeps a distinct `Float` key from the integer `2` (this
748
779
  // deliberately does NOT take the ordered path's `ScalarKey`
749
780
  // integral-to-`Int` canonicalization — the two paths have
750
- // genuinely different number semantics).
751
- ItemKey::Float(crate::value::fold_signed_zero(f).to_bits())
781
+ // genuinely different number semantics), and it collapses
782
+ // every `NaN` onto one shared key regardless of bits.
783
+ ItemKey::Float(deephash_float_bits(f))
752
784
  } else if let Some(i) = n.as_i64() {
753
785
  ItemKey::Int(i128::from(i))
754
786
  } else {
@@ -177,12 +177,14 @@ pub(crate) struct IgnoreOrderMemo {
177
177
  node_table: RefCell<BTreeMap<MemberHashKey, (NodeId, RepId)>>,
178
178
  /// Set-member content interning: each distinct [`MemberContent`] gets one
179
179
  /// [`RepId`] (its `usize` index), so content-equal members — a naive and an
180
- /// aware datetime at one instant included — collapse to one id. A
181
- /// [`BTreeMap`] for the same collision-immunity reason as `node_table`;
182
- /// per-lookup cost is `O(log n)` comparisons, each a full walk of the
183
- /// probed `MemberContent` — a `MemberContent::UnhashableDict` key is
184
- /// itself keyed by each of the dict's own keys' `ItemKey` trees (a
185
- /// `tuple` dict key included), not a cheap string ordering.
180
+ /// aware datetime at one instant, and (via the [`ItemKey::Float`] a
181
+ /// `MemberContent::Scalar` embeds) every `NaN` regardless of its own
182
+ /// bits — collapse to one id. A [`BTreeMap`] for the same
183
+ /// collision-immunity reason as `node_table`; per-lookup cost is
184
+ /// `O(log n)` comparisons, each a full walk of the probed
185
+ /// `MemberContent` — a `MemberContent::UnhashableDict` key is itself
186
+ /// keyed by each of the dict's own keys' `ItemKey` trees (a `tuple`
187
+ /// dict key included), not a cheap string ordering.
186
188
  member_content: RefCell<BTreeMap<MemberContent, RepId>>,
187
189
  enabled: bool,
188
190
  /// Total number of times [`Self::put`] has actually run — every distance
@@ -3065,7 +3065,7 @@ fn dist_hash(value: &CValue) -> u64 {
3065
3065
  /// aware pair shifted by its offset to the same moment).
3066
3066
  #[test]
3067
3067
  fn dist_key_hash_agrees_with_equality_on_tricky_equal_values() {
3068
- let float = |f: f64| CValue::Number(crate::value::Number::from_f64(f).unwrap());
3068
+ let float = |f: f64| CValue::Number(crate::value::Number::from_f64(f));
3069
3069
  let nested_set = |order: [i64; 3]| {
3070
3070
  CValue::Set(SetItems::new(vec![
3071
3071
  ctup(&[json!(order[0])]),
@@ -3116,6 +3116,37 @@ fn dist_key_hash_agrees_with_equality_on_tricky_equal_values() {
3116
3116
  }
3117
3117
  }
3118
3118
 
3119
+ /// `NaN` cannot appear among the pairs above — no two `NaN`s are ever
3120
+ /// `Value`-equal (`NaN != NaN`, matching Python), so there is no equal-values
3121
+ /// case to add. What NEEDS pinning instead is the deliberately *coarser*
3122
+ /// hash: `DistKey`'s `Hash` collapses every `NaN` bit pattern onto one bucket
3123
+ /// (`crate::ignore_order::hash::number_key`, matching `DeepHash`'s own
3124
+ /// `NaN`-insensitive digest — see that function's own doc), so two
3125
+ /// genuinely distinct, never-`Value`-equal `NaN`s hash *equal* here — the
3126
+ /// opposite direction from the property above, and safe only because a hash
3127
+ /// collision is not itself a lookup match: `DistKey`'s `Eq` is `Value`'s
3128
+ /// real equality, and it must still tell the two apart, or the memo would
3129
+ /// silently hand one `NaN`'s cached distance to the other.
3130
+ #[test]
3131
+ fn dist_key_hash_collision_on_distinct_nans_never_becomes_equality() {
3132
+ let nan_a = CValue::Number(crate::value::Number::from_f64(f64::NAN));
3133
+ let nan_b = CValue::Number(crate::value::Number::from_f64(-f64::NAN));
3134
+
3135
+ assert_ne!(nan_a, nan_b, "two NaN values are never Value-equal");
3136
+ assert_eq!(
3137
+ dist_hash(&nan_a),
3138
+ dist_hash(&nan_b),
3139
+ "NaN's dist-key hash deliberately ignores sign/payload"
3140
+ );
3141
+
3142
+ let key_a = super::hash::DistKey::new(&nan_a);
3143
+ let key_b = super::hash::DistKey::new(&nan_b);
3144
+ assert!(
3145
+ key_a != key_b,
3146
+ "a hash collision must not make DistKey::eq treat two distinct NaNs as the same cache entry"
3147
+ );
3148
+ }
3149
+
3119
3150
  /// An `arbitrary` compact value covering every equality class the distance-key
3120
3151
  /// hash must respect — including the ones JSON cannot express, so they are
3121
3152
  /// actually generated: tuples, sets, frozensets, datetimes (naive and aware),
@@ -3137,15 +3168,28 @@ fn arb_cvalue() -> impl Strategy<Value = CValue> {
3137
3168
  )
3138
3169
  .prop_map(|(y, mo, d, h, mi, s, off)| cdt_at(y, mo, d, h, mi, s, 0, off));
3139
3170
  let arb_date = (2000i32..2025, 1u8..=12, 1u8..=28).prop_map(|(y, m, d)| cdate(y, m, d));
3171
+ // `NaN` is excluded, not `any::<f64>()`'s non-finite values generally:
3172
+ // `Infinity`/`-Infinity` are ordinary equal-to-themselves floats and
3173
+ // `structural_twin` leaves them untouched (only a signed zero gets
3174
+ // perturbed), so they exercise this property harmlessly. A `NaN` cannot:
3175
+ // `Value::eq` never calls two `NaN`s equal (matching Python's
3176
+ // `nan != nan`), so `prop_assert_eq!(&value, &twin)` below would fail on
3177
+ // any tree containing one, structural twin or not — this proptest is
3178
+ // about the hash/equality *agreement*, which a `NaN` leaf has no
3179
+ // meaningful instance of (see
3180
+ // `dist_key_hash_collision_on_distinct_nans_never_becomes_equality` for
3181
+ // the coverage a `NaN` does need).
3140
3182
  let arb_float = prop_oneof![
3141
3183
  Just(0.0f64),
3142
3184
  Just(-0.0f64),
3143
3185
  Just(1.0f64),
3144
3186
  Just(2.0f64),
3187
+ Just(f64::INFINITY),
3188
+ Just(f64::NEG_INFINITY),
3145
3189
  any::<f64>(),
3146
3190
  ]
3147
- .prop_filter_map("finite floats only", |f| {
3148
- crate::value::Number::from_f64(f).map(CValue::Number)
3191
+ .prop_filter_map("not NaN", |f| {
3192
+ (!f.is_nan()).then(|| CValue::Number(crate::value::Number::from_f64(f)))
3149
3193
  });
3150
3194
  let leaf = prop_oneof![
3151
3195
  Just(CValue::Null),
@@ -3283,9 +3327,7 @@ fn structural_twin(value: &CValue, in_set: bool) -> CValue {
3283
3327
  CValue::Number(n) if n.is_f64() => {
3284
3328
  let f = n.as_f64().expect("is_f64 guarantees as_f64");
3285
3329
  let flipped = if f == 0.0 { -f } else { f };
3286
- CValue::Number(
3287
- crate::value::Number::from_f64(flipped).expect("finite float stays finite"),
3288
- )
3330
+ CValue::Number(crate::value::Number::from_f64(flipped))
3289
3331
  }
3290
3332
  scalar => scalar.clone(),
3291
3333
  }
@@ -92,6 +92,9 @@ pub(crate) enum ScalarKey {
92
92
  /// Bit pattern of a non-integral (or too-large-to-be-exact) float —
93
93
  /// hashed through [`mix_float_bits`]; see this type's hand-written `Hash`.
94
94
  Float(u64),
95
+ /// A `NaN`, keyed by the address of the [`Value`] node it was read from
96
+ /// — see [`python_scalar_key`]'s `NaN` case for why.
97
+ Nan(usize),
95
98
  /// A `datetime`, keyed by whether it is aware and by its instant —
96
99
  /// Python's own `datetime.__eq__`/`__hash__` pair, which compares two
97
100
  /// aware values by instant (so `10:00+00:00 == 12:00+02:00`) but never
@@ -150,6 +153,13 @@ impl std::hash::Hash for ScalarKey {
150
153
  Self::Str(s) => s.hash(state),
151
154
  Self::Int(i) => i.hash(state),
152
155
  Self::Float(bits) => mix_float_bits(*bits).hash(state),
156
+ // A `Value` node's address is 8/16-byte-aligned like any other
157
+ // pointer, so its low bits carry no entropy; avalanche it the
158
+ // same way as a trailing-zero-heavy float bit pattern (see
159
+ // `mix_float_bits`'s own doc) rather than trust the raw address.
160
+ // `usize as u64` never truncates: `u64` is at least as wide as
161
+ // `usize` on every target this crate builds for.
162
+ Self::Nan(id) => mix_float_bits(*id as u64).hash(state),
153
163
  Self::DateTime { aware, instant } | Self::Time { aware, instant } => {
154
164
  aware.hash(state);
155
165
  instant.hash(state);
@@ -219,6 +229,26 @@ fn scalar_key(value: &Value) -> ScalarKey {
219
229
  /// `==`; `crate::ignore_order` needs the same rule twice more, for
220
230
  /// `DeepHash`'s cache identity and for the `list(t1) == t2` coercion test,
221
231
  /// and shares this one rather than restating it.
232
+ ///
233
+ /// A `NaN` gets [`ScalarKey::Nan`], keyed by `value`'s own address, because
234
+ /// no bit-pattern-based key could be right here: `NaN != NaN` in Python
235
+ /// regardless of bits, confirmed against `deepdiff==9.1.0` — two distinct
236
+ /// `NaN` objects with the *same* bits still fail `difflib`'s `==`-based
237
+ /// match (`[1, nan_a, 2]` vs `[1, 2, nan_b]` reports a `type_changes` at the
238
+ /// `nan`/`2` positions rather than treating the insertion as a clean shift,
239
+ /// which a bit-based key that let `nan_a` match `nan_b` would get wrong).
240
+ /// Each call therefore has to hand back a key that cannot equal *any* other
241
+ /// call's — including another `NaN` read from the exact same bits — and an
242
+ /// address is the only per-call-distinct, already-available `usize` this
243
+ /// function has: `value` is borrowed from a [`Value`] tree that outlives the
244
+ /// whole comparison this key feeds, so the address is stable for exactly as
245
+ /// long as the key needs to be looked up, and (being a fresh allocation per
246
+ /// converted Python object) two different `NaN` occurrences can never share
247
+ /// one. See `tests/golden/README.md`'s "Non-finite floats" section for the
248
+ /// one case this cannot reproduce (the *same* Python `NaN` object compared
249
+ /// or hashed against itself, which real `DeepDiff`/`CPython` sometimes treat
250
+ /// as matching via object identity — a concept this crate's value model
251
+ /// does not carry).
222
252
  pub(crate) fn python_scalar_key(value: &Value) -> Option<ScalarKey> {
223
253
  Some(match value {
224
254
  Value::Null => ScalarKey::Null,
@@ -233,6 +263,9 @@ pub(crate) fn python_scalar_key(value: &Value) -> Option<ScalarKey> {
233
263
  return Some(ScalarKey::Int(i));
234
264
  }
235
265
  let f = n.as_f64().expect("a serde_json Number is i64, u64, or f64");
266
+ if f.is_nan() {
267
+ return Some(ScalarKey::Nan(std::ptr::from_ref(value) as usize));
268
+ }
236
269
  if f.fract() == 0.0 && f.abs() <= MAX_EXACT_F64_INT {
237
270
  #[allow(
238
271
  clippy::cast_possible_truncation,
@@ -104,6 +104,67 @@ fn non_integral_floats_compare_by_value() {
104
104
  assert!(!python_scalar_eq(&json!(1.5), &json!(2.5)));
105
105
  }
106
106
 
107
+ // `NaN` cannot arrive via `json!`/`cv` (`serde_json::Number` is finite by
108
+ // construction), so these build the compact `Value` directly rather than
109
+ // through the `serde_json`-literal wrapper the tests above share.
110
+ #[test]
111
+ fn two_distinct_nans_are_never_python_equal() {
112
+ use crate::value::{Number, Value};
113
+
114
+ let a = Value::Number(Number::from_f64(f64::NAN));
115
+ let b = Value::Number(Number::from_f64(f64::NAN));
116
+ assert_ne!(
117
+ super::python_scalar_key(&a),
118
+ super::python_scalar_key(&b),
119
+ "two independently-built NaN values must never share a ScalarKey"
120
+ );
121
+ }
122
+
123
+ #[test]
124
+ fn the_same_nan_value_reference_yields_the_same_scalar_key() {
125
+ use crate::value::{Number, Value};
126
+
127
+ let a = Value::Number(Number::from_f64(f64::NAN));
128
+ assert_eq!(
129
+ super::python_scalar_key(&a),
130
+ super::python_scalar_key(&a),
131
+ "hashing/keying the same Value node twice must agree with itself"
132
+ );
133
+ }
134
+
135
+ #[test]
136
+ fn nan_and_infinity_never_share_a_scalar_key() {
137
+ use crate::value::{Number, Value};
138
+
139
+ let nan = Value::Number(Number::from_f64(f64::NAN));
140
+ let inf = Value::Number(Number::from_f64(f64::INFINITY));
141
+ assert_ne!(
142
+ super::python_scalar_key(&nan),
143
+ super::python_scalar_key(&inf)
144
+ );
145
+ }
146
+
147
+ #[test]
148
+ fn scalar_key_nan_hashes_and_is_usable_as_a_hashmap_key() {
149
+ use std::collections::HashMap;
150
+
151
+ use crate::value::{Number, Value};
152
+
153
+ let a = Value::Number(Number::from_f64(f64::NAN));
154
+ let b = Value::Number(Number::from_f64(f64::NAN));
155
+ let key_a = super::python_scalar_key(&a).expect("NaN is a scalar");
156
+ let key_b = super::python_scalar_key(&b).expect("NaN is a scalar");
157
+
158
+ let mut map: HashMap<super::ScalarKey, &str> = HashMap::new();
159
+ map.insert(key_a.clone(), "a");
160
+ map.insert(key_b.clone(), "b");
161
+ // Two distinct NaNs never collapse to one entry, matching Python's own
162
+ // `nan != nan` (see `super::ScalarKey::Nan`'s own doc).
163
+ assert_eq!(map.len(), 2);
164
+ assert_eq!(map.get(&key_a), Some(&"a"));
165
+ assert_eq!(map.get(&key_b), Some(&"b"));
166
+ }
167
+
107
168
  // --- compute_opcodes -------------------------------------------------
108
169
 
109
170
  fn vals(items: &[i64]) -> Vec<serde_json::Value> {
@@ -601,6 +601,17 @@ fn escape_non_printable(out: &mut String, c: char) {
601
601
  /// is involved. Verified against real Python `repr()` over a million random
602
602
  /// bit patterns in the bindings suite.
603
603
  pub(crate) fn python_float_repr(value: f64) -> String {
604
+ if !value.is_finite() {
605
+ // `{:e}` has no exponent form for these, so the digit-count logic
606
+ // below (which expects an `e` separator) never applies to them.
607
+ return if value.is_nan() {
608
+ "nan".to_string()
609
+ } else if value.is_sign_positive() {
610
+ "inf".to_string()
611
+ } else {
612
+ "-inf".to_string()
613
+ };
614
+ }
604
615
  let shortest = format!("{value:e}");
605
616
  let significant = shortest
606
617
  .split_once('e')
@@ -879,7 +890,7 @@ mod tests {
879
890
  ];
880
891
  for (input, expected) in cases {
881
892
  assert_eq!(
882
- set_item_repr(&Value::Number(Number::from_f64(input).expect("finite"))),
893
+ set_item_repr(&Value::Number(Number::from_f64(input))),
883
894
  expected,
884
895
  "for {input:?}"
885
896
  );
@@ -1135,7 +1146,7 @@ mod tests {
1135
1146
  ];
1136
1147
  for (input, expected) in cases {
1137
1148
  assert_eq!(
1138
- set_item_repr(&Value::Number(Number::from_f64(input).expect("finite"))),
1149
+ set_item_repr(&Value::Number(Number::from_f64(input))),
1139
1150
  expected,
1140
1151
  "for {input:?}"
1141
1152
  );
@@ -109,6 +109,6 @@ pub(crate) fn cnum(n: &serde_json::Number) -> Number {
109
109
  } else if let Some(i) = n.as_i64() {
110
110
  Number::from_i64(i)
111
111
  } else {
112
- Number::from_f64(n.as_f64().expect("serde Number is u64/i64/f64")).expect("finite")
112
+ Number::from_f64(n.as_f64().expect("serde Number is u64/i64/f64"))
113
113
  }
114
114
  }