commit 79b5ab98aabda9a3605bbbf6d82500a746dacde5 Author: linus björnstam Date: Sun Feb 1 20:52:23 2026 +0100 First diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..add57be --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/NiceBtree.sln b/NiceBtree.sln new file mode 100644 index 0000000..12c6d02 --- /dev/null +++ b/NiceBtree.sln @@ -0,0 +1,36 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B0432C7A-80E2-4EA6-8FAB-B8F23A8C39DE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PersistentMap", "PersistentMap\PersistentMap.csproj", "{CA49AA3C-0CE6-4735-887F-FB3631D63CEE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestProject1", "TestProject1\TestProject1.csproj", "{9E499000-5E37-42F8-89D2-E18A53F0EF0C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{E38B3FCB-0D4D-401D-A2FC-EDF41B755E53}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgainstImmutableDict", "benchmarks\AgainstImmutableDict\AgainstImmutableDict.csproj", "{13304F19-7ED3-4C40-9A08-46D539667D50}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {CA49AA3C-0CE6-4735-887F-FB3631D63CEE} = {B0432C7A-80E2-4EA6-8FAB-B8F23A8C39DE} + {13304F19-7ED3-4C40-9A08-46D539667D50} = {E38B3FCB-0D4D-401D-A2FC-EDF41B755E53} + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CA49AA3C-0CE6-4735-887F-FB3631D63CEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA49AA3C-0CE6-4735-887F-FB3631D63CEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA49AA3C-0CE6-4735-887F-FB3631D63CEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA49AA3C-0CE6-4735-887F-FB3631D63CEE}.Release|Any CPU.Build.0 = Release|Any CPU + {9E499000-5E37-42F8-89D2-E18A53F0EF0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9E499000-5E37-42F8-89D2-E18A53F0EF0C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E499000-5E37-42F8-89D2-E18A53F0EF0C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9E499000-5E37-42F8-89D2-E18A53F0EF0C}.Release|Any CPU.Build.0 = Release|Any CPU + {13304F19-7ED3-4C40-9A08-46D539667D50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {13304F19-7ED3-4C40-9A08-46D539667D50}.Debug|Any CPU.Build.0 = Debug|Any CPU + {13304F19-7ED3-4C40-9A08-46D539667D50}.Release|Any CPU.ActiveCfg = Release|Any CPU + {13304F19-7ED3-4C40-9A08-46D539667D50}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/PersistentMap/BTreeFunctions.cs b/PersistentMap/BTreeFunctions.cs new file mode 100644 index 0000000..2617dae --- /dev/null +++ b/PersistentMap/BTreeFunctions.cs @@ -0,0 +1,629 @@ +using System.Runtime.CompilerServices; + +namespace PersistentMap +{ + public static class BTreeFunctions + { + // --------------------------------------------------------- + // Public API + // --------------------------------------------------------- + + public static bool TryGetValue(Node root, K key, TStrategy strategy, out V value) + where TStrategy : IKeyStrategy + { + Node current = root; + while (true) + { + if (current.IsLeaf) + { + var leaf = current.AsLeaf(); + // Leaf uses standard FindIndex (Lower Bound) to find exact match + int index = FindIndex(leaf, key, strategy); + if (index < leaf.Header.Count && strategy.Compare(leaf.Keys[index], key) == 0) + { + value = leaf.Values[index]; + return true; + } + value = default!; + return false; + } + else + { + // FIX: Internal uses FindRoutingIndex (Upper Bound) + var internalNode = current.AsInternal(); + int index = FindRoutingIndex(internalNode, key, strategy); + current = internalNode.Children[index]!; + } + } + } + + public static Node Set(Node root, K key, V value, TStrategy strategy, OwnerId owner) + where TStrategy : IKeyStrategy + { + // Root CoW check + root = root.EnsureEditable(owner); + + var splitResult = InsertRecursive(root, key, value, strategy, owner); + + if (splitResult != null) + { + // The root split. Create a new root. + var newRoot = new InternalNode(owner); + newRoot.Children[0] = root; + newRoot.Keys[0] = splitResult.Separator; + newRoot.Children[1] = splitResult.NewNode; + newRoot.SetCount(1); + + // Prefixes for internal nodes are derived from the separator keys + newRoot._prefixes![0] = strategy.GetPrefix(splitResult.Separator); + + return newRoot; + } + + return root; + } + + public static Node Remove(Node root, K key, TStrategy strategy, OwnerId owner) + where TStrategy : IKeyStrategy + { + root = root.EnsureEditable(owner); + + bool rebalanceNeeded = RemoveRecursive(root, key, strategy, owner); + + // If root is internal and became empty (count 0), replace with its only child + if (rebalanceNeeded) + { + if (!root.IsLeaf) + { + // CHANGE: Use AsInternal() + var internalRoot = root.AsInternal(); + if (internalRoot.Header.Count == 0) + { + return internalRoot.Children[0]!; + } + } + } + + return root; + } + + // --------------------------------------------------------- + // Internal Helpers: Search + // --------------------------------------------------------- + + // Used by Leaf Nodes: Finds the first key >= searchKey (Lower Bound) + // 2. Propagate to Helpers + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static int FindIndex(Node node, K key, TStrategy strategy) + where TStrategy : IKeyStrategy + { + long keyPrefix = strategy.GetPrefix(key); + int index = PrefixScanner.FindFirstGreaterOrEqual(node.Prefixes, keyPrefix); + + // Pass strategy to Refine + return RefineSearch(index, node.GetKeys(), key, strategy); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int RefineSearch(int startIndex, Span keys, K key, TStrategy strategy) + where TStrategy : IKeyStrategy + { + int i = startIndex; + // JIT can now inline 'strategy.Compare' here! + while (i < keys.Length && strategy.Compare(keys[i], key) < 0) + { + i++; + } + return i; + } + + // Used by Internal Nodes: Finds the child index to descend into. + // If Key == Separator, we must go RIGHT (index + 1), so we need (Upper Bound). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static int FindRoutingIndex(InternalNode node, K key, TStrategy strategy) + where TStrategy : IKeyStrategy + { + long keyPrefix = strategy.GetPrefix(key); + + // SIMD still finds >=. + int index = PrefixScanner.FindFirstGreaterOrEqual(node.Prefixes, keyPrefix); + + // Refine using Upper Bound logic (<= 0 instead of < 0) + return RefineRouting(index, node.Keys, node.Header.Count, key, strategy); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int RefineRouting(int startIndex, K[] keys, int count, K key, TStrategy strategy) + where TStrategy : IKeyStrategy + { + int i = startIndex; + // DIFFERENCE: We continue past valid matches. + // We want the first key STRICTLY GREATER than target. + // If keys[i] == key, we increment (go to right child). + while (i < count && strategy.Compare(keys[i], key) <= 0) + { + i++; + } + return i; + } + + // --------------------------------------------------------- + // Insertion Logic + // --------------------------------------------------------- + + private class SplitResult + { + public Node NewNode; + public K Separator; + public SplitResult(Node newNode, K separator) { NewNode = newNode; Separator = separator; } + } + + private static SplitResult? InsertRecursive(Node node, K key, V value, TStrategy strategy, OwnerId owner) + where TStrategy : IKeyStrategy + { + // DELETE the single FindIndex call at the top. + // int index = FindIndex(node, key, strategy); <-- REMOVE THIS + + // --- LEAF CASE --- + if (node.IsLeaf) + { + var leaf = node.AsLeaf(); + // Leaf uses FindIndex + int index = FindIndex(leaf, key, strategy); + + if (index < leaf.Header.Count && strategy.Compare(leaf.Keys[index], key) == 0) + { + leaf.Values[index] = value; + return null; + } + + if (leaf.Header.Count < LeafNode.Capacity) + { + InsertIntoLeaf(leaf, index, key, value, strategy); + return null; + } + else + { + return SplitLeaf(leaf, index, key, value, strategy, owner); + } + } + + // --- INTERNAL CASE --- + var internalNode = node.AsInternal(); + + // FIX: Internal uses FindRoutingIndex + int childIndex = FindRoutingIndex(internalNode, key, strategy); + + var child = internalNode.Children[childIndex]!.EnsureEditable(owner); + internalNode.Children[childIndex] = child; + + var split = InsertRecursive(child, key, value, strategy, owner); + + if (split != null) + { + // ... checks ... + // Use childIndex here + if (internalNode.Header.Count < InternalNode.Capacity - 1) + { + InsertIntoInternal(internalNode, childIndex, split.Separator, split.NewNode, strategy); + return null; + } + else + { + return SplitInternal(internalNode, childIndex, split.Separator, split.NewNode, strategy, owner); + } + } + return null; + } + private static void InsertIntoLeaf(LeafNode leaf, int index, K key, V value, TStrategy strategy) + where TStrategy : IKeyStrategy + { + int count = leaf.Header.Count; + if (index < count) + { + // Arrays allow access up to .Length (64), ignoring 'Count' + Array.Copy(leaf.Keys, index, leaf.Keys, index + 1, count - index); + + // This fails if leaf.Values is a Span of length 'count' + Array.Copy(leaf.Values, index, leaf.Values, index + 1, count - index); + + Array.Copy(leaf._prefixes!, index, leaf._prefixes!, index + 1, count - index); + } + + leaf.Keys[index] = key; + + // This fails if leaf.Values is a Span of length 'count' + leaf.Values[index] = value; + + leaf._prefixes![index] = strategy.GetPrefix(key); + + leaf.SetCount(count + 1); + } + private static SplitResult SplitLeaf(LeafNode left, int insertIndex, K key, V value, TStrategy strategy, OwnerId owner) + where TStrategy : IKeyStrategy + { + var right = new LeafNode(owner); + int totalCount = left.Header.Count; + + // Heuristics + int splitPoint; + if (insertIndex == totalCount) splitPoint = totalCount; // Append: Keep all in Left (90/10 logic effectively) + else if (insertIndex == 0) splitPoint = 0; // Prepend: Right gets all + else splitPoint = totalCount / 2; + + // Move items to Right + int moveCount = totalCount - splitPoint; + if (moveCount > 0) + { + Array.Copy(left.Keys, splitPoint, right.Keys, 0, moveCount); + Array.Copy(left.Values, splitPoint, right.Values, 0, moveCount); + // Manually copy prefixes if needed or re-calculate + for(int i=0; i(right, right.Keys[0]); + } + + private static void InsertIntoInternal(InternalNode node, int index, K separator, Node newChild, TStrategy strategy) + where TStrategy : IKeyStrategy + { + int count = node.Header.Count; + + // Shift Keys and Prefixes + if (index < count) + { + Array.Copy(node.Keys, index, node.Keys, index + 1, count - index); + + // FIX: Shift raw prefix array + Array.Copy(node._prefixes!, index, node._prefixes!, index + 1, count - index); + } + + // Shift Children + // Children buffer is indexable like an array but requires manual loop or Unsafe copy + // if we don't want to use unsafe pointers. + // Since it's a small struct buffer (size 33), a loop is fine/fast. + for (int i = count + 1; i > index + 1; i--) + { + node.Children[i] = node.Children[i - 1]; + } + + node.Keys[index] = separator; + + // FIX: Write to raw array + node._prefixes![index] = strategy.GetPrefix(separator); + + node.Children[index + 1] = newChild; + node.SetCount(count + 1); + } + + private static SplitResult SplitInternal(InternalNode left, int insertIndex, K separator, Node newChild, TStrategy strategy, OwnerId owner) + where TStrategy : IKeyStrategy + { + var right = new InternalNode(owner); + int count = left.Header.Count; + int splitPoint = count / 2; // Internal nodes usually split 50/50 to keep tree fat + + // The key at splitPoint moves UP to become the separator. + // Keys > splitPoint move to Right. + + K upKey = left.Keys[splitPoint]; + + // Move Keys/Prefixes to Right + int moveCount = count - splitPoint - 1; // -1 because splitPoint key goes up + Array.Copy(left.Keys, splitPoint + 1, right.Keys, 0, moveCount); + for(int i=0; i(right, upKey); + } + + // --------------------------------------------------------- + // Removal Logic + // --------------------------------------------------------- + + // --------------------------------------------------------- + // Removal Logic (Fixed Type Inference & Casting) + // --------------------------------------------------------- + + private static bool RemoveRecursive(Node node, K key, TStrategy strategy, OwnerId owner) + where TStrategy : IKeyStrategy + { + + if (node.IsLeaf) + { + var leaf = node.AsLeaf(); + int index = FindIndex(leaf, key, strategy); + // Exact match check + if (index < leaf.Header.Count && strategy.Compare(leaf.Keys[index], key) == 0) + { + + RemoveFromLeaf(leaf, index); + return leaf.Header.Count < LeafNode.MergeThreshold; + } + return false; // Key not found + } + else + { + + // Internal Node + var internalNode = node.AsInternal(); + + int index = FindRoutingIndex(internalNode, key, strategy); + + // Descend + var child = internalNode.Children[index]!.EnsureEditable(owner); + internalNode.Children[index] = child; + + // FIX 1: Explicitly specify here. + // The compiler cannot infer 'V' because it is not in the arguments. + bool childUnderflow = RemoveRecursive(child, key, strategy, owner); + + if (childUnderflow) + { + // FIX 2: HandleUnderflow also needs to know to perform Leaf casts correctly + return HandleUnderflow(internalNode, index, strategy, owner); + } + + return false; + } + } + + private static void RemoveFromLeaf(LeafNode leaf, int index) + { + int count = leaf.Header.Count; + Array.Copy(leaf.Keys, index + 1, leaf.Keys, index, count - index - 1); + Array.Copy(leaf.Values, index + 1, leaf.Values, index, count - index - 1); + + var p = leaf._prefixes; + for(int i=index; i to HandleUnderflow + private static bool HandleUnderflow(InternalNode parent, int childIndex, TStrategy strategy, OwnerId owner) + where TStrategy : IKeyStrategy + { + // Try to borrow from Right Sibling + if (childIndex < parent.Header.Count) + { + var rightSibling = parent.Children[childIndex + 1]!.EnsureEditable(owner); + parent.Children[childIndex + 1] = rightSibling; + var leftChild = parent.Children[childIndex]!; + + if (CanBorrow(rightSibling)) + { + RotateLeft(parent, childIndex, leftChild, rightSibling, strategy); + return false; + } + else + { + Merge(parent, childIndex, leftChild, rightSibling, strategy); + return parent.Header.Count < LeafNode.MergeThreshold; + } + } + // Try to borrow from Left Sibling + else if (childIndex > 0) + { + var leftSibling = parent.Children[childIndex - 1]!.EnsureEditable(owner); + parent.Children[childIndex - 1] = leftSibling; + var rightChild = parent.Children[childIndex]!; + + if (CanBorrow(leftSibling)) + { + RotateRight(parent, childIndex - 1, leftSibling, rightChild, strategy); + return false; + } + else + { + // Merge Left and Current. Note separator index is 'childIndex - 1' + Merge(parent, childIndex - 1, leftSibling, rightChild, strategy); + return parent.Header.Count < LeafNode.MergeThreshold; + } + } + + return true; + } + + private static bool CanBorrow(Node node) + { + // Note: LeafNode.MergeThreshold is constant 8, so we can access it statically or via 8 + return node.Header.Count > 8 + 1; + } + + // FIX 4: Added to Merge/Rotate so we can cast to LeafNode successfully. + private static void Merge(InternalNode parent, int separatorIndex, Node left, Node right, TStrategy strategy) + where TStrategy : IKeyStrategy + { + // Case A: Merging Leaves + if (left.IsLeaf) + { + var leftLeaf = left.AsLeaf(); + var rightLeaf = right.AsLeaf(); + + int lCount = leftLeaf.Header.Count; + int rCount = rightLeaf.Header.Count; + + Array.Copy(rightLeaf.Keys, 0, leftLeaf.Keys, lCount, rCount); + Array.Copy(rightLeaf.Values, 0, leftLeaf.Values, lCount, rCount); + Array.Copy(rightLeaf._prefixes.ToArray(), 0, leftLeaf._prefixes.ToArray(), lCount, rCount); + + leftLeaf.SetCount(lCount + rCount); + leftLeaf.Next = rightLeaf.Next; + } + // Case B: Merging Internal Nodes + else + { + var leftInternal = left.AsInternal(); + var rightInternal = right.AsInternal(); + + // Pull separator from parent + K separator = parent.Keys[separatorIndex]; + + int lCount = leftInternal.Header.Count; + leftInternal.Keys[lCount] = separator; + leftInternal._prefixes[lCount] = strategy.GetPrefix(separator); + + int rCount = rightInternal.Header.Count; + Array.Copy(rightInternal.Keys, 0, leftInternal.Keys, lCount + 1, rCount); + Array.Copy(rightInternal._prefixes.ToArray(), 0, leftInternal._prefixes.ToArray(), lCount + 1, rCount); + + for (int i = 0; i <= rCount; i++) + { + leftInternal.Children[lCount + 1 + i] = rightInternal.Children[i]; + } + + leftInternal.SetCount(lCount + 1 + rCount); + } + + // Remove Separator and Right Child from Parent + int pCount = parent.Header.Count; + Array.Copy(parent.Keys, separatorIndex + 1, parent.Keys, separatorIndex, pCount - separatorIndex - 1); + + var pp = parent._prefixes; + for(int i=separatorIndex; i(InternalNode parent, int separatorIndex, Node left, Node right, TStrategy strategy) + where TStrategy : IKeyStrategy + { + // Move one item from Right to Left + if (left.IsLeaf) + { + var leftLeaf = left.AsLeaf(); + var rightLeaf = right.AsLeaf(); + + // Move first of right to end of left + InsertIntoLeaf(leftLeaf, leftLeaf.Header.Count, rightLeaf.Keys[0], rightLeaf.Values[0], strategy); + RemoveFromLeaf(rightLeaf, 0); + + // Update Parent Separator + parent.Keys[separatorIndex] = rightLeaf.Keys[0]; + parent._prefixes[separatorIndex] = strategy.GetPrefix(rightLeaf.Keys[0]); + } + else + { + var leftInternal = left.AsInternal(); + var rightInternal = right.AsInternal(); + + // 1. Move Parent Separator to Left End + K sep = parent.Keys[separatorIndex]; + InsertIntoInternal(leftInternal, leftInternal.Header.Count, sep, rightInternal.Children[0]!, strategy); + + // 2. Move Right[0] Key to Parent + parent.Keys[separatorIndex] = rightInternal.Keys[0]; + parent._prefixes[separatorIndex] = strategy.GetPrefix(rightInternal.Keys[0]); + + // 3. Fix Right (Remove key 0 and shift child 0 out) + // We basically remove key at 0. Child 0 was moved to left. Child 1 becomes Child 0. + // Re-using Remove logic implies shifts. + // Manual shift for performance: + int rCount = rightInternal.Header.Count; + + // Shift children + for(int i=0; i(InternalNode parent, int separatorIndex, Node left, Node right, TStrategy strategy) + where TStrategy : IKeyStrategy + { + // Move one item from Left to Right + if (left.IsLeaf) + { + var leftLeaf = left.AsLeaf(); + var rightLeaf = right.AsLeaf(); + int last = leftLeaf.Header.Count - 1; + + InsertIntoLeaf(rightLeaf, 0, leftLeaf.Keys[last], leftLeaf.Values[last], strategy); + RemoveFromLeaf(leftLeaf, last); + + parent.Keys[separatorIndex] = rightLeaf.Keys[0]; + parent._prefixes[separatorIndex] = strategy.GetPrefix(rightLeaf.Keys[0]); + } + else + { + var leftInternal = (InternalNode)left; + var rightInternal = (InternalNode)right; + int last = leftInternal.Header.Count - 1; + + // 1. Move Parent Separator to Right Start + K sep = parent.Keys[separatorIndex]; + // The child moving to right is the *last* child of left (index count) + InsertIntoInternal(rightInternal, 0, sep, leftInternal.Children[last + 1]!, strategy); + + // 2. Move Left[last] Key to Parent + parent.Keys[separatorIndex] = leftInternal.Keys[last]; + parent._prefixes[separatorIndex] = strategy.GetPrefix(leftInternal.Keys[last]); + + // 3. Truncate Left + leftInternal.SetCount(last); + } + } + } +} \ No newline at end of file diff --git a/PersistentMap/BaseOrderedMap.cs b/PersistentMap/BaseOrderedMap.cs new file mode 100644 index 0000000..4d6b6ad --- /dev/null +++ b/PersistentMap/BaseOrderedMap.cs @@ -0,0 +1,83 @@ +using System.Collections; + +namespace PersistentMap; + +public abstract class BaseOrderedMap : IEnumerable> where TStrategy : IKeyStrategy +{ + internal Node _root; + internal readonly TStrategy _strategy; + + public int Count { get; protected set; } + + protected BaseOrderedMap(Node root, TStrategy strategy, int count) + { + _root = root ?? throw new ArgumentNullException(nameof(root)); + _strategy = strategy ?? throw new ArgumentNullException(nameof(strategy)); + Count = count; + + } + + // --------------------------------------------------------- + // Read Operations (Shared) + // --------------------------------------------------------- + + public bool TryGetValue(K key, out V value) + { + return BTreeFunctions.TryGetValue(_root, key, _strategy, out value); + } + + public bool ContainsKey(K key) + { + return BTreeFunctions.TryGetValue(_root, key, _strategy, out _); + } + + + + // --------------------------------------------------------- + // Bootstrap / Factory Helpers + // --------------------------------------------------------- + + public static PersistentMap Create(TStrategy strategy) + { + // Start with an empty leaf owned by None so the first write triggers CoW. + var emptyRoot = new LeafNode(OwnerId.None); + return new PersistentMap(emptyRoot, strategy, 0); + } + + public static TransientMap CreateTransient(TStrategy strategy) + { + var emptyRoot = new LeafNode(OwnerId.None); + return new TransientMap(emptyRoot, strategy,0); + } + + + public BTreeEnumerator GetEnumerator() + { + return AsEnumerable().GetEnumerator(); + } + + IEnumerator> IEnumerable>.GetEnumerator() + { + return GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + // 1. Full Scan + public BTreeEnumerable AsEnumerable() + => new(_root, _strategy, false, default, false, default); + +// 2. Exact Range + public BTreeEnumerable Range(K min, K max) + => new(_root, _strategy, true, min, true, max); + +// 3. Start From (Open Ended) + public BTreeEnumerable From(K min) => new(_root, _strategy, true, min, false, default); + +// 4. Until (Start at beginning) + public BTreeEnumerable Until(K max) + => new(_root, _strategy, false, default, true, max); +} \ No newline at end of file diff --git a/PersistentMap/Iterator.cs b/PersistentMap/Iterator.cs new file mode 100644 index 0000000..7706f71 --- /dev/null +++ b/PersistentMap/Iterator.cs @@ -0,0 +1,245 @@ +using System.Collections; +using System.Runtime.CompilerServices; + +namespace PersistentMap; + + + +public struct BTreeEnumerable : IEnumerable> +where TStrategy : IKeyStrategy +{ + private readonly Node _root; + private readonly TStrategy _strategy; + private readonly K _min, _max; + private readonly bool _hasMin, _hasMax; + + public BTreeEnumerable(Node root, TStrategy strategy, bool hasMin, K min, bool hasMax, K max) + { + _root = root; _strategy = strategy; + _hasMin = hasMin; _min = min; + _hasMax = hasMax; _max = max; + } + + public BTreeEnumerator GetEnumerator() + { + return new BTreeEnumerator(_root, _strategy, _hasMin, _min, _hasMax, _max); + } + + IEnumerator> IEnumerable>.GetEnumerator() => GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + +// Fixed-size buffer for the path. +// Depth 16 * 32 (branching factor) = Exabytes of capacity. +[InlineArray(16)] +internal struct IterNodeBuffer +{ + private Node _element0; +} + +[InlineArray(16)] +internal struct IterIndexBuffer +{ + private int _element0; +} + +public struct BTreeEnumerator : IEnumerator> + where TStrategy : IKeyStrategy + { + private readonly TStrategy _strategy; + private readonly Node _root; + + // --- BOUNDS --- + private readonly bool _hasMax; + private readonly K _maxKey; + private readonly bool _hasMin; + private readonly K _minKey; + + // --- INLINE STACK --- + private IterNodeBuffer _nodeStack; + private IterIndexBuffer _indexStack; + private int _depth; + + // --- STATE --- + private LeafNode? _currentLeaf; + private int _currentLeafIndex; + private KeyValuePair _current; + + // Unified Constructor + // We use boolean flags because 'K' might be a struct where 'null' is impossible. + public BTreeEnumerator(Node root, TStrategy strategy, bool hasMin, K minKey, bool hasMax, K maxKey) + { + _root = root; + _strategy = strategy; + _hasMax = hasMax; + _maxKey = maxKey; + _hasMin = hasMin; + _minKey = minKey; + + _nodeStack = new IterNodeBuffer(); + _indexStack = new IterIndexBuffer(); // Explicit struct init + _depth = 0; + _currentLeaf = null; + _currentLeafIndex = -1; + _current = default; + + if (root != null) + { + if (hasMin) + { + Seek(minKey); + } + else + { + DiveLeft(); + } + } + } + + // Logic 1: Unbounded Start (Go to very first item) + private void DiveLeft() + { + Node node = _root; + _depth = 0; + + while (!node.IsLeaf) + { + var internalNode = node.AsInternal(); + _nodeStack[_depth] = internalNode; + _indexStack[_depth] = 0; // Always take left-most child + _depth++; + node = internalNode.Children[0]!; + } + + _currentLeaf = node.AsLeaf(); + _currentLeafIndex = -1; // Position before the first element (0) + } + + // Logic 2: Bounded Start (Go to specific key) + private void Seek(K key) + { + Node node = _root; + _depth = 0; + + // Dive using Routing + while (!node.IsLeaf) + { + var internalNode = node.AsInternal(); + int idx = BTreeFunctions.FindRoutingIndex(internalNode, key, _strategy); + + _nodeStack[_depth] = internalNode; + _indexStack[_depth] = idx; + _depth++; + + node = internalNode.Children[idx]!; + } + + // Find index in Leaf + _currentLeaf = node.AsLeaf(); + int index = BTreeFunctions.FindIndex(_currentLeaf, key, _strategy); + + // Set position to (index - 1) so that the first MoveNext() lands on 'index' + _currentLeafIndex = index - 1; + } + + public bool MoveNext() + { + if (_currentLeaf == null) return false; + + // 1. Try to advance in current leaf + if (++_currentLeafIndex < _currentLeaf.Header.Count) + { + // OPTIMIZATION: Check Max Bound (if active) + if (_hasMax) + { + // If Current Key > Max Key, we are done. + if (_strategy.Compare(_currentLeaf.Keys[_currentLeafIndex], _maxKey) > 0) + { + _currentLeaf = null; // Close iterator + return false; + } + } + + _current = new KeyValuePair(_currentLeaf.Keys[_currentLeafIndex], _currentLeaf.Values[_currentLeafIndex]); + return true; + } + + // 2. Leaf exhausted. Find next leaf. + if (FindNextLeaf()) + { + // Found new leaf, index reset to 0. + // Check Max Bound immediately for the first item + if (_hasMax) + { + if (_strategy.Compare(_currentLeaf!.Keys[0], _maxKey) > 0) + { + _currentLeaf = null; + return false; + } + } + + _current = new KeyValuePair(_currentLeaf.Keys[0], _currentLeaf.Values[0]); + return true; + } + + return false; + } + + private bool FindNextLeaf() + { + while (_depth > 0) + { + _depth--; + var internalNode = _nodeStack[_depth].AsInternal(); + int currentIndex = _indexStack[_depth]; + + if (currentIndex < internalNode.Header.Count) + { + int nextIndex = currentIndex + 1; + _indexStack[_depth] = nextIndex; + _depth++; + + Node node = internalNode.Children[nextIndex]!; + while (!node.IsLeaf) + { + _nodeStack[_depth] = node; + _indexStack[_depth] = 0; + _depth++; + node = node.AsInternal().Children[0]!; + } + + _currentLeaf = node.AsLeaf(); + _currentLeafIndex = 0; + return true; + } + } + return false; + } + + public KeyValuePair Current => _current; + object IEnumerator.Current => _current; + public void Reset() + { + // 1. Clear current state + _depth = 0; + _currentLeaf = null; + _currentLeafIndex = -1; + _current = default; + + // 2. Re-initialize based on how the iterator was created + if (_root != null) + { + if (_hasMin) + { + // If we had a start range, find it again + Seek(_minKey); + } + else + { + // Otherwise, go back to the very first leaf + DiveLeft(); + } + } + } + public void Dispose() { } + } \ No newline at end of file diff --git a/PersistentMap/KeyStrategies.cs b/PersistentMap/KeyStrategies.cs new file mode 100644 index 0000000..f179f31 --- /dev/null +++ b/PersistentMap/KeyStrategies.cs @@ -0,0 +1,162 @@ +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.X86; + +namespace PersistentMap; + +using System; +using System.Buffers.Binary; +using System.Runtime.CompilerServices; + +public interface IKeyStrategy +{ + int Compare(K x, K y); + long GetPrefix(K key); +} + + +public struct UnicodeStrategy : IKeyStrategy +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int Compare(string? x, string? y) => string.CompareOrdinal(x, y); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public long GetPrefix(string key) + { + if (string.IsNullOrEmpty(key)) return long.MinValue; + + // 1. Prepare Buffer (8 bytes) + // stackalloc is virtually free (pointer bump) + Span utf8Bytes = stackalloc byte[8]; + + // 2. Transcode (The "Safe" Magic) + // This intrinsic handles ASCII efficiently and converts Surrogates/Chinese + // into bytes that maintain the correct "Magnitude" (Sort Order). + // Invalid surrogates become 0xEF (Replacement Char), which sorts > ASCII. + System.Text.Unicode.Utf8.FromUtf16( + key.AsSpan(0, Math.Min(key.Length, 8)), + utf8Bytes, + out _, + out _, + replaceInvalidSequences: true); // True ensures we get 0xEF for broken chars + + // 3. Load as Big Endian Long + long packed = BinaryPrimitives.ReadInt64BigEndian(utf8Bytes); + + // 4. Sign Toggle + // Maps the byte range 0x00..0xFF to the signed long range Min..Max + // Essential for the < and > operators to work correctly. + return packed ^ unchecked((long)0x8080808080808080); + } +} + +public struct IntStrategy : IKeyStrategy +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int Compare(int x, int y) => x.CompareTo(y); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public long GetPrefix(int key) + { + // Pack the 32-bit int into the high 32-bits of the long. + // This preserves sorting order when scanning the long array. + // Cast to uint first to prevent sign extension confusion during the shift, + // though standard int shifting usually works fine for direct mapping. + return (long)key << 32; + } +} +/// +/// Helper for SIMD accelerated prefix scanning. +/// +public static class PrefixScanner +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int FindFirstGreaterOrEqual(ReadOnlySpan prefixes, long targetPrefix) + { + // Fallback for short arrays or unsupported hardware + if (!Avx2.IsSupported || prefixes.Length < 4) + return LinearScan(prefixes, targetPrefix); + + return Avx512F.IsSupported + ? ScanAvx512(prefixes, targetPrefix) + : ScanAvx2(prefixes, targetPrefix); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int LinearScan(ReadOnlySpan prefixes, long target) + { + for (var i = 0; i < prefixes.Length; i++) + if (prefixes[i] >= target) + return i; + return prefixes.Length; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static unsafe int ScanAvx2(ReadOnlySpan prefixes, long target) + { + // Create a vector where every element is the target prefix + var vTarget = Vector256.Create(target); + var i = 0; + var len = prefixes.Length; + + // Process 4 longs at a time (256 bits) + for (; i <= len - 4; i += 4) + fixed (long* ptr = prefixes) + { + var vData = Avx2.LoadVector256(ptr + i); + + // Compare: result is -1 (all 1s) if true, 0 if false + // We want Data >= Target. + // AVX2 CompareGreaterThan is for signed. Longs should be treated carefully, + // but for text prefixes (positive), signed compare is usually sufficient. + // Effectively: !(Data < Target) could be safer if signs vary, + // but here we assume prefixes are derived from unsigned chars. + // Standard AVX2 hack for CompareGreaterOrEqual (Signed): + // No native _mm256_cmpge_epi64 in AVX2. + // Use CompareGreaterThan(Data, Target - 1) + var vResult = Avx2.CompareGreaterThan(vData, Vector256.Create(target - 1)); + + var mask = Avx2.MoveMask(vResult.AsByte()); + + if (mask != 0) + { + // Identify the first set bit corresponding to a 64-bit element + // MoveMask returns 32 bits (1 per byte). Each long is 8 bytes. + // We check bits 0, 8, 16, 24. + if ((mask & 0xFF) != 0) return i + 0; + if ((mask & 0xFF00) != 0) return i + 1; + if ((mask & 0xFF0000) != 0) return i + 2; + return i + 3; + } + } + + return LinearScan(prefixes.Slice(i), target) + i; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static unsafe int ScanAvx512(ReadOnlySpan prefixes, long target) + { + var vTarget = Vector512.Create(target); + var i = 0; + var len = prefixes.Length; + + for (; i <= len - 8; i += 8) + fixed (long* ptr = prefixes) + { + var vData = Avx512F.LoadVector512(ptr + i); + // AVX512 has dedicated Compare Greater Than or Equal Long + var mask = Avx512F.CompareGreaterThanOrEqual(vData, vTarget); + + if (mask != Vector512.Zero) + { + // Extract most significant bit mask + var m = mask.ExtractMostSignificantBits(); + // Count trailing zeros to find the index + return i + BitOperations.TrailingZeroCount(m); + } + } + + return LinearScan(prefixes.Slice(i), target) + i; + } +} \ No newline at end of file diff --git a/PersistentMap/Nodes.cs b/PersistentMap/Nodes.cs new file mode 100644 index 0000000..b784229 --- /dev/null +++ b/PersistentMap/Nodes.cs @@ -0,0 +1,310 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace PersistentMap; + +[Flags] +public enum NodeFlags : byte +{ + None = 0, + IsLeaf = 1 << 0, + IsRoot = 1 << 1, + HasPrefixes = 1 << 2 +} + +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct NodeHeader +{ + // 6 Bytes: OwnerId for Copy-on-Write (CoW) + public OwnerId Owner; + + // 1 Byte: Number of items currently used + public byte Count; + + // 1 Byte: Type flags (Leaf, Root, etc.) + public NodeFlags Flags; + + public NodeHeader(OwnerId owner, byte count, NodeFlags flags) + { + Owner = owner; + Count = count; + Flags = flags; + } +} + +// Constraint: Internal Nodes fixed at 32 children. +// This removes the need for a separate array allocation for children references. +[InlineArray(32)] +public struct NodeBuffer +{ + private Node? _element0; +} + +public abstract class Node +{ + public NodeHeader Header; + + // FIX: Change to 'internal' so BTreeFunctions can shift the array directly. + internal long[]? _prefixes; + + protected Node(OwnerId owner, NodeFlags flags) + { + Header = new NodeHeader(owner, 0, flags); + } + + public abstract Span GetKeys(); + + // Keep this for Search (Read-Only): it limits the view to valid items only. + public Span Prefixes => _prefixes.AsSpan(0, Header.Count); + + public bool IsLeaf => (Header.Flags & NodeFlags.IsLeaf) != 0; + + public abstract Node EnsureEditable(OwnerId transactionId); + + public void SetCount(int newCount) => Header.Count = (byte)newCount; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public LeafNode AsLeaf() + { + // Zero-overhead cast. Assumes you checked IsLeaf or know logic flow. + return Unsafe.As>(this); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public InternalNode AsInternal() + { + // Zero-overhead cast. Assumes you checked !IsLeaf or know logic flow. + return Unsafe.As>(this); + } +} + +public sealed class LeafNode : Node +{ + public const int Capacity = 64; + public const int MergeThreshold = 8; + + // Leaf stores Keys and Values + public K[] Keys; + public LeafNode? Next; // For range scans + public V[] Values; + + public LeafNode(OwnerId owner) : base(owner, NodeFlags.IsLeaf | NodeFlags.HasPrefixes) + { + Keys = new K[Capacity]; + Values = new V[Capacity]; + _prefixes = new long[Capacity]; + } + + // Copy Constructor for CoW + private LeafNode(LeafNode original, OwnerId newOwner) + : base(newOwner, original.Header.Flags) + { + Header.Count = original.Header.Count; + Next = original.Next; + + // Allocate new arrays + Keys = new K[Capacity]; + Values = new V[Capacity]; + _prefixes = new long[Capacity]; + + // Copy data + Array.Copy(original.Keys, Keys, original.Header.Count); + Array.Copy(original.Values, Values, original.Header.Count); + if (original._prefixes != null) + Array.Copy(original._prefixes, _prefixes, original.Header.Count); + } + + public override Node EnsureEditable(OwnerId transactionId) + { + // CASE 1: Persistent Mode (transactionId is None). + // We MUST create a copy, because we cannot distinguish "Shared Immutable Node (0)" + // from "New Mutable Node (0)" based on ID alone. + // However, since BTreeFunctions only calls this once before descending, + // we won't copy the same fresh node twice. + if (transactionId == OwnerId.None) + { + return new LeafNode(this, OwnerId.None); + } + + // CASE 2: Transient Mode. + // If we own the node, return it. + if (Header.Owner == transactionId) + { + return this; + } + + // CASE 3: CoW needed (Ownership mismatch). + return new LeafNode(this, transactionId); + } + + public override Span GetKeys() + { + return Keys.AsSpan(0, Header.Count); + } + + public Span GetValues() + { + return Values.AsSpan(0, Header.Count); + } +} + +public sealed class InternalNode : Node +{ + public const int Capacity = 32; + + // Inline buffer for children (no array object overhead) + public NodeBuffer Children; + + // Internal stores Keys (separators) and Children + public K[] Keys; + + public InternalNode(OwnerId owner) : base(owner, NodeFlags.HasPrefixes) + { + Keys = new K[Capacity]; + _prefixes = new long[Capacity]; + // Children buffer is a struct, zero-initialized by default + } + + // Copy Constructor for CoW + private InternalNode(InternalNode original, OwnerId newOwner) + : base(newOwner, original.Header.Flags) + { + Header.Count = original.Header.Count; + + Keys = new K[Capacity]; + _prefixes = new long[Capacity]; + + // Copy Keys and Prefixes + Array.Copy(original.Keys, Keys, original.Header.Count); + if (original._prefixes != null) + Array.Copy(original._prefixes, _prefixes, original.Header.Count); + + // Copy Children (Manual loop required for InlineArray in generic context usually, + // but here we can iterate the span) + var srcChildren = original.GetChildren(); + for (var i = 0; i < srcChildren.Length; i++) Children[i] = srcChildren[i]; + } + + public override Node EnsureEditable(OwnerId transactionId) + { + if (transactionId == OwnerId.None) + { + return new InternalNode(this, OwnerId.None); + } + + if (Header.Owner == transactionId) + { + return this; + } + + return new InternalNode(this, transactionId); + } + public override Span GetKeys() + { + return Keys.AsSpan(0, Header.Count); + } + + // Exposes the InlineArray as a Span + public Span?> GetChildren() + { + return MemoryMarshal.CreateSpan?>(ref Children[0]!, Header.Count + 1); + } + + public void SetChild(int index, Node node) + { + Children[index] = node; + } +} + +[StructLayout(LayoutKind.Auto, Pack = 1)] +public readonly struct OwnerId(uint id, ushort gen) : IEquatable +{ + private const int BatchSize = 100; + + // The max of allocated IDs globally. + // Starts at 0, so the first batch reserves IDs 1 to 100. + private static long _globalHighWaterMark; + + + // These fields are unique to each thread. They initialize to 0/default. + // The current ID value this thread is handing out. + [ThreadStatic] private static long _localCurrentId; + + // How many IDs are left in this thread's current batch. + [ThreadStatic] private static int _localRemaining; + + // --------------------------------------------------------- + // Instance Data (6 Bytes) + // --------------------------------------------------------- + private readonly uint Id = id; // 4 bytes + private readonly ushort Gen = gen; // 2 bytes + + /// + /// Generates the next unique OwnerId. + /// mostly non-blocking (thread-local), hits Interlocked only once per 100 IDs. + /// + public static OwnerId Next() + { + // We have IDs remaining in our local batch. + // This executes with zero locking overhead. + if (_localRemaining > 0) + { + _localRemaining--; + var val = ++_localCurrentId; + return new OwnerId((uint)val, (ushort)(val >> 32)); + } + + // SLOW PATH: We ran out (or this is the thread's first call). + return NextBatch(); + } + + private static OwnerId NextBatch() + { + // Atomically reserve a new block of IDs from the global counter. + // Only one thread contends for this cache line at a time. + var reservedEnd = Interlocked.Add(ref _globalHighWaterMark, BatchSize); + + // Calculate the start of our new range. + var reservedStart = reservedEnd - BatchSize + 1; + + // Reset the local cache. + // We set _localCurrentId to (start - 1) so that the first increment + // inside the logic below lands exactly on 'reservedStart'. + _localCurrentId = reservedStart - 1; + _localRemaining = BatchSize; + + // Perform the generation logic (same as Fast Path) + _localRemaining--; + var val = ++_localCurrentId; + return new OwnerId((uint)val, (ushort)(val >> 32)); + } + + public static readonly OwnerId None = new(0, 0); + + public bool IsNone => Id == 0 && Gen == 0; + + public bool Equals(OwnerId other) + { + return Id == other.Id && Gen == other.Gen; + } + + public override bool Equals(object? obj) + { + return obj is OwnerId other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(Id, Gen); + } + + public static bool operator ==(OwnerId left, OwnerId right) + { + return left.Equals(right); + } + + public static bool operator !=(OwnerId left, OwnerId right) + { + return !left.Equals(right); + } +} \ No newline at end of file diff --git a/PersistentMap/PersistentMap.cs b/PersistentMap/PersistentMap.cs new file mode 100644 index 0000000..db58c9c --- /dev/null +++ b/PersistentMap/PersistentMap.cs @@ -0,0 +1,43 @@ +using System.Collections; + +namespace PersistentMap; + +public sealed class PersistentMap : BaseOrderedMap, IEnumerable, IEnumerable> where TStrategy : IKeyStrategy +{ + internal PersistentMap(Node root, TStrategy strategy, int count) + : base(root, strategy, count) { } + + // --------------------------------------------------------- + // Immutable Write API (Returns new Map) + // --------------------------------------------------------- + public PersistentMap Set(K key, V value) + { + // OPTIMIZATION: Use OwnerId.None (0). + // This signals EnsureEditable to always copy the root path, + // producing a new tree of nodes that also have OwnerId.None. + var newRoot = BTreeFunctions.Set(_root, key, value, _strategy, OwnerId.None); + return new PersistentMap(newRoot, _strategy, Count + 1); + } + + public static PersistentMap Empty(TStrategy strategy) + { + // Create an empty Leaf Node. + // 'default(OwnerId)' (usually 0) marks this node as Immutable/Persistent. + // This ensures that any subsequent Set/Remove will clone this node + // instead of modifying it in place. + var emptyRoot = new LeafNode(default(OwnerId)); + + return new PersistentMap(emptyRoot, strategy, 0); + } + + public PersistentMap Remove(K key) + { + var newRoot = BTreeFunctions.Remove(_root, key, _strategy, OwnerId.None); + return new PersistentMap(newRoot, _strategy, Count - 1); + } + + public TransientMap ToTransient() + { + return new TransientMap(_root, _strategy, Count); + } +} diff --git a/PersistentMap/TransientMap.cs b/PersistentMap/TransientMap.cs new file mode 100644 index 0000000..3268112 --- /dev/null +++ b/PersistentMap/TransientMap.cs @@ -0,0 +1,43 @@ +using System.Collections; + +namespace PersistentMap; + +public sealed class TransientMap : BaseOrderedMap, IEnumerable> where TStrategy : IKeyStrategy +{ + // This is mutable, but we treat it as readonly for the ID generation logic usually. + private OwnerId _transactionId; + + internal TransientMap(Node root, TStrategy strategy, int count) + : base(root, strategy, count) + { + _transactionId = OwnerId.Next(); + } + + public void Set(K key, V value) + { + _root = BTreeFunctions.Set(_root, key, value, _strategy, _transactionId); + } + + public void Remove(K key) + { + _root = BTreeFunctions.Remove(_root, key, _strategy, _transactionId); + } + + public PersistentMap ToPersistent() + { + // 1. Create the snapshot. + // The nodes currently have _transactionId. + // The PersistentMap will read them fine (it reads anything). + // BUT: If we write to PersistentMap, it uses OwnerId.None, so it COPIES. (Safe) + + var snapshot = new PersistentMap(_root, _strategy, Count); + + // 2. Protect the snapshot from THIS TransientMap. + // If we Set() again on this map, we have the same _transactionId. + // We would mutate the nodes we just gave to the snapshot. + // FIX: "Seal" the current transaction by rolling to a new ID. + _transactionId = OwnerId.Next(); + + return snapshot; + } +} \ No newline at end of file diff --git a/TestProject1/FuzzTest.cs b/TestProject1/FuzzTest.cs new file mode 100644 index 0000000..5673030 --- /dev/null +++ b/TestProject1/FuzzTest.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; +using Xunit.Abstractions; +using PersistentMap; + +public class BTreeFuzzTests +{ + private readonly ITestOutputHelper _output; + private readonly IntStrategy _strategy = new(); + + public BTreeFuzzTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void Fuzz_Insert_And_Remove_consistency() + { + // CONFIGURATION + const int Iterations = 100_000; // High enough to trigger all splits/merges + const int KeyRange = 5000; // Small enough to cause frequent collisions + int Seed = Environment.TickCount; + + // ORACLES + var reference = new SortedDictionary(); + var subject = BaseOrderedMap.CreateTransient(_strategy); + + var random = new Random(Seed); + _output.WriteLine($"Starting Fuzz Test with Seed: {Seed}"); + + try + { + for (int i = 0; i < Iterations; i++) + { + // 1. Pick an Action: 70% Insert/Update, 30% Remove + bool isInsert = random.NextDouble() < 0.7; + int key = random.Next(KeyRange); + int val = key * 100; + + if (isInsert) + { + // ACTION: INSERT + reference[key] = val; + subject.Set(key, val); + } + else + { + // ACTION: REMOVE + if (reference.ContainsKey(key)) + { + reference.Remove(key); + subject.Remove(key); + } + else + { + // Try removing non-existent key (should be safe) + subject.Remove(key); + } + } + + // 2. VERIFY CONSISTENCY (Expensive but necessary) + // We check consistency every 1000 ops or if the tree is small, + // to keep the test fast enough. + if (i % 1000 == 0 || reference.Count < 100) + { + AssertConsistency(reference, subject); + } + } + + // Final check + AssertConsistency(reference, subject); + } + catch (Exception) + { + _output.WriteLine($"FAILED at iteration with SEED: {Seed}"); + throw; // Re-throw to fail the test + } + } + + [Fact] + public void Fuzz_Range_Queries() + { + // Validates that your Range Enumerator matches LINQ on the reference + const int Iterations = 1000; + const int KeyRange = 2000; + int Seed = Environment.TickCount; + + var reference = new SortedDictionary(); + var subject = BaseOrderedMap.CreateTransient(_strategy); + var random = new Random(Seed); + + // Fill Data + for(int i=0; i= min + + // 1. Reference Result (LINQ) + // Note: SortedDictionary doesn't have a direct Range query, so we filter memory. + var expected = reference + .Where(kv => kv.Key >= min && kv.Key <= max) + .Select(kv => kv.Key) + .ToList(); + + // 2. Subject Result + var actual = persistent.Range(min, max) + .Select(kv => kv.Key) + .ToList(); + + // 3. Compare + if (!expected.SequenceEqual(actual)) + { + _output.WriteLine($"Range Mismatch! Range: [{min}, {max}]"); + _output.WriteLine($"Expected: {string.Join(",", expected)}"); + _output.WriteLine($"Actual: {string.Join(",", actual)}"); + Assert.Fail("Range query results differ."); + } + } + } + + private void AssertConsistency(SortedDictionary expected, TransientMap actual) + { + // 1. Count + // if (expected.Count != actual.Count) + // { + // throw new Exception($"Count Mismatch! Expected {expected.Count}, Got {actual.Count}"); + // } + + // 2. Full Scan Verification + using var enumerator = actual.GetEnumerator(); + foreach (var kvp in expected) + { + if (!enumerator.MoveNext()) + { + throw new Exception("Enumerator ended too early!"); + } + + if (enumerator.Current.Key != kvp.Key || enumerator.Current.Value != kvp.Value) + { + throw new Exception($"Content Mismatch! Expected [{kvp.Key}:{kvp.Value}], Got [{enumerator.Current.Key}:{enumerator.Current.Value}]"); + } + } + + if (enumerator.MoveNext()) + { + throw new Exception("Enumerator has extra items!"); + } + } +} \ No newline at end of file diff --git a/TestProject1/IteratorTests.cs b/TestProject1/IteratorTests.cs new file mode 100644 index 0000000..acd07e3 --- /dev/null +++ b/TestProject1/IteratorTests.cs @@ -0,0 +1,152 @@ +using Xunit; +using PersistentMap; +using System.Linq; +using System.Collections.Generic; + +public class EnumeratorTests +{ + // Use IntStrategy for simple numeric testing + private readonly IntStrategy _strategy = new(); + + // Helper to create a populated PersistentMap quickly + private PersistentMap CreateMap(params int[] keys) + { + var map = BaseOrderedMap.CreateTransient(_strategy); + foreach (var k in keys) + { + map.Set(k, k * 10); // Value is key * 10 (e.g., 5 -> 50) + } + return map.ToPersistent(); + } + + [Fact] + public void EmptyMap_EnumeratesNothing() + { + var map = PersistentMap.Empty(_strategy); + var list = new List(); + + foreach(var kv in map) list.Add(kv.Key); + + Assert.Empty(list); + } + + [Fact] + public void FullScan_ReturnsSortedItems() + { + // Insert in random order to prove sorting works + var map = CreateMap(5, 1, 3, 2, 4); + + var keys = map.AsEnumerable().Select(x => x.Key).ToList(); + + Assert.Equal(new[] { 1, 2, 3, 4, 5 }, keys); + } + + [Fact] + public void Range_Inclusive_ExactMatches() + { + // Tree: 10, 20, 30, 40, 50 + var map = CreateMap(10, 20, 30, 40, 50); + + // Range 20..40 should give 20, 30, 40 + var result = map.Range(20, 40).Select(x => x.Key).ToList(); + + Assert.Equal(new[] { 20, 30, 40 }, result); + } + + [Fact] + public void Range_Inclusive_WithGaps() + { + // Tree: 10, 20, 30, 40, 50 + var map = CreateMap(10, 20, 30, 40, 50); + + // Range 15..45: + // Start: 15 -> Seeks to first key >= 15 (which is 20) + // End: 45 -> Stops after 40 (next key 50 is > 45) + var result = map.Range(15, 45).Select(x => x.Key).ToList(); + + Assert.Equal(new[] { 20, 30, 40 }, result); + } + + [Fact] + public void Range_SingleItem_Exact() + { + var map = CreateMap(1, 2, 3); + + // Range 2..2 should just return 2 + var result = map.Range(2, 2).Select(x => x.Key).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0]); + } + + [Fact] + public void From_StartOpen_ToEnd() + { + // Tree: 10, 20, 30, 40, 50 + var map = CreateMap(10, 20, 30, 40, 50); + + // From 35 -> Should be 40, 50 (everything >= 35) + var result = map.From(35).Select(x => x.Key).ToList(); + + Assert.Equal(new[] { 40, 50 }, result); + } + + [Fact] + public void Until_StartTo_EndOpen() + { + // Tree: 10, 20, 30, 40, 50 + var map = CreateMap(10, 20, 30, 40, 50); + + // Until 25 -> Should be 10, 20 (everything <= 25) + var result = map.Until(25).Select(x => x.Key).ToList(); + + Assert.Equal(new[] { 10, 20 }, result); + } + + [Fact] + public void Range_OutOfBounds_ReturnsEmpty() + { + var map = CreateMap(10, 20, 30); + + // Range 40..50 (Past end) + Assert.Empty(map.Range(40, 50)); + + // Range 0..5 (Before start) + Assert.Empty(map.Range(0, 5)); + + // Range 15..16 (Gap between keys) + Assert.Empty(map.Range(15, 16)); + } + + [Fact] + public void Range_LargeDataset_CrossesLeafBoundaries() + { + // Force splits. Leaf Capacity is 32, so 100 items ensures ~3-4 leaves. + var tMap = BaseOrderedMap.CreateTransient(_strategy); + for(int i=0; i<100; i++) tMap.Set(i, i); + var map = tMap.ToPersistent(); + + // Range spanning multiple leaves (e.g. 20 to 80) + var list = map.Range(20, 80).Select(x => x.Key).ToList(); + + Assert.Equal(61, list.Count); // 20 through 80 inclusive is 61 items + Assert.Equal(20, list.First()); + Assert.Equal(80, list.Last()); + } + + [Fact] + public void Enumerator_Reset_Works() + { + // Verify that Reset() logic (re-seek/re-dive) works + var map = CreateMap(1, 2, 3); + using var enumerator = map.GetEnumerator(); + + enumerator.MoveNext(); // 1 + enumerator.MoveNext(); // 2 + + enumerator.Reset(); // Should go back to start + + Assert.True(enumerator.MoveNext()); + Assert.Equal(1, enumerator.Current.Key); + } +} \ No newline at end of file diff --git a/TestProject1/PersistenceTests.cs b/TestProject1/PersistenceTests.cs new file mode 100644 index 0000000..713df90 --- /dev/null +++ b/TestProject1/PersistenceTests.cs @@ -0,0 +1,61 @@ +namespace TestProject1; +using PersistentMap; +public class PersistenceTests +{ + private readonly UnicodeStrategy _strategy = new UnicodeStrategy(); + + [Fact] + public void PersistentMap_IsTrulyImmutable() + { + var v0 = BaseOrderedMap.Create(_strategy); + var v1 = v0.Set("A", 1); + var v2 = v1.Set("B", 2); + + // v0 should be empty + Assert.False(v0.ContainsKey("A")); + + // v1 should have A but not B + Assert.True(v1.ContainsKey("A")); + Assert.False(v1.ContainsKey("B")); + + // v2 should have both + Assert.True(v2.ContainsKey("A")); + Assert.True(v2.ContainsKey("B")); + } + + [Fact] + public void TransientToPersistent_CreatesSnapshot() + { + var tMap = BaseOrderedMap.CreateTransient(_strategy); + tMap.Set("A", 1); + + // Create Snapshot + var pMap = tMap.ToPersistent(); + + // Mutate Transient Map further + tMap.Set("B", 2); + tMap.Remove("A"); + + // Assert Transient State + Assert.False(tMap.ContainsKey("A")); + Assert.True(tMap.ContainsKey("B")); + + // Assert Persistent Snapshot (Should be isolated) + Assert.True(pMap.ContainsKey("A")); // A should still be here + Assert.False(pMap.ContainsKey("B")); // B should not exist + } + + [Fact] + public void PersistentToTransient_DoesNotCorruptSource() + { + var pMap = BaseOrderedMap.Create(_strategy); + pMap = pMap.Set("Fixed", 1); + + var tMap = pMap.ToTransient(); + tMap.Set("Fixed", 999); // Modify shared key + + // pMap should remain 1 + Assert.True(pMap.TryGetValue("Fixed", out int val)); + Assert.Equal(1, val); + } +} \ No newline at end of file diff --git a/TestProject1/StressTest.cs b/TestProject1/StressTest.cs new file mode 100644 index 0000000..ef086c3 --- /dev/null +++ b/TestProject1/StressTest.cs @@ -0,0 +1,87 @@ +namespace TestProject1; + +using PersistentMap; + +public class StressTests +{ + private readonly UnicodeStrategy _strategy = new UnicodeStrategy(); + + [Fact] + public void LargeInsert_SplitsCorrectly() + { + var map = BaseOrderedMap.CreateTransient(_strategy); + int count = 10_000; + + // 1. Insert 10k items + for (int i = 0; i < count; i++) + { + // Pad with 0s to ensure consistent length sorting for simple debugging + map.Set($"Key_{i:D6}", i); + } + + + // 2. Read back all items + for (int i = 0; i < count; i++) + { + bool found = map.TryGetValue($"Key_{i:D6}", out int val); + Assert.True(found, $"Failed to find Key_{i:D6}"); + Assert.Equal(i, val); + } + + // 3. Verify Non-existent + Assert.False(map.ContainsKey("Key_999999")); + } + + [Fact] + public void ReverseInsert_HandlesPrependSplits() + { + // Inserting in reverse order triggers the "Left/Right 90/10" split heuristic specific to prepends + var map = BaseOrderedMap.CreateTransient(_strategy); + int count = 5000; + + for (int i = count; i > 0; i--) + { + map.Set(i.ToString("D6"), i); + } + + for (int i = 1; i <= count; i++) + { + Assert.True(map.ContainsKey(i.ToString("D6"))); + } + } + + [Fact] + public void Random_InsertDelete_Churn() + { + // Fuzzing test to catch edge cases in Rebalance/Merge + var map = BaseOrderedMap.CreateTransient(_strategy); + var rng = new Random(12345); + var reference = new Dictionary(); + + for (int i = 0; i < 5000; i++) + { + string key = rng.Next(0, 1000).ToString(); // High collision chance + int op = rng.Next(0, 3); // 0=Set, 1=Remove, 2=Check + + if (op == 0) + { + map.Set(key, i); + reference[key] = i; + } + else if (op == 1) + { + map.Remove(key); + reference.Remove(key); + } + else + { + bool mapHas = map.TryGetValue(key, out int v1); + bool refHas = reference.TryGetValue(key, out int v2); + + Assert.Equal(refHas, mapHas); + if (mapHas) Assert.Equal(v2, v1); + } + } + Console.WriteLine("bp"); + } +} \ No newline at end of file