From 11c11d5ff4287017ded38625306a0c13578130f0 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 11:07:56 +0200 Subject: [PATCH 01/23] test: add cache-parameterized comprehensive proptests Run the same randomized operation sequence at cache sizes 0, 1, 4, 16, 64, 256 to catch size-dependent correctness bugs. Each variant uses 3 proptest cases to keep total runtime reasonable. --- src/btreemap/proptests.rs | 56 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/btreemap/proptests.rs b/src/btreemap/proptests.rs index 523c0b4c..6cf57da3 100644 --- a/src/btreemap/proptests.rs +++ b/src/btreemap/proptests.rs @@ -81,6 +81,62 @@ fn comprehensive_cached(#[strategy(pvec(operation_strategy(), 100..5_000))] ops: } } +fn run_comprehensive(ops: Vec, cache_slots: usize) { + let mem = make_memory(); + let mut btree = BTreeMap::new(mem).with_node_cache(cache_slots); + let mut std_btree = StdBTreeMap::new(); + + for op in ops.into_iter() { + execute_operation(&mut std_btree, &mut btree, op); + } +} + +// Cache-parameterized variants: run the same comprehensive operation sequence +// at different cache sizes to catch size-dependent correctness bugs. +// Fewer cases per variant to keep total runtime reasonable. + +#[proptest(cases = 3)] +fn comprehensive_cache_0( + #[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec, +) { + run_comprehensive(ops, 0); +} + +#[proptest(cases = 3)] +fn comprehensive_cache_1( + #[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec, +) { + run_comprehensive(ops, 1); +} + +#[proptest(cases = 3)] +fn comprehensive_cache_4( + #[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec, +) { + run_comprehensive(ops, 4); +} + +#[proptest(cases = 3)] +fn comprehensive_cache_16( + #[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec, +) { + run_comprehensive(ops, 16); +} + +#[proptest(cases = 3)] +fn comprehensive_cache_64( + #[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec, +) { + run_comprehensive(ops, 64); +} + +#[proptest(cases = 3)] +fn comprehensive_cache_256( + #[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec, +) { + run_comprehensive(ops, 256); +} + // A comprehensive fuzz test that runs until it's explicitly terminated. To run: // // ``` From bda22812dcbaddd04a6c5c2779fac193667457a5 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 11:09:09 +0200 Subject: [PATCH 02/23] test: add cache-vs-no-cache equivalence test Run a deterministic operation sequence (insert, overwrite, get, remove, pop, iterate) with cache=0 and cache=1/4/16/64/256, assert all return values are identical. This is the strongest cache invariant: the cache must be invisible to the API. --- src/btreemap/tests.rs | 90 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/src/btreemap/tests.rs b/src/btreemap/tests.rs index 0845980e..d7280073 100644 --- a/src/btreemap/tests.rs +++ b/src/btreemap/tests.rs @@ -2021,3 +2021,93 @@ fn deallocating_root_does_not_leak_memory() { assert_eq!(btree.allocator.num_allocated_chunks(), 0); } + +// --------------------------------------------------------------------------- +// Cache correctness tests +// --------------------------------------------------------------------------- + +/// Runs `f` against a V2 BTreeMap with the given cache size. +fn run_with_cache(cache_slots: usize, f: impl Fn(BTreeMap) -> R) +where + K: Storable + Ord + Clone, + V: Storable, +{ + let mem = make_memory(); + let tree = BTreeMap::new(mem).with_node_cache(cache_slots); + f(tree); +} + +/// Runs `f` with several cache sizes (disabled, tiny, default, large). +fn run_with_various_cache_sizes(f: impl Fn(BTreeMap) -> R) +where + K: Storable + Ord + Clone, + V: Storable, +{ + for slots in [0, 1, 4, 16, 64] { + run_with_cache(slots, &f); + } +} + +/// The cache must be invisible to the API: the same deterministic operation +/// sequence must produce identical results regardless of cache size. +#[test] +fn cache_vs_no_cache_equivalence() { + let n = 500u64; + + // Collect results from a fixed operation sequence. + let run_ops = |cache_slots: usize| -> Vec { + let mem = make_memory(); + let mut btree: BTreeMap = + BTreeMap::new(mem).with_node_cache(cache_slots); + let mut results = Vec::new(); + + // Insert + for i in 0..n { + results.push(format!("insert({i})={:?}", btree.insert(i, i * 10))); + } + // Overwrite half + for i in (0..n).step_by(2) { + results.push(format!( + "overwrite({i})={:?}", + btree.insert(i, i * 100) + )); + } + // Get all + for i in 0..n { + results.push(format!("get({i})={:?}", btree.get(&i))); + } + // Contains + for i in 0..n + 10 { + results.push(format!("contains({i})={}", btree.contains_key(&i))); + } + // Remove some + for i in (0..n).step_by(3) { + results.push(format!("remove({i})={:?}", btree.remove(&i))); + } + // Get remaining + for i in 0..n { + results.push(format!("get2({i})={:?}", btree.get(&i))); + } + // first/last + results.push(format!("first={:?}", btree.first_key_value())); + results.push(format!("last={:?}", btree.last_key_value())); + // Pop + results.push(format!("pop_first={:?}", btree.pop_first())); + results.push(format!("pop_last={:?}", btree.pop_last())); + // Iterate remaining + let entries: Vec<_> = btree.iter().map(|e| e.into_pair()).collect(); + results.push(format!("len={}", entries.len())); + results.push(format!("entries={entries:?}")); + + results + }; + + let baseline = run_ops(0); + for slots in [1, 4, 16, 64, 256] { + assert_eq!( + baseline, + run_ops(slots), + "Results diverged with cache_slots={slots}" + ); + } +} From 7b8ba394564663eb5daf754601bd89af2002af30 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 11:10:16 +0200 Subject: [PATCH 03/23] test: add targeted cache coherence tests Add 8 deterministic tests that exercise specific cache-interaction patterns with small caches (0, 1, 4 slots) to maximize collision and eviction pressure: - insert-then-get - overwrite-then-get (the pattern that broke save_and_cache_node) - split-then-get-all - merge-then-get-remaining - sequential-inserts-then-gets - interleaved-insert-get-remove - pop correctness with cache - first/last_key_value during mutations Each test runs at multiple cache sizes via run_with_various_cache_sizes. --- src/btreemap/tests.rs | 192 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/src/btreemap/tests.rs b/src/btreemap/tests.rs index d7280073..13ddd6f9 100644 --- a/src/btreemap/tests.rs +++ b/src/btreemap/tests.rs @@ -2111,3 +2111,195 @@ fn cache_vs_no_cache_equivalence() { ); } } + +/// Insert N keys, then get each one. Verifies the cache does not serve stale data. +#[test] +fn cache_insert_then_get() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 200; + for i in 0..n { + btree.insert(i, i * 10); + } + for i in 0..n { + assert_eq!(btree.get(&i), Some(i * 10), "get({i}) after insert"); + } + }); +} + +/// Overwrite then get: must return new value, not stale cached value. +/// This is the exact pattern that broke the save_and_cache_node attempt. +#[test] +fn cache_overwrite_then_get() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 200; + for i in 0..n { + btree.insert(i, i); + } + // Overwrite every key with a new value. + for i in 0..n { + let old = btree.insert(i, i + 1000); + assert_eq!(old, Some(i), "overwrite({i}) old value"); + } + // Read back: must see the new value, not the old cached one. + for i in 0..n { + assert_eq!( + btree.get(&i), + Some(i + 1000), + "get({i}) after overwrite" + ); + } + }); +} + +/// Insert until splits occur, then get every key. +/// Verifies split invalidation is correct. +#[test] +fn cache_split_then_get_all() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + // 500 keys is enough to cause multiple levels of splits. + let n = 500; + for i in 0..n { + btree.insert(i, i); + } + for i in 0..n { + assert_eq!(btree.get(&i), Some(i), "get({i}) after splits"); + } + }); +} + +/// Insert many, remove until merges occur, get every remaining key. +/// Verifies merge invalidation. +#[test] +fn cache_merge_then_get_remaining() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 500; + for i in 0..n { + btree.insert(i, i); + } + // Remove every other key to trigger merges/rotations. + for i in (0..n).step_by(2) { + assert_eq!(btree.remove(&i), Some(i)); + } + // Remaining odd keys must all be present. + for i in (1..n).step_by(2) { + assert_eq!(btree.get(&i), Some(i), "get({i}) after merges"); + } + // Removed keys must be gone. + for i in (0..n).step_by(2) { + assert_eq!(btree.get(&i), None, "get({i}) should be None"); + } + }); +} + +/// Sequential inserts into the same leaf area, then get all. +/// Tests the hot-leaf cache path. +#[test] +fn cache_sequential_inserts_then_gets() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 300; + for i in 0..n { + btree.insert(i, i); + // Immediately verify the just-inserted key. + assert_eq!(btree.get(&i), Some(i), "get({i}) right after insert"); + } + }); +} + +/// Interleave insert, get, and remove on overlapping keys with a small cache. +/// Maximum eviction pressure. +#[test] +fn cache_interleaved_insert_get_remove() { + for cache_slots in [1, 2, 4] { + run_with_cache(cache_slots, |mut btree: BTreeMap| { + let n = 300u64; + // Phase 1: insert all + for i in 0..n { + btree.insert(i, i); + } + // Phase 2: interleave operations + for i in 0..n { + // Get existing + if i % 3 != 0 { + assert_eq!(btree.get(&i), Some(i), "get({i}) interleaved"); + } + // Remove some + if i % 3 == 0 { + assert_eq!(btree.remove(&i), Some(i)); + } + // Re-insert with new value + if i % 5 == 0 { + btree.insert(i, i + 1000); + } + } + // Phase 3: verify final state + for i in 0..n { + let expected = if i % 5 == 0 { + Some(i + 1000) + } else if i % 3 == 0 { + None + } else { + Some(i) + }; + assert_eq!(btree.get(&i), expected, "final get({i})"); + } + }); + } +} + +/// pop_first/pop_last in a loop, verify each returned entry. +/// Tests cache interaction with the single-pass pop algorithms. +#[test] +fn cache_pop_correctness() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 200; + for i in 0..n { + btree.insert(i, i); + } + + // Pop from front + for i in 0..n / 2 { + assert_eq!(btree.pop_first(), Some((i, i)), "pop_first step {i}"); + } + + // Pop from back + for i in (n / 2..n).rev() { + assert_eq!(btree.pop_last(), Some((i, i)), "pop_last step {i}"); + } + + assert!(btree.is_empty()); + }); +} + +/// Call first_key_value/last_key_value between inserts and removes. +/// Reproduces the pattern from the test_first_key_value failure. +#[test] +fn cache_first_last_during_mutations() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 200; + + // Insert in reverse order, check first_key_value after each insert. + for i in (0..n).rev() { + btree.insert(i, i); + assert_eq!( + btree.first_key_value(), + Some((i, i)), + "first_key_value after insert({i})" + ); + assert_eq!( + btree.last_key_value(), + Some((n - 1, n - 1)), + "last_key_value after insert({i})" + ); + } + + // Remove from front, check first_key_value updates. + for i in 0..n - 1 { + btree.remove(&i); + assert_eq!( + btree.first_key_value(), + Some((i + 1, i + 1)), + "first_key_value after remove({i})" + ); + } + }); +} From 0e6d16695447d525b6ea2d8bec23737f3e883042 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 11:11:24 +0200 Subject: [PATCH 04/23] test: add forward-looking tests for save-and-cache-node Add 3 tests designed to catch stale-cache-after-save bugs if save_and_cache_node is implemented in the future: - saved leaf other entries readable (lazy ByRef survives save) - overwrite varying value sizes (layout changes vs cached offsets) - rebalance then read siblings (rotation/merge vs cached neighbors) All pass today with invalidate-on-save and serve as acceptance criteria for the optimization. --- src/btreemap/tests.rs | 100 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/src/btreemap/tests.rs b/src/btreemap/tests.rs index 13ddd6f9..6ca6511d 100644 --- a/src/btreemap/tests.rs +++ b/src/btreemap/tests.rs @@ -2303,3 +2303,103 @@ fn cache_first_last_during_mutations() { } }); } + +// --------------------------------------------------------------------------- +// Forward-looking tests for save-and-cache-node optimization +// +// These tests pass today (with invalidate-on-save). They are designed to +// catch stale-cache-after-save bugs if save_and_cache_node is implemented. +// --------------------------------------------------------------------------- + +/// Insert entries into a leaf, then verify ALL entries (including ones that +/// were not just inserted) are still readable. Tests that lazy ByRef entries +/// in a cached node survive a save cycle. +#[test] +fn cache_saved_leaf_other_entries_readable() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + // Insert enough keys to have multiple entries per leaf. + let n = 100u64; + for i in 0..n { + btree.insert(i, i * 10); + } + // Now overwrite a single key. + btree.insert(50, 999); + // Read ALL keys, not just the overwritten one. + // If the cached leaf has stale lazy values, a different entry will + // return wrong data. + for i in 0..n { + let expected = if i == 50 { 999 } else { i * 10 }; + assert_eq!(btree.get(&i), Some(expected), "get({i})"); + } + }); +} + +/// Overwrite values of different sizes in the same node. +/// Tests that layout changes (from value size differences) do not break +/// cached lazy offsets. +#[test] +fn cache_overwrite_varying_value_sizes() { + run_with_various_cache_sizes(|mut btree: BTreeMap, _>| { + let n = 100u64; + // Insert with small values. + for i in 0..n { + btree.insert(i, vec![i as u8; 5]); + } + // Overwrite some with larger values. + for i in (0..n).step_by(3) { + btree.insert(i, vec![i as u8; 50]); + } + // Overwrite some with smaller values. + for i in (1..n).step_by(3) { + btree.insert(i, vec![i as u8; 1]); + } + // Verify all entries. + for i in 0..n { + let expected_len = if i % 3 == 0 { + 50 + } else if i % 3 == 1 { + 1 + } else { + 5 + }; + let val = btree.get(&i).unwrap(); + assert_eq!(val.len(), expected_len, "get({i}) value length"); + assert!(val.iter().all(|&b| b == i as u8), "get({i}) value content"); + } + }); +} + +/// Trigger rotations/rebalancing, then read sibling entries. +/// Tests that rebalancing + cache does not corrupt neighboring nodes. +#[test] +fn cache_rebalance_then_read_siblings() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 500u64; + for i in 0..n { + btree.insert(i, i); + } + // Remove keys in a pattern that forces rotations and merges at + // various tree levels: remove every 3rd key, then every 2nd of + // what remains. + for i in (0..n).step_by(3) { + btree.remove(&i); + } + let remaining: Vec = (0..n).filter(|i| i % 3 != 0).collect(); + for &i in remaining.iter().step_by(2) { + btree.remove(&i); + } + // Verify all remaining keys are correct. + for i in 0..n { + let removed_pass1 = i % 3 == 0; + let removed_pass2 = !removed_pass1 && { + let pos = (0..n).filter(|j| j % 3 != 0).position(|j| j == i); + pos.map_or(false, |p| p % 2 == 0) + }; + if removed_pass1 || removed_pass2 { + assert_eq!(btree.get(&i), None, "get({i}) should be removed"); + } else { + assert_eq!(btree.get(&i), Some(i), "get({i}) should survive"); + } + } + }); +} From bd39823bf030fd5ab0941c6d1a1398bda2ee3089 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 11:13:10 +0200 Subject: [PATCH 05/23] test: run key existing tests with cache variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add cached variants of 7 important existing tests at cache=1 (max collisions) and cache=64 (warm cache). Uses u32/u64 types only to keep runtime bounded — the originals already cover the full type grid. Tests covered: insert+get, insert overwrites, insert+remove many, pop_first, pop_last, first_key_value, last_key_value. --- src/btreemap/tests.rs | 202 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/src/btreemap/tests.rs b/src/btreemap/tests.rs index 6ca6511d..dbfb5674 100644 --- a/src/btreemap/tests.rs +++ b/src/btreemap/tests.rs @@ -152,6 +152,20 @@ where f(tree_v2); } +/// Like `run_btree_test` but only tests V2 with a specific cache size. +/// Used for cache-variant tests where V1/migration coverage is not needed +/// (the original non-cached test already covers those). +pub fn run_btree_test_cached(cache_slots: usize, f: F) +where + K: Storable + Ord + Clone, + V: Storable, + F: Fn(BTreeMap) -> R, +{ + let mem = make_memory(); + let tree = BTreeMap::new(mem).with_node_cache(cache_slots); + f(tree); +} + /// Checks that objects from boundary u32 values are strictly increasing. /// This ensures multi-byte conversions preserve order. fn verify_monotonic() { @@ -2403,3 +2417,191 @@ fn cache_rebalance_then_read_siblings() { } }); } + +// --------------------------------------------------------------------------- +// Cached variants of existing tests +// +// Re-run selected existing test functions with specific cache sizes. +// Uses u32 key/value only (not the full type grid) to keep runtime bounded — +// the non-cached originals already cover the full type matrix. +// --------------------------------------------------------------------------- + +#[test] +fn cached_insert_get_cache_1() { + let (key, value) = (u32::build, u32::build); + run_btree_test_cached(1, |mut btree| { + let n = 1_000; + for i in 0..n { + assert_eq!(btree.insert(key(i), value(i)), None); + assert_eq!(btree.get(&key(i)), Some(value(i))); + } + }); +} + +#[test] +fn cached_insert_get_cache_64() { + let (key, value) = (u32::build, u32::build); + run_btree_test_cached(64, |mut btree| { + let n = 1_000; + for i in 0..n { + assert_eq!(btree.insert(key(i), value(i)), None); + assert_eq!(btree.get(&key(i)), Some(value(i))); + } + }); +} + +#[test] +fn cached_insert_overwrites_cache_1() { + let (key, value) = (u32::build, u32::build); + run_btree_test_cached(1, |mut btree| { + let n = 1_000; + for i in 0..n { + assert_eq!(btree.insert(key(i), value(i)), None); + assert_eq!(btree.insert(key(i), value(i + 1)), Some(value(i))); + assert_eq!(btree.get(&key(i)), Some(value(i + 1))); + } + }); +} + +#[test] +fn cached_insert_overwrites_cache_64() { + let (key, value) = (u32::build, u32::build); + run_btree_test_cached(64, |mut btree| { + let n = 1_000; + for i in 0..n { + assert_eq!(btree.insert(key(i), value(i)), None); + assert_eq!(btree.insert(key(i), value(i + 1)), Some(value(i))); + assert_eq!(btree.get(&key(i)), Some(value(i + 1))); + } + }); +} + +#[test] +fn cached_insert_remove_many_cache_1() { + let (key, value) = (u32::build, u32::build); + run_btree_test_cached(1, |mut btree| { + let n = 500; + for i in 0..n { + assert_eq!(btree.insert(key(i), value(i)), None); + } + for i in 0..n { + assert_eq!(btree.remove(&key(i)), Some(value(i))); + } + assert!(btree.is_empty()); + }); +} + +#[test] +fn cached_insert_remove_many_cache_64() { + let (key, value) = (u32::build, u32::build); + run_btree_test_cached(64, |mut btree| { + let n = 500; + for i in 0..n { + assert_eq!(btree.insert(key(i), value(i)), None); + } + for i in 0..n { + assert_eq!(btree.remove(&key(i)), Some(value(i))); + } + assert!(btree.is_empty()); + }); +} + +#[test] +fn cached_pop_first_cache_1() { + run_btree_test_cached(1, |mut btree: BTreeMap| { + let n = 200; + for i in 0..n { + btree.insert(i, i); + } + for i in 0..n { + assert_eq!(btree.pop_first(), Some((i, i))); + } + assert!(btree.is_empty()); + }); +} + +#[test] +fn cached_pop_first_cache_64() { + run_btree_test_cached(64, |mut btree: BTreeMap| { + let n = 200; + for i in 0..n { + btree.insert(i, i); + } + for i in 0..n { + assert_eq!(btree.pop_first(), Some((i, i))); + } + assert!(btree.is_empty()); + }); +} + +#[test] +fn cached_pop_last_cache_1() { + run_btree_test_cached(1, |mut btree: BTreeMap| { + let n = 200; + for i in 0..n { + btree.insert(i, i); + } + for i in (0..n).rev() { + assert_eq!(btree.pop_last(), Some((i, i))); + } + assert!(btree.is_empty()); + }); +} + +#[test] +fn cached_pop_last_cache_64() { + run_btree_test_cached(64, |mut btree: BTreeMap| { + let n = 200; + for i in 0..n { + btree.insert(i, i); + } + for i in (0..n).rev() { + assert_eq!(btree.pop_last(), Some((i, i))); + } + assert!(btree.is_empty()); + }); +} + +#[test] +fn cached_first_key_value_cache_1() { + run_btree_test_cached(1, |mut btree: BTreeMap| { + let n = 200; + for i in (0..n).rev() { + btree.insert(i, i); + assert_eq!(btree.first_key_value(), Some((i, i))); + } + }); +} + +#[test] +fn cached_first_key_value_cache_64() { + run_btree_test_cached(64, |mut btree: BTreeMap| { + let n = 200; + for i in (0..n).rev() { + btree.insert(i, i); + assert_eq!(btree.first_key_value(), Some((i, i))); + } + }); +} + +#[test] +fn cached_last_key_value_cache_1() { + run_btree_test_cached(1, |mut btree: BTreeMap| { + let n = 200; + for i in 0..n { + btree.insert(i, i); + assert_eq!(btree.last_key_value(), Some((i, i))); + } + }); +} + +#[test] +fn cached_last_key_value_cache_64() { + run_btree_test_cached(64, |mut btree: BTreeMap| { + let n = 200; + for i in 0..n { + btree.insert(i, i); + assert_eq!(btree.last_key_value(), Some((i, i))); + } + }); +} From 5d339482659634dab769c7259b895082cd2f4c2f Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 11:15:44 +0200 Subject: [PATCH 06/23] test: add cache metrics smoke tests Verify the cache is actually used and metrics are consistent: - hits > 0 after get-heavy workload with cache enabled - hits == 0 and misses == 0 with cache disabled (0 slots) - metrics reset after clear_new() --- src/btreemap/tests.rs | 66 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/btreemap/tests.rs b/src/btreemap/tests.rs index dbfb5674..7c459479 100644 --- a/src/btreemap/tests.rs +++ b/src/btreemap/tests.rs @@ -2605,3 +2605,69 @@ fn cached_last_key_value_cache_64() { } }); } + +// --------------------------------------------------------------------------- +// Cache metrics smoke tests +// --------------------------------------------------------------------------- + +/// After inserting N keys and getting them all, the cache hit count must be > 0. +#[test] +fn cache_metrics_nonzero_hits() { + let mem = make_memory(); + let mut btree: BTreeMap = BTreeMap::new(mem).with_node_cache(16); + + let n = 200u64; + for i in 0..n { + btree.insert(i, i); + } + // Get all keys — traversal should hit cached upper-level nodes. + for i in 0..n { + assert_eq!(btree.get(&i), Some(i)); + } + + let metrics = btree.node_cache_metrics(); + assert!( + metrics.hits() > 0, + "Expected cache hits > 0 after {n} gets, got {:?}", + metrics + ); +} + +/// With cache disabled (0 slots), operations succeed and metrics show 0 hits. +#[test] +fn cache_disabled_metrics_zero() { + let mem = make_memory(); + let mut btree: BTreeMap = BTreeMap::new(mem).with_node_cache(0); + + for i in 0..100u64 { + btree.insert(i, i); + } + for i in 0..100u64 { + assert_eq!(btree.get(&i), Some(i)); + } + + let metrics = btree.node_cache_metrics(); + assert_eq!(metrics.hits(), 0, "Disabled cache should have 0 hits"); + assert_eq!(metrics.misses(), 0, "Disabled cache should have 0 misses"); +} + +/// After clear_new(), cache metrics should be reset. +#[test] +fn cache_metrics_reset_after_clear() { + let mem = make_memory(); + let mut btree: BTreeMap = BTreeMap::new(mem).with_node_cache(16); + + for i in 0..100u64 { + btree.insert(i, i); + } + for i in 0..100u64 { + let _ = btree.get(&i); + } + assert!(btree.node_cache_metrics().hits() > 0); + + btree.clear_new(); + + let metrics = btree.node_cache_metrics(); + assert_eq!(metrics.hits(), 0, "Metrics should reset after clear_new"); + assert_eq!(metrics.misses(), 0, "Metrics should reset after clear_new"); +} From ee58006287d522fb72a91aec021a1f7c1c8ad850 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 11:17:17 +0200 Subject: [PATCH 07/23] fmt --- src/btreemap/proptests.rs | 20 +++++--------------- src/btreemap/tests.rs | 14 +++----------- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/src/btreemap/proptests.rs b/src/btreemap/proptests.rs index 6cf57da3..763fb886 100644 --- a/src/btreemap/proptests.rs +++ b/src/btreemap/proptests.rs @@ -96,37 +96,27 @@ fn run_comprehensive(ops: Vec, cache_slots: usize) { // Fewer cases per variant to keep total runtime reasonable. #[proptest(cases = 3)] -fn comprehensive_cache_0( - #[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec, -) { +fn comprehensive_cache_0(#[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec) { run_comprehensive(ops, 0); } #[proptest(cases = 3)] -fn comprehensive_cache_1( - #[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec, -) { +fn comprehensive_cache_1(#[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec) { run_comprehensive(ops, 1); } #[proptest(cases = 3)] -fn comprehensive_cache_4( - #[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec, -) { +fn comprehensive_cache_4(#[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec) { run_comprehensive(ops, 4); } #[proptest(cases = 3)] -fn comprehensive_cache_16( - #[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec, -) { +fn comprehensive_cache_16(#[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec) { run_comprehensive(ops, 16); } #[proptest(cases = 3)] -fn comprehensive_cache_64( - #[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec, -) { +fn comprehensive_cache_64(#[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec) { run_comprehensive(ops, 64); } diff --git a/src/btreemap/tests.rs b/src/btreemap/tests.rs index 7c459479..dc186202 100644 --- a/src/btreemap/tests.rs +++ b/src/btreemap/tests.rs @@ -2071,8 +2071,7 @@ fn cache_vs_no_cache_equivalence() { // Collect results from a fixed operation sequence. let run_ops = |cache_slots: usize| -> Vec { let mem = make_memory(); - let mut btree: BTreeMap = - BTreeMap::new(mem).with_node_cache(cache_slots); + let mut btree: BTreeMap = BTreeMap::new(mem).with_node_cache(cache_slots); let mut results = Vec::new(); // Insert @@ -2081,10 +2080,7 @@ fn cache_vs_no_cache_equivalence() { } // Overwrite half for i in (0..n).step_by(2) { - results.push(format!( - "overwrite({i})={:?}", - btree.insert(i, i * 100) - )); + results.push(format!("overwrite({i})={:?}", btree.insert(i, i * 100))); } // Get all for i in 0..n { @@ -2156,11 +2152,7 @@ fn cache_overwrite_then_get() { } // Read back: must see the new value, not the old cached one. for i in 0..n { - assert_eq!( - btree.get(&i), - Some(i + 1000), - "get({i}) after overwrite" - ); + assert_eq!(btree.get(&i), Some(i + 1000), "get({i}) after overwrite"); } }); } From 3fd4649465c930874a440eb3491cf54e378828ff Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 11:23:02 +0200 Subject: [PATCH 08/23] test: use non-power-of-two cache sizes and clear metrics before measuring - Add non-power-of-two cache sizes (3, 7, 50) to proptests, run_with_various_cache_sizes, and the equivalence test. Users can pass any value to with_node_cache, so correctness must not depend on power-of-two alignment. - Cache metrics tests now explicitly call node_cache_clear_metrics() before the measured workload so counters reflect only that workload. - Improve doc comments on NodeCacheMetrics and node_cache_clear_metrics to explain that counters accumulate and must be cleared before measuring a specific workload. --- src/btreemap.rs | 25 +++++++++++++++++++++++-- src/btreemap/node_cache.rs | 5 +++++ src/btreemap/proptests.rs | 14 ++++++++++---- src/btreemap/tests.rs | 38 +++++++++++++++++++++++++++++--------- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/btreemap.rs b/src/btreemap.rs index 80b42906..a4b7fa4a 100644 --- a/src/btreemap.rs +++ b/src/btreemap.rs @@ -377,12 +377,22 @@ where /// Resets cache metrics (hit/miss counters) without evicting /// cached nodes. + /// + /// Call this before the workload you want to measure so that + /// counters reflect only that workload, not the entire lifetime + /// of the map. pub fn node_cache_clear_metrics(&mut self) { self.cache.get_mut().clear_metrics(); } /// Returns node-cache performance metrics. /// + /// Counters accumulate from map creation (or the last call to + /// [`node_cache_clear_metrics`](Self::node_cache_clear_metrics)) + /// and are never cleared automatically. To measure a specific + /// workload, call `node_cache_clear_metrics` first, run the + /// workload, then read the metrics. + /// /// # Examples /// /// ```rust @@ -391,8 +401,19 @@ where /// let mut map: BTreeMap = /// BTreeMap::init(DefaultMemoryImpl::default()) /// .with_node_cache(32); - /// map.insert(1, 100); - /// let _ = map.get(&1); + /// + /// // Populate the map (metrics accumulate during inserts). + /// for i in 0..100u64 { + /// map.insert(i, i); + /// } + /// + /// // Clear counters before the workload we care about. + /// map.node_cache_clear_metrics(); + /// + /// // Workload: read every key. + /// for i in 0..100u64 { + /// let _ = map.get(&i); + /// } /// /// let metrics = map.node_cache_metrics(); /// println!("hit ratio: {:.1}%", metrics.hit_ratio() * 100.0); diff --git a/src/btreemap/node_cache.rs b/src/btreemap/node_cache.rs index 4777bebd..9897894c 100644 --- a/src/btreemap/node_cache.rs +++ b/src/btreemap/node_cache.rs @@ -4,6 +4,11 @@ use crate::Storable; use super::node::Node; /// Node-cache performance metrics. +/// +/// Counters accumulate over the lifetime of the cache and are **never +/// cleared automatically**. To measure a specific workload, call +/// [`BTreeMap::node_cache_clear_metrics`](super::BTreeMap::node_cache_clear_metrics) +/// before the workload, then read the metrics afterward. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct NodeCacheMetrics { /// Successful cache lookups. diff --git a/src/btreemap/proptests.rs b/src/btreemap/proptests.rs index 763fb886..8e193823 100644 --- a/src/btreemap/proptests.rs +++ b/src/btreemap/proptests.rs @@ -93,6 +93,7 @@ fn run_comprehensive(ops: Vec, cache_slots: usize) { // Cache-parameterized variants: run the same comprehensive operation sequence // at different cache sizes to catch size-dependent correctness bugs. +// Includes non-power-of-two sizes — users can pass any value to with_node_cache. // Fewer cases per variant to keep total runtime reasonable. #[proptest(cases = 3)] @@ -106,8 +107,13 @@ fn comprehensive_cache_1(#[strategy(pvec(operation_strategy(), 100..5_000))] ops } #[proptest(cases = 3)] -fn comprehensive_cache_4(#[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec) { - run_comprehensive(ops, 4); +fn comprehensive_cache_3(#[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec) { + run_comprehensive(ops, 3); +} + +#[proptest(cases = 3)] +fn comprehensive_cache_7(#[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec) { + run_comprehensive(ops, 7); } #[proptest(cases = 3)] @@ -116,8 +122,8 @@ fn comprehensive_cache_16(#[strategy(pvec(operation_strategy(), 100..5_000))] op } #[proptest(cases = 3)] -fn comprehensive_cache_64(#[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec) { - run_comprehensive(ops, 64); +fn comprehensive_cache_50(#[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec) { + run_comprehensive(ops, 50); } #[proptest(cases = 3)] diff --git a/src/btreemap/tests.rs b/src/btreemap/tests.rs index dc186202..98518666 100644 --- a/src/btreemap/tests.rs +++ b/src/btreemap/tests.rs @@ -2052,12 +2052,13 @@ where } /// Runs `f` with several cache sizes (disabled, tiny, default, large). +/// Includes non-power-of-two sizes — users can pass any value to with_node_cache. fn run_with_various_cache_sizes(f: impl Fn(BTreeMap) -> R) where K: Storable + Ord + Clone, V: Storable, { - for slots in [0, 1, 4, 16, 64] { + for slots in [0, 1, 3, 7, 16, 50] { run_with_cache(slots, &f); } } @@ -2113,7 +2114,7 @@ fn cache_vs_no_cache_equivalence() { }; let baseline = run_ops(0); - for slots in [1, 4, 16, 64, 256] { + for slots in [1, 3, 7, 16, 50, 256] { assert_eq!( baseline, run_ops(slots), @@ -2399,7 +2400,7 @@ fn cache_rebalance_then_read_siblings() { let removed_pass1 = i % 3 == 0; let removed_pass2 = !removed_pass1 && { let pos = (0..n).filter(|j| j % 3 != 0).position(|j| j == i); - pos.map_or(false, |p| p % 2 == 0) + pos.is_some_and(|p| p % 2 == 0) }; if removed_pass1 || removed_pass2 { assert_eq!(btree.get(&i), None, "get({i}) should be removed"); @@ -2602,7 +2603,8 @@ fn cached_last_key_value_cache_64() { // Cache metrics smoke tests // --------------------------------------------------------------------------- -/// After inserting N keys and getting them all, the cache hit count must be > 0. +/// After getting N keys, the cache hit count must be > 0. +/// Clears metrics before the measured workload so counters reflect only gets. #[test] fn cache_metrics_nonzero_hits() { let mem = make_memory(); @@ -2612,7 +2614,11 @@ fn cache_metrics_nonzero_hits() { for i in 0..n { btree.insert(i, i); } - // Get all keys — traversal should hit cached upper-level nodes. + + // Clear counters accumulated during inserts. + btree.node_cache_clear_metrics(); + + // Workload: get all keys — traversal should hit cached upper-level nodes. for i in 0..n { assert_eq!(btree.get(&i), Some(i)); } @@ -2623,6 +2629,11 @@ fn cache_metrics_nonzero_hits() { "Expected cache hits > 0 after {n} gets, got {:?}", metrics ); + assert!( + metrics.hit_ratio() > 0.0, + "Expected hit_ratio > 0 for get-heavy workload, got {:?}", + metrics + ); } /// With cache disabled (0 slots), operations succeed and metrics show 0 hits. @@ -2634,6 +2645,9 @@ fn cache_disabled_metrics_zero() { for i in 0..100u64 { btree.insert(i, i); } + + btree.node_cache_clear_metrics(); + for i in 0..100u64 { assert_eq!(btree.get(&i), Some(i)); } @@ -2641,6 +2655,7 @@ fn cache_disabled_metrics_zero() { let metrics = btree.node_cache_metrics(); assert_eq!(metrics.hits(), 0, "Disabled cache should have 0 hits"); assert_eq!(metrics.misses(), 0, "Disabled cache should have 0 misses"); + assert_eq!(metrics.total(), 0, "Disabled cache should have 0 lookups"); } /// After clear_new(), cache metrics should be reset. @@ -2652,14 +2667,19 @@ fn cache_metrics_reset_after_clear() { for i in 0..100u64 { btree.insert(i, i); } + + btree.node_cache_clear_metrics(); + for i in 0..100u64 { let _ = btree.get(&i); } - assert!(btree.node_cache_metrics().hits() > 0); + let before = btree.node_cache_metrics(); + assert!(before.hits() > 0); btree.clear_new(); - let metrics = btree.node_cache_metrics(); - assert_eq!(metrics.hits(), 0, "Metrics should reset after clear_new"); - assert_eq!(metrics.misses(), 0, "Metrics should reset after clear_new"); + let after = btree.node_cache_metrics(); + assert_eq!(after.hits(), 0, "Metrics should reset after clear_new"); + assert_eq!(after.misses(), 0, "Metrics should reset after clear_new"); + assert_eq!(after.total(), 0, "Metrics should reset after clear_new"); } From ec82e81d7b3189ceb56885d6c6e879b5775bfd60 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 11:30:52 +0200 Subject: [PATCH 09/23] add CLAUDE.md --- .claude/CLAUDE.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .claude/CLAUDE.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 00000000..299505e1 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,27 @@ +# Project: ic-stable-structures + +Rust library for data structures in Internet Computer stable memory. + +## Local CI checks + +Run these before pushing. They mirror the CI pipeline in `.github/workflows/ci.yml`. + +```bash +# Format (must pass, instant) +cargo fmt --all -- --check + +# Clippy (must pass, ~10s) +cargo clippy --tests --benches -- -D clippy::all + +# Tests (must pass, ~80s) +cargo test +``` + +After changing code, always run at least `cargo fmt` and `cargo clippy --tests --benches -- -D clippy::all`. +Run `cargo test` when changes affect logic. + +## Code style + +- Follow existing patterns in the codebase +- No unnecessary comments, docstrings, or type annotations on unchanged code +- Test names should describe the invariant being checked From 29f20cb77b32cc1a55524372074558d02e50c998 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 11:35:12 +0200 Subject: [PATCH 10/23] test: add read-warm-write-read cache coherence tests Add 5 tests that exercise the pattern: populate tree, read all keys (warming the cache), modify the tree, then read again. This catches bugs where a write invalidates only the nodes it touches but leaves other (now-stale) nodes in the cache from the read phase. - read-warm then insert (with splits) - read-warm then overwrite - read-warm then remove (with merges) - read-warm then pop_first/pop_last - repeated read-write cycles --- src/btreemap/tests.rs | 135 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/src/btreemap/tests.rs b/src/btreemap/tests.rs index 98518666..2f28585a 100644 --- a/src/btreemap/tests.rs +++ b/src/btreemap/tests.rs @@ -2411,6 +2411,141 @@ fn cache_rebalance_then_read_siblings() { }); } +// --------------------------------------------------------------------------- +// Read-warm-write-read tests +// +// Verify correctness when the cache is warmed by reads, then a write +// modifies the tree, then reads must still return correct data. +// This catches bugs where a write invalidates some cached nodes but +// leaves other (now-stale) nodes in cache. +// --------------------------------------------------------------------------- + +/// Warm cache with reads, insert new keys (causing splits), read again. +#[test] +fn cache_read_warm_insert_read() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 200u64; + // Populate. + for i in 0..n { + btree.insert(i, i); + } + // Warm cache: read every key. + for i in 0..n { + assert_eq!(btree.get(&i), Some(i)); + } + // Write: insert more keys, causing splits in the warmed tree. + for i in n..n * 2 { + btree.insert(i, i); + } + // Read all again: both old and new keys must be correct. + for i in 0..n * 2 { + assert_eq!(btree.get(&i), Some(i), "get({i}) after warm+insert"); + } + }); +} + +/// Warm cache with reads, overwrite existing keys, read again. +#[test] +fn cache_read_warm_overwrite_read() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 200u64; + for i in 0..n { + btree.insert(i, i); + } + // Warm cache. + for i in 0..n { + assert_eq!(btree.get(&i), Some(i)); + } + // Overwrite every other key. + for i in (0..n).step_by(2) { + btree.insert(i, i + 1000); + } + // Read all: overwritten keys must return new value, + // non-overwritten keys must still return original value. + for i in 0..n { + let expected = if i % 2 == 0 { i + 1000 } else { i }; + assert_eq!(btree.get(&i), Some(expected), "get({i}) after warm+overwrite"); + } + }); +} + +/// Warm cache with reads, remove keys (causing merges), read remaining. +#[test] +fn cache_read_warm_remove_read() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 300u64; + for i in 0..n { + btree.insert(i, i); + } + // Warm cache. + for i in 0..n { + assert_eq!(btree.get(&i), Some(i)); + } + // Remove half the keys, triggering merges in the warmed tree. + for i in (0..n).step_by(2) { + assert_eq!(btree.remove(&i), Some(i)); + } + // Read all: removed keys must be None, remaining must be correct. + for i in 0..n { + if i % 2 == 0 { + assert_eq!(btree.get(&i), None, "get({i}) should be removed"); + } else { + assert_eq!(btree.get(&i), Some(i), "get({i}) should remain"); + } + } + }); +} + +/// Warm cache, pop_first/pop_last (single-pass algorithms), verify remaining. +#[test] +fn cache_read_warm_pop_read() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 200u64; + for i in 0..n { + btree.insert(i, i); + } + // Warm cache. + for i in 0..n { + assert_eq!(btree.get(&i), Some(i)); + } + // Pop from both ends. + for i in 0..20 { + assert_eq!(btree.pop_first(), Some((i, i))); + } + for i in (n - 20..n).rev() { + assert_eq!(btree.pop_last(), Some((i, i))); + } + // Read remaining: middle keys must be intact. + for i in 20..n - 20 { + assert_eq!(btree.get(&i), Some(i), "get({i}) after warm+pop"); + } + }); +} + +/// Multiple read-write-read cycles to test cache coherence over time. +#[test] +fn cache_repeated_read_write_cycles() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 100u64; + + for cycle in 0..5u64 { + let base = cycle * n; + // Write phase: insert a batch of keys. + for i in base..base + n { + btree.insert(i, i); + } + // Read phase: verify ALL keys inserted so far. + for i in 0..base + n { + assert_eq!( + btree.get(&i), + Some(i), + "get({i}) in cycle {cycle}" + ); + } + } + }); +} + // --------------------------------------------------------------------------- // Cached variants of existing tests // From ee5242abde65a8a23b1bcdffecdae28d0a957f70 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 11:40:08 +0200 Subject: [PATCH 11/23] test: add cache path-reuse correctness tests - get_miss: non-existent keys with warm cache - get_then_remove: get warms path, remove modifies same path - get_then_insert: get warms path, overwrite modifies same path - contains_then_remove: contains warms path without reading value - peek_then_pop: last_key_value/first_key_value then pop - deeper_tree: 2000 entries for deeper cache pressure --- src/btreemap/tests.rs | 147 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/src/btreemap/tests.rs b/src/btreemap/tests.rs index 2f28585a..93395ad5 100644 --- a/src/btreemap/tests.rs +++ b/src/btreemap/tests.rs @@ -2546,6 +2546,153 @@ fn cache_repeated_read_write_cycles() { }); } +// --------------------------------------------------------------------------- +// Cache path-reuse tests +// --------------------------------------------------------------------------- + +/// Get non-existent keys with warm cache must return None. +#[test] +fn cache_get_miss() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 300u64; + // Insert even keys only. + for i in (0..n).step_by(2) { + btree.insert(i, i); + } + // Warm cache by reading existing keys. + for i in (0..n).step_by(2) { + assert_eq!(btree.get(&i), Some(i)); + } + // Get odd keys (misses): must all return None. + for i in (1..n).step_by(2) { + assert_eq!(btree.get(&i), None, "get({i}) should miss"); + } + // Get keys beyond range: also misses. + for i in n..n + 50 { + assert_eq!(btree.get(&i), None, "get({i}) beyond range should miss"); + } + }); +} + +/// Get a key then immediately remove it. The get warms the exact cache path +/// that remove then modifies. +#[test] +fn cache_get_then_remove() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 300u64; + for i in 0..n { + btree.insert(i, i); + } + // For each key: get (warms path), then immediately remove. + for i in 0..n { + assert_eq!(btree.get(&i), Some(i), "get({i}) before remove"); + assert_eq!(btree.remove(&i), Some(i), "remove({i})"); + } + assert!(btree.is_empty()); + }); +} + +/// Get a key then immediately overwrite it. The get warms the exact cache +/// path that insert then modifies. +#[test] +fn cache_get_then_insert() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 300u64; + for i in 0..n { + btree.insert(i, i); + } + // For each key: get (warms path), then overwrite with new value. + for i in 0..n { + assert_eq!(btree.get(&i), Some(i), "get({i}) before overwrite"); + assert_eq!(btree.insert(i, i + 1000), Some(i), "overwrite({i})"); + } + // Verify all new values. + for i in 0..n { + assert_eq!(btree.get(&i), Some(i + 1000), "get({i}) after overwrite"); + } + }); +} + +/// contains_key then immediately remove. contains_key warms the path +/// without reading the value. +#[test] +fn cache_contains_then_remove() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 300u64; + for i in 0..n { + btree.insert(i, i); + } + for i in 0..n { + assert!(btree.contains_key(&i), "contains({i})"); + assert_eq!(btree.remove(&i), Some(i), "remove({i})"); + assert!(!btree.contains_key(&i), "contains({i}) after remove"); + } + assert!(btree.is_empty()); + }); +} + +/// Peek boundary then pop: last_key_value followed by pop_last, and +/// first_key_value followed by pop_first. The peek reads through cache, +/// then pop modifies the same leftmost/rightmost path. +#[test] +fn cache_peek_then_pop() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 200u64; + for i in 0..n { + btree.insert(i, i); + } + // Peek-then-pop from back. + for i in (n / 2..n).rev() { + let peeked = btree.last_key_value(); + assert_eq!(peeked, Some((i, i)), "peek last at step {i}"); + let popped = btree.pop_last(); + assert_eq!(popped, Some((i, i)), "pop last at step {i}"); + } + // Peek-then-pop from front. + for i in 0..n / 2 { + let peeked = btree.first_key_value(); + assert_eq!(peeked, Some((i, i)), "peek first at step {i}"); + let popped = btree.pop_first(); + assert_eq!(popped, Some((i, i)), "pop first at step {i}"); + } + assert!(btree.is_empty()); + }); +} + +/// Deeper tree (2000 entries, depth ~5) to stress cache beyond the typical +/// test size of 200-500 entries. +#[test] +fn cache_deeper_tree() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 2000u64; + for i in 0..n { + btree.insert(i, i); + } + // Read all. + for i in 0..n { + assert_eq!(btree.get(&i), Some(i), "get({i})"); + } + // Overwrite every 7th key (non-power-of-two stride). + for i in (0..n).step_by(7) { + btree.insert(i, i + n); + } + // Remove every 11th key. + for i in (0..n).step_by(11) { + btree.remove(&i); + } + // Verify final state. + for i in 0..n { + if i % 11 == 0 { + assert_eq!(btree.get(&i), None, "get({i}) removed"); + } else if i % 7 == 0 { + assert_eq!(btree.get(&i), Some(i + n), "get({i}) overwritten"); + } else { + assert_eq!(btree.get(&i), Some(i), "get({i}) original"); + } + } + }); +} + // --------------------------------------------------------------------------- // Cached variants of existing tests // From 71510db4e4fa6b160b72f06b97fe8df6af7ff75d Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 11:48:58 +0200 Subject: [PATCH 12/23] test: add cache resize mid-use and partial iteration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cache_resize_mid_use: resize cache between operations (16 → 1 → 0 → 64), verify correctness after each resize - cache_partial_iter_then_mutate: partial iteration warms cache, then mutations modify the tree, then full iteration must be consistent --- src/btreemap/tests.rs | 73 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/btreemap/tests.rs b/src/btreemap/tests.rs index 93395ad5..1fe04a5f 100644 --- a/src/btreemap/tests.rs +++ b/src/btreemap/tests.rs @@ -2693,6 +2693,79 @@ fn cache_deeper_tree() { }); } +/// Resize cache mid-use: populate and read with one cache size, resize, +/// then continue operating. Resizing drops all cached nodes. +#[test] +fn cache_resize_mid_use() { + let mem = make_memory(); + let mut btree: BTreeMap = BTreeMap::new(mem).with_node_cache(16); + + let n = 300u64; + for i in 0..n { + btree.insert(i, i); + } + // Warm cache at size 16. + for i in 0..n { + assert_eq!(btree.get(&i), Some(i)); + } + + // Resize to 1 slot — drops all cached nodes. + btree.node_cache_resize(1); + for i in 0..n { + assert_eq!(btree.get(&i), Some(i), "get({i}) after resize to 1"); + } + + // Resize to 0 — disable cache entirely. + btree.node_cache_resize(0); + btree.insert(n, n); + assert_eq!(btree.remove(&0), Some(0)); + for i in 1..=n { + assert_eq!(btree.get(&i), Some(i), "get({i}) after disable"); + } + + // Resize back to 64. + btree.node_cache_resize(64); + for i in 1..=n { + assert_eq!(btree.get(&i), Some(i), "get({i}) after re-enable"); + } +} + +/// Partial iteration warms cache, then mutation modifies the tree, +/// then full iteration must return correct data. +#[test] +fn cache_partial_iter_then_mutate() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 300u64; + for i in 0..n { + btree.insert(i, i); + } + + // Partial forward iteration (warms cache for part of the tree). + let partial: Vec<_> = btree.iter().take(50).map(|e| e.into_pair()).collect(); + assert_eq!(partial.len(), 50); + + // Partial reverse iteration (warms cache for other end). + let partial_rev: Vec<_> = btree.iter().rev().take(50).map(|e| e.into_pair()).collect(); + assert_eq!(partial_rev.len(), 50); + + // Mutate: overwrite, insert new, remove some. + for i in (0..n).step_by(3) { + btree.insert(i, i + 1000); + } + btree.insert(n, n); + for i in (1..n).step_by(5) { + btree.remove(&i); + } + + // Full iteration must be consistent with get. + let all: Vec<_> = btree.iter().map(|e| e.into_pair()).collect(); + for (k, v) in &all { + assert_eq!(btree.get(k), Some(*v), "iter vs get mismatch at key {k}"); + } + assert_eq!(all.len(), btree.len() as usize); + }); +} + // --------------------------------------------------------------------------- // Cached variants of existing tests // From 700c6d23ebcdd3ca994f69dfacae011ed00c53cc Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 11:49:37 +0200 Subject: [PATCH 13/23] update .claude --- .claude/CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 299505e1..5394113f 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -17,7 +17,7 @@ cargo clippy --tests --benches -- -D clippy::all cargo test ``` -After changing code, always run at least `cargo fmt` and `cargo clippy --tests --benches -- -D clippy::all`. +After changing code, always run at least `cargo fmt --all -- --check` and `cargo clippy --tests --benches -- -D clippy::all`. Run `cargo test` when changes affect logic. ## Code style From 9b6248675ca9c8b08b3d84af2347b10f9e9d8d2c Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 11:50:31 +0200 Subject: [PATCH 14/23] fmt --- src/btreemap/tests.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/btreemap/tests.rs b/src/btreemap/tests.rs index 1fe04a5f..ab1d9ed6 100644 --- a/src/btreemap/tests.rs +++ b/src/btreemap/tests.rs @@ -2464,7 +2464,11 @@ fn cache_read_warm_overwrite_read() { // non-overwritten keys must still return original value. for i in 0..n { let expected = if i % 2 == 0 { i + 1000 } else { i }; - assert_eq!(btree.get(&i), Some(expected), "get({i}) after warm+overwrite"); + assert_eq!( + btree.get(&i), + Some(expected), + "get({i}) after warm+overwrite" + ); } }); } @@ -2536,11 +2540,7 @@ fn cache_repeated_read_write_cycles() { } // Read phase: verify ALL keys inserted so far. for i in 0..base + n { - assert_eq!( - btree.get(&i), - Some(i), - "get({i}) in cycle {cycle}" - ); + assert_eq!(btree.get(&i), Some(i), "get({i}) in cycle {cycle}"); } } }); From bf8ad72a4a5bb4419814206678b53786fb1d7584 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 12:03:57 +0200 Subject: [PATCH 15/23] docs: add project-level CLAUDE.md with CI checks and style guidelines --- .claude/CLAUDE.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 5394113f..fc70f795 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -25,3 +25,19 @@ Run `cargo test` when changes affect logic. - Follow existing patterns in the codebase - No unnecessary comments, docstrings, or type annotations on unchanged code - Test names should describe the invariant being checked + +## Commit messages + +- Use conventional commit prefix (test, feat, fix, perf, refactor, docs, chore, ci) +- First line: under 70 characters, short summary of what changed +- Body (if needed): why the change was made, not how +- Must be correct, short, clear and informative + +## PR title and description + +When asked to "write PR description" or similar: +- Look at all commits in the current branch vs main +- Title: under 70 characters, use conventional commit prefix (test, feat, fix, perf, refactor, docs, chore, ci) +- Description: a single line summary on top, followed by a short explanation of what was added and why +- Both must be correct, short, clear and informative +- Do not use excessive formatting From abc9dd870c0173ec58b39191de3dff75ec02af8c Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 12:07:58 +0200 Subject: [PATCH 16/23] . --- .claude/CLAUDE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index fc70f795..31b58d8e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -40,4 +40,5 @@ When asked to "write PR description" or similar: - Title: under 70 characters, use conventional commit prefix (test, feat, fix, perf, refactor, docs, chore, ci) - Description: a single line summary on top, followed by a short explanation of what was added and why - Both must be correct, short, clear and informative -- Do not use excessive formatting +- Do not use excessive formatting, but use bullet points or tables if it improves readability +- Don't list commits or files changed, the PR view already shows that From 34db6203916bd76c0f2e3b42f4a2c9d055c47ed2 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 13:26:49 +0200 Subject: [PATCH 17/23] test: add mixed read operations between writes cache test Mix get, contains_key, first/last_key_value on the same cached nodes between bulk overwrites across multiple rounds. --- src/btreemap/tests.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/btreemap/tests.rs b/src/btreemap/tests.rs index ab1d9ed6..21d7a486 100644 --- a/src/btreemap/tests.rs +++ b/src/btreemap/tests.rs @@ -2766,6 +2766,35 @@ fn cache_partial_iter_then_mutate() { }); } +/// Mix different read operations (get, contains_key, first/last_key_value) +/// between bulk overwrites across multiple rounds. +#[test] +fn cache_mixed_reads_between_writes() { + run_with_various_cache_sizes(|mut btree: BTreeMap| { + let n = 300u64; + for i in 0..n { + btree.insert(i, i); + } + + for round in 0..3u64 { + // Multiple read types on same tree state. + for i in 0..n { + assert!(btree.contains_key(&i), "round {round} contains({i})"); + } + for i in 0..n { + assert_eq!(btree.get(&i), Some(i + round * 1000), "round {round} get({i})"); + } + assert_eq!(btree.first_key_value(), Some((0, round * 1000))); + assert_eq!(btree.last_key_value(), Some((n - 1, n - 1 + round * 1000))); + + // Write: overwrite all values. + for i in 0..n { + btree.insert(i, i + (round + 1) * 1000); + } + } + }); +} + // --------------------------------------------------------------------------- // Cached variants of existing tests // From 8289a2ce33d0c4e3593e2e811df7f7a1c7731e7b Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Fri, 3 Apr 2026 13:33:23 +0200 Subject: [PATCH 18/23] fmt --- src/btreemap/tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/btreemap/tests.rs b/src/btreemap/tests.rs index 21d7a486..4ffd544c 100644 --- a/src/btreemap/tests.rs +++ b/src/btreemap/tests.rs @@ -2782,7 +2782,11 @@ fn cache_mixed_reads_between_writes() { assert!(btree.contains_key(&i), "round {round} contains({i})"); } for i in 0..n { - assert_eq!(btree.get(&i), Some(i + round * 1000), "round {round} get({i})"); + assert_eq!( + btree.get(&i), + Some(i + round * 1000), + "round {round} get({i})" + ); } assert_eq!(btree.first_key_value(), Some((0, round * 1000))); assert_eq!(btree.last_key_value(), Some((n - 1, n - 1 + round * 1000))); From d99a3149dee68a327ef39787ed8ce8c125670639 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Sat, 4 Apr 2026 09:58:36 +0200 Subject: [PATCH 19/23] cleanup --- .claude/CLAUDE.md | 44 -------------------------------------------- 1 file changed, 44 deletions(-) delete mode 100644 .claude/CLAUDE.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 100644 index 31b58d8e..00000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1,44 +0,0 @@ -# Project: ic-stable-structures - -Rust library for data structures in Internet Computer stable memory. - -## Local CI checks - -Run these before pushing. They mirror the CI pipeline in `.github/workflows/ci.yml`. - -```bash -# Format (must pass, instant) -cargo fmt --all -- --check - -# Clippy (must pass, ~10s) -cargo clippy --tests --benches -- -D clippy::all - -# Tests (must pass, ~80s) -cargo test -``` - -After changing code, always run at least `cargo fmt --all -- --check` and `cargo clippy --tests --benches -- -D clippy::all`. -Run `cargo test` when changes affect logic. - -## Code style - -- Follow existing patterns in the codebase -- No unnecessary comments, docstrings, or type annotations on unchanged code -- Test names should describe the invariant being checked - -## Commit messages - -- Use conventional commit prefix (test, feat, fix, perf, refactor, docs, chore, ci) -- First line: under 70 characters, short summary of what changed -- Body (if needed): why the change was made, not how -- Must be correct, short, clear and informative - -## PR title and description - -When asked to "write PR description" or similar: -- Look at all commits in the current branch vs main -- Title: under 70 characters, use conventional commit prefix (test, feat, fix, perf, refactor, docs, chore, ci) -- Description: a single line summary on top, followed by a short explanation of what was added and why -- Both must be correct, short, clear and informative -- Do not use excessive formatting, but use bullet points or tables if it improves readability -- Don't list commits or files changed, the PR view already shows that From c127a04b8181e71d8508ff407fd146d7235f06b9 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Sat, 4 Apr 2026 10:06:07 +0200 Subject: [PATCH 20/23] cleanup --- src/btreemap.rs | 25 ++----------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/src/btreemap.rs b/src/btreemap.rs index a4b7fa4a..80b42906 100644 --- a/src/btreemap.rs +++ b/src/btreemap.rs @@ -377,22 +377,12 @@ where /// Resets cache metrics (hit/miss counters) without evicting /// cached nodes. - /// - /// Call this before the workload you want to measure so that - /// counters reflect only that workload, not the entire lifetime - /// of the map. pub fn node_cache_clear_metrics(&mut self) { self.cache.get_mut().clear_metrics(); } /// Returns node-cache performance metrics. /// - /// Counters accumulate from map creation (or the last call to - /// [`node_cache_clear_metrics`](Self::node_cache_clear_metrics)) - /// and are never cleared automatically. To measure a specific - /// workload, call `node_cache_clear_metrics` first, run the - /// workload, then read the metrics. - /// /// # Examples /// /// ```rust @@ -401,19 +391,8 @@ where /// let mut map: BTreeMap = /// BTreeMap::init(DefaultMemoryImpl::default()) /// .with_node_cache(32); - /// - /// // Populate the map (metrics accumulate during inserts). - /// for i in 0..100u64 { - /// map.insert(i, i); - /// } - /// - /// // Clear counters before the workload we care about. - /// map.node_cache_clear_metrics(); - /// - /// // Workload: read every key. - /// for i in 0..100u64 { - /// let _ = map.get(&i); - /// } + /// map.insert(1, 100); + /// let _ = map.get(&1); /// /// let metrics = map.node_cache_metrics(); /// println!("hit ratio: {:.1}%", metrics.hit_ratio() * 100.0); From fbf879d892c6c9dae49af4d0c86b2cebb0f7be0e Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Sat, 4 Apr 2026 10:35:30 +0200 Subject: [PATCH 21/23] . --- src/btreemap/tests.rs | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/btreemap/tests.rs b/src/btreemap/tests.rs index 4ffd544c..47a39509 100644 --- a/src/btreemap/tests.rs +++ b/src/btreemap/tests.rs @@ -2040,17 +2040,6 @@ fn deallocating_root_does_not_leak_memory() { // Cache correctness tests // --------------------------------------------------------------------------- -/// Runs `f` against a V2 BTreeMap with the given cache size. -fn run_with_cache(cache_slots: usize, f: impl Fn(BTreeMap) -> R) -where - K: Storable + Ord + Clone, - V: Storable, -{ - let mem = make_memory(); - let tree = BTreeMap::new(mem).with_node_cache(cache_slots); - f(tree); -} - /// Runs `f` with several cache sizes (disabled, tiny, default, large). /// Includes non-power-of-two sizes — users can pass any value to with_node_cache. fn run_with_various_cache_sizes(f: impl Fn(BTreeMap) -> R) @@ -2059,7 +2048,7 @@ where V: Storable, { for slots in [0, 1, 3, 7, 16, 50] { - run_with_cache(slots, &f); + run_btree_test_cached(slots, &f); } } @@ -2217,7 +2206,7 @@ fn cache_sequential_inserts_then_gets() { #[test] fn cache_interleaved_insert_get_remove() { for cache_slots in [1, 2, 4] { - run_with_cache(cache_slots, |mut btree: BTreeMap| { + run_btree_test_cached(cache_slots, |mut btree: BTreeMap| { let n = 300u64; // Phase 1: insert all for i in 0..n { From 3538118f06f9e98873fcd4e4f56a00dad166caee Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Sat, 4 Apr 2026 10:37:33 +0200 Subject: [PATCH 22/23] . --- src/btreemap/tests.rs | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/btreemap/tests.rs b/src/btreemap/tests.rs index 47a39509..4e1c6d29 100644 --- a/src/btreemap/tests.rs +++ b/src/btreemap/tests.rs @@ -152,20 +152,6 @@ where f(tree_v2); } -/// Like `run_btree_test` but only tests V2 with a specific cache size. -/// Used for cache-variant tests where V1/migration coverage is not needed -/// (the original non-cached test already covers those). -pub fn run_btree_test_cached(cache_slots: usize, f: F) -where - K: Storable + Ord + Clone, - V: Storable, - F: Fn(BTreeMap) -> R, -{ - let mem = make_memory(); - let tree = BTreeMap::new(mem).with_node_cache(cache_slots); - f(tree); -} - /// Checks that objects from boundary u32 values are strictly increasing. /// This ensures multi-byte conversions preserve order. fn verify_monotonic() { @@ -2040,6 +2026,19 @@ fn deallocating_root_does_not_leak_memory() { // Cache correctness tests // --------------------------------------------------------------------------- +/// Runs `f` against a V2 BTreeMap with the given cache size. +/// Used for cache-variant tests where V1/migration coverage is not needed +/// (the original non-cached test already covers those). +fn run_btree_test_cached(cache_slots: usize, f: impl Fn(BTreeMap) -> R) +where + K: Storable + Ord + Clone, + V: Storable, +{ + let mem = make_memory(); + let tree = BTreeMap::new(mem).with_node_cache(cache_slots); + f(tree); +} + /// Runs `f` with several cache sizes (disabled, tiny, default, large). /// Includes non-power-of-two sizes — users can pass any value to with_node_cache. fn run_with_various_cache_sizes(f: impl Fn(BTreeMap) -> R) From a5e79270dea6014a7cdb1f0eb793fd70cab404a3 Mon Sep 17 00:00:00 2001 From: Maksym Arutyunyan Date: Sat, 4 Apr 2026 10:39:08 +0200 Subject: [PATCH 23/23] cleanup --- src/btreemap/node_cache.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/btreemap/node_cache.rs b/src/btreemap/node_cache.rs index 9897894c..4777bebd 100644 --- a/src/btreemap/node_cache.rs +++ b/src/btreemap/node_cache.rs @@ -4,11 +4,6 @@ use crate::Storable; use super::node::Node; /// Node-cache performance metrics. -/// -/// Counters accumulate over the lifetime of the cache and are **never -/// cleared automatically**. To measure a specific workload, call -/// [`BTreeMap::node_cache_clear_metrics`](super::BTreeMap::node_cache_clear_metrics) -/// before the workload, then read the metrics afterward. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct NodeCacheMetrics { /// Successful cache lookups.