1#[cfg(feature = "native")]
13use rayon::prelude::*;
14const TERM_DEGREE_VALUE_BYTES: usize = std::mem::size_of::<[u32; 2]>();
15const CANDIDATE_ENTRY_BYTES: usize = std::mem::size_of::<(usize, u32)>();
16const PARALLEL_BP_MIN_ENTITIES: usize = 1_048_576;
20const MIN_RELATIVE_OBJECTIVE_IMPROVEMENT: f64 = 1e-6;
21const MIN_OBJECTIVE_ITERATIONS: usize = 4;
22const OBJECTIVE_STALL_ITERATIONS: usize = 2;
23
24fn term_degree_bytes(num_terms: usize) -> usize {
25 num_terms
26 .saturating_mul(TERM_DEGREE_VALUE_BYTES)
27 .saturating_add(num_terms.div_ceil(64).saturating_mul(8))
28}
29
30fn parallel_bisect_depth(
31 memory_budget_bytes: usize,
32 non_degree_bytes: usize,
33 num_terms: usize,
34) -> usize {
35 let per_node = term_degree_bytes(num_terms).max(1);
36 let affordable_nodes = memory_budget_bytes
37 .saturating_sub(non_degree_bytes)
38 .checked_div(per_node)
39 .unwrap_or(0)
40 .max(1);
41 #[cfg(feature = "native")]
42 let worker_limit = rayon::current_num_threads().max(1);
43 #[cfg(not(feature = "native"))]
44 let worker_limit = 1usize;
45 affordable_nodes.min(worker_limit).ilog2() as usize
46}
47
48struct TermDegrees {
56 values: Vec<std::mem::MaybeUninit<[u32; 2]>>,
57 initialized: Vec<u64>,
58}
59
60impl TermDegrees {
61 fn new(num_terms: usize) -> Self {
62 let mut values = Vec::with_capacity(num_terms);
63 values.resize_with(num_terms, std::mem::MaybeUninit::uninit);
64 Self {
65 values,
66 initialized: vec![0; num_terms.div_ceil(64)],
67 }
68 }
69
70 #[inline]
71 fn entry_mut(&mut self, term: usize) -> &mut [u32; 2] {
72 let word = term / 64;
73 let mask = 1u64 << (term % 64);
74 if self.initialized[word] & mask == 0 {
75 self.values[term].write([0, 0]);
76 self.initialized[word] |= mask;
77 }
78 unsafe { self.values[term].assume_init_mut() }
80 }
81
82 #[inline]
83 fn get(&self, term: usize) -> [u32; 2] {
84 let word = term / 64;
85 let mask = 1u64 << (term % 64);
86 if self.initialized[word] & mask == 0 {
87 return [0, 0];
88 }
89 unsafe { *self.values[term].assume_init_ref() }
92 }
93
94 fn merge_from(&mut self, other: &Self) {
95 for (word_idx, &initialized) in other.initialized.iter().enumerate() {
96 let mut pending = initialized;
97 while pending != 0 {
98 let bit = pending.trailing_zeros() as usize;
99 let term = word_idx * 64 + bit;
100 let [left, right] = unsafe { *other.values[term].assume_init_ref() };
102 let entry = self.entry_mut(term);
103 entry[0] += left;
104 entry[1] += right;
105 pending &= pending - 1;
106 }
107 }
108 }
109
110 fn bisection_objective(&self, left_size: usize, right_size: usize, log_table: &[f32]) -> f64 {
117 let mut objective = 0.0f64;
118 let side_log = [
119 fast_log2_lookup(left_size, log_table) as f64,
120 fast_log2_lookup(right_size, log_table) as f64,
121 ];
122 for (word_idx, &initialized) in self.initialized.iter().enumerate() {
123 let mut pending = initialized;
124 while pending != 0 {
125 let bit = pending.trailing_zeros() as usize;
126 let term = word_idx * 64 + bit;
127 let [left, right] = unsafe { *self.values[term].assume_init_ref() };
129 for (side, count) in [left, right].into_iter().enumerate() {
130 if count > 0 {
131 objective += count as f64
132 * (fast_log2_lookup(count as usize + 1, log_table) as f64
133 - side_log[side]);
134 }
135 }
136 pending &= pending - 1;
137 }
138 }
139 objective
140 }
141}
142
143struct TermDeltas {
151 values: Vec<std::mem::MaybeUninit<i64>>,
152 initialized: Vec<u64>,
153}
154
155impl TermDeltas {
156 fn new(num_terms: usize) -> Self {
157 let mut values = Vec::with_capacity(num_terms);
158 values.resize_with(num_terms, std::mem::MaybeUninit::uninit);
159 Self {
160 values,
161 initialized: vec![0; num_terms.div_ceil(64)],
162 }
163 }
164
165 #[inline]
166 fn entry_mut(&mut self, term: usize) -> &mut i64 {
167 let word = term / 64;
168 let mask = 1u64 << (term % 64);
169 if self.initialized[word] & mask == 0 {
170 self.values[term].write(0);
171 self.initialized[word] |= mask;
172 }
173 unsafe { self.values[term].assume_init_mut() }
175 }
176
177 fn merge_from(&mut self, other: &Self) {
178 for (word_idx, &initialized) in other.initialized.iter().enumerate() {
179 let mut pending = initialized;
180 while pending != 0 {
181 let bit = pending.trailing_zeros() as usize;
182 let term = word_idx * 64 + bit;
183 let delta = unsafe { *other.values[term].assume_init_ref() };
185 *self.entry_mut(term) += delta;
186 pending &= pending - 1;
187 }
188 }
189 }
190
191 fn apply_to(&self, degrees: &mut TermDegrees) {
192 for (word_idx, &initialized) in self.initialized.iter().enumerate() {
193 let mut pending = initialized;
194 while pending != 0 {
195 let bit = pending.trailing_zeros() as usize;
196 let term = word_idx * 64 + bit;
197 let delta = unsafe { *self.values[term].assume_init_ref() };
199 let degree = degrees.entry_mut(term);
200 let new_left = degree[0] as i64 + delta;
201 let new_right = degree[1] as i64 - delta;
202 debug_assert!(new_left >= 0 && new_right >= 0);
203 debug_assert!(new_left <= u32::MAX as i64 && new_right <= u32::MAX as i64);
204 degree[0] = new_left as u32;
205 degree[1] = new_right as u32;
206 pending &= pending - 1;
207 }
208 }
209 }
210}
211
212pub(crate) struct ForwardIndex {
218 terms: Vec<u32>,
219 offsets: Vec<u64>,
224 pub num_terms: usize,
225 parallel_bisect_depth: usize,
229 budget_limited: bool,
233}
234
235fn build_csr_offsets(counts: &[u32]) -> Vec<u64> {
238 let mut offsets = Vec::with_capacity(counts.len() + 1);
239 offsets.push(0u64);
240 for &c in counts {
241 offsets.push(offsets.last().unwrap() + c as u64);
242 }
243 offsets
244}
245
246impl ForwardIndex {
247 #[inline]
248 pub fn num_docs(&self) -> usize {
249 if self.offsets.is_empty() {
250 0
251 } else {
252 self.offsets.len() - 1
253 }
254 }
255
256 #[inline]
257 fn doc_terms(&self, doc: usize) -> &[u32] {
258 let start = self.offsets[doc] as usize;
259 let end = self.offsets[doc + 1] as usize;
260 &self.terms[start..end]
261 }
262
263 pub fn total_postings(&self) -> u64 {
265 self.offsets.last().copied().unwrap_or(0)
266 }
267
268 #[inline]
269 pub fn budget_limited(&self) -> bool {
270 self.budget_limited
271 }
272}
273
274pub(crate) fn build_vid_maps(
284 bmp: &crate::segment::reader::bmp::BmpIndex,
285) -> crate::Result<(Vec<u32>, Vec<u32>)> {
286 let ids = bmp.doc_map_ids_slice();
287 let num_virtual = bmp.num_virtual_docs as usize;
288 let expected_real = bmp.num_real_docs() as usize;
289 let mut virtual_to_real = vec![u32::MAX; num_virtual];
290 let mut real_to_virtual = Vec::with_capacity(expected_real);
291 for (vid, (slot, chunk)) in virtual_to_real
292 .iter_mut()
293 .zip(ids.as_chunks::<4>().0)
294 .enumerate()
295 {
296 let doc_id = u32::from_le_bytes(*chunk);
297 if doc_id != u32::MAX {
298 if real_to_virtual.len() == expected_real {
299 return Err(crate::Error::Corruption(format!(
300 "BMP document map contains more than the footer's {expected_real} real slots"
301 )));
302 }
303 *slot = real_to_virtual.len() as u32;
304 real_to_virtual.push(vid as u32);
305 }
306 }
307 if real_to_virtual.len() != expected_real {
308 return Err(crate::Error::Corruption(format!(
309 "BMP document map has {} real slots but footer declares {expected_real}",
310 real_to_virtual.len(),
311 )));
312 }
313 Ok((virtual_to_real, real_to_virtual))
314}
315
316struct BlockJob {
322 src: u32,
323 block_id: u32,
324 real_start: u32,
326 real_len: u32,
328}
329
330fn build_block_jobs(
333 bmps: &[&crate::segment::reader::bmp::BmpIndex],
334 vid_maps: &[(Vec<u32>, Vec<u32>)],
335) -> Vec<BlockJob> {
336 let total_blocks: usize = bmps.iter().map(|b| b.num_blocks as usize).sum();
337 let mut jobs = Vec::with_capacity(total_blocks);
338 for (src, (bmp, (v2r, _))) in bmps.iter().zip(vid_maps).enumerate() {
339 let block_size = bmp.bmp_block_size as usize;
340 let mut real_cursor = 0u32;
341 for block_id in 0..bmp.num_blocks as usize {
342 let vid_start = block_id * block_size;
343 let vid_end = ((block_id + 1) * block_size).min(v2r.len());
344 let real_len = v2r[vid_start..vid_end]
345 .iter()
346 .filter(|&&r| r != u32::MAX)
347 .count() as u32;
348 jobs.push(BlockJob {
349 src: src as u32,
350 block_id: block_id as u32,
351 real_start: real_cursor,
352 real_len,
353 });
354 real_cursor += real_len;
355 }
356 }
357 jobs
358}
359
360#[cfg(test)]
374pub(crate) fn build_forward_index_from_bmps(
375 bmps: &[&crate::segment::reader::bmp::BmpIndex],
376 min_doc_freq: usize,
377 max_doc_freq: usize,
378 memory_budget_bytes: usize,
379) -> crate::Result<(ForwardIndex, Vec<usize>)> {
380 let vid_maps: Vec<(Vec<u32>, Vec<u32>)> = bmps
381 .iter()
382 .map(|bmp| build_vid_maps(bmp))
383 .collect::<crate::Result<_>>()?;
384 Ok(build_forward_index_from_bmps_with_maps(
385 bmps,
386 &vid_maps,
387 min_doc_freq,
388 max_doc_freq,
389 memory_budget_bytes,
390 ))
391}
392
393pub(crate) fn build_forward_index_from_bmps_with_maps(
397 bmps: &[&crate::segment::reader::bmp::BmpIndex],
398 vid_maps: &[(Vec<u32>, Vec<u32>)],
399 min_doc_freq: usize,
400 max_doc_freq: usize,
401 memory_budget_bytes: usize,
402) -> (ForwardIndex, Vec<usize>) {
403 debug_assert_eq!(bmps.len(), vid_maps.len());
404 let source_doc_counts: Vec<usize> = vid_maps.iter().map(|(_, r2v)| r2v.len()).collect();
405 let total_docs: usize = source_doc_counts.iter().sum();
406
407 if total_docs == 0 {
408 return (
409 ForwardIndex {
410 terms: Vec::new(),
411 offsets: Vec::new(),
412 num_terms: 0,
413 parallel_bisect_depth: 0,
414 budget_limited: false,
415 },
416 source_doc_counts,
417 );
418 }
419
420 let jobs = build_block_jobs(bmps, vid_maps);
425
426 let max_dims = bmps
430 .iter()
431 .map(|bmp| bmp.dims() as usize)
432 .max()
433 .unwrap_or(0);
434 let jobs_bytes = jobs
435 .len()
436 .saturating_mul(std::mem::size_of::<BlockJob>().saturating_add(40));
437 let frequency_bytes =
438 max_dims.saturating_mul(std::mem::size_of::<std::sync::atomic::AtomicU32>());
439 if frequency_bytes > memory_budget_bytes.saturating_sub(jobs_bytes) {
440 log::warn!(
441 "[reorder] memory budget {} cannot hold the {} dimension-frequency table; using identity order",
442 crate::format_bytes(memory_budget_bytes as u64),
443 crate::format_bytes(frequency_bytes as u64),
444 );
445 return (
446 ForwardIndex {
447 terms: Vec::new(),
448 offsets: Vec::new(),
449 num_terms: 0,
450 parallel_bisect_depth: 0,
451 budget_limited: true,
452 },
453 source_doc_counts,
454 );
455 }
456 let dim_df: Vec<std::sync::atomic::AtomicU32> = (0..max_dims)
457 .map(|_| std::sync::atomic::AtomicU32::new(0))
458 .collect();
459 let count_block_df = |job: &BlockJob| {
460 let bmp = bmps[job.src as usize];
461 let (v2r, _) = &vid_maps[job.src as usize];
462 let block_size = bmp.bmp_block_size as usize;
463 for (dim_id, _, postings) in bmp.iter_block_terms(job.block_id) {
464 let mut n = 0usize;
465 for p in postings {
466 let vid = job.block_id as usize * block_size + p.local_slot as usize;
467 if v2r[vid] != u32::MAX && p.impact > 0 {
468 n += 1;
469 }
470 }
471 if n > 0
472 && let Some(count) = dim_df.get(dim_id as usize)
473 {
474 count.fetch_add(n as u32, std::sync::atomic::Ordering::Relaxed);
475 }
476 }
477 };
478 #[cfg(feature = "native")]
479 jobs.par_iter().for_each(count_block_df);
480 #[cfg(not(feature = "native"))]
481 jobs.iter().for_each(count_block_df);
482
483 let eligible_candidate_count = dim_df
487 .iter()
488 .filter(|df| {
489 let df = df.load(std::sync::atomic::Ordering::Relaxed) as usize;
490 df >= min_doc_freq && df <= max_doc_freq
491 })
492 .count();
493 let candidate_capacity = memory_budget_bytes
494 .saturating_sub(jobs_bytes)
495 .saturating_sub(frequency_bytes)
496 .checked_div(std::mem::size_of::<(usize, u32)>())
497 .unwrap_or(0)
498 .min(eligible_candidate_count);
499 let mut candidate_heap = std::collections::BinaryHeap::with_capacity(candidate_capacity);
500 for (dim_id, df) in dim_df.iter().enumerate() {
501 let df = df.load(std::sync::atomic::Ordering::Relaxed) as usize;
502 if df < min_doc_freq || df > max_doc_freq {
503 continue;
504 }
505 let candidate = (df, dim_id as u32);
506 if candidate_heap.len() < candidate_capacity {
507 candidate_heap.push(candidate);
508 } else if candidate_capacity > 0 && candidate < *candidate_heap.peek().unwrap() {
509 candidate_heap.pop();
510 candidate_heap.push(candidate);
511 }
512 }
513 drop(dim_df);
514 let mut eligible: Vec<(u32, usize)> = candidate_heap
515 .into_vec()
516 .into_iter()
517 .map(|(df, dim_id)| (dim_id, df))
518 .collect();
519 let mut budget_limited = eligible.len() < eligible_candidate_count;
520
521 let total_postings_est = eligible
525 .iter()
526 .fold(0usize, |total, (_, df)| total.saturating_add(*df));
527 let entity_scratch_bytes = total_docs.saturating_mul(32);
528 let remap_bytes = max_dims.saturating_mul(4);
529 let fixed_bytes = entity_scratch_bytes
530 .saturating_add(remap_bytes)
531 .saturating_add(jobs_bytes);
532 let estimated_bytes = total_postings_est
533 .saturating_mul(4)
534 .saturating_add(fixed_bytes)
535 .saturating_add(eligible.len().saturating_mul(CANDIDATE_ENTRY_BYTES))
538 .saturating_add(term_degree_bytes(eligible.len()));
539
540 if estimated_bytes > memory_budget_bytes && !eligible.is_empty() {
541 eligible.sort_by_key(|&(_, df)| df);
544
545 let mut used_bytes = fixed_bytes;
550 let mut cum = 0usize;
551 let mut keep_count = 0;
552 for &(_, df) in &eligible {
553 let term_bytes = df
554 .saturating_mul(4)
555 .saturating_add(TERM_DEGREE_VALUE_BYTES + 1)
556 .saturating_add(CANDIDATE_ENTRY_BYTES);
557 if term_bytes > memory_budget_bytes.saturating_sub(used_bytes) {
558 break;
559 }
560 used_bytes = used_bytes.saturating_add(term_bytes);
561 cum = cum.saturating_add(df);
562 keep_count += 1;
563 }
564
565 let dropped = eligible.len() - keep_count;
566 eligible.truncate(keep_count);
567 budget_limited |= dropped > 0;
568
569 log::warn!(
570 "[reorder] memory budget {}: estimated {}, dropped {} highest-df dims, keeping {} ({} postings)",
571 crate::format_bytes(memory_budget_bytes as u64),
572 crate::format_bytes(estimated_bytes as u64),
573 dropped,
574 keep_count,
575 cum,
576 );
577 }
578
579 if eligible.is_empty() {
580 return (
585 ForwardIndex {
586 terms: Vec::new(),
587 offsets: Vec::new(),
588 num_terms: 0,
589 parallel_bisect_depth: 0,
590 budget_limited,
591 },
592 source_doc_counts,
593 );
594 }
595
596 let mut term_remap = vec![u32::MAX; max_dims];
597 for (compact_id, &(dim_id, _)) in eligible.iter().enumerate() {
598 term_remap[dim_id as usize] = compact_id as u32;
599 }
600 let num_active_terms = eligible.len();
601 let retained_postings = eligible
602 .iter()
603 .fold(0usize, |total, (_, df)| total.saturating_add(*df));
604 let non_degree_bytes = fixed_bytes.saturating_add(retained_postings.saturating_mul(4));
605 let parallel_bisect_depth =
606 parallel_bisect_depth(memory_budget_bytes, non_degree_bytes, num_active_terms);
607 drop(eligible);
608
609 let mut counts = vec![0u32; total_docs];
611 let fill_block_counts = |job: &BlockJob, out: &mut [u32]| {
612 let bmp = bmps[job.src as usize];
613 let (v2r, _) = &vid_maps[job.src as usize];
614 let block_size = bmp.bmp_block_size as usize;
615 for (dim_id, _, postings) in bmp.iter_block_terms(job.block_id) {
616 if term_remap.get(dim_id as usize).copied().unwrap_or(u32::MAX) == u32::MAX {
617 continue;
618 }
619 for p in postings {
620 let vid = job.block_id as usize * block_size + p.local_slot as usize;
621 let real = v2r[vid];
622 if real != u32::MAX && p.impact > 0 {
623 out[(real - job.real_start) as usize] += 1;
624 }
625 }
626 }
627 };
628 {
629 let mut slices: Vec<(&BlockJob, &mut [u32])> = Vec::with_capacity(jobs.len());
630 let mut rest: &mut [u32] = &mut counts;
631 for job in &jobs {
632 let (head, tail) = rest.split_at_mut(job.real_len as usize);
633 slices.push((job, head));
634 rest = tail;
635 }
636 #[cfg(feature = "native")]
637 slices
638 .into_par_iter()
639 .for_each(|(job, out)| fill_block_counts(job, out));
640 #[cfg(not(feature = "native"))]
641 for (job, out) in slices {
642 fill_block_counts(job, out);
643 }
644 }
645
646 let offsets = build_csr_offsets(&counts);
648 let total = *offsets.last().unwrap() as usize;
649 drop(counts);
650
651 let mut terms = vec![0u32; total];
654 let fill_block_terms = |job: &BlockJob, global_real_start: usize, out: &mut [u32]| {
655 let bmp = bmps[job.src as usize];
656 let (v2r, _) = &vid_maps[job.src as usize];
657 let block_size = bmp.bmp_block_size as usize;
658 assert!(job.real_len as usize <= 256, "BMP block exceeds 256 docs");
660 let mut cursor = [0u32; 256];
661 let base = offsets[global_real_start] as usize;
662 for (dim_id, _, postings) in bmp.iter_block_terms(job.block_id) {
663 let compact = term_remap.get(dim_id as usize).copied().unwrap_or(u32::MAX);
664 if compact == u32::MAX {
665 continue;
666 }
667 for p in postings {
668 let vid = job.block_id as usize * block_size + p.local_slot as usize;
669 let real = v2r[vid];
670 if real != u32::MAX && p.impact > 0 {
671 let local = (real - job.real_start) as usize;
672 let pos =
673 offsets[global_real_start + local] as usize - base + cursor[local] as usize;
674 out[pos] = compact;
675 cursor[local] += 1;
676 }
677 }
678 }
679 };
680 {
681 let mut slices: Vec<(&BlockJob, usize, &mut [u32])> = Vec::with_capacity(jobs.len());
682 let mut rest: &mut [u32] = &mut terms;
683 let mut global_real = 0usize;
684 for job in &jobs {
685 let len =
686 (offsets[global_real + job.real_len as usize] - offsets[global_real]) as usize;
687 let (head, tail) = rest.split_at_mut(len);
688 slices.push((job, global_real, head));
689 rest = tail;
690 global_real += job.real_len as usize;
691 }
692 #[cfg(feature = "native")]
693 slices
694 .into_par_iter()
695 .for_each(|(job, g, out)| fill_block_terms(job, g, out));
696 #[cfg(not(feature = "native"))]
697 for (job, g, out) in slices {
698 fill_block_terms(job, g, out);
699 }
700 }
701
702 (
703 ForwardIndex {
704 terms,
705 offsets,
706 num_terms: num_active_terms,
707 parallel_bisect_depth,
708 budget_limited,
709 },
710 source_doc_counts,
711 )
712}
713
714pub(crate) fn build_forward_index_from_blocks(
722 bmps: &[&crate::segment::reader::bmp::BmpIndex],
723 memory_budget_bytes: usize,
724) -> ForwardIndex {
725 let total_blocks: usize = bmps.iter().map(|b| b.num_blocks as usize).sum();
726 if total_blocks == 0 {
727 return ForwardIndex {
728 terms: Vec::new(),
729 offsets: Vec::new(),
730 num_terms: 0,
731 parallel_bisect_depth: 0,
732 budget_limited: false,
733 };
734 }
735
736 let blocks: Vec<(u32, u32)> = bmps
738 .iter()
739 .enumerate()
740 .flat_map(|(src, bmp)| (0..bmp.num_blocks).map(move |b| (src as u32, b)))
741 .collect();
742
743 let max_dims = bmps
745 .iter()
746 .map(|bmp| bmp.dims() as usize)
747 .max()
748 .unwrap_or(0);
749 let blocks_bytes = blocks
750 .len()
751 .saturating_mul(std::mem::size_of::<(u32, u32)>().saturating_add(32));
752 let frequency_bytes =
753 max_dims.saturating_mul(std::mem::size_of::<std::sync::atomic::AtomicU32>());
754 if frequency_bytes > memory_budget_bytes.saturating_sub(blocks_bytes) {
755 log::warn!(
756 "[reorder] block-level frequency table exceeds memory budget; using identity order"
757 );
758 return ForwardIndex {
759 terms: Vec::new(),
760 offsets: Vec::new(),
761 num_terms: 0,
762 parallel_bisect_depth: 0,
763 budget_limited: true,
764 };
765 }
766 let dim_bf: Vec<std::sync::atomic::AtomicU32> = (0..max_dims)
767 .map(|_| std::sync::atomic::AtomicU32::new(0))
768 .collect();
769 let count_block_bf = |&(src, block_id): &(u32, u32)| {
770 for (dim_id, _, _) in bmps[src as usize].iter_block_terms(block_id) {
771 if let Some(count) = dim_bf.get(dim_id as usize) {
772 count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
773 }
774 }
775 };
776 #[cfg(feature = "native")]
777 blocks.par_iter().for_each(count_block_bf);
778 #[cfg(not(feature = "native"))]
779 blocks.iter().for_each(count_block_bf);
780
781 let max_bf = (total_blocks as f64 * 0.9) as usize;
782 let eligible_candidate_count = dim_bf
783 .iter()
784 .filter(|bf| {
785 let bf = bf.load(std::sync::atomic::Ordering::Relaxed) as usize;
786 bf >= 2 && bf <= max_bf.max(2)
787 })
788 .count();
789 let candidate_capacity = memory_budget_bytes
790 .saturating_sub(blocks_bytes)
791 .saturating_sub(frequency_bytes)
792 .checked_div(std::mem::size_of::<(usize, u32)>())
793 .unwrap_or(0)
794 .min(eligible_candidate_count);
795 let mut candidate_heap = std::collections::BinaryHeap::with_capacity(candidate_capacity);
796 for (dim_id, bf) in dim_bf.iter().enumerate() {
797 let bf = bf.load(std::sync::atomic::Ordering::Relaxed) as usize;
798 if bf < 2 || bf > max_bf.max(2) {
799 continue;
800 }
801 let candidate = (bf, dim_id as u32);
802 if candidate_heap.len() < candidate_capacity {
803 candidate_heap.push(candidate);
804 } else if candidate_capacity > 0 && candidate < *candidate_heap.peek().unwrap() {
805 candidate_heap.pop();
806 candidate_heap.push(candidate);
807 }
808 }
809 drop(dim_bf);
810 let mut eligible: Vec<(u32, usize)> = candidate_heap
811 .into_vec()
812 .into_iter()
813 .map(|(bf, dim_id)| (dim_id, bf))
814 .collect();
815 let mut budget_limited = eligible.len() < eligible_candidate_count;
816
817 let total_postings_est = eligible
818 .iter()
819 .fold(0usize, |total, (_, bf)| total.saturating_add(*bf));
820 let entity_scratch_bytes = total_blocks.saturating_mul(32);
821 let remap_bytes = max_dims.saturating_mul(4);
822 let fixed_bytes = entity_scratch_bytes
823 .saturating_add(remap_bytes)
824 .saturating_add(blocks_bytes);
825 let estimated_bytes = total_postings_est
826 .saturating_mul(4)
827 .saturating_add(fixed_bytes)
828 .saturating_add(eligible.len().saturating_mul(CANDIDATE_ENTRY_BYTES))
829 .saturating_add(term_degree_bytes(eligible.len()));
830 if estimated_bytes > memory_budget_bytes && !eligible.is_empty() {
831 eligible.sort_by_key(|&(_, bf)| bf);
832 let mut used_bytes = fixed_bytes;
833 let mut cum = 0usize;
834 let mut keep = 0;
835 for &(_, bf) in &eligible {
836 let term_bytes = bf
837 .saturating_mul(4)
838 .saturating_add(TERM_DEGREE_VALUE_BYTES + 1)
839 .saturating_add(CANDIDATE_ENTRY_BYTES);
840 if term_bytes > memory_budget_bytes.saturating_sub(used_bytes) {
841 break;
842 }
843 used_bytes = used_bytes.saturating_add(term_bytes);
844 cum = cum.saturating_add(bf);
845 keep += 1;
846 }
847 let dropped = eligible.len() - keep;
848 budget_limited |= dropped > 0;
849 log::warn!(
850 "[reorder] block-level fwd index over budget — dropped {} highest-bf dims",
851 dropped,
852 );
853 eligible.truncate(keep);
854 }
855
856 if eligible.is_empty() {
857 return ForwardIndex {
858 terms: Vec::new(),
859 offsets: Vec::new(),
860 num_terms: 0,
861 parallel_bisect_depth: 0,
862 budget_limited,
863 };
864 }
865
866 let mut term_remap = vec![u32::MAX; max_dims];
867 for (compact, &(dim_id, _)) in eligible.iter().enumerate() {
868 term_remap[dim_id as usize] = compact as u32;
869 }
870 let num_terms = eligible.len();
871 let retained_postings = eligible
872 .iter()
873 .fold(0usize, |total, (_, bf)| total.saturating_add(*bf));
874 let non_degree_bytes = fixed_bytes.saturating_add(retained_postings.saturating_mul(4));
875 let parallel_bisect_depth =
876 parallel_bisect_depth(memory_budget_bytes, non_degree_bytes, num_terms);
877 drop(eligible);
878
879 let count_remapped = |&(src, block_id): &(u32, u32)| -> u32 {
882 bmps[src as usize]
883 .iter_block_terms(block_id)
884 .filter(|(dim_id, _, _)| {
885 term_remap
886 .get(*dim_id as usize)
887 .copied()
888 .unwrap_or(u32::MAX)
889 != u32::MAX
890 })
891 .count() as u32
892 };
893 #[cfg(feature = "native")]
894 let counts: Vec<u32> = blocks.par_iter().map(count_remapped).collect();
895 #[cfg(not(feature = "native"))]
896 let counts: Vec<u32> = blocks.iter().map(count_remapped).collect();
897
898 let offsets = build_csr_offsets(&counts);
899 let total = *offsets.last().unwrap() as usize;
900 drop(counts);
901
902 let mut terms = vec![0u32; total];
903 let fill_block = |&(src, block_id): &(u32, u32), out: &mut [u32]| {
904 let mut n = 0usize;
905 for (dim_id, _, _) in bmps[src as usize].iter_block_terms(block_id) {
906 let compact = term_remap.get(dim_id as usize).copied().unwrap_or(u32::MAX);
907 if compact != u32::MAX {
908 out[n] = compact;
909 n += 1;
910 }
911 }
912 };
913 {
914 let mut slices: Vec<(&(u32, u32), &mut [u32])> = Vec::with_capacity(blocks.len());
915 let mut rest: &mut [u32] = &mut terms;
916 for (gb, b) in blocks.iter().enumerate() {
917 let len = (offsets[gb + 1] - offsets[gb]) as usize;
918 let (head, tail) = rest.split_at_mut(len);
919 slices.push((b, head));
920 rest = tail;
921 }
922 #[cfg(feature = "native")]
923 slices
924 .into_par_iter()
925 .for_each(|(b, out)| fill_block(b, out));
926 #[cfg(not(feature = "native"))]
927 for (b, out) in slices {
928 fill_block(b, out);
929 }
930 }
931
932 ForwardIndex {
933 terms,
934 offsets,
935 num_terms,
936 parallel_bisect_depth,
937 budget_limited,
938 }
939}
940
941#[derive(Clone, Copy, Debug, Default)]
949pub struct BpBudget {
950 pub min_partition_docs: Option<usize>,
955 pub time_budget: Option<std::time::Duration>,
959}
960
961impl BpBudget {
962 pub fn full() -> Self {
964 Self::default()
965 }
966}
967
968fn build_term_degrees(
974 docs: &[u32],
975 mid: usize,
976 fwd: &ForwardIndex,
977 degree_lanes: usize,
978) -> TermDegrees {
979 let build_range = |start: usize, chunk: &[u32]| {
980 let mut degrees = TermDegrees::new(fwd.num_terms);
981 for (offset, &doc) in chunk.iter().enumerate() {
982 let side = usize::from(start + offset >= mid);
983 for &term in fwd.doc_terms(doc as usize) {
984 degrees.entry_mut(term as usize)[side] += 1;
985 }
986 }
987 degrees
988 };
989
990 #[cfg(feature = "native")]
991 {
992 let workers = degree_lanes
993 .max(1)
994 .min(docs.len().div_ceil(PARALLEL_BP_MIN_ENTITIES).max(1));
995 if workers > 1 {
996 let chunk_len = docs.len().div_ceil(workers);
997 return docs
998 .par_chunks(chunk_len)
999 .enumerate()
1000 .map(|(chunk, docs)| build_range(chunk * chunk_len, docs))
1001 .reduce_with(|mut left, right| {
1002 left.merge_from(&right);
1003 left
1004 })
1005 .unwrap_or_else(|| TermDegrees::new(fwd.num_terms));
1006 }
1007 }
1008 #[cfg(not(feature = "native"))]
1009 let _ = degree_lanes;
1010
1011 build_range(0, docs)
1012}
1013
1014#[inline]
1020fn gain_order_key(gain: f32) -> u32 {
1021 let bits = gain.to_bits();
1022 if bits & 0x8000_0000 != 0 {
1023 !bits
1024 } else {
1025 bits ^ 0x8000_0000
1026 }
1027}
1028
1029fn select_gain_threshold(gains: &[f32], left_count: usize) -> (u32, usize) {
1033 debug_assert!(left_count > 0 && left_count <= gains.len());
1034 let mut rank_within_prefix = left_count - 1;
1035 let mut strictly_lower = 0usize;
1036 let mut prefix = 0u32;
1037 let mut prefix_mask = 0u32;
1038
1039 for shift in [24u32, 16, 8, 0] {
1040 let histogram = {
1041 #[cfg(feature = "native")]
1042 {
1043 if gains.len() >= PARALLEL_BP_MIN_ENTITIES {
1044 gains
1045 .par_iter()
1046 .fold(
1047 || Box::new([0usize; 256]),
1048 |mut counts, &gain| {
1049 let key = gain_order_key(gain);
1050 if key & prefix_mask == prefix {
1051 counts[((key >> shift) & 0xff) as usize] += 1;
1052 }
1053 counts
1054 },
1055 )
1056 .reduce(
1057 || Box::new([0usize; 256]),
1058 |mut left, right| {
1059 for (dst, &count) in left.iter_mut().zip(right.iter()) {
1060 *dst += count;
1061 }
1062 left
1063 },
1064 )
1065 } else {
1066 let mut counts = [0usize; 256];
1067 for &gain in gains {
1068 let key = gain_order_key(gain);
1069 if key & prefix_mask == prefix {
1070 counts[((key >> shift) & 0xff) as usize] += 1;
1071 }
1072 }
1073 Box::new(counts)
1074 }
1075 }
1076 #[cfg(not(feature = "native"))]
1077 {
1078 let mut counts = [0usize; 256];
1079 for &gain in gains {
1080 let key = gain_order_key(gain);
1081 if key & prefix_mask == prefix {
1082 counts[((key >> shift) & 0xff) as usize] += 1;
1083 }
1084 }
1085 counts
1086 }
1087 };
1088
1089 let mut before_bucket = 0usize;
1090 let mut selected_bucket = None;
1091 for (bucket, count) in histogram.iter().copied().enumerate() {
1092 if rank_within_prefix < before_bucket + count {
1093 selected_bucket = Some(bucket as u32);
1094 rank_within_prefix -= before_bucket;
1095 strictly_lower += before_bucket;
1096 break;
1097 }
1098 before_bucket += count;
1099 }
1100 let selected_bucket = selected_bucket.expect("BP radix selection lost the requested rank");
1101 prefix |= selected_bucket << shift;
1102 prefix_mask |= 0xffu32 << shift;
1103 }
1104
1105 (prefix, strictly_lower)
1106}
1107
1108enum PartitionDegreeUpdate {
1109 Deltas(TermDeltas),
1111 Ranked,
1115 Threshold {
1118 threshold_key: u32,
1119 ties_left: usize,
1120 },
1121}
1122
1123struct PartitionOutcome {
1124 swap_count: usize,
1125 degree_update: PartitionDegreeUpdate,
1126}
1127
1128#[derive(Clone, Copy)]
1129struct PartitionChunk {
1130 start: usize,
1131 end: usize,
1132 strictly_lower: usize,
1133 equal: usize,
1134 ties_left: usize,
1135}
1136
1137#[inline]
1138fn select_left(key: u32, threshold_key: u32, equal_seen: &mut usize, ties_left: usize) -> bool {
1139 if key < threshold_key {
1140 true
1141 } else if key == threshold_key {
1142 let selected = *equal_seen < ties_left;
1143 *equal_seen += 1;
1144 selected
1145 } else {
1146 false
1147 }
1148}
1149
1150fn partition_by_gain(
1159 docs: &[u32],
1160 gains: &[f32],
1161 mid: usize,
1162 fwd: &ForwardIndex,
1163 degree_lanes: usize,
1164 output: &mut [u32],
1165 ranked_scratch: &mut Vec<usize>,
1166) -> PartitionOutcome {
1167 #[cfg(not(feature = "native"))]
1168 let _ = (fwd, degree_lanes);
1169
1170 #[cfg(feature = "native")]
1171 if degree_lanes > 1 && docs.len() >= PARALLEL_BP_MIN_ENTITIES {
1172 let (threshold_key, strictly_lower) = select_gain_threshold(gains, mid);
1173 let ties_left = mid - strictly_lower;
1174
1175 let chunk_count = degree_lanes
1178 .saturating_sub(1)
1179 .max(1)
1180 .min(docs.len().div_ceil(PARALLEL_BP_MIN_ENTITIES).max(1));
1181 let chunk_len = docs.len().div_ceil(chunk_count);
1182 let mut chunks: Vec<PartitionChunk> = gains
1183 .par_chunks(chunk_len)
1184 .enumerate()
1185 .map(|(chunk_id, chunk)| {
1186 let mut lower = 0usize;
1187 let mut equal = 0usize;
1188 for &gain in chunk {
1189 match gain_order_key(gain).cmp(&threshold_key) {
1190 std::cmp::Ordering::Less => lower += 1,
1191 std::cmp::Ordering::Equal => equal += 1,
1192 std::cmp::Ordering::Greater => {}
1193 }
1194 }
1195 let start = chunk_id * chunk_len;
1196 PartitionChunk {
1197 start,
1198 end: start + chunk.len(),
1199 strictly_lower: lower,
1200 equal,
1201 ties_left: 0,
1202 }
1203 })
1204 .collect();
1205
1206 let mut remaining_ties = ties_left;
1207 for chunk in &mut chunks {
1208 chunk.ties_left = remaining_ties.min(chunk.equal);
1209 remaining_ties -= chunk.ties_left;
1210 }
1211 debug_assert_eq!(remaining_ties, 0);
1212
1213 let (mut left_rest, mut right_rest) = output.split_at_mut(mid);
1214 let mut jobs = Vec::with_capacity(chunks.len());
1215 for chunk in chunks {
1216 let left_len = chunk.strictly_lower + chunk.ties_left;
1217 let right_len = chunk.end - chunk.start - left_len;
1218 let (left_out, next_left) = left_rest.split_at_mut(left_len);
1219 let (right_out, next_right) = right_rest.split_at_mut(right_len);
1220 jobs.push((
1221 chunk.start,
1222 &docs[chunk.start..chunk.end],
1223 &gains[chunk.start..chunk.end],
1224 chunk.ties_left,
1225 left_out,
1226 right_out,
1227 ));
1228 left_rest = next_left;
1229 right_rest = next_right;
1230 }
1231 debug_assert!(left_rest.is_empty() && right_rest.is_empty());
1232
1233 let (swap_count, deltas) = jobs
1234 .into_par_iter()
1235 .map(
1236 |(start, docs, gains, ties_for_chunk, left_out, right_out)| {
1237 let mut deltas = TermDeltas::new(fwd.num_terms);
1238 let mut equal_seen = 0usize;
1239 let mut left_cursor = 0usize;
1240 let mut right_cursor = 0usize;
1241 let mut swaps = 0usize;
1242
1243 for (offset, (&doc, &gain)) in docs.iter().zip(gains).enumerate() {
1244 let key = gain_order_key(gain);
1245 let now_left =
1246 select_left(key, threshold_key, &mut equal_seen, ties_for_chunk);
1247 if now_left {
1248 left_out[left_cursor] = doc;
1249 left_cursor += 1;
1250 } else {
1251 right_out[right_cursor] = doc;
1252 right_cursor += 1;
1253 }
1254
1255 let was_left = start + offset < mid;
1256 if was_left != now_left {
1257 swaps += 1;
1258 let left_delta = if was_left { -1 } else { 1 };
1259 for &term in fwd.doc_terms(doc as usize) {
1260 *deltas.entry_mut(term as usize) += left_delta;
1261 }
1262 }
1263 }
1264 debug_assert_eq!(left_cursor, left_out.len());
1265 debug_assert_eq!(right_cursor, right_out.len());
1266 (swaps, deltas)
1267 },
1268 )
1269 .reduce_with(|(left_swaps, mut left), (right_swaps, right)| {
1270 left.merge_from(&right);
1271 (left_swaps + right_swaps, left)
1272 })
1273 .unwrap_or_else(|| (0, TermDeltas::new(fwd.num_terms)));
1274
1275 return PartitionOutcome {
1276 swap_count,
1277 degree_update: PartitionDegreeUpdate::Deltas(deltas),
1278 };
1279 }
1280
1281 if docs.len() < PARALLEL_BP_MIN_ENTITIES {
1282 ranked_scratch.clear();
1283 ranked_scratch.extend(0..docs.len());
1284 ranked_scratch.select_nth_unstable_by(mid, |&left, &right| {
1285 gains[left]
1286 .total_cmp(&gains[right])
1287 .then_with(|| left.cmp(&right))
1288 });
1289
1290 let mut swaps = 0usize;
1291 for (rank, &old_index) in ranked_scratch.iter().enumerate() {
1292 output[rank] = docs[old_index];
1293 swaps += usize::from((old_index < mid) != (rank < mid));
1294 }
1295 return PartitionOutcome {
1296 swap_count: swaps,
1297 degree_update: PartitionDegreeUpdate::Ranked,
1298 };
1299 }
1300
1301 let (threshold_key, strictly_lower) = select_gain_threshold(gains, mid);
1302 let ties_left = mid - strictly_lower;
1303 let mut equal_seen = 0usize;
1304 let mut left_cursor = 0usize;
1305 let mut right_cursor = mid;
1306 let mut swaps = 0usize;
1307 for (idx, (&doc, &gain)) in docs.iter().zip(gains).enumerate() {
1308 let key = gain_order_key(gain);
1309 let now_left = select_left(key, threshold_key, &mut equal_seen, ties_left);
1310 if now_left {
1311 output[left_cursor] = doc;
1312 left_cursor += 1;
1313 } else {
1314 output[right_cursor] = doc;
1315 right_cursor += 1;
1316 }
1317 swaps += usize::from((idx < mid) != now_left);
1318 }
1319 debug_assert_eq!(left_cursor, mid);
1320 debug_assert_eq!(right_cursor, docs.len());
1321
1322 PartitionOutcome {
1323 swap_count: swaps,
1324 degree_update: PartitionDegreeUpdate::Threshold {
1325 threshold_key,
1326 ties_left,
1327 },
1328 }
1329}
1330
1331fn update_degrees_for_threshold_partition(
1333 docs: &[u32],
1334 gains: &[f32],
1335 mid: usize,
1336 threshold_key: u32,
1337 ties_left: usize,
1338 fwd: &ForwardIndex,
1339 degrees: &mut TermDegrees,
1340) {
1341 let mut equal_seen = 0usize;
1342 for (idx, (&doc, &gain)) in docs.iter().zip(gains).enumerate() {
1343 let key = gain_order_key(gain);
1344 let now_left = select_left(key, threshold_key, &mut equal_seen, ties_left);
1345 let was_left = idx < mid;
1346 if was_left == now_left {
1347 continue;
1348 }
1349 let left_delta = if was_left { -1i64 } else { 1i64 };
1350 for &term in fwd.doc_terms(doc as usize) {
1351 let degree = degrees.entry_mut(term as usize);
1352 let new_left = degree[0] as i64 + left_delta;
1353 let new_right = degree[1] as i64 - left_delta;
1354 debug_assert!(new_left >= 0 && new_right >= 0);
1355 degree[0] = new_left as u32;
1356 degree[1] = new_right as u32;
1357 }
1358 }
1359}
1360
1361fn update_degrees_for_ranked_partition(
1364 docs: &[u32],
1365 ranked: &[usize],
1366 mid: usize,
1367 fwd: &ForwardIndex,
1368 degrees: &mut TermDegrees,
1369) {
1370 for (rank, &old_index) in ranked.iter().enumerate() {
1371 let was_left = old_index < mid;
1372 let now_left = rank < mid;
1373 if was_left == now_left {
1374 continue;
1375 }
1376 let left_delta = if was_left { -1i64 } else { 1i64 };
1377 for &term in fwd.doc_terms(docs[old_index] as usize) {
1378 let degree = degrees.entry_mut(term as usize);
1379 let new_left = degree[0] as i64 + left_delta;
1380 let new_right = degree[1] as i64 - left_delta;
1381 debug_assert!(new_left >= 0 && new_right >= 0);
1382 degree[0] = new_left as u32;
1383 degree[1] = new_right as u32;
1384 }
1385 }
1386}
1387
1388#[derive(Clone, Copy)]
1389pub(crate) struct BpProgressLabel<'a> {
1390 pub index: &'a str,
1391 pub field: &'a str,
1392 pub entity_kind: &'static str,
1393}
1394
1395#[cfg(test)]
1396impl BpProgressLabel<'static> {
1397 fn anonymous() -> Self {
1398 Self {
1399 index: "unknown",
1400 field: "unknown",
1401 entity_kind: "entities",
1402 }
1403 }
1404}
1405
1406#[cfg(feature = "native")]
1407struct BpProgress<'a> {
1408 label: BpProgressLabel<'a>,
1409 start: std::time::Instant,
1410 total_entities: usize,
1411 total_postings: u64,
1412 expected_depth: usize,
1413 next_log_ms: std::sync::atomic::AtomicU64,
1414 active_partitions: std::sync::atomic::AtomicU64,
1415 partitions_started: std::sync::atomic::AtomicU64,
1416 partitions_completed: std::sync::atomic::AtomicU64,
1417 iterations: std::sync::atomic::AtomicU64,
1418 entity_passes: std::sync::atomic::AtomicU64,
1419 swaps: std::sync::atomic::AtomicU64,
1420 deepest_level: std::sync::atomic::AtomicU64,
1421 objective_stops: std::sync::atomic::AtomicU64,
1422 last_objective_delta_bits: std::sync::atomic::AtomicU64,
1423 last_relative_delta_bits: std::sync::atomic::AtomicU64,
1424 active_metric_released: std::sync::atomic::AtomicBool,
1425}
1426
1427#[cfg(feature = "native")]
1428impl<'a> BpProgress<'a> {
1429 fn new(
1430 label: BpProgressLabel<'a>,
1431 total_entities: usize,
1432 total_postings: u64,
1433 expected_depth: usize,
1434 ) -> Self {
1435 log::info!(
1436 "[reorder][bp] started: index={} field={} entity_kind={} entities={} postings={} expected_depth={} objective_stall_threshold={:.1e}x{} min_objective_iterations={}",
1437 label.index,
1438 label.field,
1439 label.entity_kind,
1440 total_entities,
1441 total_postings,
1442 expected_depth,
1443 MIN_RELATIVE_OBJECTIVE_IMPROVEMENT,
1444 OBJECTIVE_STALL_ITERATIONS,
1445 MIN_OBJECTIVE_ITERATIONS,
1446 );
1447 crate::observe::reorder_bp_started(label.index, label.field, label.entity_kind);
1448 Self {
1449 label,
1450 start: std::time::Instant::now(),
1451 total_entities,
1452 total_postings,
1453 expected_depth,
1454 next_log_ms: std::sync::atomic::AtomicU64::new(30_000),
1455 active_partitions: std::sync::atomic::AtomicU64::new(0),
1456 partitions_started: std::sync::atomic::AtomicU64::new(0),
1457 partitions_completed: std::sync::atomic::AtomicU64::new(0),
1458 iterations: std::sync::atomic::AtomicU64::new(0),
1459 entity_passes: std::sync::atomic::AtomicU64::new(0),
1460 swaps: std::sync::atomic::AtomicU64::new(0),
1461 deepest_level: std::sync::atomic::AtomicU64::new(0),
1462 objective_stops: std::sync::atomic::AtomicU64::new(0),
1463 last_objective_delta_bits: std::sync::atomic::AtomicU64::new(0f64.to_bits()),
1464 last_relative_delta_bits: std::sync::atomic::AtomicU64::new(0f64.to_bits()),
1465 active_metric_released: std::sync::atomic::AtomicBool::new(false),
1466 }
1467 }
1468
1469 fn partition_started(&self, level: usize) {
1470 self.active_partitions
1471 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1472 self.partitions_started
1473 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1474 self.deepest_level
1475 .fetch_max(level as u64, std::sync::atomic::Ordering::Relaxed);
1476 }
1477
1478 fn partition_finished(&self) {
1479 self.partitions_completed
1480 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1481 self.active_partitions
1482 .fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
1483 }
1484
1485 fn iteration(&self, entities: usize, swaps: usize, objective_delta: f64, relative_delta: f64) {
1486 self.iterations
1487 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1488 self.entity_passes
1489 .fetch_add(entities as u64, std::sync::atomic::Ordering::Relaxed);
1490 self.swaps
1491 .fetch_add(swaps as u64, std::sync::atomic::Ordering::Relaxed);
1492 self.last_objective_delta_bits.store(
1493 objective_delta.to_bits(),
1494 std::sync::atomic::Ordering::Relaxed,
1495 );
1496 self.last_relative_delta_bits.store(
1497 relative_delta.to_bits(),
1498 std::sync::atomic::Ordering::Relaxed,
1499 );
1500 self.maybe_log();
1501 }
1502
1503 fn objective_stop(&self) {
1504 self.objective_stops
1505 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1506 }
1507
1508 fn maybe_log(&self) {
1509 let elapsed_ms = self.start.elapsed().as_millis().min(u64::MAX as u128) as u64;
1510 let next = self.next_log_ms.load(std::sync::atomic::Ordering::Relaxed);
1511 if elapsed_ms < next
1512 || self
1513 .next_log_ms
1514 .compare_exchange(
1515 next,
1516 elapsed_ms.saturating_add(30_000),
1517 std::sync::atomic::Ordering::Relaxed,
1518 std::sync::atomic::Ordering::Relaxed,
1519 )
1520 .is_err()
1521 {
1522 return;
1523 }
1524
1525 let active = self
1526 .active_partitions
1527 .load(std::sync::atomic::Ordering::Relaxed);
1528 let started = self
1529 .partitions_started
1530 .load(std::sync::atomic::Ordering::Relaxed);
1531 let completed = self
1532 .partitions_completed
1533 .load(std::sync::atomic::Ordering::Relaxed);
1534 let iterations = self.iterations.load(std::sync::atomic::Ordering::Relaxed);
1535 let entity_passes = self
1536 .entity_passes
1537 .load(std::sync::atomic::Ordering::Relaxed);
1538 let swaps = self.swaps.load(std::sync::atomic::Ordering::Relaxed);
1539 let deepest = self
1540 .deepest_level
1541 .load(std::sync::atomic::Ordering::Relaxed);
1542 let objective_delta = f64::from_bits(
1543 self.last_objective_delta_bits
1544 .load(std::sync::atomic::Ordering::Relaxed),
1545 );
1546 let relative_delta = f64::from_bits(
1547 self.last_relative_delta_bits
1548 .load(std::sync::atomic::Ordering::Relaxed),
1549 );
1550 log::info!(
1551 "[reorder][bp] progress: index={} field={} entity_kind={} elapsed={:.1}s depth={}/{} partitions={}/{} active={} iterations={} entity_passes={} swaps={} last_objective_delta={:.3} relative={:.3e}",
1552 self.label.index,
1553 self.label.field,
1554 self.label.entity_kind,
1555 self.start.elapsed().as_secs_f64(),
1556 deepest,
1557 self.expected_depth,
1558 completed,
1559 started,
1560 active,
1561 iterations,
1562 entity_passes,
1563 swaps,
1564 objective_delta,
1565 relative_delta,
1566 );
1567 }
1568
1569 fn finish(&self, converged: bool, memory_limited: bool, deadline_exhausted: bool) {
1570 let elapsed = self.start.elapsed().as_secs_f64();
1571 let partitions = self
1572 .partitions_completed
1573 .load(std::sync::atomic::Ordering::Relaxed);
1574 let iterations = self.iterations.load(std::sync::atomic::Ordering::Relaxed);
1575 let entity_passes = self
1576 .entity_passes
1577 .load(std::sync::atomic::Ordering::Relaxed);
1578 let swaps = self.swaps.load(std::sync::atomic::Ordering::Relaxed);
1579 let deepest = self
1580 .deepest_level
1581 .load(std::sync::atomic::Ordering::Relaxed);
1582 let objective_stops = self
1583 .objective_stops
1584 .load(std::sync::atomic::Ordering::Relaxed);
1585 let stop_reason = if memory_limited {
1586 "memory_budget"
1587 } else if deadline_exhausted {
1588 "time_budget"
1589 } else if objective_stops > 0 {
1590 "objective"
1591 } else {
1592 "complete"
1593 };
1594 log::info!(
1595 "[reorder][bp] completed: index={} field={} entity_kind={} entities={} postings={} elapsed={:.1}s depth={}/{} partitions={} iterations={} entity_passes={} swaps={} objective_stops={} converged={} stop_reason={}",
1596 self.label.index,
1597 self.label.field,
1598 self.label.entity_kind,
1599 self.total_entities,
1600 self.total_postings,
1601 elapsed,
1602 deepest,
1603 self.expected_depth,
1604 partitions,
1605 iterations,
1606 entity_passes,
1607 swaps,
1608 objective_stops,
1609 converged,
1610 stop_reason,
1611 );
1612 crate::observe::reorder_bp_pass(
1613 self.label.index,
1614 self.label.field,
1615 self.label.entity_kind,
1616 stop_reason,
1617 elapsed,
1618 self.total_entities,
1619 self.total_postings,
1620 partitions,
1621 iterations,
1622 entity_passes,
1623 swaps,
1624 converged,
1625 );
1626 self.release_active_metric();
1627 }
1628
1629 fn release_active_metric(&self) {
1630 if !self
1631 .active_metric_released
1632 .swap(true, std::sync::atomic::Ordering::AcqRel)
1633 {
1634 crate::observe::reorder_bp_finished(
1635 self.label.index,
1636 self.label.field,
1637 self.label.entity_kind,
1638 );
1639 }
1640 }
1641}
1642
1643#[cfg(feature = "native")]
1644impl Drop for BpProgress<'_> {
1645 fn drop(&mut self) {
1646 self.release_active_metric();
1647 }
1648}
1649
1650#[cfg(not(feature = "native"))]
1651struct BpProgress<'a>(std::marker::PhantomData<&'a ()>);
1652
1653#[cfg(not(feature = "native"))]
1654impl BpProgress<'_> {
1655 fn new(_: BpProgressLabel<'_>, _: usize, _: u64, _: usize) -> Self {
1656 Self(std::marker::PhantomData)
1657 }
1658 fn partition_started(&self, _: usize) {}
1659 fn partition_finished(&self) {}
1660 fn iteration(&self, _: usize, _: usize, _: f64, _: f64) {}
1661 fn objective_stop(&self) {}
1662 fn finish(&self, _: bool, _: bool, _: bool) {}
1663}
1664
1665#[cfg(test)]
1676pub(crate) fn graph_bisection(
1677 fwd: &ForwardIndex,
1678 min_partition_size: usize,
1679 max_iters: usize,
1680 budget: BpBudget,
1681) -> (Vec<u32>, bool) {
1682 graph_bisection_with_progress(
1683 fwd,
1684 min_partition_size,
1685 max_iters,
1686 budget,
1687 BpProgressLabel::anonymous(),
1688 )
1689}
1690
1691pub(crate) fn graph_bisection_with_progress(
1692 fwd: &ForwardIndex,
1693 min_partition_size: usize,
1694 max_iters: usize,
1695 budget: BpBudget,
1696 progress_label: BpProgressLabel<'_>,
1697) -> (Vec<u32>, bool) {
1698 let n = fwd.num_docs();
1699 if n == 0 {
1700 return (Vec::new(), !fwd.budget_limited);
1701 }
1702
1703 let effective_min_partition = budget
1704 .min_partition_docs
1705 .unwrap_or(0)
1706 .max(min_partition_size);
1707
1708 let mut docs: Vec<u32> = (0..n as u32).collect();
1709 let depth = if effective_min_partition > 0 {
1710 ((n as f64) / (effective_min_partition as f64))
1711 .log2()
1712 .ceil() as usize
1713 } else {
1714 0
1715 };
1716 let log_table = build_log_table(4096);
1717 let progress = BpProgress::new(progress_label, n, fwd.total_postings(), depth);
1718
1719 log::debug!(
1720 "BP graph_bisection: n={}, min_partition={}, max_iters={}, depth=~{}, time_budget={:?}",
1721 n,
1722 effective_min_partition,
1723 max_iters,
1724 depth,
1725 budget.time_budget,
1726 );
1727
1728 #[cfg(feature = "native")]
1729 let deadline = budget.time_budget.map(|duration| {
1730 let now = std::time::Instant::now();
1731 now.checked_add(duration).unwrap_or(now)
1732 });
1733 #[cfg(not(feature = "native"))]
1734 let deadline: Option<()> = None;
1735
1736 let exhausted = std::sync::atomic::AtomicBool::new(false);
1737 let context = BisectContext {
1738 fwd,
1739 min_partition_size: effective_min_partition,
1740 max_iters,
1741 log_table: &log_table,
1742 #[cfg(feature = "native")]
1743 deadline,
1744 #[cfg(not(feature = "native"))]
1745 deadline,
1746 exhausted: &exhausted,
1747 progress: &progress,
1748 };
1749 #[cfg(feature = "native")]
1750 bisect(&mut docs, fwd.parallel_bisect_depth, 0, &context);
1751 #[cfg(not(feature = "native"))]
1752 bisect(&mut docs, 0, 0, &context);
1753
1754 let deadline_exhausted = exhausted.load(std::sync::atomic::Ordering::Relaxed);
1755 let converged = !fwd.budget_limited && !deadline_exhausted;
1756 progress.finish(converged, fwd.budget_limited, deadline_exhausted);
1757 if !converged {
1758 log::info!(
1759 "BP graph_bisection: budget incomplete at n={} (time={:?}, memory_limited={}) — emitting partial (still valid) permutation",
1760 n,
1761 budget.time_budget,
1762 fwd.budget_limited,
1763 );
1764 }
1765 (docs, converged)
1766}
1767
1768struct BisectContext<'a> {
1777 fwd: &'a ForwardIndex,
1778 min_partition_size: usize,
1779 max_iters: usize,
1780 log_table: &'a [f32],
1781 #[cfg(feature = "native")]
1782 deadline: Option<std::time::Instant>,
1783 #[cfg(not(feature = "native"))]
1784 deadline: Option<()>,
1785 exhausted: &'a std::sync::atomic::AtomicBool,
1786 progress: &'a BpProgress<'a>,
1787}
1788
1789fn bisect(docs: &mut [u32], parallel_depth: usize, level: usize, context: &BisectContext<'_>) {
1790 #[cfg(not(feature = "native"))]
1791 let _ = parallel_depth;
1792 let n = docs.len();
1793 if n <= context.min_partition_size {
1794 return;
1795 }
1796 if context.exhausted.load(std::sync::atomic::Ordering::Relaxed) {
1798 return;
1799 }
1800 #[cfg(feature = "native")]
1801 if let Some(dl) = context.deadline
1802 && std::time::Instant::now() >= dl
1803 {
1804 context
1805 .exhausted
1806 .store(true, std::sync::atomic::Ordering::Relaxed);
1807 return;
1808 }
1809 #[cfg(not(feature = "native"))]
1810 let _ = context.deadline;
1811
1812 context.progress.partition_started(level);
1813 let mid = n / 2;
1814
1815 let effective_iters = if n > 100_000 {
1819 context.max_iters.min(12)
1820 } else {
1821 context.max_iters
1822 };
1823 let degree_lanes = 1usize
1824 .checked_shl(parallel_depth as u32)
1825 .unwrap_or(usize::MAX)
1826 .max(1);
1827
1828 let mut degrees = build_term_degrees(docs, mid, context.fwd, degree_lanes);
1833 let track_objective = n >= PARALLEL_BP_MIN_ENTITIES;
1838 let mut previous_objective = if track_objective {
1839 degrees.bisection_objective(mid, n - mid, context.log_table)
1840 } else {
1841 0.0
1842 };
1843 let mut best_objective = previous_objective;
1844 let mut objective_stalls = 0usize;
1845
1846 let mut gains: Vec<f32> = vec![0.0; n];
1848 let mut partitioned: Vec<u32> = vec![0; n];
1849 let mut ranked_scratch: Vec<usize> = Vec::new();
1850
1851 for iter in 0..effective_iters {
1852 #[cfg(feature = "native")]
1854 if let Some(dl) = context.deadline
1855 && std::time::Instant::now() >= dl
1856 {
1857 context
1858 .exhausted
1859 .store(true, std::sync::atomic::Ordering::Relaxed);
1860 break;
1861 }
1862 compute_gains(
1865 docs,
1866 context.fwd,
1867 mid,
1868 °rees,
1869 context.log_table,
1870 &mut gains,
1871 );
1872
1873 let partition = partition_by_gain(
1878 docs,
1879 &gains,
1880 mid,
1881 context.fwd,
1882 degree_lanes,
1883 &mut partitioned,
1884 &mut ranked_scratch,
1885 );
1886
1887 if partition.swap_count == 0 {
1888 context.progress.iteration(n, 0, 0.0, 0.0);
1889 docs.copy_from_slice(&partitioned);
1893 break;
1894 }
1895
1896 match &partition.degree_update {
1897 PartitionDegreeUpdate::Deltas(deltas) => deltas.apply_to(&mut degrees),
1898 PartitionDegreeUpdate::Ranked => update_degrees_for_ranked_partition(
1899 docs,
1900 &ranked_scratch,
1901 mid,
1902 context.fwd,
1903 &mut degrees,
1904 ),
1905 PartitionDegreeUpdate::Threshold {
1906 threshold_key,
1907 ties_left,
1908 } => update_degrees_for_threshold_partition(
1909 docs,
1910 &gains,
1911 mid,
1912 *threshold_key,
1913 *ties_left,
1914 context.fwd,
1915 &mut degrees,
1916 ),
1917 }
1918
1919 let (new_objective, objective_improvement, relative_improvement) = if track_objective {
1920 let new_objective = degrees.bisection_objective(mid, n - mid, context.log_table);
1921 let objective_improvement = new_objective - previous_objective;
1922 let relative_improvement = objective_improvement / previous_objective.abs().max(1.0);
1923 (new_objective, objective_improvement, relative_improvement)
1924 } else {
1925 (0.0, 0.0, 0.0)
1926 };
1927 context.progress.iteration(
1928 n,
1929 partition.swap_count,
1930 objective_improvement,
1931 relative_improvement,
1932 );
1933
1934 docs.copy_from_slice(&partitioned);
1941 if track_objective {
1942 previous_objective = new_objective;
1943 let relative_best_improvement =
1944 (new_objective - best_objective) / best_objective.abs().max(1.0);
1945 if relative_best_improvement >= MIN_RELATIVE_OBJECTIVE_IMPROVEMENT {
1946 best_objective = new_objective;
1947 objective_stalls = 0;
1948 } else if iter + 1 >= MIN_OBJECTIVE_ITERATIONS {
1949 objective_stalls += 1;
1950 }
1951 if objective_stalls >= OBJECTIVE_STALL_ITERATIONS {
1952 context.progress.objective_stop();
1953 break;
1954 }
1955 }
1956
1957 if iter > 2 && partition.swap_count < n / 200 {
1959 break;
1960 }
1961
1962 if !track_objective && iter > 5 {
1966 let max_abs_gain = gains
1967 .iter()
1968 .copied()
1969 .fold(0.0f32, |max_gain, gain| max_gain.max(gain.abs()));
1970 if max_abs_gain < 0.001 {
1971 break;
1972 }
1973 }
1974 }
1975
1976 drop(degrees);
1978 drop(gains);
1979 drop(partitioned);
1980 context.progress.partition_finished();
1981
1982 let (left, right) = docs.split_at_mut(mid);
1983 #[cfg(feature = "native")]
1984 if parallel_depth > 0 {
1985 rayon::join(
1986 || bisect(left, parallel_depth - 1, level + 1, context),
1987 || bisect(right, parallel_depth - 1, level + 1, context),
1988 );
1989 } else {
1990 bisect(left, 0, level + 1, context);
1994 bisect(right, 0, level + 1, context);
1995 }
1996 #[cfg(not(feature = "native"))]
1997 {
1998 bisect(left, 0, level + 1, context);
1999 bisect(right, 0, level + 1, context);
2000 }
2001}
2002
2003#[inline(never)]
2009fn compute_gains(
2010 docs: &[u32],
2011 fwd: &ForwardIndex,
2012 mid: usize,
2013 degrees: &TermDegrees,
2014 log_table: &[f32],
2015 gains: &mut [f32],
2016) {
2017 let gain_for_doc = |i: usize| -> f32 {
2026 let doc = docs[i] as usize;
2027 let in_left = i < mid;
2028 let mut g = 0.0f32;
2029 for &term in fwd.doc_terms(doc) {
2030 let [left, right] = degrees.get(term as usize);
2031 let (from, to) = if in_left {
2032 (left, right)
2033 } else {
2034 (right, left)
2035 };
2036 let move_gain = fast_log2_lookup(to as usize + 2, log_table)
2037 - fast_log2_lookup(from as usize, log_table)
2038 - std::f32::consts::LOG2_E / (1.0 + to as f32);
2039 g += if in_left { move_gain } else { -move_gain };
2040 }
2041 g
2042 };
2043
2044 #[cfg(feature = "native")]
2045 {
2046 if docs.len() > 4096 {
2047 gains
2048 .par_iter_mut()
2049 .enumerate()
2050 .for_each(|(i, gain)| *gain = gain_for_doc(i));
2051 } else {
2052 for (i, gain) in gains.iter_mut().enumerate().take(docs.len()) {
2053 *gain = gain_for_doc(i);
2054 }
2055 }
2056 }
2057 #[cfg(not(feature = "native"))]
2058 {
2059 for (i, gain) in gains.iter_mut().enumerate().take(docs.len()) {
2060 *gain = gain_for_doc(i);
2061 }
2062 }
2063}
2064
2065fn build_log_table(size: usize) -> Vec<f32> {
2069 let mut table = vec![0.0f32; size];
2070 table[0] = -10.0;
2072 for (i, entry) in table.iter_mut().enumerate().skip(1) {
2073 *entry = (i as f32).log2();
2074 }
2075 table
2076}
2077
2078#[inline]
2080fn fast_log2_lookup(val: usize, table: &[f32]) -> f32 {
2081 if val < table.len() {
2082 table[val]
2083 } else {
2084 (val as f32).log2()
2085 }
2086}
2087
2088#[cfg(test)]
2089mod tests {
2090 use super::*;
2091
2092 #[test]
2093 fn lazy_term_degrees_initialize_only_on_first_write() {
2094 let mut degrees = TermDegrees::new(130);
2095 assert_eq!(degrees.get(65), [0, 0]);
2096 degrees.entry_mut(65)[0] += 3;
2097 degrees.entry_mut(65)[1] += 2;
2098 assert_eq!(degrees.get(65), [3, 2]);
2099 assert_eq!(degrees.get(64), [0, 0]);
2100 assert_eq!(
2101 degrees
2102 .initialized
2103 .iter()
2104 .map(|w| w.count_ones())
2105 .sum::<u32>(),
2106 1
2107 );
2108 }
2109
2110 #[test]
2111 fn gain_radix_key_matches_total_cmp() {
2112 let values = [
2113 f32::from_bits(0xffc0_0001),
2114 f32::NEG_INFINITY,
2115 -42.0,
2116 -0.0,
2117 0.0,
2118 42.0,
2119 f32::INFINITY,
2120 f32::from_bits(0x7fc0_0001),
2121 ];
2122 let mut by_cmp = values;
2123 by_cmp.sort_by(f32::total_cmp);
2124 let mut by_key = values;
2125 by_key.sort_by_key(|value| gain_order_key(*value));
2126 assert_eq!(
2127 by_cmp.map(f32::to_bits),
2128 by_key.map(f32::to_bits),
2129 "radix selection must preserve the former total_cmp order"
2130 );
2131 }
2132
2133 #[test]
2134 fn radix_threshold_matches_exact_rank_with_ties() {
2135 let gains = [3.0, -1.0, 7.0, -1.0, 0.0, -0.0, 3.0, 9.0, 3.0, 2.0, 2.0];
2136 let mut sorted: Vec<(u32, usize)> = gains
2137 .iter()
2138 .enumerate()
2139 .map(|(idx, &gain)| (gain_order_key(gain), idx))
2140 .collect();
2141 sorted.sort_unstable();
2142
2143 for left_count in 1..=gains.len() {
2144 let (threshold, lower) = select_gain_threshold(&gains, left_count);
2145 assert_eq!(threshold, sorted[left_count - 1].0);
2146 assert_eq!(lower, sorted.partition_point(|&(key, _)| key < threshold),);
2147 }
2148 }
2149
2150 #[cfg(feature = "native")]
2151 #[test]
2152 fn parallel_partition_matches_exact_selection_and_degree_rebuild() {
2153 const N: usize = PARALLEL_BP_MIN_ENTITIES + 1;
2154 const TERMS: usize = 101;
2155 let mut terms = Vec::with_capacity(N * 3);
2156 let mut offsets = Vec::with_capacity(N + 1);
2157 offsets.push(0);
2158 for doc in 0..N {
2159 terms.extend_from_slice(&[
2160 (doc % TERMS) as u32,
2161 ((doc / 7) % TERMS) as u32,
2162 ((doc * 13) % TERMS) as u32,
2163 ]);
2164 offsets.push(terms.len() as u64);
2165 }
2166 let fwd = ForwardIndex {
2167 terms,
2168 offsets,
2169 num_terms: TERMS,
2170 parallel_bisect_depth: 2,
2171 budget_limited: false,
2172 };
2173 let docs: Vec<u32> = (0..N as u32)
2174 .map(|idx| ((idx as usize * 7_919) % N) as u32)
2175 .collect();
2176 let gains: Vec<f32> = docs
2177 .iter()
2178 .map(|&doc| ((doc as usize * 37) % 257) as f32 - 128.0)
2179 .collect();
2180 let mid = N / 2;
2181
2182 let mut ranked: Vec<usize> = (0..N).collect();
2183 ranked.sort_unstable_by(|&left, &right| {
2184 gains[left]
2185 .total_cmp(&gains[right])
2186 .then_with(|| left.cmp(&right))
2187 });
2188 let mut selected_left = vec![false; N];
2189 for &idx in &ranked[..mid] {
2190 selected_left[idx] = true;
2191 }
2192 let expected: Vec<u32> = docs
2193 .iter()
2194 .enumerate()
2195 .filter(|(idx, _)| selected_left[*idx])
2196 .chain(
2197 docs.iter()
2198 .enumerate()
2199 .filter(|(idx, _)| !selected_left[*idx]),
2200 )
2201 .map(|(_, &doc)| doc)
2202 .collect();
2203
2204 let mut output = vec![0; N];
2205 let mut ranked_scratch = Vec::new();
2206 let outcome = partition_by_gain(
2207 &docs,
2208 &gains,
2209 mid,
2210 &fwd,
2211 4,
2212 &mut output,
2213 &mut ranked_scratch,
2214 );
2215 assert_eq!(output, expected);
2216 assert!(
2217 matches!(&outcome.degree_update, PartitionDegreeUpdate::Deltas(_)),
2218 "test must exercise parallel deltas"
2219 );
2220
2221 let mut updated = build_term_degrees(&docs, mid, &fwd, 4);
2222 let PartitionDegreeUpdate::Deltas(deltas) = outcome.degree_update else {
2223 unreachable!("assertion above verifies the parallel path")
2224 };
2225 deltas.apply_to(&mut updated);
2226 let rebuilt = build_term_degrees(&output, mid, &fwd, 4);
2227 for term in 0..TERMS {
2228 assert_eq!(
2229 updated.get(term),
2230 rebuilt.get(term),
2231 "parallel moved-term delta mismatch for term {term}"
2232 );
2233 }
2234 }
2235
2236 #[test]
2242 fn test_csr_offsets_do_not_wrap_past_u32() {
2243 let counts = [1_500_000_000u32; 3]; let offsets = build_csr_offsets(&counts);
2245 assert_eq!(
2246 offsets,
2247 vec![0, 1_500_000_000, 3_000_000_000, 4_500_000_000]
2248 );
2249 assert!(*offsets.last().unwrap() > u32::MAX as u64);
2250 }
2251
2252 fn make_fwd(docs: &[&[u32]], num_terms: usize) -> ForwardIndex {
2254 let mut terms = Vec::new();
2255 let mut offsets = vec![0u64];
2256 for doc_terms in docs {
2257 terms.extend_from_slice(doc_terms);
2258 offsets.push(terms.len() as u64);
2259 }
2260 ForwardIndex {
2261 terms,
2262 offsets,
2263 num_terms,
2264 parallel_bisect_depth: 0,
2265 budget_limited: false,
2266 }
2267 }
2268
2269 #[test]
2270 fn test_bp_empty() {
2271 let fwd = ForwardIndex {
2272 terms: Vec::new(),
2273 offsets: Vec::new(),
2274 num_terms: 0,
2275 parallel_bisect_depth: 0,
2276 budget_limited: false,
2277 };
2278 let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
2279 assert!(perm.is_empty());
2280 }
2281
2282 #[test]
2283 fn test_bp_small() {
2284 let fwd = make_fwd(&[&[0, 1], &[0, 2], &[1, 3], &[2, 3]], 4);
2286 let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
2287 assert_eq!(perm.len(), 4);
2288 let mut sorted = perm.clone();
2290 sorted.sort();
2291 assert_eq!(sorted, vec![0, 1, 2, 3]);
2292 }
2293
2294 #[test]
2295 fn test_bp_clusters() {
2296 let fwd = make_fwd(
2300 &[
2301 &[0, 1],
2302 &[0, 1],
2303 &[0, 1],
2304 &[0, 1],
2305 &[2, 3],
2306 &[2, 3],
2307 &[2, 3],
2308 &[2, 3],
2309 ],
2310 4,
2311 );
2312 let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
2313 assert_eq!(perm.len(), 8);
2314
2315 let left: Vec<u32> = perm[..4].to_vec();
2317
2318 let a_in_left = left.iter().filter(|&&d| d < 4).count();
2320 let b_in_left = left.iter().filter(|&&d| d >= 4).count();
2321 assert!(
2322 (a_in_left == 4 && b_in_left == 0) || (a_in_left == 0 && b_in_left == 4),
2323 "Clusters should be separated: a_left={}, b_left={}",
2324 a_in_left,
2325 b_in_left,
2326 );
2327 }
2328
2329 #[test]
2330 fn test_bp_permutation_valid() {
2331 let docs: Vec<Vec<u32>> = (0..16).map(|i| vec![i / 4, 10 + i / 2]).collect();
2333 let doc_refs: Vec<&[u32]> = docs.iter().map(|v| v.as_slice()).collect();
2334 let fwd = make_fwd(&doc_refs, 18); let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
2336
2337 assert_eq!(perm.len(), 16);
2338 let mut sorted = perm.clone();
2340 sorted.sort();
2341 let expected: Vec<u32> = (0..16).collect();
2342 assert_eq!(sorted, expected);
2343 }
2344
2345 #[test]
2350 fn test_bp_depth_cap_separates_clusters_and_converges() {
2351 let fwd = make_fwd(
2354 &[
2355 &[0, 1],
2356 &[0, 1],
2357 &[0, 1],
2358 &[2, 3],
2359 &[0, 1],
2360 &[2, 3],
2361 &[2, 3],
2362 &[2, 3],
2363 ],
2364 4,
2365 );
2366 let budget = BpBudget {
2367 min_partition_docs: Some(4),
2368 time_budget: None,
2369 };
2370 let (perm, converged) = graph_bisection(&fwd, 2, 20, budget);
2371 assert!(converged, "depth cap must report converged");
2372 assert_eq!(perm.len(), 8);
2373 let mut sorted = perm.clone();
2374 sorted.sort();
2375 assert_eq!(
2376 sorted,
2377 (0..8).collect::<Vec<u32>>(),
2378 "must stay a valid permutation"
2379 );
2380 let cluster_a = [0u32, 1, 2, 4];
2383 let a_in_left = perm[..4].iter().filter(|d| cluster_a.contains(d)).count();
2384 assert!(
2385 a_in_left == 4 || a_in_left == 0,
2386 "clusters should separate at the top level: {:?}",
2387 perm
2388 );
2389 }
2390
2391 #[test]
2394 fn test_bp_zero_time_budget_emits_valid_partial_permutation() {
2395 let docs: Vec<Vec<u32>> = (0..64).map(|i| vec![i % 4]).collect();
2396 let doc_refs: Vec<&[u32]> = docs.iter().map(|v| v.as_slice()).collect();
2397 let fwd = make_fwd(&doc_refs, 4);
2398 let budget = BpBudget {
2399 min_partition_docs: None,
2400 time_budget: Some(std::time::Duration::ZERO),
2401 };
2402 let (perm, converged) = graph_bisection(&fwd, 4, 20, budget);
2403 assert!(!converged, "zero budget must report unconverged");
2404 assert_eq!(perm.len(), 64);
2405 let mut sorted = perm.clone();
2406 sorted.sort();
2407 assert_eq!(sorted, (0..64).collect::<Vec<u32>>());
2408 }
2409
2410 #[test]
2411 fn test_memory_limited_graph_never_reports_converged() {
2412 let mut fwd = make_fwd(&[&[0], &[0], &[1], &[1]], 2);
2413 fwd.budget_limited = true;
2414
2415 let (perm, converged) = graph_bisection(&fwd, 2, 20, BpBudget::full());
2416
2417 assert!(!converged);
2418 let mut sorted = perm;
2419 sorted.sort_unstable();
2420 assert_eq!(sorted, vec![0, 1, 2, 3]);
2421 }
2422
2423 #[test]
2424 fn test_fast_log2() {
2425 let table = build_log_table(4096);
2426 assert!((table[1] - 0.0).abs() < 0.001);
2427 assert!((table[2] - 1.0).abs() < 0.001);
2428 assert!((table[4] - 2.0).abs() < 0.001);
2429 assert!((table[1024] - 10.0).abs() < 0.001);
2430 let val = fast_log2_lookup(8192, &table);
2432 assert!((val - 13.0).abs() < 0.001);
2433 }
2434}