Skip to main content

prolly/prolly/
boundary.rs

1//! Boundary detection for Prolly Tree chunking
2//!
3//! Determines where nodes should split based on content hashing.
4//! Uses xxHash64 for fast, deterministic boundary detection.
5
6use std::collections::VecDeque;
7use std::hash::Hasher;
8use xxhash_rust::xxh64::Xxh64;
9
10use super::config::Config;
11use super::error::Error;
12use super::format::{BoundaryInput, BoundaryRule, ChunkMeasure, ChunkingSpec};
13use super::node::Node;
14
15const LEVEL_SALT: u64 = 0x9e37_79b9_7f4a_7c15;
16
17/// Resettable boundary state for one ordered tree level.
18pub struct BoundaryDetector {
19    spec: ChunkingSpec,
20    seed: u64,
21    entries: u64,
22    logical_bytes: u64,
23    encoded_bytes: u64,
24    previous_measure: u64,
25    rolling_window: VecDeque<u8>,
26    rolling_hash: u64,
27}
28
29impl BoundaryDetector {
30    /// Create a detector for a persisted policy and tree level.
31    pub fn new(spec: ChunkingSpec, level: u16) -> Result<Self, Error> {
32        spec.validate()?;
33        let seed = if spec.level_salt {
34            spec.hash_seed ^ u64::from(level).wrapping_mul(LEVEL_SALT)
35        } else {
36            spec.hash_seed
37        };
38        Ok(Self {
39            spec,
40            seed,
41            entries: 0,
42            logical_bytes: 0,
43            encoded_bytes: 0,
44            previous_measure: 0,
45            rolling_window: VecDeque::new(),
46            rolling_hash: 0,
47        })
48    }
49
50    /// Observe one ordered entry and return whether the chunk ends after it.
51    pub fn observe(
52        &mut self,
53        key: &[u8],
54        value: &[u8],
55        encoded_entry_bytes: usize,
56    ) -> Result<bool, Error> {
57        let encoded_entry_bytes = encoded_entry_bytes as u64;
58        if self.entries == 0 && encoded_entry_bytes > self.spec.hard_max_node_bytes {
59            return Err(Error::EntryTooLarge {
60                encoded_bytes: encoded_entry_bytes,
61                limit: self.spec.hard_max_node_bytes,
62            });
63        }
64
65        self.previous_measure = self.measure();
66        self.entries = self.entries.saturating_add(1);
67        self.logical_bytes = self
68            .logical_bytes
69            .saturating_add(key.len() as u64)
70            .saturating_add(value.len() as u64);
71        self.encoded_bytes = self.encoded_bytes.saturating_add(encoded_entry_bytes);
72
73        let input_hash = hash_entry(self.seed, &self.spec.input, key, value);
74        if matches!(self.spec.rule, BoundaryRule::RollingBuzHash { .. }) {
75            self.observe_rolling(key, value);
76        }
77
78        let measure = self.measure();
79        let boundary = if self.encoded_bytes >= self.spec.hard_max_node_bytes
80            || measure >= self.spec.max
81        {
82            true
83        } else if measure < self.spec.min {
84            false
85        } else {
86            match self.spec.rule {
87                BoundaryRule::HashThreshold { factor } => (input_hash as u32) <= u32::MAX / factor,
88                BoundaryRule::Weibull { shape } => weibull_boundary(
89                    input_hash,
90                    self.previous_measure,
91                    measure,
92                    self.spec.target,
93                    shape,
94                ),
95                BoundaryRule::RollingBuzHash { .. } => {
96                    self.rolling_hash <= u64::MAX / self.spec.target.max(1)
97                }
98            }
99        };
100        if boundary {
101            self.reset();
102        }
103        Ok(boundary)
104    }
105
106    /// Reset state at the beginning of a new chunk.
107    pub fn reset(&mut self) {
108        self.entries = 0;
109        self.logical_bytes = 0;
110        self.encoded_bytes = 0;
111        self.previous_measure = 0;
112        self.rolling_window.clear();
113        self.rolling_hash = 0;
114    }
115
116    pub(crate) fn supports_independent_hashing(&self) -> bool {
117        self.spec.measure == ChunkMeasure::EntryCount
118            && matches!(self.spec.rule, BoundaryRule::HashThreshold { .. })
119    }
120
121    pub(crate) fn independent_hash_boundary(&self, key: &[u8], value: &[u8]) -> Option<bool> {
122        let BoundaryRule::HashThreshold { factor } = self.spec.rule else {
123            return None;
124        };
125        if self.spec.measure != ChunkMeasure::EntryCount {
126            return None;
127        }
128        Some((hash_entry(self.seed, &self.spec.input, key, value) as u32) <= u32::MAX / factor)
129    }
130
131    fn measure(&self) -> u64 {
132        match self.spec.measure {
133            ChunkMeasure::EntryCount => self.entries,
134            ChunkMeasure::LogicalBytes => self.logical_bytes,
135            ChunkMeasure::EncodedBytes => self.encoded_bytes,
136        }
137    }
138
139    fn observe_rolling(&mut self, key: &[u8], value: &[u8]) {
140        let window = match self.spec.rule {
141            BoundaryRule::RollingBuzHash { window } => usize::from(window),
142            _ => return,
143        };
144        rolling_feed_len(self, key.len() as u64, window);
145        for byte in key {
146            self.roll_byte(*byte, window);
147        }
148        if self.spec.input == BoundaryInput::KeyValue {
149            rolling_feed_len(self, value.len() as u64, window);
150            for byte in value {
151                self.roll_byte(*byte, window);
152            }
153        }
154    }
155
156    fn roll_byte(&mut self, byte: u8, window: usize) {
157        self.rolling_hash = self.rolling_hash.rotate_left(1) ^ byte_hash(self.seed, byte);
158        self.rolling_window.push_back(byte);
159        if self.rolling_window.len() > window {
160            if let Some(old) = self.rolling_window.pop_front() {
161                self.rolling_hash ^= byte_hash(self.seed, old).rotate_left((window % 64) as u32);
162            }
163        }
164    }
165}
166
167fn rolling_feed_len(detector: &mut BoundaryDetector, len: u64, window: usize) {
168    for byte in len.to_be_bytes() {
169        detector.roll_byte(byte, window);
170    }
171}
172
173fn byte_hash(seed: u64, byte: u8) -> u64 {
174    let mut hasher = Xxh64::new(seed ^ 0xa076_1d64_78bd_642f);
175    hasher.write_u8(byte);
176    hasher.finish()
177}
178
179fn hash_entry(seed: u64, input: &BoundaryInput, key: &[u8], value: &[u8]) -> u64 {
180    let mut hasher = Xxh64::new(seed);
181    hasher.write(&(key.len() as u64).to_be_bytes());
182    hasher.write(key);
183    if *input == BoundaryInput::KeyValue {
184        hasher.write(&(value.len() as u64).to_be_bytes());
185        hasher.write(value);
186    }
187    hasher.finish()
188}
189
190pub(crate) fn entry_count_boundary(
191    spec: &ChunkingSpec,
192    level: u16,
193    count: usize,
194    key: &[u8],
195) -> Result<bool, Error> {
196    spec.validate()?;
197    let BoundaryRule::HashThreshold { factor } = spec.rule else {
198        return Err(Error::InvalidFormat(
199            "entry-count boundary probe requires a hash-threshold rule".to_string(),
200        ));
201    };
202    if spec.measure != ChunkMeasure::EntryCount || spec.input != BoundaryInput::Key {
203        return Err(Error::InvalidFormat(
204            "entry-count boundary probe requires key-only entry-count chunking".to_string(),
205        ));
206    }
207    let count = count as u64;
208    if count >= spec.max {
209        return Ok(true);
210    }
211    if count < spec.min {
212        return Ok(false);
213    }
214    let seed = if spec.level_salt {
215        spec.hash_seed ^ u64::from(level).wrapping_mul(LEVEL_SALT)
216    } else {
217        spec.hash_seed
218    };
219    Ok((hash_entry(seed, &spec.input, key, &[]) as u32) <= u32::MAX / factor)
220}
221
222fn weibull_boundary(hash: u64, previous: u64, current: u64, target: u64, shape: u32) -> bool {
223    let scale = target.max(1) as f64;
224    let shape = shape as f64;
225    let before = (previous as f64 / scale).powf(shape);
226    let after = (current as f64 / scale).powf(shape);
227    let probability = 1.0 - (-(after - before).max(0.0)).exp();
228    let sample = hash as f64 / u64::MAX as f64;
229    sample <= probability
230}
231
232/// Check if entry at index creates a chunk boundary in a node.
233///
234/// Boundary detection rules:
235/// 1. Below min_chunk_size: never split (returns false)
236/// 2. At or above max_chunk_size: always split (returns true)
237/// 3. Otherwise: hash-based probabilistic boundary
238///
239/// The hash-based boundary uses xxHash64 on the key+value pair.
240/// A boundary is detected when the lower 32 bits of the hash
241/// are less than or equal to `u32::MAX / chunking_factor`.
242///
243/// # Arguments
244/// * `node` - The node containing the entry
245/// * `idx` - Index of the entry to check
246///
247/// # Returns
248/// `true` if a boundary should be created after this entry
249pub fn is_boundary(node: &Node, idx: usize) -> bool {
250    let count = node.keys.len();
251
252    // Below min size: never split
253    if count < node.min_chunk_size() {
254        return false;
255    }
256
257    // At or above max size: always split
258    if count >= node.max_chunk_size() {
259        return true;
260    }
261
262    is_hash_boundary(
263        node.hash_seed(),
264        node.chunking_factor(),
265        &node.keys[idx],
266        &node.vals[idx],
267    )
268}
269
270/// Check if entry creates a chunk boundary using Config.
271///
272/// Same logic as `is_boundary()` but takes Config and entry data directly
273/// instead of a Node reference. Useful for tree-level operations where
274/// you don't have a fully constructed node.
275///
276/// # Arguments
277/// * `config` - Tree configuration with chunking parameters
278/// * `count` - Current number of entries in the node
279/// * `key` - Key bytes of the entry to check
280/// * `val` - Value bytes of the entry to check
281///
282/// # Returns
283/// `true` if a boundary should be created after this entry
284pub fn is_boundary_config(config: &Config, count: usize, key: &[u8], val: &[u8]) -> bool {
285    // Below min size: never split
286    if count < config.min_chunk_size() {
287        return false;
288    }
289
290    // At or above max size: always split
291    if count >= config.max_chunk_size() {
292        return true;
293    }
294
295    is_hash_boundary_config(config, key, val)
296}
297
298/// Check only the hash predicate for a boundary, without applying min/max size rules.
299///
300/// Bulk builders can precompute this part in parallel, then apply the min/max
301/// checks using the current chunk-local entry count.
302pub(crate) fn is_hash_boundary_config(config: &Config, key: &[u8], val: &[u8]) -> bool {
303    is_hash_boundary(config.hash_seed(), config.chunking_factor(), key, val)
304}
305
306fn is_hash_boundary(hash_seed: u64, chunking_factor: u32, key: &[u8], val: &[u8]) -> bool {
307    let mut hasher = Xxh64::new(hash_seed);
308    hasher.write(key);
309    hasher.write(val);
310    let hash = hasher.finish();
311
312    // Use lower 32 bits for threshold comparison
313    let hash_val = (hash & 0xFFFF_FFFF) as u32;
314
315    // Threshold: lower = more boundaries = smaller nodes
316    let threshold = u32::MAX / chunking_factor;
317    hash_val <= threshold
318}
319
320#[cfg(test)]
321mod tests {
322    use super::super::encoding::Encoding;
323    use super::*;
324
325    #[test]
326    fn test_is_boundary_below_min_chunk_size() {
327        // Node with fewer entries than min_chunk_size should never trigger boundary
328        let node = Node::builder()
329            .keys(vec![b"a".to_vec(), b"b".to_vec()])
330            .vals(vec![b"1".to_vec(), b"2".to_vec()])
331            .min_chunk_size(4)
332            .max_chunk_size(100)
333            .chunking_factor(128)
334            .build();
335
336        // 2 entries < min_chunk_size of 4, so no boundary
337        assert!(!is_boundary(&node, 0));
338        assert!(!is_boundary(&node, 1));
339    }
340
341    #[test]
342    fn test_is_boundary_at_max_chunk_size() {
343        // Node at max_chunk_size should always trigger boundary
344        let keys: Vec<Vec<u8>> = (0..10).map(|i| vec![i]).collect();
345        let vals: Vec<Vec<u8>> = (0..10).map(|i| vec![i]).collect();
346
347        let node = Node::builder()
348            .keys(keys)
349            .vals(vals)
350            .min_chunk_size(2)
351            .max_chunk_size(10) // exactly at max
352            .chunking_factor(128)
353            .build();
354
355        // At max_chunk_size, should always return true
356        assert!(is_boundary(&node, 0));
357    }
358
359    #[test]
360    fn test_is_boundary_deterministic() {
361        // Same node should always produce same boundary result
362        let node = Node::builder()
363            .keys(vec![
364                b"key1".to_vec(),
365                b"key2".to_vec(),
366                b"key3".to_vec(),
367                b"key4".to_vec(),
368                b"key5".to_vec(),
369            ])
370            .vals(vec![
371                b"val1".to_vec(),
372                b"val2".to_vec(),
373                b"val3".to_vec(),
374                b"val4".to_vec(),
375                b"val5".to_vec(),
376            ])
377            .min_chunk_size(2)
378            .max_chunk_size(100)
379            .chunking_factor(128)
380            .hash_seed(42)
381            .build();
382
383        let result1 = is_boundary(&node, 2);
384        let result2 = is_boundary(&node, 2);
385        assert_eq!(result1, result2);
386    }
387
388    #[test]
389    fn test_is_boundary_config_below_min() {
390        let config = Config::builder()
391            .min_chunk_size(4)
392            .max_chunk_size(100)
393            .chunking_factor(128)
394            .build();
395
396        // count=2 < min_chunk_size=4, so no boundary
397        assert!(!is_boundary_config(&config, 2, b"key", b"val"));
398    }
399
400    #[test]
401    fn test_is_boundary_config_at_max() {
402        let config = Config::builder()
403            .min_chunk_size(2)
404            .max_chunk_size(10)
405            .chunking_factor(128)
406            .build();
407
408        // count=10 >= max_chunk_size=10, so always boundary
409        assert!(is_boundary_config(&config, 10, b"key", b"val"));
410    }
411
412    #[test]
413    fn test_is_boundary_config_deterministic() {
414        let config = Config::builder()
415            .min_chunk_size(2)
416            .max_chunk_size(100)
417            .chunking_factor(128)
418            .hash_seed(42)
419            .build();
420
421        let result1 = is_boundary_config(&config, 5, b"test_key", b"test_val");
422        let result2 = is_boundary_config(&config, 5, b"test_key", b"test_val");
423        assert_eq!(result1, result2);
424    }
425
426    #[test]
427    fn test_is_boundary_matches_is_boundary_config() {
428        // Both functions should produce the same result for equivalent inputs
429        let node = Node::builder()
430            .keys(vec![
431                b"a".to_vec(),
432                b"b".to_vec(),
433                b"c".to_vec(),
434                b"d".to_vec(),
435                b"e".to_vec(),
436            ])
437            .vals(vec![
438                b"1".to_vec(),
439                b"2".to_vec(),
440                b"3".to_vec(),
441                b"4".to_vec(),
442                b"5".to_vec(),
443            ])
444            .min_chunk_size(2)
445            .max_chunk_size(100)
446            .chunking_factor(128)
447            .hash_seed(42)
448            .encoding(Encoding::Raw)
449            .build();
450
451        let config = Config::builder()
452            .min_chunk_size(2)
453            .max_chunk_size(100)
454            .chunking_factor(128)
455            .hash_seed(42)
456            .encoding(Encoding::Raw)
457            .build();
458
459        for idx in 0..node.keys.len() {
460            let node_result = is_boundary(&node, idx);
461            let config_result =
462                is_boundary_config(&config, node.keys.len(), &node.keys[idx], &node.vals[idx]);
463            assert_eq!(
464                node_result, config_result,
465                "Mismatch at index {}: is_boundary={}, is_boundary_config={}",
466                idx, node_result, config_result
467            );
468        }
469    }
470
471    #[test]
472    fn entry_count_threshold_exposes_parallel_hash_predicate() {
473        let spec = ChunkingSpec {
474            min: 1,
475            max: 128,
476            ..ChunkingSpec::default()
477        };
478        let mut detector = BoundaryDetector::new(spec, 0).unwrap();
479
480        let independent = detector
481            .independent_hash_boundary(b"parallel-key", b"ignored-value")
482            .expect("entry-count threshold hashing is independent");
483
484        assert_eq!(
485            detector
486                .observe(b"parallel-key", b"ignored-value", 32)
487                .unwrap(),
488            independent
489        );
490    }
491
492    #[test]
493    fn test_different_seeds_produce_different_results() {
494        let config1 = Config::builder()
495            .min_chunk_size(2)
496            .max_chunk_size(100)
497            .chunking_factor(128)
498            .hash_seed(1)
499            .build();
500
501        let config2 = Config::builder()
502            .min_chunk_size(2)
503            .max_chunk_size(100)
504            .chunking_factor(128)
505            .hash_seed(999999)
506            .build();
507
508        // Test with multiple keys to find at least one difference
509        let mut found_difference = false;
510        for i in 0..100 {
511            let key = format!("key{}", i).into_bytes();
512            let val = format!("val{}", i).into_bytes();
513            let r1 = is_boundary_config(&config1, 5, &key, &val);
514            let r2 = is_boundary_config(&config2, 5, &key, &val);
515            if r1 != r2 {
516                found_difference = true;
517                break;
518            }
519        }
520        assert!(
521            found_difference,
522            "Different seeds should produce different boundary patterns"
523        );
524    }
525
526    #[test]
527    fn test_higher_chunking_factor_fewer_boundaries() {
528        // Higher chunking factor = higher threshold = fewer boundaries
529        let config_low = Config::builder()
530            .min_chunk_size(2)
531            .max_chunk_size(1000)
532            .chunking_factor(4) // Low factor = more boundaries
533            .hash_seed(0)
534            .build();
535
536        let config_high = Config::builder()
537            .min_chunk_size(2)
538            .max_chunk_size(1000)
539            .chunking_factor(1024) // High factor = fewer boundaries
540            .hash_seed(0)
541            .build();
542
543        let mut low_boundaries = 0;
544        let mut high_boundaries = 0;
545
546        for i in 0..1000 {
547            let key = format!("key{:04}", i).into_bytes();
548            let val = format!("val{:04}", i).into_bytes();
549            if is_boundary_config(&config_low, 100, &key, &val) {
550                low_boundaries += 1;
551            }
552            if is_boundary_config(&config_high, 100, &key, &val) {
553                high_boundaries += 1;
554            }
555        }
556
557        assert!(
558            low_boundaries > high_boundaries,
559            "Lower chunking factor should produce more boundaries: low={}, high={}",
560            low_boundaries,
561            high_boundaries
562        );
563    }
564}