1use 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
17pub 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 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 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 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
232pub fn is_boundary(node: &Node, idx: usize) -> bool {
250 let count = node.keys.len();
251
252 if count < node.min_chunk_size() {
254 return false;
255 }
256
257 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
270pub fn is_boundary_config(config: &Config, count: usize, key: &[u8], val: &[u8]) -> bool {
285 if count < config.min_chunk_size() {
287 return false;
288 }
289
290 if count >= config.max_chunk_size() {
292 return true;
293 }
294
295 is_hash_boundary_config(config, key, val)
296}
297
298pub(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 let hash_val = (hash & 0xFFFF_FFFF) as u32;
314
315 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 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 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 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) .chunking_factor(128)
353 .build();
354
355 assert!(is_boundary(&node, 0));
357 }
358
359 #[test]
360 fn test_is_boundary_deterministic() {
361 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 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 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 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 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 let config_low = Config::builder()
530 .min_chunk_size(2)
531 .max_chunk_size(1000)
532 .chunking_factor(4) .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) .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}