commonware_utils/bitmap/historical/
batch.rs

1use super::Error;
2use crate::bitmap::{historical::BitMap, Prunable};
3#[cfg(not(feature = "std"))]
4use alloc::{collections::BTreeMap, vec::Vec};
5#[cfg(feature = "std")]
6use std::collections::BTreeMap;
7
8/// An active batch that tracks mutations as a diff layer.
9///
10/// A batch records changes without modifying the underlying bitmap. When committed,
11/// these changes are applied atomically. If dropped without committing, all changes
12/// are discarded.
13///
14/// # De-duplication and Cancellation
15///
16/// **The batch de-duplicates during operations, not at commit time.**
17///
18/// Operations that cancel out are handled automatically:
19///
20/// ```text
21/// Example 1: push + pop = no-op
22///   push(true)  → appended_bits=[true], projected_len=11
23///   pop()       → appended_bits=[], projected_len=10
24///   Result: batch state unchanged from base!
25///
26/// Example 2: set_bit + set_bit = last write wins
27///   set_bit(5, true)   → modified_bits={5: true}
28///   set_bit(5, false)  → modified_bits={5: false}
29///   Result: only final value recorded
30///
31/// Example 3: set_bit + pop = cancels modification
32///   set_bit(9, true)  → modified_bits={9: true}
33///   pop()             → modified_bits={} (removed), projected_len=9
34///   Result: bit 9 no longer exists, modification discarded
35/// ```
36///
37/// The capture functions see only the **final delta**, not intermediate operations.
38///
39/// # Key Invariants
40///
41/// 1. **Base immutability**: `base_len` and `base_pruned_chunks` never change after batch creation
42/// 2. **Appended region**: Always occupies `[projected_len - appended_bits.len(), projected_len)`
43/// 3. **Modified region**: `modified_bits` only contains offsets in `[0, projected_len - appended_bits.len())`
44///    - These are modifications to the base bitmap, never to appended bits
45///    - Appended bits are modified by directly updating the `appended_bits` vector
46/// 4. **No overlap**: A bit is either in `modified_bits` OR `appended_bits`, never both
47pub(super) struct Batch<const N: usize> {
48    /// Bitmap state when batch started (immutable).
49    pub(super) base_len: u64,
50    pub(super) base_pruned_chunks: usize,
51
52    /// What the bitmap will look like after commit (mutable).
53    pub(super) projected_len: u64,
54    pub(super) projected_pruned_chunks: usize,
55
56    /// Modifications to bits that existed in the bitmap (not appended bits).
57    /// Contains offsets in [0, projected_len - appended_bits.len()).
58    /// Maps: bit -> new_value
59    pub(super) modified_bits: BTreeMap<u64, bool>,
60
61    /// New bits pushed in this batch (in order).
62    /// Logical position: [projected_len - appended_bits.len(), projected_len)
63    pub(super) appended_bits: Vec<bool>,
64
65    /// Old chunk data for chunks being pruned.
66    /// Captured eagerly during `prune_to_bit()` for historical reconstruction.
67    pub(super) chunks_to_prune: BTreeMap<usize, [u8; N]>,
68}
69
70/// Guard for an active batch on a historical bitmap.
71///
72/// Provides mutable access to an active batch, allowing operations that modify the bitmap
73/// through a diff layer. Changes are not applied to the underlying bitmap until
74/// [commit](Self::commit) is called.
75///
76/// # Lifecycle
77///
78/// The guard **must** be either:
79/// - **Committed**: Call [commit(commit_number)](Self::commit) to apply changes
80///   and store a historical snapshot.
81/// - **Dropped**: Drop without committing to discard all changes.
82///
83/// # Examples
84///
85/// ```
86/// # use commonware_utils::bitmap::historical::BitMap;
87/// let mut bitmap: BitMap<4> = BitMap::new();
88///
89/// // Create a batch guard and make changes
90/// let mut batch = bitmap.start_batch();
91/// batch.push(true);
92/// batch.push(false);
93/// batch.commit(1).unwrap();
94///
95/// // Bitmap is now modified
96/// assert_eq!(bitmap.len(), 2);
97/// ```
98///
99/// ## Discarding Changes
100///
101/// ```
102/// # use commonware_utils::bitmap::historical::BitMap;
103/// let mut bitmap: BitMap<4> = BitMap::new();
104///
105/// {
106///     let mut batch = bitmap.start_batch();
107///     batch.push(true);
108///     // batch dropped here without commit
109/// }
110///
111/// // Bitmap is unchanged
112/// assert_eq!(bitmap.len(), 0);
113/// ```
114#[must_use = "batches must be committed or explicitly dropped"]
115pub struct BatchGuard<'a, const N: usize> {
116    pub(super) bitmap: &'a mut BitMap<N>,
117    pub(super) committed: bool,
118}
119
120impl<'a, const N: usize> BatchGuard<'a, N> {
121    /// Get the length of the bitmap as it would be after committing this batch.
122    #[inline]
123    pub fn len(&self) -> u64 {
124        self.bitmap
125            .active_batch
126            .as_ref()
127            .expect("active batch must exist since we have this guard")
128            .projected_len
129    }
130
131    /// Returns true if the bitmap would be empty after committing this batch.
132    #[inline]
133    pub fn is_empty(&self) -> bool {
134        self.len() == 0
135    }
136
137    /// Get the number of pruned chunks after this batch.
138    #[inline]
139    pub fn pruned_chunks(&self) -> usize {
140        self.bitmap
141            .active_batch
142            .as_ref()
143            .expect("active batch must exist since we have this guard")
144            .projected_pruned_chunks
145    }
146
147    /// Get a bit value with read-through semantics.
148    ///
149    /// Returns the bit's value as it would be after committing this batch.
150    /// Priority: appended bits > modified bits > original bitmap.
151    ///
152    /// # Panics
153    ///
154    /// Panics if the bit offset is out of bounds or if the bit has been pruned.
155    pub fn get_bit(&self, bit: u64) -> bool {
156        let batch = self.bitmap.active_batch.as_ref().unwrap();
157
158        assert!(
159            bit < batch.projected_len,
160            "bit offset {bit} out of bounds (len: {})",
161            batch.projected_len
162        );
163
164        let chunk_idx = Prunable::<N>::unpruned_chunk(bit);
165        assert!(
166            chunk_idx >= batch.projected_pruned_chunks,
167            "cannot get bit {bit}: chunk {chunk_idx} is pruned (pruned up to chunk {})",
168            batch.projected_pruned_chunks
169        );
170
171        // Priority 1: Check if bit is in appended region.
172        // Must use appended_start, not base_len, to handle net pops + appends.
173        let appended_start = batch.projected_len - batch.appended_bits.len() as u64;
174        if bit >= appended_start {
175            let append_offset = (bit - appended_start) as usize;
176            return batch.appended_bits[append_offset];
177        }
178
179        // Priority 2: Check if bit was modified in this batch.
180        if let Some(&value) = batch.modified_bits.get(&bit) {
181            return value;
182        }
183
184        // Priority 3: Fall through to original bitmap.
185        self.bitmap.current.get_bit(bit)
186    }
187
188    /// Get a chunk value with read-through semantics.
189    ///
190    /// Reconstructs the chunk if it has modifications, otherwise returns from current.
191    ///
192    /// # Panics
193    ///
194    /// Panics if the bit offset is out of bounds or if the chunk has been pruned.
195    pub fn get_chunk(&self, bit: u64) -> [u8; N] {
196        let batch = self.bitmap.active_batch.as_ref().unwrap();
197
198        // Check bounds
199        assert!(
200            bit < batch.projected_len,
201            "bit offset {bit} out of bounds (len: {})",
202            batch.projected_len
203        );
204
205        let chunk_idx = Prunable::<N>::unpruned_chunk(bit);
206
207        // Check if chunk is in pruned range
208        assert!(
209            chunk_idx >= batch.projected_pruned_chunks,
210            "cannot get chunk at bit offset {bit}: chunk {chunk_idx} is pruned (pruned up to chunk {})",
211            batch.projected_pruned_chunks
212        );
213
214        let chunk_start_bit = chunk_idx as u64 * Prunable::<N>::CHUNK_SIZE_BITS;
215        let chunk_end_bit = chunk_start_bit + Prunable::<N>::CHUNK_SIZE_BITS;
216
217        // Determine if this chunk needs reconstruction.
218        let appended_start = batch.projected_len - batch.appended_bits.len() as u64;
219
220        // Skip reconstruction only if chunk is entirely outside modified regions
221        let chunk_entirely_past_end = chunk_start_bit >= batch.projected_len;
222        let chunk_entirely_before_changes =
223            chunk_end_bit <= appended_start && chunk_end_bit <= batch.projected_len;
224
225        let chunk_needs_reconstruction =
226            // Chunk overlaps with pops or appends
227            !(chunk_entirely_past_end || chunk_entirely_before_changes)
228            // OR chunk has explicit bit modifications
229            || (chunk_start_bit..chunk_end_bit.min(batch.base_len))
230                .any(|bit| batch.modified_bits.contains_key(&bit));
231
232        if chunk_needs_reconstruction {
233            // Reconstruct chunk from current + batch modifications
234            self.reconstruct_modified_chunk(chunk_start_bit)
235        } else {
236            // Fall through to current bitmap
237            *self.bitmap.current.get_chunk_containing(bit)
238        }
239    }
240
241    /// Reconstruct a chunk that has modifications, appends, or pops.
242    fn reconstruct_modified_chunk(&self, chunk_start: u64) -> [u8; N] {
243        let batch = self.bitmap.active_batch.as_ref().unwrap();
244
245        // Start with current chunk if it exists
246        let mut chunk = if chunk_start < self.bitmap.current.len() {
247            *self.bitmap.current.get_chunk_containing(chunk_start)
248        } else {
249            [0u8; N]
250        };
251
252        // Calculate appended region boundary
253        let appended_start = batch.projected_len - batch.appended_bits.len() as u64;
254
255        // Apply batch modifications and zero out popped bits
256        for bit_in_chunk in 0..Prunable::<N>::CHUNK_SIZE_BITS {
257            let bit = chunk_start + bit_in_chunk;
258
259            let byte_idx = (bit_in_chunk / 8) as usize;
260            let bit_idx = bit_in_chunk % 8;
261            let mask = 1u8 << bit_idx;
262
263            if bit >= batch.projected_len {
264                // Bit is beyond projected length (popped), zero it out
265                chunk[byte_idx] &= !mask;
266            } else if let Some(&value) = batch.modified_bits.get(&bit) {
267                // Bit was explicitly modified in the batch
268                if value {
269                    chunk[byte_idx] |= mask;
270                } else {
271                    chunk[byte_idx] &= !mask;
272                }
273            } else if bit >= appended_start {
274                // This is an appended bit
275                let append_offset = (bit - appended_start) as usize;
276                if append_offset < batch.appended_bits.len() {
277                    let value = batch.appended_bits[append_offset];
278                    if value {
279                        chunk[byte_idx] |= mask;
280                    } else {
281                        chunk[byte_idx] &= !mask;
282                    }
283                }
284            }
285        }
286
287        chunk
288    }
289
290    /// Set a bit value in the batch.
291    ///
292    /// # Panics
293    ///
294    /// Panics if the bit offset is out of bounds or if the bit has been pruned.
295    pub fn set_bit(&mut self, bit: u64, value: bool) -> &mut Self {
296        let batch = self.bitmap.active_batch.as_mut().unwrap();
297
298        assert!(
299            bit < batch.projected_len,
300            "cannot set bit {bit}: out of bounds (len: {})",
301            batch.projected_len
302        );
303
304        let chunk_idx = Prunable::<N>::unpruned_chunk(bit);
305        assert!(
306            chunk_idx >= batch.projected_pruned_chunks,
307            "cannot set bit {bit}: chunk {chunk_idx} is pruned (pruned up to chunk {})",
308            batch.projected_pruned_chunks
309        );
310
311        // Determine which region this bit belongs to.
312        // Appended region: bits pushed in this batch, starting at projected_len - appended_bits.len()
313        let appended_start = batch.projected_len - batch.appended_bits.len() as u64;
314
315        if bit >= appended_start {
316            // Bit is in the appended region: update the appended_bits vector directly.
317            let append_offset = (bit - appended_start) as usize;
318            batch.appended_bits[append_offset] = value;
319        } else {
320            // Bit is in the base region: record as a modification.
321            batch.modified_bits.insert(bit, value);
322        }
323
324        self
325    }
326
327    /// Push a bit to the end of the bitmap.
328    pub fn push(&mut self, bit: bool) -> &mut Self {
329        let batch = self.bitmap.active_batch.as_mut().unwrap();
330
331        batch.appended_bits.push(bit);
332        batch.projected_len += 1;
333
334        self
335    }
336
337    /// Push a byte to the end of the bitmap.
338    pub fn push_byte(&mut self, byte: u8) -> &mut Self {
339        for i in 0..8 {
340            let bit = (byte >> i) & 1 == 1;
341            self.push(bit);
342        }
343        self
344    }
345
346    /// Push a full chunk to the end of the bitmap.
347    pub fn push_chunk(&mut self, chunk: &[u8; N]) -> &mut Self {
348        for byte in chunk {
349            self.push_byte(*byte);
350        }
351        self
352    }
353
354    /// Pop the last bit from the bitmap.
355    ///
356    /// Returns the value of the popped bit, accounting for any modifications in this batch.
357    ///
358    /// # Panics
359    ///
360    /// Panics if the bitmap is empty.
361    pub fn pop(&mut self) -> bool {
362        let batch = self.bitmap.active_batch.as_mut().unwrap();
363
364        assert!(batch.projected_len > 0, "cannot pop from empty bitmap");
365
366        let old_projected_len = batch.projected_len;
367        batch.projected_len -= 1;
368        let bit = batch.projected_len;
369
370        // Determine which region the popped bit came from.
371        // The appended region contains bits pushed in this batch: [appended_start, old_projected_len)
372        let appended_start = old_projected_len - batch.appended_bits.len() as u64;
373
374        if bit >= appended_start {
375            // Popping from appended region: remove from appended_bits vector.
376            batch.appended_bits.pop().unwrap()
377        } else {
378            // Popping from base region: check if it was modified in this batch.
379            if let Some(&modified_value) = batch.modified_bits.get(&bit) {
380                batch.modified_bits.remove(&bit);
381                modified_value
382            } else {
383                // Not modified in batch, return original value.
384                self.bitmap.current.get_bit(bit)
385            }
386        }
387    }
388
389    /// Prune chunks up to the chunk containing the given bit offset.
390    ///
391    /// Note: `bit` can equal `projected_len` when pruning at a chunk boundary.
392    ///
393    /// # Panics
394    ///
395    /// Panics if `bit` is > the projected length of the batch.
396    pub fn prune_to_bit(&mut self, bit: u64) -> &mut Self {
397        let batch = self.bitmap.active_batch.as_mut().unwrap();
398
399        assert!(
400            bit <= batch.projected_len,
401            "cannot prune to bit {bit}: beyond projected length ({})",
402            batch.projected_len
403        );
404
405        let chunk_num = Prunable::<N>::unpruned_chunk(bit);
406
407        if chunk_num <= batch.projected_pruned_chunks {
408            return self; // Already pruned
409        }
410
411        // Capture preimages of chunks being pruned
412        let current_pruned = self.bitmap.current.pruned_chunks();
413        for chunk_idx in batch.projected_pruned_chunks..chunk_num {
414            if batch.chunks_to_prune.contains_key(&chunk_idx) {
415                continue; // Already captured
416            }
417
418            // Invariant: chunk_idx should always be >= current_pruned because
419            // projected_pruned_chunks starts at base_pruned_chunks (= current_pruned)
420            assert!(
421                chunk_idx >= current_pruned,
422                "attempting to prune chunk {chunk_idx} which is already pruned (current pruned_chunks={current_pruned})",
423            );
424
425            let bitmap_idx = chunk_idx - current_pruned;
426
427            // Get chunk data, which may come from batch if it's appended
428            let chunk_data = if bitmap_idx < self.bitmap.current.chunks_len() {
429                // Chunk exists in current bitmap
430                *self.bitmap.current.get_chunk(bitmap_idx)
431            } else {
432                // Chunk only exists in this batch's appended bits
433                // Manually reconstruct it from appended_bits
434                let chunk_start_bit = chunk_idx as u64 * Prunable::<N>::CHUNK_SIZE_BITS;
435                let appended_start = batch.projected_len - batch.appended_bits.len() as u64;
436
437                let mut chunk = [0u8; N];
438                for bit_in_chunk in 0..Prunable::<N>::CHUNK_SIZE_BITS {
439                    let bit = chunk_start_bit + bit_in_chunk;
440                    if bit >= batch.projected_len {
441                        break;
442                    }
443                    if bit >= appended_start {
444                        let append_idx = (bit - appended_start) as usize;
445                        if append_idx < batch.appended_bits.len() && batch.appended_bits[append_idx]
446                        {
447                            let byte_idx = (bit_in_chunk / 8) as usize;
448                            let bit_idx = bit_in_chunk % 8;
449                            chunk[byte_idx] |= 1u8 << bit_idx;
450                        }
451                    }
452                }
453                chunk
454            };
455
456            batch.chunks_to_prune.insert(chunk_idx, chunk_data);
457        }
458
459        batch.projected_pruned_chunks = chunk_num;
460
461        self
462    }
463
464    /// Commit the batch, applying its changes and storing a historical snapshot.
465    ///
466    /// # Errors
467    ///
468    /// Returns [Error::NonMonotonicCommit] if the commit number is not
469    /// greater than the previous commit.
470    ///
471    /// Returns [Error::ReservedCommitNumber] if the commit number is `u64::MAX`.
472    pub fn commit(mut self, commit_number: u64) -> Result<(), Error> {
473        // Validate commit number is not reserved
474        if commit_number == u64::MAX {
475            return Err(Error::ReservedCommitNumber);
476        }
477
478        // Validate commit number is monotonically increasing
479        if let Some(&max_commit) = self.bitmap.commits.keys().next_back() {
480            if commit_number <= max_commit {
481                return Err(Error::NonMonotonicCommit {
482                    previous: max_commit,
483                    attempted: commit_number,
484                });
485            }
486        }
487
488        // Take the batch
489        let batch = self.bitmap.active_batch.take().unwrap();
490
491        // Build reverse diff (captures OLD state before applying batch)
492        let reverse_diff = self.bitmap.build_reverse_diff(&batch);
493
494        // Apply batch changes to current bitmap
495        self.bitmap.apply_batch_to_current(&batch);
496
497        // Store the reverse diff
498        self.bitmap.commits.insert(commit_number, reverse_diff);
499
500        // Mark as committed
501        self.committed = true;
502
503        Ok(())
504    }
505}
506
507impl<'a, const N: usize> Drop for BatchGuard<'a, N> {
508    fn drop(&mut self) {
509        if !self.committed {
510            // Batch is being dropped without commit - discard the diff layer
511            self.bitmap.active_batch = None;
512        }
513    }
514}