Skip to main content

hermes_core/segment/builder/
graph_bisection.rs

1//! Recursive Graph Bisection (BP) for BMP document ordering.
2//!
3//! Based on Dhulipala et al. (KDD 2016) and Mackenzie et al. — the same
4//! algorithm used in Lucene and PISA for document reordering.
5//!
6//! Directly optimizes log-gap cost: docs sharing dimensions end up in the
7//! same BMP blocks, producing tight upper bounds and effective pruning.
8//!
9//! Memory: forward index ~200 bytes/doc (temporary) + degree arrays ~840 KB.
10
11#[cfg(feature = "native")]
12use rayon::prelude::*;
13use rustc_hash::FxHashMap;
14
15// ── Forward index (CSR) ──────────────────────────────────────────────────
16
17/// Forward index in CSR format: doc `d`'s terms are `terms[offsets[d]..offsets[d+1]]`.
18///
19/// Term IDs are remapped to compact range `0..num_terms` for flat-array degree tracking.
20pub(crate) struct ForwardIndex {
21    terms: Vec<u32>,
22    offsets: Vec<u32>,
23    pub num_terms: usize,
24}
25
26impl ForwardIndex {
27    #[inline]
28    pub fn num_docs(&self) -> usize {
29        if self.offsets.is_empty() {
30            0
31        } else {
32            self.offsets.len() - 1
33        }
34    }
35
36    #[inline]
37    fn doc_terms(&self, doc: usize) -> &[u32] {
38        let start = self.offsets[doc] as usize;
39        let end = self.offsets[doc + 1] as usize;
40        &self.terms[start..end]
41    }
42
43    /// Total postings in the forward index.
44    pub fn total_postings(&self) -> u32 {
45        self.offsets.last().copied().unwrap_or(0)
46    }
47}
48
49/// Build virtual→real and real→virtual vid maps from a BMP doc map.
50///
51/// A virtual slot is real iff its doc-map entry is not the `u32::MAX` padding
52/// sentinel. Realness must come from the doc map itself: block-copy merged
53/// segments carry each source's tail padding as *interior* padding, so
54/// `vid < num_real_docs` does NOT identify real docs there.
55///
56/// Returns `(virtual_to_real, real_to_virtual)` where `virtual_to_real[vid]`
57/// is the dense real index or `u32::MAX` for padding.
58pub(crate) fn build_vid_maps(bmp: &crate::segment::reader::bmp::BmpIndex) -> (Vec<u32>, Vec<u32>) {
59    let ids = bmp.doc_map_ids_slice();
60    let num_virtual = bmp.num_virtual_docs as usize;
61    let mut virtual_to_real = vec![u32::MAX; num_virtual];
62    let mut real_to_virtual = Vec::with_capacity(bmp.num_real_docs() as usize);
63    for (vid, (slot, chunk)) in virtual_to_real
64        .iter_mut()
65        .zip(ids.as_chunks::<4>().0)
66        .enumerate()
67    {
68        let doc_id = u32::from_le_bytes(*chunk);
69        if doc_id != u32::MAX {
70            *slot = real_to_virtual.len() as u32;
71            real_to_virtual.push(vid as u32);
72        }
73    }
74    if real_to_virtual.len() != bmp.num_real_docs() as usize {
75        log::warn!(
76            "[reorder] BMP doc map has {} real slots but footer says num_real_docs={} — trusting the doc map",
77            real_to_virtual.len(),
78            bmp.num_real_docs(),
79        );
80    }
81    (virtual_to_real, real_to_virtual)
82}
83
84/// Build forward index from BmpIndex sources (single or multi-source).
85///
86/// Documents are identified by dense *real* indices assigned sequentially
87/// across sources: source 0 gets 0..n0, source 1 gets n0..n0+n1, etc., where
88/// each n is the source's real (non-padding) doc count derived from its doc
89/// map via [`build_vid_maps`]. Returns `(forward_index, per_source_real_doc_counts)`.
90///
91/// Filters dims with doc_freq outside `[min_doc_freq, max_doc_freq]`.
92/// If the estimated forward index memory exceeds `memory_budget_bytes`, the
93/// highest-frequency dims are dropped to stay within budget. This prevents OOM
94/// for huge segments at the cost of slightly reduced reorder quality.
95///
96/// Remaps term IDs to compact range for flat-array degree tracking.
97pub(crate) fn build_forward_index_from_bmps(
98    bmps: &[&crate::segment::reader::bmp::BmpIndex],
99    min_doc_freq: usize,
100    max_doc_freq: usize,
101    memory_budget_bytes: usize,
102) -> (ForwardIndex, Vec<usize>) {
103    // Per-source virtual→real maps (padding-aware realness from the doc map).
104    let vid_maps: Vec<(Vec<u32>, Vec<u32>)> = bmps.iter().map(|b| build_vid_maps(b)).collect();
105    let source_doc_counts: Vec<usize> = vid_maps.iter().map(|(_, r2v)| r2v.len()).collect();
106    let total_docs: usize = source_doc_counts.iter().sum();
107
108    if total_docs == 0 {
109        return (
110            ForwardIndex {
111                terms: Vec::new(),
112                offsets: Vec::new(),
113                num_terms: 0,
114            },
115            source_doc_counts,
116        );
117    }
118
119    // Phase 1: count doc freq per dimension across all sources + assign compact IDs
120    let mut dim_df: FxHashMap<u32, usize> = FxHashMap::default();
121    for (bmp, (v2r, _)) in bmps.iter().zip(&vid_maps) {
122        let num_blocks = bmp.num_blocks as usize;
123        let block_size = bmp.bmp_block_size as usize;
124        for block_id in 0..num_blocks {
125            for (dim_id, postings) in bmp.iter_block_terms(block_id as u32) {
126                for p in postings {
127                    let vid = block_id * block_size + p.local_slot as usize;
128                    if v2r[vid] != u32::MAX && p.impact > 0 {
129                        *dim_df.entry(dim_id).or_insert(0) += 1;
130                    }
131                }
132            }
133        }
134    }
135
136    // Filter dims by [min_doc_freq, max_doc_freq] range
137    let mut eligible: Vec<(u32, usize)> = dim_df
138        .iter()
139        .filter(|&(_, df)| *df >= min_doc_freq && *df <= max_doc_freq)
140        .map(|(&dim_id, &df)| (dim_id, df))
141        .collect();
142    drop(dim_df);
143
144    // Memory budget: estimate forward index + bisection scratch.
145    // Peak ≈ 4*total_postings (terms array) + 28*total_docs (offsets, counts,
146    //   docs, gains, indices, new_left, new_right) + 8*num_terms (degree arrays).
147    let total_postings_est: usize = eligible.iter().map(|(_, df)| *df).sum();
148    let estimated_bytes = total_postings_est * 4 + total_docs * 28 + eligible.len() * 8;
149
150    if estimated_bytes > memory_budget_bytes && !eligible.is_empty() {
151        // Sort by df ascending — keep discriminative low-df dims first,
152        // drop highest-df dims which contribute the most postings.
153        eligible.sort_by_key(|&(_, df)| df);
154
155        let target_postings =
156            memory_budget_bytes.saturating_sub(total_docs * 28 + eligible.len() * 8) / 4;
157        let mut cum = 0usize;
158        let mut keep_count = 0;
159        for &(_, df) in &eligible {
160            if cum + df > target_postings {
161                break;
162            }
163            cum += df;
164            keep_count += 1;
165        }
166
167        let dropped = eligible.len() - keep_count;
168        eligible.truncate(keep_count);
169
170        log::warn!(
171            "[reorder] memory budget {:.0} MB: estimated {:.0} MB, dropped {} highest-df dims, keeping {} ({} postings)",
172            memory_budget_bytes as f64 / (1024.0 * 1024.0),
173            estimated_bytes as f64 / (1024.0 * 1024.0),
174            dropped,
175            keep_count,
176            cum,
177        );
178    }
179
180    let mut term_remap: FxHashMap<u32, u32> = FxHashMap::default();
181    for &(dim_id, _) in &eligible {
182        let compact_id = term_remap.len() as u32;
183        term_remap.insert(dim_id, compact_id);
184    }
185    let num_active_terms = term_remap.len();
186    drop(eligible);
187
188    // Phase 2: count terms per doc (filtered)
189    let mut counts = vec![0u32; total_docs];
190    let mut real_offset = 0usize;
191
192    for (src_idx, bmp) in bmps.iter().enumerate() {
193        let (v2r, _) = &vid_maps[src_idx];
194        let num_blocks = bmp.num_blocks as usize;
195        let block_size = bmp.bmp_block_size as usize;
196        for block_id in 0..num_blocks {
197            for (dim_id, postings) in bmp.iter_block_terms(block_id as u32) {
198                if !term_remap.contains_key(&dim_id) {
199                    continue;
200                }
201                for p in postings {
202                    let vid = block_id * block_size + p.local_slot as usize;
203                    let real = v2r[vid];
204                    if real != u32::MAX && p.impact > 0 {
205                        counts[real_offset + real as usize] += 1;
206                    }
207                }
208            }
209        }
210        real_offset += source_doc_counts[src_idx];
211    }
212
213    // Phase 3: build CSR offsets
214    let mut offsets = Vec::with_capacity(total_docs + 1);
215    offsets.push(0u32);
216    for &c in &counts {
217        offsets.push(offsets.last().unwrap() + c);
218    }
219    let total = *offsets.last().unwrap() as usize;
220
221    // Phase 4: fill terms (compact IDs)
222    let mut terms = vec![0u32; total];
223    counts.fill(0);
224    real_offset = 0;
225
226    for (src_idx, bmp) in bmps.iter().enumerate() {
227        let (v2r, _) = &vid_maps[src_idx];
228        let num_blocks = bmp.num_blocks as usize;
229        let block_size = bmp.bmp_block_size as usize;
230        for block_id in 0..num_blocks {
231            for (dim_id, postings) in bmp.iter_block_terms(block_id as u32) {
232                let Some(&compact) = term_remap.get(&dim_id) else {
233                    continue;
234                };
235                for p in postings {
236                    let vid = block_id * block_size + p.local_slot as usize;
237                    let real = v2r[vid];
238                    if real != u32::MAX && p.impact > 0 {
239                        let global_real = real_offset + real as usize;
240                        let pos = offsets[global_real] as usize + counts[global_real] as usize;
241                        terms[pos] = compact;
242                        counts[global_real] += 1;
243                    }
244                }
245            }
246        }
247        real_offset += source_doc_counts[src_idx];
248    }
249
250    (
251        ForwardIndex {
252            terms,
253            offsets,
254            num_terms: num_active_terms,
255        },
256        source_doc_counts,
257    )
258}
259
260/// Build a forward index over BLOCKS (one entity per block, its terms = the
261/// block's header dim list). Used by block-level reorder: BP over blocks is
262/// ~block_size× cheaper than over records and only needs to decide superblock
263/// assignment. Blocks are numbered globally across sources in source order.
264///
265/// Dims appearing in fewer than 2 blocks carry no clustering signal and are
266/// dropped; the memory budget applies as in the record-level builder.
267pub(crate) fn build_forward_index_from_blocks(
268    bmps: &[&crate::segment::reader::bmp::BmpIndex],
269    memory_budget_bytes: usize,
270) -> ForwardIndex {
271    let total_blocks: usize = bmps.iter().map(|b| b.num_blocks as usize).sum();
272    if total_blocks == 0 {
273        return ForwardIndex {
274            terms: Vec::new(),
275            offsets: Vec::new(),
276            num_terms: 0,
277        };
278    }
279
280    // Phase 1: dim → number of blocks containing it (block-level df)
281    let mut dim_bf: FxHashMap<u32, usize> = FxHashMap::default();
282    for bmp in bmps {
283        for block_id in 0..bmp.num_blocks {
284            for (dim_id, _) in bmp.iter_block_terms(block_id) {
285                *dim_bf.entry(dim_id).or_insert(0) += 1;
286            }
287        }
288    }
289
290    let max_bf = (total_blocks as f64 * 0.9) as usize;
291    let mut eligible: Vec<(u32, usize)> = dim_bf
292        .iter()
293        .filter(|&(_, bf)| *bf >= 2 && *bf <= max_bf.max(2))
294        .map(|(&dim_id, &bf)| (dim_id, bf))
295        .collect();
296    drop(dim_bf);
297
298    let total_postings_est: usize = eligible.iter().map(|(_, bf)| *bf).sum();
299    let estimated_bytes = total_postings_est * 4 + total_blocks * 28 + eligible.len() * 8;
300    if estimated_bytes > memory_budget_bytes && !eligible.is_empty() {
301        eligible.sort_by_key(|&(_, bf)| bf);
302        let target = memory_budget_bytes.saturating_sub(total_blocks * 28 + eligible.len() * 8) / 4;
303        let mut cum = 0usize;
304        let mut keep = 0;
305        for &(_, bf) in &eligible {
306            if cum + bf > target {
307                break;
308            }
309            cum += bf;
310            keep += 1;
311        }
312        log::warn!(
313            "[reorder] block-level fwd index over budget — dropped {} highest-bf dims",
314            eligible.len() - keep,
315        );
316        eligible.truncate(keep);
317    }
318
319    let mut term_remap: FxHashMap<u32, u32> = FxHashMap::default();
320    for &(dim_id, _) in &eligible {
321        let compact = term_remap.len() as u32;
322        term_remap.insert(dim_id, compact);
323    }
324    let num_terms = term_remap.len();
325    drop(eligible);
326
327    // Phase 2+3: counts and CSR fill
328    let mut counts = vec![0u32; total_blocks];
329    let mut gb = 0usize;
330    for bmp in bmps {
331        for block_id in 0..bmp.num_blocks {
332            for (dim_id, _) in bmp.iter_block_terms(block_id) {
333                if term_remap.contains_key(&dim_id) {
334                    counts[gb] += 1;
335                }
336            }
337            gb += 1;
338        }
339    }
340    let mut offsets = Vec::with_capacity(total_blocks + 1);
341    offsets.push(0u32);
342    for &c in &counts {
343        offsets.push(offsets.last().unwrap() + c);
344    }
345    let total = *offsets.last().unwrap() as usize;
346    let mut terms = vec![0u32; total];
347    counts.fill(0);
348    gb = 0;
349    for bmp in bmps {
350        for block_id in 0..bmp.num_blocks {
351            for (dim_id, _) in bmp.iter_block_terms(block_id) {
352                if let Some(&compact) = term_remap.get(&dim_id) {
353                    let pos = offsets[gb] as usize + counts[gb] as usize;
354                    terms[pos] = compact;
355                    counts[gb] += 1;
356                }
357            }
358            gb += 1;
359        }
360    }
361
362    ForwardIndex {
363        terms,
364        offsets,
365        num_terms,
366    }
367}
368
369// ── Recursive Graph Bisection ────────────────────────────────────────────
370
371/// CPU/depth budget for a BP pass. BP is an anytime algorithm: stopping at
372/// any depth or deadline still yields a valid permutation, and because the
373/// output layout becomes the next pass's input order, repeated budgeted
374/// passes warm-start and deepen (top levels converge in ~0 swaps, the budget
375/// flows to deeper levels).
376#[derive(Clone, Copy, Debug, Default)]
377pub struct BpBudget {
378    /// Stop recursion at partitions of at most this many docs instead of
379    /// descending to block granularity. `None` = full depth. Capping at
380    /// superblock granularity (superblock_size × block_size docs) keeps most
381    /// of the superblock-pruning win at ~⅓ less depth.
382    pub min_partition_docs: Option<usize>,
383    /// Wall-clock cap for the whole BP computation. The pass ends cleanly at
384    /// the deadline with whatever depth it reached (`converged = false`).
385    /// Ignored on wasm (no monotonic clock).
386    pub time_budget: Option<std::time::Duration>,
387}
388
389impl BpBudget {
390    /// Unbudgeted: full depth, no deadline.
391    pub fn full() -> Self {
392        Self::default()
393    }
394}
395
396/// Recursive graph bisection. Returns `(perm, converged)` where
397/// `perm[new_pos] = old_index` and `converged` is false iff the wall-clock
398/// budget ended the pass before it finished (a depth cap alone is a chosen
399/// target, not an interruption — it reports converged).
400///
401/// `min_partition_size` should be the BMP block_size (64).
402/// `max_iters` controls convergence (20 is standard).
403///
404/// Term IDs in the forward index must be compact (0..num_terms) so we can
405/// use flat arrays for O(1) degree lookups instead of hash maps.
406pub(crate) fn graph_bisection(
407    fwd: &ForwardIndex,
408    min_partition_size: usize,
409    max_iters: usize,
410    budget: BpBudget,
411) -> (Vec<u32>, bool) {
412    let n = fwd.num_docs();
413    if n == 0 {
414        return (Vec::new(), true);
415    }
416
417    let effective_min_partition = budget
418        .min_partition_docs
419        .unwrap_or(0)
420        .max(min_partition_size);
421
422    let mut docs: Vec<u32> = (0..n as u32).collect();
423    let depth = if effective_min_partition > 0 {
424        ((n as f64) / (effective_min_partition as f64))
425            .log2()
426            .ceil() as usize
427    } else {
428        0
429    };
430    let log_table = build_log_table(4096);
431
432    log::debug!(
433        "BP graph_bisection: n={}, min_partition={}, max_iters={}, depth=~{}, time_budget={:?}",
434        n,
435        effective_min_partition,
436        max_iters,
437        depth,
438        budget.time_budget,
439    );
440
441    #[cfg(feature = "native")]
442    let deadline = budget.time_budget.map(|d| std::time::Instant::now() + d);
443    #[cfg(not(feature = "native"))]
444    let deadline: Option<()> = None;
445    #[cfg(not(feature = "native"))]
446    let _ = deadline;
447
448    let exhausted = std::sync::atomic::AtomicBool::new(false);
449    #[cfg(feature = "native")]
450    bisect(
451        &mut docs,
452        fwd,
453        effective_min_partition,
454        max_iters,
455        &log_table,
456        deadline,
457        &exhausted,
458    );
459    #[cfg(not(feature = "native"))]
460    bisect(
461        &mut docs,
462        fwd,
463        effective_min_partition,
464        max_iters,
465        &log_table,
466        None,
467        &exhausted,
468    );
469
470    let converged = !exhausted.load(std::sync::atomic::Ordering::Relaxed);
471    if !converged {
472        log::info!(
473            "BP graph_bisection: wall-clock budget {:?} exhausted at n={} — emitting partial (still valid) permutation",
474            budget.time_budget,
475            n,
476        );
477    }
478    (docs, converged)
479}
480
481/// Recursive bisection of a document slice.
482///
483/// Uses flat `Vec<u32>` degree arrays indexed by compact term_id for cache-friendly
484/// O(1) lookups (vs FxHashMap which has poor cache locality at scale).
485///
486/// Gain computation is parallelized via rayon for large partitions (n > 4096).
487/// Adaptive iteration count reduces work at top levels where coarse splits
488/// converge faster and dominate total runtime.
489fn bisect(
490    docs: &mut [u32],
491    fwd: &ForwardIndex,
492    min_partition_size: usize,
493    max_iters: usize,
494    log_table: &[f32],
495    #[cfg(feature = "native")] deadline: Option<std::time::Instant>,
496    #[cfg(not(feature = "native"))] deadline: Option<()>,
497    exhausted: &std::sync::atomic::AtomicBool,
498) {
499    let n = docs.len();
500    if n <= min_partition_size {
501        return;
502    }
503    // Anytime cutoff: leave this subtree in its current (valid) order.
504    if exhausted.load(std::sync::atomic::Ordering::Relaxed) {
505        return;
506    }
507    #[cfg(feature = "native")]
508    if let Some(dl) = deadline
509        && std::time::Instant::now() >= dl
510    {
511        exhausted.store(true, std::sync::atomic::Ordering::Relaxed);
512        return;
513    }
514    #[cfg(not(feature = "native"))]
515    let _ = deadline;
516
517    let mid = n / 2;
518    let nt = fwd.num_terms;
519
520    // Adaptive iteration count: large partitions converge faster with
521    // coarse splits, so fewer refinement passes suffice. The fine-grained
522    // clustering is handled by deeper recursion levels with full iterations.
523    let effective_iters = if n > 100_000 {
524        max_iters.min(12)
525    } else {
526        max_iters
527    };
528
529    // Flat degree arrays: left_deg[term] and right_deg[term].
530    // Allocated per bisection level; freed on return before recursion uses the
531    // memory for sub-problems. Peak = O(num_terms × log2(n/min_partition_size)).
532    let mut left_deg = vec![0u32; nt];
533    let mut right_deg = vec![0u32; nt];
534
535    for (i, &doc) in docs.iter().enumerate() {
536        let target = if i < mid {
537            &mut left_deg
538        } else {
539            &mut right_deg
540        };
541        for &term in fwd.doc_terms(doc as usize) {
542            target[term as usize] += 1;
543        }
544    }
545
546    // Scratch buffers reused across iterations
547    let mut gains: Vec<f32> = vec![0.0; n];
548    let mut indices: Vec<usize> = (0..n).collect();
549    let mut new_left: Vec<u32> = Vec::with_capacity(mid);
550    let mut new_right: Vec<u32> = Vec::with_capacity(n - mid);
551
552    for iter in 0..effective_iters {
553        // Anytime cutoff between refinement passes: keep the current split.
554        #[cfg(feature = "native")]
555        if let Some(dl) = deadline
556            && std::time::Instant::now() >= dl
557        {
558            exhausted.store(true, std::sync::atomic::Ordering::Relaxed);
559            break;
560        }
561        // Compute gain for each document (approx_1 from Dhulipala et al.)
562        // Parallelized for large partitions where per-doc work dominates.
563        compute_gains(docs, fwd, mid, &left_deg, &right_deg, log_table, &mut gains);
564
565        // Partition: the `mid` LOWEST keys (strongest left affinity) go left
566        indices.clear();
567        indices.extend(0..n);
568        indices.select_nth_unstable_by(mid, |&a, &b| {
569            gains[a]
570                .partial_cmp(&gains[b])
571                .unwrap_or(std::cmp::Ordering::Equal)
572        });
573
574        // Apply partition, update degree arrays for swapped docs
575        new_left.clear();
576        new_right.clear();
577        let mut swap_count: usize = 0;
578
579        for (rank, &idx) in indices.iter().enumerate() {
580            let doc = docs[idx];
581            let was_left = idx < mid;
582            let now_left = rank < mid;
583
584            if now_left {
585                new_left.push(doc);
586            } else {
587                new_right.push(doc);
588            }
589
590            if was_left != now_left {
591                swap_count += 1;
592                for &term in fwd.doc_terms(doc as usize) {
593                    let t = term as usize;
594                    if was_left {
595                        left_deg[t] -= 1;
596                        right_deg[t] += 1;
597                    } else {
598                        right_deg[t] -= 1;
599                        left_deg[t] += 1;
600                    }
601                }
602            }
603        }
604
605        docs[..mid].copy_from_slice(&new_left);
606        docs[mid..].copy_from_slice(&new_right);
607
608        if swap_count == 0 {
609            break;
610        }
611
612        // Early termination: if < 0.5% of docs swapped, partition is stable
613        if iter > 2 && swap_count < n / 200 {
614            break;
615        }
616
617        // Cooling: break early if gains are negligible
618        if iter > 5 {
619            let max_gain = gains.iter().copied().fold(f32::NEG_INFINITY, f32::max);
620            if max_gain.abs() < 0.001 {
621                break;
622            }
623        }
624    }
625
626    // Drop scratch before recursion to free memory for sub-problems
627    drop(left_deg);
628    drop(right_deg);
629    drop(gains);
630    drop(indices);
631    drop(new_left);
632    drop(new_right);
633
634    let (left, right) = docs.split_at_mut(mid);
635    #[cfg(feature = "native")]
636    rayon::join(
637        || {
638            bisect(
639                left,
640                fwd,
641                min_partition_size,
642                max_iters,
643                log_table,
644                deadline,
645                exhausted,
646            )
647        },
648        || {
649            bisect(
650                right,
651                fwd,
652                min_partition_size,
653                max_iters,
654                log_table,
655                deadline,
656                exhausted,
657            )
658        },
659    );
660    #[cfg(not(feature = "native"))]
661    {
662        bisect(
663            left,
664            fwd,
665            min_partition_size,
666            max_iters,
667            log_table,
668            deadline,
669            exhausted,
670        );
671        bisect(
672            right,
673            fwd,
674            min_partition_size,
675            max_iters,
676            log_table,
677            deadline,
678            exhausted,
679        );
680    }
681}
682
683/// Compute gains for all documents, parallelized via rayon for large partitions.
684///
685/// Each doc's gain is independent: iterate its terms, accumulate the log-gap
686/// cost delta of moving it to the other side. Read-only access to degree arrays
687/// makes this embarrassingly parallel.
688#[inline(never)]
689fn compute_gains(
690    docs: &[u32],
691    fwd: &ForwardIndex,
692    mid: usize,
693    left_deg: &[u32],
694    right_deg: &[u32],
695    log_table: &[f32],
696    gains: &mut [f32],
697) {
698    // Single coherent key: HIGH = belongs in the RIGHT half.
699    // Left docs get +approx_one(from=left, to=right) — a misplaced left doc
700    // (terms concentrated right) scores high. Right docs get
701    // -approx_one(from=right, to=left) — a misplaced right doc scores low.
702    // This matches the reference two-sided formulation (compute_gains_left /
703    // compute_gains_right with negation); ranking both halves by raw
704    // "move gain" instead made both sides' misplaced docs rank identically,
705    // so the partition step could never exchange them.
706    let gain_for_doc = |i: usize| -> f32 {
707        let doc = docs[i] as usize;
708        let in_left = i < mid;
709        let mut g = 0.0f32;
710        for &term in fwd.doc_terms(doc) {
711            let t = term as usize;
712            let (from, to) = if in_left {
713                (left_deg[t], right_deg[t])
714            } else {
715                (right_deg[t], left_deg[t])
716            };
717            let move_gain = fast_log2_lookup(to as usize + 2, log_table)
718                - fast_log2_lookup(from as usize, log_table)
719                - std::f32::consts::LOG2_E / (1.0 + to as f32);
720            g += if in_left { move_gain } else { -move_gain };
721        }
722        g
723    };
724
725    #[cfg(feature = "native")]
726    {
727        if docs.len() > 4096 {
728            gains
729                .par_iter_mut()
730                .enumerate()
731                .for_each(|(i, gain)| *gain = gain_for_doc(i));
732        } else {
733            for (i, gain) in gains.iter_mut().enumerate().take(docs.len()) {
734                *gain = gain_for_doc(i);
735            }
736        }
737    }
738    #[cfg(not(feature = "native"))]
739    {
740        for (i, gain) in gains.iter_mut().enumerate().take(docs.len()) {
741            *gain = gain_for_doc(i);
742        }
743    }
744}
745
746// ── Helpers ──────────────────────────────────────────────────────────────
747
748/// Build precomputed log2 table for values 0..size.
749fn build_log_table(size: usize) -> Vec<f32> {
750    let mut table = vec![0.0f32; size];
751    // log2(0) is undefined; use a large negative value
752    table[0] = -10.0;
753    for (i, entry) in table.iter_mut().enumerate().skip(1) {
754        *entry = (i as f32).log2();
755    }
756    table
757}
758
759/// Fast log2 with precomputed table lookup.
760#[inline]
761fn fast_log2_lookup(val: usize, table: &[f32]) -> f32 {
762    if val < table.len() {
763        table[val]
764    } else {
765        (val as f32).log2()
766    }
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    /// Build a simple forward index from (doc_id, terms) pairs.
774    fn make_fwd(docs: &[&[u32]], num_terms: usize) -> ForwardIndex {
775        let mut terms = Vec::new();
776        let mut offsets = vec![0u32];
777        for doc_terms in docs {
778            terms.extend_from_slice(doc_terms);
779            offsets.push(terms.len() as u32);
780        }
781        ForwardIndex {
782            terms,
783            offsets,
784            num_terms,
785        }
786    }
787
788    #[test]
789    fn test_bp_empty() {
790        let fwd = ForwardIndex {
791            terms: Vec::new(),
792            offsets: Vec::new(),
793            num_terms: 0,
794        };
795        let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
796        assert!(perm.is_empty());
797    }
798
799    #[test]
800    fn test_bp_small() {
801        // 4 docs, min_partition_size=4 → no bisection, identity
802        let fwd = make_fwd(&[&[0, 1], &[0, 2], &[1, 3], &[2, 3]], 4);
803        let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
804        assert_eq!(perm.len(), 4);
805        // All docs present
806        let mut sorted = perm.clone();
807        sorted.sort();
808        assert_eq!(sorted, vec![0, 1, 2, 3]);
809    }
810
811    #[test]
812    fn test_bp_clusters() {
813        // 8 docs in 2 clear clusters:
814        // Cluster A (docs 0-3): share terms 0, 1
815        // Cluster B (docs 4-7): share terms 2, 3
816        let fwd = make_fwd(
817            &[
818                &[0, 1],
819                &[0, 1],
820                &[0, 1],
821                &[0, 1],
822                &[2, 3],
823                &[2, 3],
824                &[2, 3],
825                &[2, 3],
826            ],
827            4,
828        );
829        let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
830        assert_eq!(perm.len(), 8);
831
832        // After bisection, docs from same cluster should be in same half
833        let left: Vec<u32> = perm[..4].to_vec();
834
835        // Either all of cluster A is in left and B in right, or vice versa
836        let a_in_left = left.iter().filter(|&&d| d < 4).count();
837        let b_in_left = left.iter().filter(|&&d| d >= 4).count();
838        assert!(
839            (a_in_left == 4 && b_in_left == 0) || (a_in_left == 0 && b_in_left == 4),
840            "Clusters should be separated: a_left={}, b_left={}",
841            a_in_left,
842            b_in_left,
843        );
844    }
845
846    #[test]
847    fn test_bp_permutation_valid() {
848        // 16 docs with mixed terms: terms range from 0..4 and 10..18
849        let docs: Vec<Vec<u32>> = (0..16).map(|i| vec![i / 4, 10 + i / 2]).collect();
850        let doc_refs: Vec<&[u32]> = docs.iter().map(|v| v.as_slice()).collect();
851        let fwd = make_fwd(&doc_refs, 18); // max term = 10 + 15/2 = 17, so need 18
852        let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
853
854        assert_eq!(perm.len(), 16);
855        // Must be a valid permutation
856        let mut sorted = perm.clone();
857        sorted.sort();
858        let expected: Vec<u32> = (0..16).collect();
859        assert_eq!(sorted, expected);
860    }
861
862    /// Depth-capped BP: with min_partition_docs above the cluster size, only
863    /// the top-level split happens — clusters still separate (coarse
864    /// clustering), the permutation stays valid, and the pass converges
865    /// (a depth cap is a chosen target, not an interruption).
866    #[test]
867    fn test_bp_depth_cap_separates_clusters_and_converges() {
868        // Mostly-separated clusters with one misplaced doc per half — the
869        // top-level swap pass must exchange docs 3 and 4.
870        let fwd = make_fwd(
871            &[
872                &[0, 1],
873                &[0, 1],
874                &[0, 1],
875                &[2, 3],
876                &[0, 1],
877                &[2, 3],
878                &[2, 3],
879                &[2, 3],
880            ],
881            4,
882        );
883        let budget = BpBudget {
884            min_partition_docs: Some(4),
885            time_budget: None,
886        };
887        let (perm, converged) = graph_bisection(&fwd, 2, 20, budget);
888        assert!(converged, "depth cap must report converged");
889        assert_eq!(perm.len(), 8);
890        let mut sorted = perm.clone();
891        sorted.sort();
892        assert_eq!(
893            sorted,
894            (0..8).collect::<Vec<u32>>(),
895            "must stay a valid permutation"
896        );
897        // Top-level split separates the clusters (docs {0,1,2,4} share terms
898        // 0/1; docs {3,5,6,7} share terms 2/3)
899        let cluster_a = [0u32, 1, 2, 4];
900        let a_in_left = perm[..4].iter().filter(|d| cluster_a.contains(d)).count();
901        assert!(
902            a_in_left == 4 || a_in_left == 0,
903            "clusters should separate at the top level: {:?}",
904            perm
905        );
906    }
907
908    /// Zero wall-clock budget: the pass ends immediately, reports
909    /// converged=false, and still emits a valid (identity) permutation.
910    #[test]
911    fn test_bp_zero_time_budget_emits_valid_partial_permutation() {
912        let docs: Vec<Vec<u32>> = (0..64).map(|i| vec![i % 4]).collect();
913        let doc_refs: Vec<&[u32]> = docs.iter().map(|v| v.as_slice()).collect();
914        let fwd = make_fwd(&doc_refs, 4);
915        let budget = BpBudget {
916            min_partition_docs: None,
917            time_budget: Some(std::time::Duration::ZERO),
918        };
919        let (perm, converged) = graph_bisection(&fwd, 4, 20, budget);
920        assert!(!converged, "zero budget must report unconverged");
921        assert_eq!(perm.len(), 64);
922        let mut sorted = perm.clone();
923        sorted.sort();
924        assert_eq!(sorted, (0..64).collect::<Vec<u32>>());
925    }
926
927    #[test]
928    fn test_fast_log2() {
929        let table = build_log_table(4096);
930        assert!((table[1] - 0.0).abs() < 0.001);
931        assert!((table[2] - 1.0).abs() < 0.001);
932        assert!((table[4] - 2.0).abs() < 0.001);
933        assert!((table[1024] - 10.0).abs() < 0.001);
934        // Fallback for values beyond table
935        let val = fast_log2_lookup(8192, &table);
936        assert!((val - 13.0).abs() < 0.001);
937    }
938}