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)>();
16
17fn term_degree_bytes(num_terms: usize) -> usize {
18 num_terms
19 .saturating_mul(TERM_DEGREE_VALUE_BYTES)
20 .saturating_add(num_terms.div_ceil(64).saturating_mul(8))
21}
22
23fn parallel_bisect_depth(
24 memory_budget_bytes: usize,
25 non_degree_bytes: usize,
26 num_terms: usize,
27) -> usize {
28 let per_node = term_degree_bytes(num_terms).max(1);
29 let affordable_nodes = memory_budget_bytes
30 .saturating_sub(non_degree_bytes)
31 .checked_div(per_node)
32 .unwrap_or(0)
33 .max(1);
34 #[cfg(feature = "native")]
35 let worker_limit = rayon::current_num_threads().max(1);
36 #[cfg(not(feature = "native"))]
37 let worker_limit = 1usize;
38 affordable_nodes.min(worker_limit).ilog2() as usize
39}
40
41struct TermDegrees {
49 values: Vec<std::mem::MaybeUninit<[u32; 2]>>,
50 initialized: Vec<u64>,
51}
52
53impl TermDegrees {
54 fn new(num_terms: usize) -> Self {
55 let mut values = Vec::with_capacity(num_terms);
56 values.resize_with(num_terms, std::mem::MaybeUninit::uninit);
57 Self {
58 values,
59 initialized: vec![0; num_terms.div_ceil(64)],
60 }
61 }
62
63 #[inline]
64 fn entry_mut(&mut self, term: usize) -> &mut [u32; 2] {
65 let word = term / 64;
66 let mask = 1u64 << (term % 64);
67 if self.initialized[word] & mask == 0 {
68 self.values[term].write([0, 0]);
69 self.initialized[word] |= mask;
70 }
71 unsafe { self.values[term].assume_init_mut() }
73 }
74
75 #[inline]
76 fn get(&self, term: usize) -> [u32; 2] {
77 let word = term / 64;
78 let mask = 1u64 << (term % 64);
79 if self.initialized[word] & mask == 0 {
80 return [0, 0];
81 }
82 unsafe { *self.values[term].assume_init_ref() }
85 }
86}
87
88pub(crate) struct ForwardIndex {
94 terms: Vec<u32>,
95 offsets: Vec<u64>,
100 pub num_terms: usize,
101 parallel_bisect_depth: usize,
105 budget_limited: bool,
109}
110
111fn build_csr_offsets(counts: &[u32]) -> Vec<u64> {
114 let mut offsets = Vec::with_capacity(counts.len() + 1);
115 offsets.push(0u64);
116 for &c in counts {
117 offsets.push(offsets.last().unwrap() + c as u64);
118 }
119 offsets
120}
121
122impl ForwardIndex {
123 #[inline]
124 pub fn num_docs(&self) -> usize {
125 if self.offsets.is_empty() {
126 0
127 } else {
128 self.offsets.len() - 1
129 }
130 }
131
132 #[inline]
133 fn doc_terms(&self, doc: usize) -> &[u32] {
134 let start = self.offsets[doc] as usize;
135 let end = self.offsets[doc + 1] as usize;
136 &self.terms[start..end]
137 }
138
139 pub fn total_postings(&self) -> u64 {
141 self.offsets.last().copied().unwrap_or(0)
142 }
143
144 #[inline]
145 pub fn budget_limited(&self) -> bool {
146 self.budget_limited
147 }
148}
149
150pub(crate) fn build_vid_maps(
160 bmp: &crate::segment::reader::bmp::BmpIndex,
161) -> crate::Result<(Vec<u32>, Vec<u32>)> {
162 let ids = bmp.doc_map_ids_slice();
163 let num_virtual = bmp.num_virtual_docs as usize;
164 let expected_real = bmp.num_real_docs() as usize;
165 let mut virtual_to_real = vec![u32::MAX; num_virtual];
166 let mut real_to_virtual = Vec::with_capacity(expected_real);
167 for (vid, (slot, chunk)) in virtual_to_real
168 .iter_mut()
169 .zip(ids.as_chunks::<4>().0)
170 .enumerate()
171 {
172 let doc_id = u32::from_le_bytes(*chunk);
173 if doc_id != u32::MAX {
174 if real_to_virtual.len() == expected_real {
175 return Err(crate::Error::Corruption(format!(
176 "BMP document map contains more than the footer's {expected_real} real slots"
177 )));
178 }
179 *slot = real_to_virtual.len() as u32;
180 real_to_virtual.push(vid as u32);
181 }
182 }
183 if real_to_virtual.len() != expected_real {
184 return Err(crate::Error::Corruption(format!(
185 "BMP document map has {} real slots but footer declares {expected_real}",
186 real_to_virtual.len(),
187 )));
188 }
189 Ok((virtual_to_real, real_to_virtual))
190}
191
192struct BlockJob {
198 src: u32,
199 block_id: u32,
200 real_start: u32,
202 real_len: u32,
204}
205
206fn build_block_jobs(
209 bmps: &[&crate::segment::reader::bmp::BmpIndex],
210 vid_maps: &[(Vec<u32>, Vec<u32>)],
211) -> Vec<BlockJob> {
212 let total_blocks: usize = bmps.iter().map(|b| b.num_blocks as usize).sum();
213 let mut jobs = Vec::with_capacity(total_blocks);
214 for (src, (bmp, (v2r, _))) in bmps.iter().zip(vid_maps).enumerate() {
215 let block_size = bmp.bmp_block_size as usize;
216 let mut real_cursor = 0u32;
217 for block_id in 0..bmp.num_blocks as usize {
218 let vid_start = block_id * block_size;
219 let vid_end = ((block_id + 1) * block_size).min(v2r.len());
220 let real_len = v2r[vid_start..vid_end]
221 .iter()
222 .filter(|&&r| r != u32::MAX)
223 .count() as u32;
224 jobs.push(BlockJob {
225 src: src as u32,
226 block_id: block_id as u32,
227 real_start: real_cursor,
228 real_len,
229 });
230 real_cursor += real_len;
231 }
232 }
233 jobs
234}
235
236#[cfg(test)]
250pub(crate) fn build_forward_index_from_bmps(
251 bmps: &[&crate::segment::reader::bmp::BmpIndex],
252 min_doc_freq: usize,
253 max_doc_freq: usize,
254 memory_budget_bytes: usize,
255) -> crate::Result<(ForwardIndex, Vec<usize>)> {
256 let vid_maps: Vec<(Vec<u32>, Vec<u32>)> = bmps
257 .iter()
258 .map(|bmp| build_vid_maps(bmp))
259 .collect::<crate::Result<_>>()?;
260 Ok(build_forward_index_from_bmps_with_maps(
261 bmps,
262 &vid_maps,
263 min_doc_freq,
264 max_doc_freq,
265 memory_budget_bytes,
266 ))
267}
268
269pub(crate) fn build_forward_index_from_bmps_with_maps(
273 bmps: &[&crate::segment::reader::bmp::BmpIndex],
274 vid_maps: &[(Vec<u32>, Vec<u32>)],
275 min_doc_freq: usize,
276 max_doc_freq: usize,
277 memory_budget_bytes: usize,
278) -> (ForwardIndex, Vec<usize>) {
279 debug_assert_eq!(bmps.len(), vid_maps.len());
280 let source_doc_counts: Vec<usize> = vid_maps.iter().map(|(_, r2v)| r2v.len()).collect();
281 let total_docs: usize = source_doc_counts.iter().sum();
282
283 if total_docs == 0 {
284 return (
285 ForwardIndex {
286 terms: Vec::new(),
287 offsets: Vec::new(),
288 num_terms: 0,
289 parallel_bisect_depth: 0,
290 budget_limited: false,
291 },
292 source_doc_counts,
293 );
294 }
295
296 let jobs = build_block_jobs(bmps, vid_maps);
301
302 let max_dims = bmps
306 .iter()
307 .map(|bmp| bmp.dims() as usize)
308 .max()
309 .unwrap_or(0);
310 let jobs_bytes = jobs
311 .len()
312 .saturating_mul(std::mem::size_of::<BlockJob>().saturating_add(40));
313 let frequency_bytes =
314 max_dims.saturating_mul(std::mem::size_of::<std::sync::atomic::AtomicU32>());
315 if frequency_bytes > memory_budget_bytes.saturating_sub(jobs_bytes) {
316 log::warn!(
317 "[reorder] memory budget {:.0} MB cannot hold the {:.0} MB dimension-frequency table; using identity order",
318 memory_budget_bytes as f64 / (1024.0 * 1024.0),
319 frequency_bytes as f64 / (1024.0 * 1024.0),
320 );
321 return (
322 ForwardIndex {
323 terms: Vec::new(),
324 offsets: Vec::new(),
325 num_terms: 0,
326 parallel_bisect_depth: 0,
327 budget_limited: true,
328 },
329 source_doc_counts,
330 );
331 }
332 let dim_df: Vec<std::sync::atomic::AtomicU32> = (0..max_dims)
333 .map(|_| std::sync::atomic::AtomicU32::new(0))
334 .collect();
335 let count_block_df = |job: &BlockJob| {
336 let bmp = bmps[job.src as usize];
337 let (v2r, _) = &vid_maps[job.src as usize];
338 let block_size = bmp.bmp_block_size as usize;
339 for (dim_id, postings) in bmp.iter_block_terms(job.block_id) {
340 let mut n = 0usize;
341 for p in postings {
342 let vid = job.block_id as usize * block_size + p.local_slot as usize;
343 if v2r[vid] != u32::MAX && p.impact > 0 {
344 n += 1;
345 }
346 }
347 if n > 0
348 && let Some(count) = dim_df.get(dim_id as usize)
349 {
350 count.fetch_add(n as u32, std::sync::atomic::Ordering::Relaxed);
351 }
352 }
353 };
354 #[cfg(feature = "native")]
355 jobs.par_iter().for_each(count_block_df);
356 #[cfg(not(feature = "native"))]
357 jobs.iter().for_each(count_block_df);
358
359 let eligible_candidate_count = dim_df
363 .iter()
364 .filter(|df| {
365 let df = df.load(std::sync::atomic::Ordering::Relaxed) as usize;
366 df >= min_doc_freq && df <= max_doc_freq
367 })
368 .count();
369 let candidate_capacity = memory_budget_bytes
370 .saturating_sub(jobs_bytes)
371 .saturating_sub(frequency_bytes)
372 .checked_div(std::mem::size_of::<(usize, u32)>())
373 .unwrap_or(0)
374 .min(eligible_candidate_count);
375 let mut candidate_heap = std::collections::BinaryHeap::with_capacity(candidate_capacity);
376 for (dim_id, df) in dim_df.iter().enumerate() {
377 let df = df.load(std::sync::atomic::Ordering::Relaxed) as usize;
378 if df < min_doc_freq || df > max_doc_freq {
379 continue;
380 }
381 let candidate = (df, dim_id as u32);
382 if candidate_heap.len() < candidate_capacity {
383 candidate_heap.push(candidate);
384 } else if candidate_capacity > 0 && candidate < *candidate_heap.peek().unwrap() {
385 candidate_heap.pop();
386 candidate_heap.push(candidate);
387 }
388 }
389 drop(dim_df);
390 let mut eligible: Vec<(u32, usize)> = candidate_heap
391 .into_vec()
392 .into_iter()
393 .map(|(df, dim_id)| (dim_id, df))
394 .collect();
395 let mut budget_limited = eligible.len() < eligible_candidate_count;
396
397 let total_postings_est = eligible
401 .iter()
402 .fold(0usize, |total, (_, df)| total.saturating_add(*df));
403 let entity_scratch_bytes = total_docs.saturating_mul(32);
404 let remap_bytes = max_dims.saturating_mul(4);
405 let fixed_bytes = entity_scratch_bytes
406 .saturating_add(remap_bytes)
407 .saturating_add(jobs_bytes);
408 let estimated_bytes = total_postings_est
409 .saturating_mul(4)
410 .saturating_add(fixed_bytes)
411 .saturating_add(eligible.len().saturating_mul(CANDIDATE_ENTRY_BYTES))
414 .saturating_add(term_degree_bytes(eligible.len()));
415
416 if estimated_bytes > memory_budget_bytes && !eligible.is_empty() {
417 eligible.sort_by_key(|&(_, df)| df);
420
421 let mut used_bytes = fixed_bytes;
426 let mut cum = 0usize;
427 let mut keep_count = 0;
428 for &(_, df) in &eligible {
429 let term_bytes = df
430 .saturating_mul(4)
431 .saturating_add(TERM_DEGREE_VALUE_BYTES + 1)
432 .saturating_add(CANDIDATE_ENTRY_BYTES);
433 if term_bytes > memory_budget_bytes.saturating_sub(used_bytes) {
434 break;
435 }
436 used_bytes = used_bytes.saturating_add(term_bytes);
437 cum = cum.saturating_add(df);
438 keep_count += 1;
439 }
440
441 let dropped = eligible.len() - keep_count;
442 eligible.truncate(keep_count);
443 budget_limited |= dropped > 0;
444
445 log::warn!(
446 "[reorder] memory budget {:.0} MB: estimated {:.0} MB, dropped {} highest-df dims, keeping {} ({} postings)",
447 memory_budget_bytes as f64 / (1024.0 * 1024.0),
448 estimated_bytes as f64 / (1024.0 * 1024.0),
449 dropped,
450 keep_count,
451 cum,
452 );
453 }
454
455 if eligible.is_empty() {
456 return (
461 ForwardIndex {
462 terms: Vec::new(),
463 offsets: Vec::new(),
464 num_terms: 0,
465 parallel_bisect_depth: 0,
466 budget_limited,
467 },
468 source_doc_counts,
469 );
470 }
471
472 let mut term_remap = vec![u32::MAX; max_dims];
473 for (compact_id, &(dim_id, _)) in eligible.iter().enumerate() {
474 term_remap[dim_id as usize] = compact_id as u32;
475 }
476 let num_active_terms = eligible.len();
477 let retained_postings = eligible
478 .iter()
479 .fold(0usize, |total, (_, df)| total.saturating_add(*df));
480 let non_degree_bytes = fixed_bytes.saturating_add(retained_postings.saturating_mul(4));
481 let parallel_bisect_depth =
482 parallel_bisect_depth(memory_budget_bytes, non_degree_bytes, num_active_terms);
483 drop(eligible);
484
485 let mut counts = vec![0u32; total_docs];
487 let fill_block_counts = |job: &BlockJob, out: &mut [u32]| {
488 let bmp = bmps[job.src as usize];
489 let (v2r, _) = &vid_maps[job.src as usize];
490 let block_size = bmp.bmp_block_size as usize;
491 for (dim_id, postings) in bmp.iter_block_terms(job.block_id) {
492 if term_remap.get(dim_id as usize).copied().unwrap_or(u32::MAX) == u32::MAX {
493 continue;
494 }
495 for p in postings {
496 let vid = job.block_id as usize * block_size + p.local_slot as usize;
497 let real = v2r[vid];
498 if real != u32::MAX && p.impact > 0 {
499 out[(real - job.real_start) as usize] += 1;
500 }
501 }
502 }
503 };
504 {
505 let mut slices: Vec<(&BlockJob, &mut [u32])> = Vec::with_capacity(jobs.len());
506 let mut rest: &mut [u32] = &mut counts;
507 for job in &jobs {
508 let (head, tail) = rest.split_at_mut(job.real_len as usize);
509 slices.push((job, head));
510 rest = tail;
511 }
512 #[cfg(feature = "native")]
513 slices
514 .into_par_iter()
515 .for_each(|(job, out)| fill_block_counts(job, out));
516 #[cfg(not(feature = "native"))]
517 for (job, out) in slices {
518 fill_block_counts(job, out);
519 }
520 }
521
522 let offsets = build_csr_offsets(&counts);
524 let total = *offsets.last().unwrap() as usize;
525 drop(counts);
526
527 let mut terms = vec![0u32; total];
530 let fill_block_terms = |job: &BlockJob, global_real_start: usize, out: &mut [u32]| {
531 let bmp = bmps[job.src as usize];
532 let (v2r, _) = &vid_maps[job.src as usize];
533 let block_size = bmp.bmp_block_size as usize;
534 assert!(job.real_len as usize <= 256, "BMP block exceeds 256 docs");
536 let mut cursor = [0u32; 256];
537 let base = offsets[global_real_start] as usize;
538 for (dim_id, postings) in bmp.iter_block_terms(job.block_id) {
539 let compact = term_remap.get(dim_id as usize).copied().unwrap_or(u32::MAX);
540 if compact == u32::MAX {
541 continue;
542 }
543 for p in postings {
544 let vid = job.block_id as usize * block_size + p.local_slot as usize;
545 let real = v2r[vid];
546 if real != u32::MAX && p.impact > 0 {
547 let local = (real - job.real_start) as usize;
548 let pos =
549 offsets[global_real_start + local] as usize - base + cursor[local] as usize;
550 out[pos] = compact;
551 cursor[local] += 1;
552 }
553 }
554 }
555 };
556 {
557 let mut slices: Vec<(&BlockJob, usize, &mut [u32])> = Vec::with_capacity(jobs.len());
558 let mut rest: &mut [u32] = &mut terms;
559 let mut global_real = 0usize;
560 for job in &jobs {
561 let len =
562 (offsets[global_real + job.real_len as usize] - offsets[global_real]) as usize;
563 let (head, tail) = rest.split_at_mut(len);
564 slices.push((job, global_real, head));
565 rest = tail;
566 global_real += job.real_len as usize;
567 }
568 #[cfg(feature = "native")]
569 slices
570 .into_par_iter()
571 .for_each(|(job, g, out)| fill_block_terms(job, g, out));
572 #[cfg(not(feature = "native"))]
573 for (job, g, out) in slices {
574 fill_block_terms(job, g, out);
575 }
576 }
577
578 (
579 ForwardIndex {
580 terms,
581 offsets,
582 num_terms: num_active_terms,
583 parallel_bisect_depth,
584 budget_limited,
585 },
586 source_doc_counts,
587 )
588}
589
590pub(crate) fn build_forward_index_from_blocks(
598 bmps: &[&crate::segment::reader::bmp::BmpIndex],
599 memory_budget_bytes: usize,
600) -> ForwardIndex {
601 let total_blocks: usize = bmps.iter().map(|b| b.num_blocks as usize).sum();
602 if total_blocks == 0 {
603 return ForwardIndex {
604 terms: Vec::new(),
605 offsets: Vec::new(),
606 num_terms: 0,
607 parallel_bisect_depth: 0,
608 budget_limited: false,
609 };
610 }
611
612 let blocks: Vec<(u32, u32)> = bmps
614 .iter()
615 .enumerate()
616 .flat_map(|(src, bmp)| (0..bmp.num_blocks).map(move |b| (src as u32, b)))
617 .collect();
618
619 let max_dims = bmps
621 .iter()
622 .map(|bmp| bmp.dims() as usize)
623 .max()
624 .unwrap_or(0);
625 let blocks_bytes = blocks
626 .len()
627 .saturating_mul(std::mem::size_of::<(u32, u32)>().saturating_add(32));
628 let frequency_bytes =
629 max_dims.saturating_mul(std::mem::size_of::<std::sync::atomic::AtomicU32>());
630 if frequency_bytes > memory_budget_bytes.saturating_sub(blocks_bytes) {
631 log::warn!(
632 "[reorder] block-level frequency table exceeds memory budget; using identity order"
633 );
634 return ForwardIndex {
635 terms: Vec::new(),
636 offsets: Vec::new(),
637 num_terms: 0,
638 parallel_bisect_depth: 0,
639 budget_limited: true,
640 };
641 }
642 let dim_bf: Vec<std::sync::atomic::AtomicU32> = (0..max_dims)
643 .map(|_| std::sync::atomic::AtomicU32::new(0))
644 .collect();
645 let count_block_bf = |&(src, block_id): &(u32, u32)| {
646 for (dim_id, _) in bmps[src as usize].iter_block_terms(block_id) {
647 if let Some(count) = dim_bf.get(dim_id as usize) {
648 count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
649 }
650 }
651 };
652 #[cfg(feature = "native")]
653 blocks.par_iter().for_each(count_block_bf);
654 #[cfg(not(feature = "native"))]
655 blocks.iter().for_each(count_block_bf);
656
657 let max_bf = (total_blocks as f64 * 0.9) as usize;
658 let eligible_candidate_count = dim_bf
659 .iter()
660 .filter(|bf| {
661 let bf = bf.load(std::sync::atomic::Ordering::Relaxed) as usize;
662 bf >= 2 && bf <= max_bf.max(2)
663 })
664 .count();
665 let candidate_capacity = memory_budget_bytes
666 .saturating_sub(blocks_bytes)
667 .saturating_sub(frequency_bytes)
668 .checked_div(std::mem::size_of::<(usize, u32)>())
669 .unwrap_or(0)
670 .min(eligible_candidate_count);
671 let mut candidate_heap = std::collections::BinaryHeap::with_capacity(candidate_capacity);
672 for (dim_id, bf) in dim_bf.iter().enumerate() {
673 let bf = bf.load(std::sync::atomic::Ordering::Relaxed) as usize;
674 if bf < 2 || bf > max_bf.max(2) {
675 continue;
676 }
677 let candidate = (bf, dim_id as u32);
678 if candidate_heap.len() < candidate_capacity {
679 candidate_heap.push(candidate);
680 } else if candidate_capacity > 0 && candidate < *candidate_heap.peek().unwrap() {
681 candidate_heap.pop();
682 candidate_heap.push(candidate);
683 }
684 }
685 drop(dim_bf);
686 let mut eligible: Vec<(u32, usize)> = candidate_heap
687 .into_vec()
688 .into_iter()
689 .map(|(bf, dim_id)| (dim_id, bf))
690 .collect();
691 let mut budget_limited = eligible.len() < eligible_candidate_count;
692
693 let total_postings_est = eligible
694 .iter()
695 .fold(0usize, |total, (_, bf)| total.saturating_add(*bf));
696 let entity_scratch_bytes = total_blocks.saturating_mul(32);
697 let remap_bytes = max_dims.saturating_mul(4);
698 let fixed_bytes = entity_scratch_bytes
699 .saturating_add(remap_bytes)
700 .saturating_add(blocks_bytes);
701 let estimated_bytes = total_postings_est
702 .saturating_mul(4)
703 .saturating_add(fixed_bytes)
704 .saturating_add(eligible.len().saturating_mul(CANDIDATE_ENTRY_BYTES))
705 .saturating_add(term_degree_bytes(eligible.len()));
706 if estimated_bytes > memory_budget_bytes && !eligible.is_empty() {
707 eligible.sort_by_key(|&(_, bf)| bf);
708 let mut used_bytes = fixed_bytes;
709 let mut cum = 0usize;
710 let mut keep = 0;
711 for &(_, bf) in &eligible {
712 let term_bytes = bf
713 .saturating_mul(4)
714 .saturating_add(TERM_DEGREE_VALUE_BYTES + 1)
715 .saturating_add(CANDIDATE_ENTRY_BYTES);
716 if term_bytes > memory_budget_bytes.saturating_sub(used_bytes) {
717 break;
718 }
719 used_bytes = used_bytes.saturating_add(term_bytes);
720 cum = cum.saturating_add(bf);
721 keep += 1;
722 }
723 let dropped = eligible.len() - keep;
724 budget_limited |= dropped > 0;
725 log::warn!(
726 "[reorder] block-level fwd index over budget — dropped {} highest-bf dims",
727 dropped,
728 );
729 eligible.truncate(keep);
730 }
731
732 if eligible.is_empty() {
733 return ForwardIndex {
734 terms: Vec::new(),
735 offsets: Vec::new(),
736 num_terms: 0,
737 parallel_bisect_depth: 0,
738 budget_limited,
739 };
740 }
741
742 let mut term_remap = vec![u32::MAX; max_dims];
743 for (compact, &(dim_id, _)) in eligible.iter().enumerate() {
744 term_remap[dim_id as usize] = compact as u32;
745 }
746 let num_terms = eligible.len();
747 let retained_postings = eligible
748 .iter()
749 .fold(0usize, |total, (_, bf)| total.saturating_add(*bf));
750 let non_degree_bytes = fixed_bytes.saturating_add(retained_postings.saturating_mul(4));
751 let parallel_bisect_depth =
752 parallel_bisect_depth(memory_budget_bytes, non_degree_bytes, num_terms);
753 drop(eligible);
754
755 let count_remapped = |&(src, block_id): &(u32, u32)| -> u32 {
758 bmps[src as usize]
759 .iter_block_terms(block_id)
760 .filter(|(dim_id, _)| {
761 term_remap
762 .get(*dim_id as usize)
763 .copied()
764 .unwrap_or(u32::MAX)
765 != u32::MAX
766 })
767 .count() as u32
768 };
769 #[cfg(feature = "native")]
770 let counts: Vec<u32> = blocks.par_iter().map(count_remapped).collect();
771 #[cfg(not(feature = "native"))]
772 let counts: Vec<u32> = blocks.iter().map(count_remapped).collect();
773
774 let offsets = build_csr_offsets(&counts);
775 let total = *offsets.last().unwrap() as usize;
776 drop(counts);
777
778 let mut terms = vec![0u32; total];
779 let fill_block = |&(src, block_id): &(u32, u32), out: &mut [u32]| {
780 let mut n = 0usize;
781 for (dim_id, _) in bmps[src as usize].iter_block_terms(block_id) {
782 let compact = term_remap.get(dim_id as usize).copied().unwrap_or(u32::MAX);
783 if compact != u32::MAX {
784 out[n] = compact;
785 n += 1;
786 }
787 }
788 };
789 {
790 let mut slices: Vec<(&(u32, u32), &mut [u32])> = Vec::with_capacity(blocks.len());
791 let mut rest: &mut [u32] = &mut terms;
792 for (gb, b) in blocks.iter().enumerate() {
793 let len = (offsets[gb + 1] - offsets[gb]) as usize;
794 let (head, tail) = rest.split_at_mut(len);
795 slices.push((b, head));
796 rest = tail;
797 }
798 #[cfg(feature = "native")]
799 slices
800 .into_par_iter()
801 .for_each(|(b, out)| fill_block(b, out));
802 #[cfg(not(feature = "native"))]
803 for (b, out) in slices {
804 fill_block(b, out);
805 }
806 }
807
808 ForwardIndex {
809 terms,
810 offsets,
811 num_terms,
812 parallel_bisect_depth,
813 budget_limited,
814 }
815}
816
817#[derive(Clone, Copy, Debug, Default)]
825pub struct BpBudget {
826 pub min_partition_docs: Option<usize>,
831 pub time_budget: Option<std::time::Duration>,
835}
836
837impl BpBudget {
838 pub fn full() -> Self {
840 Self::default()
841 }
842}
843
844pub(crate) fn graph_bisection(
855 fwd: &ForwardIndex,
856 min_partition_size: usize,
857 max_iters: usize,
858 budget: BpBudget,
859) -> (Vec<u32>, bool) {
860 let n = fwd.num_docs();
861 if n == 0 {
862 return (Vec::new(), !fwd.budget_limited);
863 }
864
865 let effective_min_partition = budget
866 .min_partition_docs
867 .unwrap_or(0)
868 .max(min_partition_size);
869
870 let mut docs: Vec<u32> = (0..n as u32).collect();
871 let depth = if effective_min_partition > 0 {
872 ((n as f64) / (effective_min_partition as f64))
873 .log2()
874 .ceil() as usize
875 } else {
876 0
877 };
878 let log_table = build_log_table(4096);
879
880 log::debug!(
881 "BP graph_bisection: n={}, min_partition={}, max_iters={}, depth=~{}, time_budget={:?}",
882 n,
883 effective_min_partition,
884 max_iters,
885 depth,
886 budget.time_budget,
887 );
888
889 #[cfg(feature = "native")]
890 let deadline = budget.time_budget.map(|duration| {
891 let now = std::time::Instant::now();
892 now.checked_add(duration).unwrap_or(now)
893 });
894 #[cfg(not(feature = "native"))]
895 let deadline: Option<()> = None;
896
897 let exhausted = std::sync::atomic::AtomicBool::new(false);
898 let context = BisectContext {
899 fwd,
900 min_partition_size: effective_min_partition,
901 max_iters,
902 log_table: &log_table,
903 #[cfg(feature = "native")]
904 deadline,
905 #[cfg(not(feature = "native"))]
906 deadline,
907 exhausted: &exhausted,
908 };
909 #[cfg(feature = "native")]
910 bisect(&mut docs, fwd.parallel_bisect_depth, &context);
911 #[cfg(not(feature = "native"))]
912 bisect(&mut docs, 0, &context);
913
914 let converged = !fwd.budget_limited && !exhausted.load(std::sync::atomic::Ordering::Relaxed);
915 if !converged {
916 log::info!(
917 "BP graph_bisection: budget incomplete at n={} (time={:?}, memory_limited={}) — emitting partial (still valid) permutation",
918 n,
919 budget.time_budget,
920 fwd.budget_limited,
921 );
922 }
923 (docs, converged)
924}
925
926struct BisectContext<'a> {
935 fwd: &'a ForwardIndex,
936 min_partition_size: usize,
937 max_iters: usize,
938 log_table: &'a [f32],
939 #[cfg(feature = "native")]
940 deadline: Option<std::time::Instant>,
941 #[cfg(not(feature = "native"))]
942 deadline: Option<()>,
943 exhausted: &'a std::sync::atomic::AtomicBool,
944}
945
946fn bisect(docs: &mut [u32], parallel_depth: usize, context: &BisectContext<'_>) {
947 #[cfg(not(feature = "native"))]
948 let _ = parallel_depth;
949 let n = docs.len();
950 if n <= context.min_partition_size {
951 return;
952 }
953 if context.exhausted.load(std::sync::atomic::Ordering::Relaxed) {
955 return;
956 }
957 #[cfg(feature = "native")]
958 if let Some(dl) = context.deadline
959 && std::time::Instant::now() >= dl
960 {
961 context
962 .exhausted
963 .store(true, std::sync::atomic::Ordering::Relaxed);
964 return;
965 }
966 #[cfg(not(feature = "native"))]
967 let _ = context.deadline;
968
969 let mid = n / 2;
970 let nt = context.fwd.num_terms;
971
972 let effective_iters = if n > 100_000 {
976 context.max_iters.min(12)
977 } else {
978 context.max_iters
979 };
980
981 let mut degrees = TermDegrees::new(nt);
984
985 for (i, &doc) in docs.iter().enumerate() {
986 let side = usize::from(i >= mid);
987 for &term in context.fwd.doc_terms(doc as usize) {
988 degrees.entry_mut(term as usize)[side] += 1;
989 }
990 }
991
992 let mut gains: Vec<f32> = vec![0.0; n];
994 let mut indices: Vec<usize> = (0..n).collect();
995 let mut new_left: Vec<u32> = Vec::with_capacity(mid);
996 let mut new_right: Vec<u32> = Vec::with_capacity(n - mid);
997
998 for iter in 0..effective_iters {
999 #[cfg(feature = "native")]
1001 if let Some(dl) = context.deadline
1002 && std::time::Instant::now() >= dl
1003 {
1004 context
1005 .exhausted
1006 .store(true, std::sync::atomic::Ordering::Relaxed);
1007 break;
1008 }
1009 compute_gains(
1012 docs,
1013 context.fwd,
1014 mid,
1015 °rees,
1016 context.log_table,
1017 &mut gains,
1018 );
1019
1020 indices.clear();
1022 indices.extend(0..n);
1023 indices.select_nth_unstable_by(mid, |&a, &b| {
1024 gains[a].total_cmp(&gains[b]).then_with(|| a.cmp(&b))
1025 });
1026
1027 new_left.clear();
1029 new_right.clear();
1030 let mut swap_count: usize = 0;
1031
1032 for (rank, &idx) in indices.iter().enumerate() {
1033 let doc = docs[idx];
1034 let was_left = idx < mid;
1035 let now_left = rank < mid;
1036
1037 if now_left {
1038 new_left.push(doc);
1039 } else {
1040 new_right.push(doc);
1041 }
1042
1043 if was_left != now_left {
1044 swap_count += 1;
1045 for &term in context.fwd.doc_terms(doc as usize) {
1046 let degree = degrees.entry_mut(term as usize);
1047 if was_left {
1048 degree[0] -= 1;
1049 degree[1] += 1;
1050 } else {
1051 degree[1] -= 1;
1052 degree[0] += 1;
1053 }
1054 }
1055 }
1056 }
1057
1058 docs[..mid].copy_from_slice(&new_left);
1059 docs[mid..].copy_from_slice(&new_right);
1060
1061 if swap_count == 0 {
1062 break;
1063 }
1064
1065 if iter > 2 && swap_count < n / 200 {
1067 break;
1068 }
1069
1070 if iter > 5 {
1072 let max_abs_gain = gains
1073 .iter()
1074 .copied()
1075 .fold(0.0f32, |max_gain, gain| max_gain.max(gain.abs()));
1076 if max_abs_gain < 0.001 {
1077 break;
1078 }
1079 }
1080 }
1081
1082 drop(degrees);
1084 drop(gains);
1085 drop(indices);
1086 drop(new_left);
1087 drop(new_right);
1088
1089 let (left, right) = docs.split_at_mut(mid);
1090 #[cfg(feature = "native")]
1091 if parallel_depth > 0 {
1092 rayon::join(
1093 || bisect(left, parallel_depth - 1, context),
1094 || bisect(right, parallel_depth - 1, context),
1095 );
1096 } else {
1097 bisect(left, 0, context);
1101 bisect(right, 0, context);
1102 }
1103 #[cfg(not(feature = "native"))]
1104 {
1105 bisect(left, 0, context);
1106 bisect(right, 0, context);
1107 }
1108}
1109
1110#[inline(never)]
1116fn compute_gains(
1117 docs: &[u32],
1118 fwd: &ForwardIndex,
1119 mid: usize,
1120 degrees: &TermDegrees,
1121 log_table: &[f32],
1122 gains: &mut [f32],
1123) {
1124 let gain_for_doc = |i: usize| -> f32 {
1133 let doc = docs[i] as usize;
1134 let in_left = i < mid;
1135 let mut g = 0.0f32;
1136 for &term in fwd.doc_terms(doc) {
1137 let [left, right] = degrees.get(term as usize);
1138 let (from, to) = if in_left {
1139 (left, right)
1140 } else {
1141 (right, left)
1142 };
1143 let move_gain = fast_log2_lookup(to as usize + 2, log_table)
1144 - fast_log2_lookup(from as usize, log_table)
1145 - std::f32::consts::LOG2_E / (1.0 + to as f32);
1146 g += if in_left { move_gain } else { -move_gain };
1147 }
1148 g
1149 };
1150
1151 #[cfg(feature = "native")]
1152 {
1153 if docs.len() > 4096 {
1154 gains
1155 .par_iter_mut()
1156 .enumerate()
1157 .for_each(|(i, gain)| *gain = gain_for_doc(i));
1158 } else {
1159 for (i, gain) in gains.iter_mut().enumerate().take(docs.len()) {
1160 *gain = gain_for_doc(i);
1161 }
1162 }
1163 }
1164 #[cfg(not(feature = "native"))]
1165 {
1166 for (i, gain) in gains.iter_mut().enumerate().take(docs.len()) {
1167 *gain = gain_for_doc(i);
1168 }
1169 }
1170}
1171
1172fn build_log_table(size: usize) -> Vec<f32> {
1176 let mut table = vec![0.0f32; size];
1177 table[0] = -10.0;
1179 for (i, entry) in table.iter_mut().enumerate().skip(1) {
1180 *entry = (i as f32).log2();
1181 }
1182 table
1183}
1184
1185#[inline]
1187fn fast_log2_lookup(val: usize, table: &[f32]) -> f32 {
1188 if val < table.len() {
1189 table[val]
1190 } else {
1191 (val as f32).log2()
1192 }
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197 use super::*;
1198
1199 #[test]
1200 fn lazy_term_degrees_initialize_only_on_first_write() {
1201 let mut degrees = TermDegrees::new(130);
1202 assert_eq!(degrees.get(65), [0, 0]);
1203 degrees.entry_mut(65)[0] += 3;
1204 degrees.entry_mut(65)[1] += 2;
1205 assert_eq!(degrees.get(65), [3, 2]);
1206 assert_eq!(degrees.get(64), [0, 0]);
1207 assert_eq!(
1208 degrees
1209 .initialized
1210 .iter()
1211 .map(|w| w.count_ones())
1212 .sum::<u32>(),
1213 1
1214 );
1215 }
1216
1217 #[test]
1223 fn test_csr_offsets_do_not_wrap_past_u32() {
1224 let counts = [1_500_000_000u32; 3]; let offsets = build_csr_offsets(&counts);
1226 assert_eq!(
1227 offsets,
1228 vec![0, 1_500_000_000, 3_000_000_000, 4_500_000_000]
1229 );
1230 assert!(*offsets.last().unwrap() > u32::MAX as u64);
1231 }
1232
1233 fn make_fwd(docs: &[&[u32]], num_terms: usize) -> ForwardIndex {
1235 let mut terms = Vec::new();
1236 let mut offsets = vec![0u64];
1237 for doc_terms in docs {
1238 terms.extend_from_slice(doc_terms);
1239 offsets.push(terms.len() as u64);
1240 }
1241 ForwardIndex {
1242 terms,
1243 offsets,
1244 num_terms,
1245 parallel_bisect_depth: 0,
1246 budget_limited: false,
1247 }
1248 }
1249
1250 #[test]
1251 fn test_bp_empty() {
1252 let fwd = ForwardIndex {
1253 terms: Vec::new(),
1254 offsets: Vec::new(),
1255 num_terms: 0,
1256 parallel_bisect_depth: 0,
1257 budget_limited: false,
1258 };
1259 let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
1260 assert!(perm.is_empty());
1261 }
1262
1263 #[test]
1264 fn test_bp_small() {
1265 let fwd = make_fwd(&[&[0, 1], &[0, 2], &[1, 3], &[2, 3]], 4);
1267 let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
1268 assert_eq!(perm.len(), 4);
1269 let mut sorted = perm.clone();
1271 sorted.sort();
1272 assert_eq!(sorted, vec![0, 1, 2, 3]);
1273 }
1274
1275 #[test]
1276 fn test_bp_clusters() {
1277 let fwd = make_fwd(
1281 &[
1282 &[0, 1],
1283 &[0, 1],
1284 &[0, 1],
1285 &[0, 1],
1286 &[2, 3],
1287 &[2, 3],
1288 &[2, 3],
1289 &[2, 3],
1290 ],
1291 4,
1292 );
1293 let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
1294 assert_eq!(perm.len(), 8);
1295
1296 let left: Vec<u32> = perm[..4].to_vec();
1298
1299 let a_in_left = left.iter().filter(|&&d| d < 4).count();
1301 let b_in_left = left.iter().filter(|&&d| d >= 4).count();
1302 assert!(
1303 (a_in_left == 4 && b_in_left == 0) || (a_in_left == 0 && b_in_left == 4),
1304 "Clusters should be separated: a_left={}, b_left={}",
1305 a_in_left,
1306 b_in_left,
1307 );
1308 }
1309
1310 #[test]
1311 fn test_bp_permutation_valid() {
1312 let docs: Vec<Vec<u32>> = (0..16).map(|i| vec![i / 4, 10 + i / 2]).collect();
1314 let doc_refs: Vec<&[u32]> = docs.iter().map(|v| v.as_slice()).collect();
1315 let fwd = make_fwd(&doc_refs, 18); let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
1317
1318 assert_eq!(perm.len(), 16);
1319 let mut sorted = perm.clone();
1321 sorted.sort();
1322 let expected: Vec<u32> = (0..16).collect();
1323 assert_eq!(sorted, expected);
1324 }
1325
1326 #[test]
1331 fn test_bp_depth_cap_separates_clusters_and_converges() {
1332 let fwd = make_fwd(
1335 &[
1336 &[0, 1],
1337 &[0, 1],
1338 &[0, 1],
1339 &[2, 3],
1340 &[0, 1],
1341 &[2, 3],
1342 &[2, 3],
1343 &[2, 3],
1344 ],
1345 4,
1346 );
1347 let budget = BpBudget {
1348 min_partition_docs: Some(4),
1349 time_budget: None,
1350 };
1351 let (perm, converged) = graph_bisection(&fwd, 2, 20, budget);
1352 assert!(converged, "depth cap must report converged");
1353 assert_eq!(perm.len(), 8);
1354 let mut sorted = perm.clone();
1355 sorted.sort();
1356 assert_eq!(
1357 sorted,
1358 (0..8).collect::<Vec<u32>>(),
1359 "must stay a valid permutation"
1360 );
1361 let cluster_a = [0u32, 1, 2, 4];
1364 let a_in_left = perm[..4].iter().filter(|d| cluster_a.contains(d)).count();
1365 assert!(
1366 a_in_left == 4 || a_in_left == 0,
1367 "clusters should separate at the top level: {:?}",
1368 perm
1369 );
1370 }
1371
1372 #[test]
1375 fn test_bp_zero_time_budget_emits_valid_partial_permutation() {
1376 let docs: Vec<Vec<u32>> = (0..64).map(|i| vec![i % 4]).collect();
1377 let doc_refs: Vec<&[u32]> = docs.iter().map(|v| v.as_slice()).collect();
1378 let fwd = make_fwd(&doc_refs, 4);
1379 let budget = BpBudget {
1380 min_partition_docs: None,
1381 time_budget: Some(std::time::Duration::ZERO),
1382 };
1383 let (perm, converged) = graph_bisection(&fwd, 4, 20, budget);
1384 assert!(!converged, "zero budget must report unconverged");
1385 assert_eq!(perm.len(), 64);
1386 let mut sorted = perm.clone();
1387 sorted.sort();
1388 assert_eq!(sorted, (0..64).collect::<Vec<u32>>());
1389 }
1390
1391 #[test]
1392 fn test_memory_limited_graph_never_reports_converged() {
1393 let mut fwd = make_fwd(&[&[0], &[0], &[1], &[1]], 2);
1394 fwd.budget_limited = true;
1395
1396 let (perm, converged) = graph_bisection(&fwd, 2, 20, BpBudget::full());
1397
1398 assert!(!converged);
1399 let mut sorted = perm;
1400 sorted.sort_unstable();
1401 assert_eq!(sorted, vec![0, 1, 2, 3]);
1402 }
1403
1404 #[test]
1405 fn test_fast_log2() {
1406 let table = build_log_table(4096);
1407 assert!((table[1] - 0.0).abs() < 0.001);
1408 assert!((table[2] - 1.0).abs() < 0.001);
1409 assert!((table[4] - 2.0).abs() < 0.001);
1410 assert!((table[1024] - 10.0).abs() < 0.001);
1411 let val = fast_log2_lookup(8192, &table);
1413 assert!((val - 13.0).abs() < 0.001);
1414 }
1415}