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}