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 is budgeted: CSR terms plus roughly 32 bytes/document of graph
10//! scratch, with lazily initialized direct-index term-degree arrays.
11
12#[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
41/// Per-partition left/right term degrees with direct compact-term indexing.
42///
43/// Recursive BP used to zero two `num_terms`-long vectors at every node. At
44/// 100k vocabulary terms and hundreds of thousands of fine partitions, that
45/// turns into a large amount of memory traffic unrelated to actual postings.
46/// A one-bit initialization map lets us retain array-speed lookups while only
47/// touching degree slots present in the current partition.
48struct 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        // SAFETY: the bit above is set only after writing this exact slot.
72        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        // SAFETY: an initialized bit is published only after the slot write;
83        // scoring reads degrees after construction, with no concurrent writes.
84        unsafe { *self.values[term].assume_init_ref() }
85    }
86}
87
88// ── Forward index (CSR) ──────────────────────────────────────────────────
89
90/// Forward index in CSR format: doc `d`'s terms are `terms[offsets[d]..offsets[d+1]]`.
91///
92/// Term IDs are remapped to compact range `0..num_terms` for flat-array degree tracking.
93pub(crate) struct ForwardIndex {
94    terms: Vec<u32>,
95    /// u64, not u32: a 58M-doc / ~85-dims-per-doc reorder pass carries ~4.9B
96    /// postings — u32 prefix sums wrapped and the CSR carving panicked
97    /// (prod 2026-07-14, "mid > len"). The old 8 GB memory budget masked it
98    /// by dropping dims below the u32 limit.
99    offsets: Vec<u64>,
100    pub num_terms: usize,
101    /// Maximum recursion depth at which both children may own a vocabulary-
102    /// sized degree array concurrently. Deeper partitions still use Rayon for
103    /// gain computation, but recurse serially to honor the memory budget.
104    parallel_bisect_depth: usize,
105    /// True when the configured memory limit forced graph signal to be
106    /// discarded. Callers must not report the resulting order as fully
107    /// converged: a later pass with a larger budget may still improve it.
108    budget_limited: bool,
109}
110
111/// Build CSR offsets (prefix sums) from per-entity counts. u64 output — the
112/// sum of counts legitimately exceeds u32::MAX on large reorder passes.
113fn 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    /// Total postings in the forward index.
140    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
150/// Build virtual→real and real→virtual vid maps from a BMP doc map.
151///
152/// A virtual slot is real iff its doc-map entry is not the `u32::MAX` padding
153/// sentinel. Realness must come from the doc map itself: block-copy merged
154/// segments carry each source's tail padding as *interior* padding, so
155/// `vid < num_real_docs` does NOT identify real docs there.
156///
157/// Returns `(virtual_to_real, real_to_virtual)` where `virtual_to_real[vid]`
158/// is the dense real index or `u32::MAX` for padding.
159pub(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
192/// One (source, block) unit of forward-index construction. Because
193/// [`build_vid_maps`] assigns real ids in ascending vid order, a block's real
194/// docs form the contiguous per-source range
195/// `real_start..real_start + real_len` — blocks can be processed in parallel
196/// with disjoint output slices.
197struct BlockJob {
198    src: u32,
199    block_id: u32,
200    /// Per-source real index of the block's first real doc.
201    real_start: u32,
202    /// Number of real (non-padding) docs in the block.
203    real_len: u32,
204}
205
206/// Enumerate jobs in (source, block) order — cumulative `real_len` tiles the
207/// global real-id space `0..total_docs` exactly.
208fn 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/// Build forward index from BmpIndex sources (single or multi-source).
237///
238/// Documents are identified by dense *real* indices assigned sequentially
239/// across sources: source 0 gets 0..n0, source 1 gets n0..n0+n1, etc., where
240/// each n is the source's real (non-padding) doc count derived from its doc
241/// map via [`build_vid_maps`]. Returns `(forward_index, per_source_real_doc_counts)`.
242///
243/// Filters dims with doc_freq outside `[min_doc_freq, max_doc_freq]`.
244/// If the estimated forward index memory exceeds `memory_budget_bytes`, the
245/// highest-frequency dims are dropped to stay within budget. This prevents OOM
246/// for huge segments at the cost of slightly reduced reorder quality.
247///
248/// Remaps term IDs to compact range for flat-array degree tracking.
249#[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
269/// Variant for reorder callers that already need the virtual/real maps during
270/// output encoding. Reusing them avoids a second full document-map scan and a
271/// duplicate real-to-virtual allocation on very large segments.
272pub(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    // Job list: one entry per (source, block). Real ids are assigned in
297    // ascending vid order (see build_vid_maps), so each block owns a
298    // contiguous real-id range — every phase below can process blocks in
299    // parallel, writing disjoint slices.
300    let jobs = build_block_jobs(bmps, vid_maps);
301
302    // Phase 1: count doc frequency in one dense atomic table. The previous
303    // Rayon fold built a vocabulary-sized hash map per worker before the
304    // budget check, multiplying peak memory by the CPU count.
305    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    // Retain the lowest-frequency candidates in a bounded heap while the
360    // frequency table is live. This makes candidate discovery itself obey the
361    // configured limit even for extremely large vocabularies.
362    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    // Memory budget: estimate forward index + bisection scratch.
398    // Includes jobs/slice descriptors, dense remap, all per-document scratch,
399    // and at least one exact TermDegrees allocation.
400    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        // Candidate metadata coexists with the dense remap until construction
412        // starts; omitting it let a huge rare-term vocabulary exceed the cap.
413        .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        // Sort by df ascending — keep discriminative low-df dims first,
418        // drop highest-df dims which contribute the most postings.
419        eligible.sort_by_key(|&(_, df)| df);
420
421        // Account for each retained term together with its postings. The old
422        // calculation charged the eight-byte degree slot for every candidate
423        // before deciding how many to retain; a large rare-term vocabulary
424        // could therefore make the target zero even when a useful subset fit.
425        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        // The caller emits an identity permutation when there is no graph
457        // signal. Avoid allocating per-document counts and u64 offsets only
458        // to discover that the terms array is empty, especially when the
459        // configured budget is below the fixed document scratch cost.
460        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    // Phase 2: count terms per doc (filtered) — per-block disjoint slices
486    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    // Phase 3: build CSR offsets (u64 — sums exceed u32::MAX at scale)
523    let offsets = build_csr_offsets(&counts);
524    let total = *offsets.last().unwrap() as usize;
525    drop(counts);
526
527    // Phase 4: fill terms (compact IDs) — each block writes the contiguous
528    // terms range covering its real docs; per-doc write cursors are local.
529    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        // local_slot is u8, so a block never holds more than 256 real docs
535        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
590/// Build a forward index over BLOCKS (one entity per block, its terms = the
591/// block's header dim list). Used by block-level reorder: BP over blocks is
592/// ~block_size× cheaper than over records and only needs to decide superblock
593/// assignment. Blocks are numbered globally across sources in source order.
594///
595/// Dims appearing in fewer than 2 blocks carry no clustering signal and are
596/// dropped; the memory budget applies as in the record-level builder.
597pub(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    // (source, block) pairs in global block order — the parallel unit.
613    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    // Phase 1: one bounded dense frequency table, shared by every worker.
620    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    // Phase 2+3: counts and CSR fill — one entity per block, so each block
756    // maps to a single count cell and a contiguous terms range.
757    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// ── Recursive Graph Bisection ────────────────────────────────────────────
818
819/// CPU/depth budget for a BP pass. BP is an anytime algorithm: stopping at
820/// any depth or deadline still yields a valid permutation, and because the
821/// output layout becomes the next pass's input order, repeated budgeted
822/// passes warm-start and deepen (top levels converge in ~0 swaps, the budget
823/// flows to deeper levels).
824#[derive(Clone, Copy, Debug, Default)]
825pub struct BpBudget {
826    /// Stop recursion at partitions of at most this many docs instead of
827    /// descending to block granularity. `None` = full depth. Capping at
828    /// superblock granularity (superblock_size × block_size docs) keeps most
829    /// of the superblock-pruning win at ~⅓ less depth.
830    pub min_partition_docs: Option<usize>,
831    /// Wall-clock cap for the whole BP computation. The pass ends cleanly at
832    /// the deadline with whatever depth it reached (`converged = false`).
833    /// Ignored on wasm (no monotonic clock).
834    pub time_budget: Option<std::time::Duration>,
835}
836
837impl BpBudget {
838    /// Unbudgeted: full depth, no deadline.
839    pub fn full() -> Self {
840        Self::default()
841    }
842}
843
844/// Recursive graph bisection. Returns `(perm, converged)` where
845/// `perm[new_pos] = old_index` and `converged` is false iff the wall-clock
846/// budget ended the pass before it finished (a depth cap alone is a chosen
847/// target, not an interruption — it reports converged).
848///
849/// `min_partition_size` should be the BMP block_size (64).
850/// `max_iters` controls convergence (20 is standard).
851///
852/// Term IDs in the forward index must be compact (0..num_terms) so we can
853/// use flat arrays for O(1) degree lookups instead of hash maps.
854pub(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
926/// Recursive bisection of a document slice.
927///
928/// Uses flat `Vec<u32>` degree arrays indexed by compact term_id for cache-friendly
929/// O(1) lookups (vs FxHashMap which has poor cache locality at scale).
930///
931/// Gain computation is parallelized via rayon for large partitions (n > 4096).
932/// Adaptive iteration count reduces work at top levels where coarse splits
933/// converge faster and dominate total runtime.
934struct 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    // Anytime cutoff: leave this subtree in its current (valid) order.
954    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    // Adaptive iteration count: large partitions converge faster with
973    // coarse splits, so fewer refinement passes suffice. The fine-grained
974    // clustering is handled by deeper recursion levels with full iterations.
975    let effective_iters = if n > 100_000 {
976        context.max_iters.min(12)
977    } else {
978        context.max_iters
979    };
980
981    // Compact term IDs permit direct indexing. Slots are initialized lazily so
982    // deep partitions do not zero the full vocabulary on every recursive node.
983    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    // Scratch buffers reused across iterations
993    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        // Anytime cutoff between refinement passes: keep the current split.
1000        #[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 gain for each document (approx_1 from Dhulipala et al.)
1010        // Parallelized for large partitions where per-doc work dominates.
1011        compute_gains(
1012            docs,
1013            context.fwd,
1014            mid,
1015            &degrees,
1016            context.log_table,
1017            &mut gains,
1018        );
1019
1020        // Partition: the `mid` LOWEST keys (strongest left affinity) go left
1021        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        // Apply partition, update degree arrays for swapped docs
1028        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        // Early termination: if < 0.5% of docs swapped, partition is stable
1066        if iter > 2 && swap_count < n / 200 {
1067            break;
1068        }
1069
1070        // Cooling: break early if gains are negligible
1071        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 scratch before recursion to free memory for sub-problems
1083    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        // Gain computation inside each node remains parallel, so serializing
1098        // recursion here bounds vocabulary-sized degree arrays without leaving
1099        // the Rayon pool idle.
1100        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/// Compute gains for all documents, parallelized via rayon for large partitions.
1111///
1112/// Each doc's gain is independent: iterate its terms, accumulate the log-gap
1113/// cost delta of moving it to the other side. Read-only access to degree arrays
1114/// makes this embarrassingly parallel.
1115#[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    // Single coherent key: HIGH = belongs in the RIGHT half.
1125    // Left docs get +approx_one(from=left, to=right) — a misplaced left doc
1126    // (terms concentrated right) scores high. Right docs get
1127    // -approx_one(from=right, to=left) — a misplaced right doc scores low.
1128    // This matches the reference two-sided formulation (compute_gains_left /
1129    // compute_gains_right with negation); ranking both halves by raw
1130    // "move gain" instead made both sides' misplaced docs rank identically,
1131    // so the partition step could never exchange them.
1132    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
1172// ── Helpers ──────────────────────────────────────────────────────────────
1173
1174/// Build precomputed log2 table for values 0..size.
1175fn build_log_table(size: usize) -> Vec<f32> {
1176    let mut table = vec![0.0f32; size];
1177    // log2(0) is undefined; use a large negative value
1178    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/// Fast log2 with precomputed table lookup.
1186#[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    /// Regression: CSR offsets were u32 and wrapped past 4.29B postings —
1218    /// a 58M-doc / ~85-dims-per-doc prod reorder pass (~4.9B postings)
1219    /// panicked with "mid > len" in the terms carving. The old 8 GB memory
1220    /// budget masked the overflow by dropping dims; raising the budget
1221    /// exposed it. Offsets must be u64.
1222    #[test]
1223    fn test_csr_offsets_do_not_wrap_past_u32() {
1224        let counts = [1_500_000_000u32; 3]; // 4.5B total > u32::MAX
1225        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    /// Build a simple forward index from (doc_id, terms) pairs.
1234    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        // 4 docs, min_partition_size=4 → no bisection, identity
1266        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        // All docs present
1270        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        // 8 docs in 2 clear clusters:
1278        // Cluster A (docs 0-3): share terms 0, 1
1279        // Cluster B (docs 4-7): share terms 2, 3
1280        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        // After bisection, docs from same cluster should be in same half
1297        let left: Vec<u32> = perm[..4].to_vec();
1298
1299        // Either all of cluster A is in left and B in right, or vice versa
1300        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        // 16 docs with mixed terms: terms range from 0..4 and 10..18
1313        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); // max term = 10 + 15/2 = 17, so need 18
1316        let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
1317
1318        assert_eq!(perm.len(), 16);
1319        // Must be a valid permutation
1320        let mut sorted = perm.clone();
1321        sorted.sort();
1322        let expected: Vec<u32> = (0..16).collect();
1323        assert_eq!(sorted, expected);
1324    }
1325
1326    /// Depth-capped BP: with min_partition_docs above the cluster size, only
1327    /// the top-level split happens — clusters still separate (coarse
1328    /// clustering), the permutation stays valid, and the pass converges
1329    /// (a depth cap is a chosen target, not an interruption).
1330    #[test]
1331    fn test_bp_depth_cap_separates_clusters_and_converges() {
1332        // Mostly-separated clusters with one misplaced doc per half — the
1333        // top-level swap pass must exchange docs 3 and 4.
1334        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        // Top-level split separates the clusters (docs {0,1,2,4} share terms
1362        // 0/1; docs {3,5,6,7} share terms 2/3)
1363        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    /// Zero wall-clock budget: the pass ends immediately, reports
1373    /// converged=false, and still emits a valid (identity) permutation.
1374    #[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        // Fallback for values beyond table
1412        let val = fast_log2_lookup(8192, &table);
1413        assert!((val - 13.0).abs() < 0.001);
1414    }
1415}