deepdiff-rs 0.5.3__tar.gz → 0.5.5__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 (58) hide show
  1. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/Cargo.lock +3 -3
  2. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/Cargo.toml +1 -1
  3. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/PKG-INFO +1 -1
  4. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/ignore_order/memo.rs +26 -0
  5. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/ignore_order/tests.rs +120 -119
  6. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/README.md +0 -0
  7. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/Cargo.toml +0 -0
  8. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/examples/stack_frame_cost.rs +0 -0
  9. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/datetime.rs +0 -0
  10. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/datetime_tests.rs +0 -0
  11. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/diff/array.rs +0 -0
  12. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/diff/dispatch.rs +0 -0
  13. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/diff/mod.rs +0 -0
  14. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/diff/object.rs +0 -0
  15. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/diff/options.rs +0 -0
  16. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/diff/scalar.rs +0 -0
  17. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/diff/set.rs +0 -0
  18. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/diff/tests.rs +0 -0
  19. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/error.rs +0 -0
  20. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/ignore_order/distance.rs +0 -0
  21. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/ignore_order/fxhash.rs +0 -0
  22. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/ignore_order/hash.rs +0 -0
  23. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/ignore_order/mod.rs +0 -0
  24. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/ignore_order/pairing.rs +0 -0
  25. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/lcs.rs +0 -0
  26. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/lcs_tests.rs +0 -0
  27. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/lib.rs +0 -0
  28. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/path.rs +0 -0
  29. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/report.rs +0 -0
  30. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/report_tests.rs +0 -0
  31. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/test_support.rs +0 -0
  32. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/value.rs +0 -0
  33. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/src/value_tests.rs +0 -0
  34. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/tests/golden.rs +0 -0
  35. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/tests/ignore_order_memory.rs +0 -0
  36. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/tests/memory_footprint.rs +0 -0
  37. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/tests/proptest_diff.rs +0 -0
  38. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-core/tests/proptest_ignore_order.rs +0 -0
  39. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/Cargo.toml +0 -0
  40. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/benchmarks/bench_bindings.py +0 -0
  41. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/src/convert.rs +0 -0
  42. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/src/deepdiff.rs +0 -0
  43. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/src/errors.rs +0 -0
  44. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/src/fast_path.rs +0 -0
  45. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/src/guard.rs +0 -0
  46. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/src/lib.rs +0 -0
  47. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/tests/test_bindings_memory.py +0 -0
  48. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/tests/test_conversions.py +0 -0
  49. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/tests/test_datetimes.py +0 -0
  50. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/tests/test_depth_guard.py +0 -0
  51. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/tests/test_differential_fuzz.py +0 -0
  52. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/tests/test_golden_parity.py +0 -0
  53. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/tests/test_sets.py +0 -0
  54. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/tests/test_signed_zero.py +0 -0
  55. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/tests/test_smoke.py +0 -0
  56. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/tests/test_suite_hygiene.py +0 -0
  57. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/crates/onix-py/tests/test_tuples.py +0 -0
  58. {deepdiff_rs-0.5.3 → deepdiff_rs-0.5.5}/pyproject.toml +0 -0
@@ -127,7 +127,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
127
127
 
128
128
  [[package]]
129
129
  name = "onix-cli"
130
- version = "0.5.3"
130
+ version = "0.5.5"
131
131
  dependencies = [
132
132
  "onix-core",
133
133
  "serde_json",
@@ -135,7 +135,7 @@ dependencies = [
135
135
 
136
136
  [[package]]
137
137
  name = "onix-core"
138
- version = "0.5.3"
138
+ version = "0.5.5"
139
139
  dependencies = [
140
140
  "proptest",
141
141
  "serde",
@@ -145,7 +145,7 @@ dependencies = [
145
145
 
146
146
  [[package]]
147
147
  name = "onix-py"
148
- version = "0.5.3"
148
+ version = "0.5.5"
149
149
  dependencies = [
150
150
  "onix-core",
151
151
  "pyo3",
@@ -3,7 +3,7 @@ resolver = "3"
3
3
  members = ["crates/onix-core", "crates/onix-py"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.5.3"
6
+ version = "0.5.5"
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.5.3
3
+ Version: 0.5.5
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -179,6 +179,15 @@ pub(crate) struct IgnoreOrderMemo {
179
179
  /// [`BTreeMap`] for the same collision-immunity reason as `node_table`.
180
180
  member_content: RefCell<BTreeMap<MemberContent, RepId>>,
181
181
  enabled: bool,
182
+ /// Total number of times [`Self::put`] has actually run — every distance
183
+ /// *recomputation*, not just the distinct entries it leaves behind (a
184
+ /// repeated `put` for a key already in `cache` overwrites the entry
185
+ /// rather than growing [`Self::cache_len`], so this is the only signal
186
+ /// that would rise if a caller re-derived a distance it should have
187
+ /// gotten from [`Self::get`] instead). Test-only: see
188
+ /// [`Self::put_count`]'s own doc for what it guards.
189
+ #[cfg(test)]
190
+ puts: std::cell::Cell<usize>,
182
191
  }
183
192
 
184
193
  impl IgnoreOrderMemo {
@@ -191,6 +200,8 @@ impl IgnoreOrderMemo {
191
200
  node_table: RefCell::new(BTreeMap::new()),
192
201
  member_content: RefCell::new(BTreeMap::new()),
193
202
  enabled: true,
203
+ #[cfg(test)]
204
+ puts: std::cell::Cell::new(0),
194
205
  }
195
206
  }
196
207
 
@@ -206,6 +217,7 @@ impl IgnoreOrderMemo {
206
217
  node_table: RefCell::new(BTreeMap::new()),
207
218
  member_content: RefCell::new(BTreeMap::new()),
208
219
  enabled: false,
220
+ puts: std::cell::Cell::new(0),
209
221
  }
210
222
  }
211
223
 
@@ -229,6 +241,18 @@ impl IgnoreOrderMemo {
229
241
  self.cache.borrow().len()
230
242
  }
231
243
 
244
+ /// The number of times [`Self::put`] has run — see that counter's own
245
+ /// field doc. A deep, self-similar nesting whose distance is computed
246
+ /// through this cache must see this count grow *linearly* with depth: a
247
+ /// cache whose lookup is broken (every candidate pair is recomputed
248
+ /// rather than reused) instead compounds `~2x` per level, which this
249
+ /// deterministically catches without timing anything — see
250
+ /// `super::tests::deep_nested_ignore_order_memoizes_distance_computations_linearly`.
251
+ #[cfg(test)]
252
+ pub(crate) fn put_count(&self) -> usize {
253
+ self.puts.get()
254
+ }
255
+
232
256
  /// The cached distance for `key`, if present.
233
257
  pub(crate) fn get(&self, key: &DistanceKey) -> Option<f64> {
234
258
  self.cache.borrow().get(key).copied()
@@ -236,6 +260,8 @@ impl IgnoreOrderMemo {
236
260
 
237
261
  /// Records `value` for `key` (moving the already-cloned key in).
238
262
  pub(crate) fn put(&self, key: DistanceKey, value: f64) {
263
+ #[cfg(test)]
264
+ self.puts.set(self.puts.get() + 1);
239
265
  self.cache.borrow_mut().insert(key, value);
240
266
  }
241
267
 
@@ -1332,15 +1332,25 @@ proptest! {
1332
1332
  }
1333
1333
  }
1334
1334
 
1335
- #[test]
1336
- fn deep_nested_ignore_order_completes_quickly_with_memoization() {
1337
- use std::time::Instant;
1338
-
1339
- // A single-element nested list of depth `d` is `d` nodes — tiny, legal
1340
- // input. Unmemoized, `ignore_order` pairing re-diffs each level twice
1341
- // (once to score the pair's distance, once to record it), compounding to
1342
- // `~2x` cost per level: this used to take ~1-2s at depth 20 and hang for
1343
- // tens of seconds by depth 25. The distance memo collapses it to linear.
1335
+ /// A memoized deep-nested `ignore_order` diff must recompute each level's
1336
+ /// pairing distance exactly once — replaces a wall-clock guard that was
1337
+ /// flaky under parallel CI (issue #33). Measured directly instead, with no
1338
+ /// clock in the loop:
1339
+ ///
1340
+ /// [`super::pairing::compute_pairs`] recomputes a container pair's distance
1341
+ /// only on an [`IgnoreOrderMemo`] cache miss (see [`IgnoreOrderMemo::put`]'s
1342
+ /// field doc), so [`IgnoreOrderMemo::put_count`] — every recomputation, not
1343
+ /// just the distinct entries a repeated `put` leaves behind — is a direct,
1344
+ /// deterministic stand-in for the "re-diffs each level twice, compounding
1345
+ /// `~2x` per level" cost the timing bound used to catch. A single-element
1346
+ /// nested list of depth `d` has exactly `d - 1` container-pair candidates
1347
+ /// (the outermost wrapper is never itself paired against anything), so a
1348
+ /// working memo leaves `put_count() == depth - 1`; confirmed by temporarily
1349
+ /// forcing [`IgnoreOrderMemo::get`] to always return `None` and re-running
1350
+ /// this test, which then reported `2^(depth - 1) - 1` puts (8,191 at depth
1351
+ /// 14 alone, where the working memo leaves 13) instead of failing on a clock.
1352
+ #[test]
1353
+ fn deep_nested_ignore_order_memoizes_distance_computations_linearly() {
1344
1354
  let opts = DiffOptions {
1345
1355
  ignore_order: true,
1346
1356
  max_depth: 100_000,
@@ -1353,28 +1363,31 @@ fn deep_nested_ignore_order_completes_quickly_with_memoization() {
1353
1363
  crate::value::Value::from(value)
1354
1364
  };
1355
1365
 
1356
- let (a, b) = (build(20, 1), build(20, 2));
1357
- let started = Instant::now();
1358
- let report = crate::diff::diff_with_options(&a, &b, &opts).expect("depth 20 diffs");
1359
- let depth_20 = started.elapsed();
1360
- assert!(
1361
- !report.is_empty(),
1362
- "depth-20 unequal input must report a change"
1363
- );
1364
- assert!(
1365
- depth_20.as_millis() < 500,
1366
- "depth-20 nested ignore_order took {depth_20:?}, over the 500ms bound"
1367
- );
1366
+ let recomputations_at = |depth: usize| -> usize {
1367
+ let (a, b) = (build(depth, 1), build(depth, 2));
1368
+ let memo = IgnoreOrderMemo::new();
1369
+ let report = crate::diff::diff_with_options_memo(&a, &b, &opts, &memo)
1370
+ .unwrap_or_else(|e| panic!("depth-{depth} diffs: {e}"));
1371
+ assert!(
1372
+ !report.is_empty(),
1373
+ "depth-{depth} unequal input must report a change"
1374
+ );
1375
+ memo.put_count()
1376
+ };
1368
1377
 
1369
- // Depth 25 previously hung (>30s); it must now complete at all.
1370
- let (a, b) = (build(25, 1), build(25, 2));
1371
- let started = Instant::now();
1372
- let _ = crate::diff::diff_with_options(&a, &b, &opts).expect("depth 25 diffs");
1373
- let depth_25 = started.elapsed();
1374
- assert!(
1375
- depth_25.as_secs() < 5,
1376
- "depth-25 nested ignore_order took {depth_25:?}, over the 5s bound"
1377
- );
1378
+ // Depth 25 previously hung for tens of seconds unmemoized; both depths
1379
+ // are kept so a regression shows up well before it would need to.
1380
+ for depth in [20usize, 25] {
1381
+ let puts = recomputations_at(depth);
1382
+ assert_eq!(
1383
+ puts,
1384
+ depth - 1,
1385
+ "depth-{depth} nested ignore_order recomputed {puts} distances, \
1386
+ expected exactly {} (one per level) — the distance memo is not \
1387
+ being hit",
1388
+ depth - 1
1389
+ );
1390
+ }
1378
1391
  }
1379
1392
 
1380
1393
  // --- tuples under ignore_order -------------------------------------------
@@ -2487,100 +2500,88 @@ fn a_deeply_nested_set_member_hashes_and_compares_without_native_recursion() {
2487
2500
  .expect("set-member hashing and comparison complete on a small stack");
2488
2501
  }
2489
2502
 
2490
- /// Interning `K` set members must stay near linear in `K`, never quadratic.
2491
- ///
2492
- /// - **Float bit-pattern collision (`SetFloat*`, `ListFloat`) — the runtime
2493
- /// guard.** A float carrying an integer or half-integer has ~50 trailing zero
2494
- /// bits; on the `FxHash` tables the crate keeps (e.g. `HashedList` for an
2495
- /// `ignore_order` list), a run of them collides unless the float bits are
2496
- /// mixed first ([`crate::lcs::mix_float_bits`]). Reverting the mixing turns
2497
- /// the float rows here red, so they genuinely guard it.
2498
- /// - **Benign near-linearity (`SetIntPair`).** A run of plain int 2-tuples
2499
- /// exercises the set-member tables on the *shape* of the crafted
2500
- /// hash-flooding attack, but does **not** stand in for the attack: sequential
2501
- /// ints do not collide under `FxHash`, so this row stays green even if the
2502
- /// tables were reverted to `FxHash`. The adversarial hazard — the tables are
2503
- /// keyed by attacker-controlled content and reached with the default
2504
- /// `ignore_order=false` — is guarded at the **type level** instead: the tables
2505
- /// are [`BTreeMap`]s, and [`super::hash::MemberHashKey`]/
2506
- /// [`super::hash::MemberContent`] no longer derive `Hash`, so putting them
2507
- /// back on an `FxHash` map fails to compile (`E0599`). This row is a plain
2508
- /// regression check that the `BTreeMap` path itself scales.
2503
+ /// Interning `K` set/list members must never collapse onto one hash bucket —
2504
+ /// replaces a wall-clock `K -> 2K` diff-time ratio that was flaky under
2505
+ /// parallel CI (issue #33). Measured directly instead, with no diff and no
2506
+ /// clock in the loop:
2509
2507
  ///
2510
- /// Each asserts the `K -> 2K` diff-time ratio stays under `3.0` — a linear (or
2511
- /// `n log n`) pass is `~2x`, a quadratic one `~4x`. Sized to run well under a
2512
- /// second.
2513
- #[test]
2514
- fn set_and_list_member_interning_scales_near_linearly() {
2515
- use crate::value::Number;
2516
-
2517
- #[derive(Clone, Copy)]
2518
- enum Shape {
2519
- SetIntFloat,
2520
- SetHalfFloat,
2521
- SetIntPair,
2522
- ListFloat,
2523
- }
2524
-
2525
- let f = |x: f64| CValue::Number(Number::from_f64(x).expect("finite"));
2526
- let i = |n: i64| CValue::Number(Number::from_i64(n));
2527
-
2528
- let build = |k: usize, shape: Shape| -> (CValue, CValue, DiffOptions) {
2529
- #[allow(clippy::cast_precision_loss)]
2530
- let member = |n: usize| -> CValue {
2531
- let n_i = i64::try_from(n).expect("test sizes fit i64");
2532
- match shape {
2533
- Shape::SetIntFloat => CValue::Tuple(vec![f(n as f64)].into_boxed_slice()),
2534
- Shape::SetHalfFloat => CValue::Tuple(vec![f(n as f64 + 0.5)].into_boxed_slice()),
2535
- Shape::SetIntPair => CValue::Tuple(vec![i(n_i), i(n_i)].into_boxed_slice()),
2536
- Shape::ListFloat => f(n as f64),
2537
- }
2538
- };
2539
- let side = |extra: bool| {
2540
- let mut items: Vec<CValue> = (0..k).map(member).collect();
2541
- if extra {
2542
- items.push(CValue::Str("sentinel".to_string().into_boxed_str()));
2543
- }
2544
- match shape {
2545
- Shape::ListFloat => CValue::Array(items.into_boxed_slice()),
2546
- _ => CValue::Set(SetItems::new(items)),
2547
- }
2548
- };
2549
- let opts = DiffOptions {
2550
- ignore_order: matches!(shape, Shape::ListFloat),
2551
- ..DiffOptions::default()
2552
- };
2553
- // Differ by one member so the whole-value fast path can't short-circuit.
2554
- (side(false), side(true), opts)
2555
- };
2508
+ /// [`super::hash::item_key`]'s `Float` arm hashes through
2509
+ /// [`crate::lcs::mix_float_bits`] before the bits ever reach one of this
2510
+ /// module's `FxHash` tables (e.g. [`super::hash::HashedList`], the table an
2511
+ /// `ignore_order` list's items are matched through) — an integral or
2512
+ /// half-integer float's raw bit pattern shares dozens of trailing zero bits
2513
+ /// over the range this test uses, and `hashbrown` picks a table's bucket
2514
+ /// from a hash's *low* bits (see `mix_float_bits`'s own doc), so an unmixed
2515
+ /// run of them would all name the same bucket and degrade every lookup to a
2516
+ /// linear scan. This hashes real [`super::hash::ItemKey`]s through the real
2517
+ /// [`FxHasher`] `HashedList` itself uses and checks the low 32 bits (a proxy
2518
+ /// wide enough that no realistic table capacity at these sizes reads outside
2519
+ /// it) of `K`, and separately `2K`, keys: with the mixing intact they land in
2520
+ /// (almost) pairwise-distinct buckets, so the distinct count grows linearly
2521
+ /// with `K`; reverting or breaking the mix collapses the whole run onto a
2522
+ /// handful of buckets regardless of `K`, which the linearity assertion below
2523
+ /// catches immediately (confirmed by temporarily reverting `hash.rs`'s
2524
+ /// `Float` arm to hash the raw bits and re-running this test, which then
2525
+ /// fails on both the distinctness and the growth checks).
2526
+ #[test]
2527
+ fn float_hash_buckets_stay_distinct_and_grow_linearly_with_member_count() {
2528
+ use std::collections::HashSet;
2529
+ use std::hash::{Hash, Hasher};
2556
2530
 
2557
- let best_diff = |k: usize, shape: Shape| -> f64 {
2558
- let (a, b, opts) = build(k, shape);
2559
- let _ = crate::diff::diff_with_options(&a, &b, &opts).expect("diffs cleanly"); // warm
2560
- (0..5)
2561
- .map(|_| {
2562
- let start = std::time::Instant::now();
2563
- let _ = crate::diff::diff_with_options(&a, &b, &opts).expect("diffs cleanly");
2564
- start.elapsed().as_secs_f64()
2565
- })
2566
- .fold(f64::INFINITY, f64::min)
2531
+ let low_bucket = |n: usize, half: bool| -> u32 {
2532
+ #[allow(
2533
+ clippy::cast_precision_loss,
2534
+ reason = "n stays well under 2^53 here; exactness is not the point of the probe"
2535
+ )]
2536
+ let value = n as f64 + if half { 0.5 } else { 0.0 };
2537
+ let key = item_key(&json!(value));
2538
+ let mut hasher = FxHasher::default();
2539
+ key.hash(&mut hasher);
2540
+ #[allow(
2541
+ clippy::cast_possible_truncation,
2542
+ reason = "only the low 32 bits are the bucket proxy under test"
2543
+ )]
2544
+ {
2545
+ hasher.finish() as u32
2546
+ }
2567
2547
  };
2568
2548
 
2569
- for (name, shape) in [
2570
- ("set int-float", Shape::SetIntFloat),
2571
- ("set half-float", Shape::SetHalfFloat),
2572
- ("set int-pair", Shape::SetIntPair),
2573
- ("ignore_order list float", Shape::ListFloat),
2574
- ] {
2549
+ for (name, half) in [("integral", false), ("half-integer", true)] {
2575
2550
  let k = 10_000;
2576
- let t1 = best_diff(k, shape);
2577
- let t2 = best_diff(2 * k, shape);
2578
- let ratio = t2 / t1;
2579
- assert!(
2580
- ratio < 3.0,
2581
- "{name}: K->2K ratio {ratio:.2} (t1={t1:.4}s t2={t2:.4}s) is super-linear — \
2582
- keys are colliding in their interning table"
2583
- );
2551
+ let distinct_count = |count: usize| -> usize {
2552
+ (0..count)
2553
+ .map(|n| low_bucket(n, half))
2554
+ .collect::<HashSet<_>>()
2555
+ .len()
2556
+ };
2557
+
2558
+ let distinct_1x = distinct_count(k);
2559
+ let distinct_2x = distinct_count(2 * k);
2560
+
2561
+ #[allow(
2562
+ clippy::cast_precision_loss,
2563
+ reason = "k is a small test constant; the ratio only needs two significant figures"
2564
+ )]
2565
+ {
2566
+ assert!(
2567
+ (distinct_1x as f64) > 0.99 * (k as f64),
2568
+ "{name} floats: only {distinct_1x}/{k} distinct hash buckets at K \
2569
+ — keys are colliding in their interning table"
2570
+ );
2571
+ assert!(
2572
+ (distinct_2x as f64) > 0.99 * (2.0 * k as f64),
2573
+ "{name} floats: only {distinct_2x}/{} distinct hash buckets at 2K \
2574
+ — keys are colliding in their interning table",
2575
+ 2 * k
2576
+ );
2577
+
2578
+ let ratio = distinct_2x as f64 / distinct_1x as f64;
2579
+ assert!(
2580
+ (1.9..=2.1).contains(&ratio),
2581
+ "{name} floats: distinct-bucket count did not grow linearly \
2582
+ with K (K->2K ratio {ratio:.3}, distinct_1x={distinct_1x}, distinct_2x={distinct_2x})"
2583
+ );
2584
+ }
2584
2585
  }
2585
2586
  }
2586
2587
 
File without changes
File without changes