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// Radix selection scans the gain array four times and parallel degree updates
17// require private vocabulary arrays. Quickselect wins decisively below this
18// scale; above it, bounded memory and worker utilization dominate.
19const PARALLEL_BP_MIN_ENTITIES: usize = 1_048_576;
20const MIN_RELATIVE_OBJECTIVE_IMPROVEMENT: f64 = 1e-6;
21const MIN_OBJECTIVE_ITERATIONS: usize = 4;
22const OBJECTIVE_STALL_ITERATIONS: usize = 2;
23
24fn term_degree_bytes(num_terms: usize) -> usize {
25    num_terms
26        .saturating_mul(TERM_DEGREE_VALUE_BYTES)
27        .saturating_add(num_terms.div_ceil(64).saturating_mul(8))
28}
29
30fn parallel_bisect_depth(
31    memory_budget_bytes: usize,
32    non_degree_bytes: usize,
33    num_terms: usize,
34) -> usize {
35    let per_node = term_degree_bytes(num_terms).max(1);
36    let affordable_nodes = memory_budget_bytes
37        .saturating_sub(non_degree_bytes)
38        .checked_div(per_node)
39        .unwrap_or(0)
40        .max(1);
41    #[cfg(feature = "native")]
42    let worker_limit = rayon::current_num_threads().max(1);
43    #[cfg(not(feature = "native"))]
44    let worker_limit = 1usize;
45    affordable_nodes.min(worker_limit).ilog2() as usize
46}
47
48/// Per-partition left/right term degrees with direct compact-term indexing.
49///
50/// Recursive BP used to zero two `num_terms`-long vectors at every node. At
51/// 100k vocabulary terms and hundreds of thousands of fine partitions, that
52/// turns into a large amount of memory traffic unrelated to actual postings.
53/// A one-bit initialization map lets us retain array-speed lookups while only
54/// touching degree slots present in the current partition.
55struct TermDegrees {
56    values: Vec<std::mem::MaybeUninit<[u32; 2]>>,
57    initialized: Vec<u64>,
58}
59
60impl TermDegrees {
61    fn new(num_terms: usize) -> Self {
62        let mut values = Vec::with_capacity(num_terms);
63        values.resize_with(num_terms, std::mem::MaybeUninit::uninit);
64        Self {
65            values,
66            initialized: vec![0; num_terms.div_ceil(64)],
67        }
68    }
69
70    #[inline]
71    fn entry_mut(&mut self, term: usize) -> &mut [u32; 2] {
72        let word = term / 64;
73        let mask = 1u64 << (term % 64);
74        if self.initialized[word] & mask == 0 {
75            self.values[term].write([0, 0]);
76            self.initialized[word] |= mask;
77        }
78        // SAFETY: the bit above is set only after writing this exact slot.
79        unsafe { self.values[term].assume_init_mut() }
80    }
81
82    #[inline]
83    fn get(&self, term: usize) -> [u32; 2] {
84        let word = term / 64;
85        let mask = 1u64 << (term % 64);
86        if self.initialized[word] & mask == 0 {
87            return [0, 0];
88        }
89        // SAFETY: an initialized bit is published only after the slot write;
90        // scoring reads degrees after construction, with no concurrent writes.
91        unsafe { *self.values[term].assume_init_ref() }
92    }
93
94    fn merge_from(&mut self, other: &Self) {
95        for (word_idx, &initialized) in other.initialized.iter().enumerate() {
96            let mut pending = initialized;
97            while pending != 0 {
98                let bit = pending.trailing_zeros() as usize;
99                let term = word_idx * 64 + bit;
100                // SAFETY: `pending` is derived from the initialized bitmap.
101                let [left, right] = unsafe { *other.values[term].assume_init_ref() };
102                let entry = self.entry_mut(term);
103                entry[0] += left;
104                entry[1] += right;
105                pending &= pending - 1;
106            }
107        }
108    }
109
110    /// Exact bisection objective optimized by the BP gain approximation.
111    ///
112    /// This returns the negative assignment-dependent BiMLogA bisection cost
113    /// from Dhulipala et al., so a larger value means lower cost. Keeping the
114    /// partition-size term matters for odd-sized partitions, whose halves
115    /// differ by one entity.
116    fn bisection_objective(&self, left_size: usize, right_size: usize, log_table: &[f32]) -> f64 {
117        let mut objective = 0.0f64;
118        let side_log = [
119            fast_log2_lookup(left_size, log_table) as f64,
120            fast_log2_lookup(right_size, log_table) as f64,
121        ];
122        for (word_idx, &initialized) in self.initialized.iter().enumerate() {
123            let mut pending = initialized;
124            while pending != 0 {
125                let bit = pending.trailing_zeros() as usize;
126                let term = word_idx * 64 + bit;
127                // SAFETY: `pending` is derived from the initialized bitmap.
128                let [left, right] = unsafe { *self.values[term].assume_init_ref() };
129                for (side, count) in [left, right].into_iter().enumerate() {
130                    if count > 0 {
131                        objective += count as f64
132                            * (fast_log2_lookup(count as usize + 1, log_table) as f64
133                                - side_log[side]);
134                    }
135                }
136                pending &= pending - 1;
137            }
138        }
139        objective
140    }
141}
142
143/// Per-term change to the left degree. The right change is its negation
144/// because every moved document leaves one side and enters the other.
145///
146/// This uses the same lazy dense representation as [`TermDegrees`]. Parallel
147/// partition workers therefore pay one vocabulary-sized allocation each, but
148/// the number of workers is carved from the existing degree-array memory
149/// allowance rather than multiplying the configured BP budget.
150struct TermDeltas {
151    values: Vec<std::mem::MaybeUninit<i64>>,
152    initialized: Vec<u64>,
153}
154
155impl TermDeltas {
156    fn new(num_terms: usize) -> Self {
157        let mut values = Vec::with_capacity(num_terms);
158        values.resize_with(num_terms, std::mem::MaybeUninit::uninit);
159        Self {
160            values,
161            initialized: vec![0; num_terms.div_ceil(64)],
162        }
163    }
164
165    #[inline]
166    fn entry_mut(&mut self, term: usize) -> &mut i64 {
167        let word = term / 64;
168        let mask = 1u64 << (term % 64);
169        if self.initialized[word] & mask == 0 {
170            self.values[term].write(0);
171            self.initialized[word] |= mask;
172        }
173        // SAFETY: the bit above is set only after writing this exact slot.
174        unsafe { self.values[term].assume_init_mut() }
175    }
176
177    fn merge_from(&mut self, other: &Self) {
178        for (word_idx, &initialized) in other.initialized.iter().enumerate() {
179            let mut pending = initialized;
180            while pending != 0 {
181                let bit = pending.trailing_zeros() as usize;
182                let term = word_idx * 64 + bit;
183                // SAFETY: `pending` is derived from the initialized bitmap.
184                let delta = unsafe { *other.values[term].assume_init_ref() };
185                *self.entry_mut(term) += delta;
186                pending &= pending - 1;
187            }
188        }
189    }
190
191    fn apply_to(&self, degrees: &mut TermDegrees) {
192        for (word_idx, &initialized) in self.initialized.iter().enumerate() {
193            let mut pending = initialized;
194            while pending != 0 {
195                let bit = pending.trailing_zeros() as usize;
196                let term = word_idx * 64 + bit;
197                // SAFETY: `pending` is derived from the initialized bitmap.
198                let delta = unsafe { *self.values[term].assume_init_ref() };
199                let degree = degrees.entry_mut(term);
200                let new_left = degree[0] as i64 + delta;
201                let new_right = degree[1] as i64 - delta;
202                debug_assert!(new_left >= 0 && new_right >= 0);
203                debug_assert!(new_left <= u32::MAX as i64 && new_right <= u32::MAX as i64);
204                degree[0] = new_left as u32;
205                degree[1] = new_right as u32;
206                pending &= pending - 1;
207            }
208        }
209    }
210}
211
212// ── Forward index (CSR) ──────────────────────────────────────────────────
213
214/// Forward index in CSR format: doc `d`'s terms are `terms[offsets[d]..offsets[d+1]]`.
215///
216/// Term IDs are remapped to compact range `0..num_terms` for flat-array degree tracking.
217pub(crate) struct ForwardIndex {
218    terms: Vec<u32>,
219    /// u64, not u32: a 58M-doc / ~85-dims-per-doc reorder pass carries ~4.9B
220    /// postings — u32 prefix sums wrapped and the CSR carving panicked
221    /// (prod 2026-07-14, "mid > len"). The old 8 GB memory budget masked it
222    /// by dropping dims below the u32 limit.
223    offsets: Vec<u64>,
224    pub num_terms: usize,
225    /// Maximum recursion depth at which both children may own a vocabulary-
226    /// sized degree array concurrently. Deeper partitions still use Rayon for
227    /// gain computation, but recurse serially to honor the memory budget.
228    parallel_bisect_depth: usize,
229    /// True when the configured memory limit forced graph signal to be
230    /// discarded. Callers must not report the resulting order as fully
231    /// converged: a later pass with a larger budget may still improve it.
232    budget_limited: bool,
233}
234
235/// Build CSR offsets (prefix sums) from per-entity counts. u64 output — the
236/// sum of counts legitimately exceeds u32::MAX on large reorder passes.
237fn build_csr_offsets(counts: &[u32]) -> Vec<u64> {
238    let mut offsets = Vec::with_capacity(counts.len() + 1);
239    offsets.push(0u64);
240    for &c in counts {
241        offsets.push(offsets.last().unwrap() + c as u64);
242    }
243    offsets
244}
245
246impl ForwardIndex {
247    #[inline]
248    pub fn num_docs(&self) -> usize {
249        if self.offsets.is_empty() {
250            0
251        } else {
252            self.offsets.len() - 1
253        }
254    }
255
256    #[inline]
257    fn doc_terms(&self, doc: usize) -> &[u32] {
258        let start = self.offsets[doc] as usize;
259        let end = self.offsets[doc + 1] as usize;
260        &self.terms[start..end]
261    }
262
263    /// Total postings in the forward index.
264    pub fn total_postings(&self) -> u64 {
265        self.offsets.last().copied().unwrap_or(0)
266    }
267
268    #[inline]
269    pub fn budget_limited(&self) -> bool {
270        self.budget_limited
271    }
272}
273
274/// Build virtual→real and real→virtual vid maps from a BMP doc map.
275///
276/// A virtual slot is real iff its doc-map entry is not the `u32::MAX` padding
277/// sentinel. Realness must come from the doc map itself: block-copy merged
278/// segments carry each source's tail padding as *interior* padding, so
279/// `vid < num_real_docs` does NOT identify real docs there.
280///
281/// Returns `(virtual_to_real, real_to_virtual)` where `virtual_to_real[vid]`
282/// is the dense real index or `u32::MAX` for padding.
283pub(crate) fn build_vid_maps(
284    bmp: &crate::segment::reader::bmp::BmpIndex,
285) -> crate::Result<(Vec<u32>, Vec<u32>)> {
286    let ids = bmp.doc_map_ids_slice();
287    let num_virtual = bmp.num_virtual_docs as usize;
288    let expected_real = bmp.num_real_docs() as usize;
289    let mut virtual_to_real = vec![u32::MAX; num_virtual];
290    let mut real_to_virtual = Vec::with_capacity(expected_real);
291    for (vid, (slot, chunk)) in virtual_to_real
292        .iter_mut()
293        .zip(ids.as_chunks::<4>().0)
294        .enumerate()
295    {
296        let doc_id = u32::from_le_bytes(*chunk);
297        if doc_id != u32::MAX {
298            if real_to_virtual.len() == expected_real {
299                return Err(crate::Error::Corruption(format!(
300                    "BMP document map contains more than the footer's {expected_real} real slots"
301                )));
302            }
303            *slot = real_to_virtual.len() as u32;
304            real_to_virtual.push(vid as u32);
305        }
306    }
307    if real_to_virtual.len() != expected_real {
308        return Err(crate::Error::Corruption(format!(
309            "BMP document map has {} real slots but footer declares {expected_real}",
310            real_to_virtual.len(),
311        )));
312    }
313    Ok((virtual_to_real, real_to_virtual))
314}
315
316/// One (source, block) unit of forward-index construction. Because
317/// [`build_vid_maps`] assigns real ids in ascending vid order, a block's real
318/// docs form the contiguous per-source range
319/// `real_start..real_start + real_len` — blocks can be processed in parallel
320/// with disjoint output slices.
321struct BlockJob {
322    src: u32,
323    block_id: u32,
324    /// Per-source real index of the block's first real doc.
325    real_start: u32,
326    /// Number of real (non-padding) docs in the block.
327    real_len: u32,
328}
329
330/// Enumerate jobs in (source, block) order — cumulative `real_len` tiles the
331/// global real-id space `0..total_docs` exactly.
332fn build_block_jobs(
333    bmps: &[&crate::segment::reader::bmp::BmpIndex],
334    vid_maps: &[(Vec<u32>, Vec<u32>)],
335) -> Vec<BlockJob> {
336    let total_blocks: usize = bmps.iter().map(|b| b.num_blocks as usize).sum();
337    let mut jobs = Vec::with_capacity(total_blocks);
338    for (src, (bmp, (v2r, _))) in bmps.iter().zip(vid_maps).enumerate() {
339        let block_size = bmp.bmp_block_size as usize;
340        let mut real_cursor = 0u32;
341        for block_id in 0..bmp.num_blocks as usize {
342            let vid_start = block_id * block_size;
343            let vid_end = ((block_id + 1) * block_size).min(v2r.len());
344            let real_len = v2r[vid_start..vid_end]
345                .iter()
346                .filter(|&&r| r != u32::MAX)
347                .count() as u32;
348            jobs.push(BlockJob {
349                src: src as u32,
350                block_id: block_id as u32,
351                real_start: real_cursor,
352                real_len,
353            });
354            real_cursor += real_len;
355        }
356    }
357    jobs
358}
359
360/// Build forward index from BmpIndex sources (single or multi-source).
361///
362/// Documents are identified by dense *real* indices assigned sequentially
363/// across sources: source 0 gets 0..n0, source 1 gets n0..n0+n1, etc., where
364/// each n is the source's real (non-padding) doc count derived from its doc
365/// map via [`build_vid_maps`]. Returns `(forward_index, per_source_real_doc_counts)`.
366///
367/// Filters dims with doc_freq outside `[min_doc_freq, max_doc_freq]`.
368/// If the estimated forward index memory exceeds `memory_budget_bytes`, the
369/// highest-frequency dims are dropped to stay within budget. This prevents OOM
370/// for huge segments at the cost of slightly reduced reorder quality.
371///
372/// Remaps term IDs to compact range for flat-array degree tracking.
373#[cfg(test)]
374pub(crate) fn build_forward_index_from_bmps(
375    bmps: &[&crate::segment::reader::bmp::BmpIndex],
376    min_doc_freq: usize,
377    max_doc_freq: usize,
378    memory_budget_bytes: usize,
379) -> crate::Result<(ForwardIndex, Vec<usize>)> {
380    let vid_maps: Vec<(Vec<u32>, Vec<u32>)> = bmps
381        .iter()
382        .map(|bmp| build_vid_maps(bmp))
383        .collect::<crate::Result<_>>()?;
384    Ok(build_forward_index_from_bmps_with_maps(
385        bmps,
386        &vid_maps,
387        min_doc_freq,
388        max_doc_freq,
389        memory_budget_bytes,
390    ))
391}
392
393/// Variant for reorder callers that already need the virtual/real maps during
394/// output encoding. Reusing them avoids a second full document-map scan and a
395/// duplicate real-to-virtual allocation on very large segments.
396pub(crate) fn build_forward_index_from_bmps_with_maps(
397    bmps: &[&crate::segment::reader::bmp::BmpIndex],
398    vid_maps: &[(Vec<u32>, Vec<u32>)],
399    min_doc_freq: usize,
400    max_doc_freq: usize,
401    memory_budget_bytes: usize,
402) -> (ForwardIndex, Vec<usize>) {
403    debug_assert_eq!(bmps.len(), vid_maps.len());
404    let source_doc_counts: Vec<usize> = vid_maps.iter().map(|(_, r2v)| r2v.len()).collect();
405    let total_docs: usize = source_doc_counts.iter().sum();
406
407    if total_docs == 0 {
408        return (
409            ForwardIndex {
410                terms: Vec::new(),
411                offsets: Vec::new(),
412                num_terms: 0,
413                parallel_bisect_depth: 0,
414                budget_limited: false,
415            },
416            source_doc_counts,
417        );
418    }
419
420    // Job list: one entry per (source, block). Real ids are assigned in
421    // ascending vid order (see build_vid_maps), so each block owns a
422    // contiguous real-id range — every phase below can process blocks in
423    // parallel, writing disjoint slices.
424    let jobs = build_block_jobs(bmps, vid_maps);
425
426    // Phase 1: count doc frequency in one dense atomic table. The previous
427    // Rayon fold built a vocabulary-sized hash map per worker before the
428    // budget check, multiplying peak memory by the CPU count.
429    let max_dims = bmps
430        .iter()
431        .map(|bmp| bmp.dims() as usize)
432        .max()
433        .unwrap_or(0);
434    let jobs_bytes = jobs
435        .len()
436        .saturating_mul(std::mem::size_of::<BlockJob>().saturating_add(40));
437    let frequency_bytes =
438        max_dims.saturating_mul(std::mem::size_of::<std::sync::atomic::AtomicU32>());
439    if frequency_bytes > memory_budget_bytes.saturating_sub(jobs_bytes) {
440        log::warn!(
441            "[reorder] memory budget {} cannot hold the {} dimension-frequency table; using identity order",
442            crate::format_bytes(memory_budget_bytes as u64),
443            crate::format_bytes(frequency_bytes as u64),
444        );
445        return (
446            ForwardIndex {
447                terms: Vec::new(),
448                offsets: Vec::new(),
449                num_terms: 0,
450                parallel_bisect_depth: 0,
451                budget_limited: true,
452            },
453            source_doc_counts,
454        );
455    }
456    let dim_df: Vec<std::sync::atomic::AtomicU32> = (0..max_dims)
457        .map(|_| std::sync::atomic::AtomicU32::new(0))
458        .collect();
459    let count_block_df = |job: &BlockJob| {
460        let bmp = bmps[job.src as usize];
461        let (v2r, _) = &vid_maps[job.src as usize];
462        let block_size = bmp.bmp_block_size as usize;
463        for (dim_id, _, postings) in bmp.iter_block_terms(job.block_id) {
464            let mut n = 0usize;
465            for p in postings {
466                let vid = job.block_id as usize * block_size + p.local_slot as usize;
467                if v2r[vid] != u32::MAX && p.impact > 0 {
468                    n += 1;
469                }
470            }
471            if n > 0
472                && let Some(count) = dim_df.get(dim_id as usize)
473            {
474                count.fetch_add(n as u32, std::sync::atomic::Ordering::Relaxed);
475            }
476        }
477    };
478    #[cfg(feature = "native")]
479    jobs.par_iter().for_each(count_block_df);
480    #[cfg(not(feature = "native"))]
481    jobs.iter().for_each(count_block_df);
482
483    // Retain the lowest-frequency candidates in a bounded heap while the
484    // frequency table is live. This makes candidate discovery itself obey the
485    // configured limit even for extremely large vocabularies.
486    let eligible_candidate_count = dim_df
487        .iter()
488        .filter(|df| {
489            let df = df.load(std::sync::atomic::Ordering::Relaxed) as usize;
490            df >= min_doc_freq && df <= max_doc_freq
491        })
492        .count();
493    let candidate_capacity = memory_budget_bytes
494        .saturating_sub(jobs_bytes)
495        .saturating_sub(frequency_bytes)
496        .checked_div(std::mem::size_of::<(usize, u32)>())
497        .unwrap_or(0)
498        .min(eligible_candidate_count);
499    let mut candidate_heap = std::collections::BinaryHeap::with_capacity(candidate_capacity);
500    for (dim_id, df) in dim_df.iter().enumerate() {
501        let df = df.load(std::sync::atomic::Ordering::Relaxed) as usize;
502        if df < min_doc_freq || df > max_doc_freq {
503            continue;
504        }
505        let candidate = (df, dim_id as u32);
506        if candidate_heap.len() < candidate_capacity {
507            candidate_heap.push(candidate);
508        } else if candidate_capacity > 0 && candidate < *candidate_heap.peek().unwrap() {
509            candidate_heap.pop();
510            candidate_heap.push(candidate);
511        }
512    }
513    drop(dim_df);
514    let mut eligible: Vec<(u32, usize)> = candidate_heap
515        .into_vec()
516        .into_iter()
517        .map(|(df, dim_id)| (dim_id, df))
518        .collect();
519    let mut budget_limited = eligible.len() < eligible_candidate_count;
520
521    // Memory budget: estimate forward index + bisection scratch.
522    // Includes jobs/slice descriptors, dense remap, all per-document scratch,
523    // and at least one exact TermDegrees allocation.
524    let total_postings_est = eligible
525        .iter()
526        .fold(0usize, |total, (_, df)| total.saturating_add(*df));
527    let entity_scratch_bytes = total_docs.saturating_mul(32);
528    let remap_bytes = max_dims.saturating_mul(4);
529    let fixed_bytes = entity_scratch_bytes
530        .saturating_add(remap_bytes)
531        .saturating_add(jobs_bytes);
532    let estimated_bytes = total_postings_est
533        .saturating_mul(4)
534        .saturating_add(fixed_bytes)
535        // Candidate metadata coexists with the dense remap until construction
536        // starts; omitting it let a huge rare-term vocabulary exceed the cap.
537        .saturating_add(eligible.len().saturating_mul(CANDIDATE_ENTRY_BYTES))
538        .saturating_add(term_degree_bytes(eligible.len()));
539
540    if estimated_bytes > memory_budget_bytes && !eligible.is_empty() {
541        // Sort by df ascending — keep discriminative low-df dims first,
542        // drop highest-df dims which contribute the most postings.
543        eligible.sort_by_key(|&(_, df)| df);
544
545        // Account for each retained term together with its postings. The old
546        // calculation charged the eight-byte degree slot for every candidate
547        // before deciding how many to retain; a large rare-term vocabulary
548        // could therefore make the target zero even when a useful subset fit.
549        let mut used_bytes = fixed_bytes;
550        let mut cum = 0usize;
551        let mut keep_count = 0;
552        for &(_, df) in &eligible {
553            let term_bytes = df
554                .saturating_mul(4)
555                .saturating_add(TERM_DEGREE_VALUE_BYTES + 1)
556                .saturating_add(CANDIDATE_ENTRY_BYTES);
557            if term_bytes > memory_budget_bytes.saturating_sub(used_bytes) {
558                break;
559            }
560            used_bytes = used_bytes.saturating_add(term_bytes);
561            cum = cum.saturating_add(df);
562            keep_count += 1;
563        }
564
565        let dropped = eligible.len() - keep_count;
566        eligible.truncate(keep_count);
567        budget_limited |= dropped > 0;
568
569        log::warn!(
570            "[reorder] memory budget {}: estimated {}, dropped {} highest-df dims, keeping {} ({} postings)",
571            crate::format_bytes(memory_budget_bytes as u64),
572            crate::format_bytes(estimated_bytes as u64),
573            dropped,
574            keep_count,
575            cum,
576        );
577    }
578
579    if eligible.is_empty() {
580        // The caller emits an identity permutation when there is no graph
581        // signal. Avoid allocating per-document counts and u64 offsets only
582        // to discover that the terms array is empty, especially when the
583        // configured budget is below the fixed document scratch cost.
584        return (
585            ForwardIndex {
586                terms: Vec::new(),
587                offsets: Vec::new(),
588                num_terms: 0,
589                parallel_bisect_depth: 0,
590                budget_limited,
591            },
592            source_doc_counts,
593        );
594    }
595
596    let mut term_remap = vec![u32::MAX; max_dims];
597    for (compact_id, &(dim_id, _)) in eligible.iter().enumerate() {
598        term_remap[dim_id as usize] = compact_id as u32;
599    }
600    let num_active_terms = eligible.len();
601    let retained_postings = eligible
602        .iter()
603        .fold(0usize, |total, (_, df)| total.saturating_add(*df));
604    let non_degree_bytes = fixed_bytes.saturating_add(retained_postings.saturating_mul(4));
605    let parallel_bisect_depth =
606        parallel_bisect_depth(memory_budget_bytes, non_degree_bytes, num_active_terms);
607    drop(eligible);
608
609    // Phase 2: count terms per doc (filtered) — per-block disjoint slices
610    let mut counts = vec![0u32; total_docs];
611    let fill_block_counts = |job: &BlockJob, out: &mut [u32]| {
612        let bmp = bmps[job.src as usize];
613        let (v2r, _) = &vid_maps[job.src as usize];
614        let block_size = bmp.bmp_block_size as usize;
615        for (dim_id, _, postings) in bmp.iter_block_terms(job.block_id) {
616            if term_remap.get(dim_id as usize).copied().unwrap_or(u32::MAX) == u32::MAX {
617                continue;
618            }
619            for p in postings {
620                let vid = job.block_id as usize * block_size + p.local_slot as usize;
621                let real = v2r[vid];
622                if real != u32::MAX && p.impact > 0 {
623                    out[(real - job.real_start) as usize] += 1;
624                }
625            }
626        }
627    };
628    {
629        let mut slices: Vec<(&BlockJob, &mut [u32])> = Vec::with_capacity(jobs.len());
630        let mut rest: &mut [u32] = &mut counts;
631        for job in &jobs {
632            let (head, tail) = rest.split_at_mut(job.real_len as usize);
633            slices.push((job, head));
634            rest = tail;
635        }
636        #[cfg(feature = "native")]
637        slices
638            .into_par_iter()
639            .for_each(|(job, out)| fill_block_counts(job, out));
640        #[cfg(not(feature = "native"))]
641        for (job, out) in slices {
642            fill_block_counts(job, out);
643        }
644    }
645
646    // Phase 3: build CSR offsets (u64 — sums exceed u32::MAX at scale)
647    let offsets = build_csr_offsets(&counts);
648    let total = *offsets.last().unwrap() as usize;
649    drop(counts);
650
651    // Phase 4: fill terms (compact IDs) — each block writes the contiguous
652    // terms range covering its real docs; per-doc write cursors are local.
653    let mut terms = vec![0u32; total];
654    let fill_block_terms = |job: &BlockJob, global_real_start: usize, out: &mut [u32]| {
655        let bmp = bmps[job.src as usize];
656        let (v2r, _) = &vid_maps[job.src as usize];
657        let block_size = bmp.bmp_block_size as usize;
658        // local_slot is u8, so a block never holds more than 256 real docs
659        assert!(job.real_len as usize <= 256, "BMP block exceeds 256 docs");
660        let mut cursor = [0u32; 256];
661        let base = offsets[global_real_start] as usize;
662        for (dim_id, _, postings) in bmp.iter_block_terms(job.block_id) {
663            let compact = term_remap.get(dim_id as usize).copied().unwrap_or(u32::MAX);
664            if compact == u32::MAX {
665                continue;
666            }
667            for p in postings {
668                let vid = job.block_id as usize * block_size + p.local_slot as usize;
669                let real = v2r[vid];
670                if real != u32::MAX && p.impact > 0 {
671                    let local = (real - job.real_start) as usize;
672                    let pos =
673                        offsets[global_real_start + local] as usize - base + cursor[local] as usize;
674                    out[pos] = compact;
675                    cursor[local] += 1;
676                }
677            }
678        }
679    };
680    {
681        let mut slices: Vec<(&BlockJob, usize, &mut [u32])> = Vec::with_capacity(jobs.len());
682        let mut rest: &mut [u32] = &mut terms;
683        let mut global_real = 0usize;
684        for job in &jobs {
685            let len =
686                (offsets[global_real + job.real_len as usize] - offsets[global_real]) as usize;
687            let (head, tail) = rest.split_at_mut(len);
688            slices.push((job, global_real, head));
689            rest = tail;
690            global_real += job.real_len as usize;
691        }
692        #[cfg(feature = "native")]
693        slices
694            .into_par_iter()
695            .for_each(|(job, g, out)| fill_block_terms(job, g, out));
696        #[cfg(not(feature = "native"))]
697        for (job, g, out) in slices {
698            fill_block_terms(job, g, out);
699        }
700    }
701
702    (
703        ForwardIndex {
704            terms,
705            offsets,
706            num_terms: num_active_terms,
707            parallel_bisect_depth,
708            budget_limited,
709        },
710        source_doc_counts,
711    )
712}
713
714/// Build a forward index over BLOCKS (one entity per block, its terms = the
715/// block's header dim list). Used by block-level reorder: BP over blocks is
716/// ~block_size× cheaper than over records and only needs to decide superblock
717/// assignment. Blocks are numbered globally across sources in source order.
718///
719/// Dims appearing in fewer than 2 blocks carry no clustering signal and are
720/// dropped; the memory budget applies as in the record-level builder.
721pub(crate) fn build_forward_index_from_blocks(
722    bmps: &[&crate::segment::reader::bmp::BmpIndex],
723    memory_budget_bytes: usize,
724) -> ForwardIndex {
725    let total_blocks: usize = bmps.iter().map(|b| b.num_blocks as usize).sum();
726    if total_blocks == 0 {
727        return ForwardIndex {
728            terms: Vec::new(),
729            offsets: Vec::new(),
730            num_terms: 0,
731            parallel_bisect_depth: 0,
732            budget_limited: false,
733        };
734    }
735
736    // (source, block) pairs in global block order — the parallel unit.
737    let blocks: Vec<(u32, u32)> = bmps
738        .iter()
739        .enumerate()
740        .flat_map(|(src, bmp)| (0..bmp.num_blocks).map(move |b| (src as u32, b)))
741        .collect();
742
743    // Phase 1: one bounded dense frequency table, shared by every worker.
744    let max_dims = bmps
745        .iter()
746        .map(|bmp| bmp.dims() as usize)
747        .max()
748        .unwrap_or(0);
749    let blocks_bytes = blocks
750        .len()
751        .saturating_mul(std::mem::size_of::<(u32, u32)>().saturating_add(32));
752    let frequency_bytes =
753        max_dims.saturating_mul(std::mem::size_of::<std::sync::atomic::AtomicU32>());
754    if frequency_bytes > memory_budget_bytes.saturating_sub(blocks_bytes) {
755        log::warn!(
756            "[reorder] block-level frequency table exceeds memory budget; using identity order"
757        );
758        return ForwardIndex {
759            terms: Vec::new(),
760            offsets: Vec::new(),
761            num_terms: 0,
762            parallel_bisect_depth: 0,
763            budget_limited: true,
764        };
765    }
766    let dim_bf: Vec<std::sync::atomic::AtomicU32> = (0..max_dims)
767        .map(|_| std::sync::atomic::AtomicU32::new(0))
768        .collect();
769    let count_block_bf = |&(src, block_id): &(u32, u32)| {
770        for (dim_id, _, _) in bmps[src as usize].iter_block_terms(block_id) {
771            if let Some(count) = dim_bf.get(dim_id as usize) {
772                count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
773            }
774        }
775    };
776    #[cfg(feature = "native")]
777    blocks.par_iter().for_each(count_block_bf);
778    #[cfg(not(feature = "native"))]
779    blocks.iter().for_each(count_block_bf);
780
781    let max_bf = (total_blocks as f64 * 0.9) as usize;
782    let eligible_candidate_count = dim_bf
783        .iter()
784        .filter(|bf| {
785            let bf = bf.load(std::sync::atomic::Ordering::Relaxed) as usize;
786            bf >= 2 && bf <= max_bf.max(2)
787        })
788        .count();
789    let candidate_capacity = memory_budget_bytes
790        .saturating_sub(blocks_bytes)
791        .saturating_sub(frequency_bytes)
792        .checked_div(std::mem::size_of::<(usize, u32)>())
793        .unwrap_or(0)
794        .min(eligible_candidate_count);
795    let mut candidate_heap = std::collections::BinaryHeap::with_capacity(candidate_capacity);
796    for (dim_id, bf) in dim_bf.iter().enumerate() {
797        let bf = bf.load(std::sync::atomic::Ordering::Relaxed) as usize;
798        if bf < 2 || bf > max_bf.max(2) {
799            continue;
800        }
801        let candidate = (bf, dim_id as u32);
802        if candidate_heap.len() < candidate_capacity {
803            candidate_heap.push(candidate);
804        } else if candidate_capacity > 0 && candidate < *candidate_heap.peek().unwrap() {
805            candidate_heap.pop();
806            candidate_heap.push(candidate);
807        }
808    }
809    drop(dim_bf);
810    let mut eligible: Vec<(u32, usize)> = candidate_heap
811        .into_vec()
812        .into_iter()
813        .map(|(bf, dim_id)| (dim_id, bf))
814        .collect();
815    let mut budget_limited = eligible.len() < eligible_candidate_count;
816
817    let total_postings_est = eligible
818        .iter()
819        .fold(0usize, |total, (_, bf)| total.saturating_add(*bf));
820    let entity_scratch_bytes = total_blocks.saturating_mul(32);
821    let remap_bytes = max_dims.saturating_mul(4);
822    let fixed_bytes = entity_scratch_bytes
823        .saturating_add(remap_bytes)
824        .saturating_add(blocks_bytes);
825    let estimated_bytes = total_postings_est
826        .saturating_mul(4)
827        .saturating_add(fixed_bytes)
828        .saturating_add(eligible.len().saturating_mul(CANDIDATE_ENTRY_BYTES))
829        .saturating_add(term_degree_bytes(eligible.len()));
830    if estimated_bytes > memory_budget_bytes && !eligible.is_empty() {
831        eligible.sort_by_key(|&(_, bf)| bf);
832        let mut used_bytes = fixed_bytes;
833        let mut cum = 0usize;
834        let mut keep = 0;
835        for &(_, bf) in &eligible {
836            let term_bytes = bf
837                .saturating_mul(4)
838                .saturating_add(TERM_DEGREE_VALUE_BYTES + 1)
839                .saturating_add(CANDIDATE_ENTRY_BYTES);
840            if term_bytes > memory_budget_bytes.saturating_sub(used_bytes) {
841                break;
842            }
843            used_bytes = used_bytes.saturating_add(term_bytes);
844            cum = cum.saturating_add(bf);
845            keep += 1;
846        }
847        let dropped = eligible.len() - keep;
848        budget_limited |= dropped > 0;
849        log::warn!(
850            "[reorder] block-level fwd index over budget — dropped {} highest-bf dims",
851            dropped,
852        );
853        eligible.truncate(keep);
854    }
855
856    if eligible.is_empty() {
857        return ForwardIndex {
858            terms: Vec::new(),
859            offsets: Vec::new(),
860            num_terms: 0,
861            parallel_bisect_depth: 0,
862            budget_limited,
863        };
864    }
865
866    let mut term_remap = vec![u32::MAX; max_dims];
867    for (compact, &(dim_id, _)) in eligible.iter().enumerate() {
868        term_remap[dim_id as usize] = compact as u32;
869    }
870    let num_terms = eligible.len();
871    let retained_postings = eligible
872        .iter()
873        .fold(0usize, |total, (_, bf)| total.saturating_add(*bf));
874    let non_degree_bytes = fixed_bytes.saturating_add(retained_postings.saturating_mul(4));
875    let parallel_bisect_depth =
876        parallel_bisect_depth(memory_budget_bytes, non_degree_bytes, num_terms);
877    drop(eligible);
878
879    // Phase 2+3: counts and CSR fill — one entity per block, so each block
880    // maps to a single count cell and a contiguous terms range.
881    let count_remapped = |&(src, block_id): &(u32, u32)| -> u32 {
882        bmps[src as usize]
883            .iter_block_terms(block_id)
884            .filter(|(dim_id, _, _)| {
885                term_remap
886                    .get(*dim_id as usize)
887                    .copied()
888                    .unwrap_or(u32::MAX)
889                    != u32::MAX
890            })
891            .count() as u32
892    };
893    #[cfg(feature = "native")]
894    let counts: Vec<u32> = blocks.par_iter().map(count_remapped).collect();
895    #[cfg(not(feature = "native"))]
896    let counts: Vec<u32> = blocks.iter().map(count_remapped).collect();
897
898    let offsets = build_csr_offsets(&counts);
899    let total = *offsets.last().unwrap() as usize;
900    drop(counts);
901
902    let mut terms = vec![0u32; total];
903    let fill_block = |&(src, block_id): &(u32, u32), out: &mut [u32]| {
904        let mut n = 0usize;
905        for (dim_id, _, _) in bmps[src as usize].iter_block_terms(block_id) {
906            let compact = term_remap.get(dim_id as usize).copied().unwrap_or(u32::MAX);
907            if compact != u32::MAX {
908                out[n] = compact;
909                n += 1;
910            }
911        }
912    };
913    {
914        let mut slices: Vec<(&(u32, u32), &mut [u32])> = Vec::with_capacity(blocks.len());
915        let mut rest: &mut [u32] = &mut terms;
916        for (gb, b) in blocks.iter().enumerate() {
917            let len = (offsets[gb + 1] - offsets[gb]) as usize;
918            let (head, tail) = rest.split_at_mut(len);
919            slices.push((b, head));
920            rest = tail;
921        }
922        #[cfg(feature = "native")]
923        slices
924            .into_par_iter()
925            .for_each(|(b, out)| fill_block(b, out));
926        #[cfg(not(feature = "native"))]
927        for (b, out) in slices {
928            fill_block(b, out);
929        }
930    }
931
932    ForwardIndex {
933        terms,
934        offsets,
935        num_terms,
936        parallel_bisect_depth,
937        budget_limited,
938    }
939}
940
941// ── Recursive Graph Bisection ────────────────────────────────────────────
942
943/// CPU/depth budget for a BP pass. BP is an anytime algorithm: stopping at
944/// any depth or deadline still yields a valid permutation, and because the
945/// output layout becomes the next pass's input order, repeated budgeted
946/// passes warm-start and deepen (top levels converge in ~0 swaps, the budget
947/// flows to deeper levels).
948#[derive(Clone, Copy, Debug, Default)]
949pub struct BpBudget {
950    /// Stop recursion at partitions of at most this many docs instead of
951    /// descending to block granularity. `None` = full depth. Capping at
952    /// superblock granularity (superblock_size × block_size docs) keeps most
953    /// of the superblock-pruning win at ~⅓ less depth.
954    pub min_partition_docs: Option<usize>,
955    /// Wall-clock cap for the whole BP computation. The pass ends cleanly at
956    /// the deadline with whatever depth it reached (`converged = false`).
957    /// Ignored on wasm (no monotonic clock).
958    pub time_budget: Option<std::time::Duration>,
959}
960
961impl BpBudget {
962    /// Unbudgeted: full depth, no deadline.
963    pub fn full() -> Self {
964        Self::default()
965    }
966}
967
968/// Build one partition's term degrees. At the coarse levels, each worker
969/// scans a contiguous document range into a private lazy degree table and
970/// the tables are reduced in parallel. `degree_lanes` is a power of two
971/// derived from `parallel_bisect_depth`, so all simultaneous sibling nodes
972/// together stay within the already-accounted vocabulary-array budget.
973fn build_term_degrees(
974    docs: &[u32],
975    mid: usize,
976    fwd: &ForwardIndex,
977    degree_lanes: usize,
978) -> TermDegrees {
979    let build_range = |start: usize, chunk: &[u32]| {
980        let mut degrees = TermDegrees::new(fwd.num_terms);
981        for (offset, &doc) in chunk.iter().enumerate() {
982            let side = usize::from(start + offset >= mid);
983            for &term in fwd.doc_terms(doc as usize) {
984                degrees.entry_mut(term as usize)[side] += 1;
985            }
986        }
987        degrees
988    };
989
990    #[cfg(feature = "native")]
991    {
992        let workers = degree_lanes
993            .max(1)
994            .min(docs.len().div_ceil(PARALLEL_BP_MIN_ENTITIES).max(1));
995        if workers > 1 {
996            let chunk_len = docs.len().div_ceil(workers);
997            return docs
998                .par_chunks(chunk_len)
999                .enumerate()
1000                .map(|(chunk, docs)| build_range(chunk * chunk_len, docs))
1001                .reduce_with(|mut left, right| {
1002                    left.merge_from(&right);
1003                    left
1004                })
1005                .unwrap_or_else(|| TermDegrees::new(fwd.num_terms));
1006        }
1007    }
1008    #[cfg(not(feature = "native"))]
1009    let _ = degree_lanes;
1010
1011    build_range(0, docs)
1012}
1013
1014/// Convert `f32::total_cmp` ordering into an unsigned radix key.
1015///
1016/// BP gains are finite, but preserving the complete IEEE total order makes
1017/// the parallel selector exactly equivalent to the former comparator even if
1018/// malformed input ever produces a signed zero, infinity, or NaN.
1019#[inline]
1020fn gain_order_key(gain: f32) -> u32 {
1021    let bits = gain.to_bits();
1022    if bits & 0x8000_0000 != 0 {
1023        !bits
1024    } else {
1025        bits ^ 0x8000_0000
1026    }
1027}
1028
1029/// Find the gain key of the last entity admitted to the left half and the
1030/// number of strictly lower keys. Four byte-histogram passes replace the old
1031/// serial `select_nth_unstable_by` over an 8-byte index per entity.
1032fn select_gain_threshold(gains: &[f32], left_count: usize) -> (u32, usize) {
1033    debug_assert!(left_count > 0 && left_count <= gains.len());
1034    let mut rank_within_prefix = left_count - 1;
1035    let mut strictly_lower = 0usize;
1036    let mut prefix = 0u32;
1037    let mut prefix_mask = 0u32;
1038
1039    for shift in [24u32, 16, 8, 0] {
1040        let histogram = {
1041            #[cfg(feature = "native")]
1042            {
1043                if gains.len() >= PARALLEL_BP_MIN_ENTITIES {
1044                    gains
1045                        .par_iter()
1046                        .fold(
1047                            || Box::new([0usize; 256]),
1048                            |mut counts, &gain| {
1049                                let key = gain_order_key(gain);
1050                                if key & prefix_mask == prefix {
1051                                    counts[((key >> shift) & 0xff) as usize] += 1;
1052                                }
1053                                counts
1054                            },
1055                        )
1056                        .reduce(
1057                            || Box::new([0usize; 256]),
1058                            |mut left, right| {
1059                                for (dst, &count) in left.iter_mut().zip(right.iter()) {
1060                                    *dst += count;
1061                                }
1062                                left
1063                            },
1064                        )
1065                } else {
1066                    let mut counts = [0usize; 256];
1067                    for &gain in gains {
1068                        let key = gain_order_key(gain);
1069                        if key & prefix_mask == prefix {
1070                            counts[((key >> shift) & 0xff) as usize] += 1;
1071                        }
1072                    }
1073                    Box::new(counts)
1074                }
1075            }
1076            #[cfg(not(feature = "native"))]
1077            {
1078                let mut counts = [0usize; 256];
1079                for &gain in gains {
1080                    let key = gain_order_key(gain);
1081                    if key & prefix_mask == prefix {
1082                        counts[((key >> shift) & 0xff) as usize] += 1;
1083                    }
1084                }
1085                counts
1086            }
1087        };
1088
1089        let mut before_bucket = 0usize;
1090        let mut selected_bucket = None;
1091        for (bucket, count) in histogram.iter().copied().enumerate() {
1092            if rank_within_prefix < before_bucket + count {
1093                selected_bucket = Some(bucket as u32);
1094                rank_within_prefix -= before_bucket;
1095                strictly_lower += before_bucket;
1096                break;
1097            }
1098            before_bucket += count;
1099        }
1100        let selected_bucket = selected_bucket.expect("BP radix selection lost the requested rank");
1101        prefix |= selected_bucket << shift;
1102        prefix_mask |= 0xffu32 << shift;
1103    }
1104
1105    (prefix, strictly_lower)
1106}
1107
1108enum PartitionDegreeUpdate {
1109    /// Parallel workers already accumulated every moved-term delta.
1110    Deltas(TermDeltas),
1111    /// Small partitions use the former unstable selection order. Keep its
1112    /// reusable rank map long enough to update only records that crossed the
1113    /// cut.
1114    Ranked,
1115    /// A large partition with only one affordable degree lane still uses the
1116    /// bounded-memory radix selector, then applies deltas serially.
1117    Threshold {
1118        threshold_key: u32,
1119        ties_left: usize,
1120    },
1121}
1122
1123struct PartitionOutcome {
1124    swap_count: usize,
1125    degree_update: PartitionDegreeUpdate,
1126}
1127
1128#[derive(Clone, Copy)]
1129struct PartitionChunk {
1130    start: usize,
1131    end: usize,
1132    strictly_lower: usize,
1133    equal: usize,
1134    ties_left: usize,
1135}
1136
1137#[inline]
1138fn select_left(key: u32, threshold_key: u32, equal_seen: &mut usize, ties_left: usize) -> bool {
1139    if key < threshold_key {
1140        true
1141    } else if key == threshold_key {
1142        let selected = *equal_seen < ties_left;
1143        *equal_seen += 1;
1144        selected
1145    } else {
1146        false
1147    }
1148}
1149
1150/// Exact, deterministic parallel partition by `(gain.total_cmp(), old_index)`.
1151///
1152/// Output is stable within each half. Parallel workers also accumulate
1153/// per-term degree deltas for moved entities, eliminating the former serial
1154/// postings update without rescanning every posting after each iteration.
1155/// Small partitions retain the old unstable selector: its allocation is tiny
1156/// there, it is faster than four radix scans, and its within-half permutation
1157/// supplies the graph algorithm's established symmetry breaking.
1158fn partition_by_gain(
1159    docs: &[u32],
1160    gains: &[f32],
1161    mid: usize,
1162    fwd: &ForwardIndex,
1163    degree_lanes: usize,
1164    output: &mut [u32],
1165    ranked_scratch: &mut Vec<usize>,
1166) -> PartitionOutcome {
1167    #[cfg(not(feature = "native"))]
1168    let _ = (fwd, degree_lanes);
1169
1170    #[cfg(feature = "native")]
1171    if degree_lanes > 1 && docs.len() >= PARALLEL_BP_MIN_ENTITIES {
1172        let (threshold_key, strictly_lower) = select_gain_threshold(gains, mid);
1173        let ties_left = mid - strictly_lower;
1174
1175        // `degrees` remains live while these deltas are built. Reserving one
1176        // lane for it keeps total vocabulary arrays <= degree_lanes.
1177        let chunk_count = degree_lanes
1178            .saturating_sub(1)
1179            .max(1)
1180            .min(docs.len().div_ceil(PARALLEL_BP_MIN_ENTITIES).max(1));
1181        let chunk_len = docs.len().div_ceil(chunk_count);
1182        let mut chunks: Vec<PartitionChunk> = gains
1183            .par_chunks(chunk_len)
1184            .enumerate()
1185            .map(|(chunk_id, chunk)| {
1186                let mut lower = 0usize;
1187                let mut equal = 0usize;
1188                for &gain in chunk {
1189                    match gain_order_key(gain).cmp(&threshold_key) {
1190                        std::cmp::Ordering::Less => lower += 1,
1191                        std::cmp::Ordering::Equal => equal += 1,
1192                        std::cmp::Ordering::Greater => {}
1193                    }
1194                }
1195                let start = chunk_id * chunk_len;
1196                PartitionChunk {
1197                    start,
1198                    end: start + chunk.len(),
1199                    strictly_lower: lower,
1200                    equal,
1201                    ties_left: 0,
1202                }
1203            })
1204            .collect();
1205
1206        let mut remaining_ties = ties_left;
1207        for chunk in &mut chunks {
1208            chunk.ties_left = remaining_ties.min(chunk.equal);
1209            remaining_ties -= chunk.ties_left;
1210        }
1211        debug_assert_eq!(remaining_ties, 0);
1212
1213        let (mut left_rest, mut right_rest) = output.split_at_mut(mid);
1214        let mut jobs = Vec::with_capacity(chunks.len());
1215        for chunk in chunks {
1216            let left_len = chunk.strictly_lower + chunk.ties_left;
1217            let right_len = chunk.end - chunk.start - left_len;
1218            let (left_out, next_left) = left_rest.split_at_mut(left_len);
1219            let (right_out, next_right) = right_rest.split_at_mut(right_len);
1220            jobs.push((
1221                chunk.start,
1222                &docs[chunk.start..chunk.end],
1223                &gains[chunk.start..chunk.end],
1224                chunk.ties_left,
1225                left_out,
1226                right_out,
1227            ));
1228            left_rest = next_left;
1229            right_rest = next_right;
1230        }
1231        debug_assert!(left_rest.is_empty() && right_rest.is_empty());
1232
1233        let (swap_count, deltas) = jobs
1234            .into_par_iter()
1235            .map(
1236                |(start, docs, gains, ties_for_chunk, left_out, right_out)| {
1237                    let mut deltas = TermDeltas::new(fwd.num_terms);
1238                    let mut equal_seen = 0usize;
1239                    let mut left_cursor = 0usize;
1240                    let mut right_cursor = 0usize;
1241                    let mut swaps = 0usize;
1242
1243                    for (offset, (&doc, &gain)) in docs.iter().zip(gains).enumerate() {
1244                        let key = gain_order_key(gain);
1245                        let now_left =
1246                            select_left(key, threshold_key, &mut equal_seen, ties_for_chunk);
1247                        if now_left {
1248                            left_out[left_cursor] = doc;
1249                            left_cursor += 1;
1250                        } else {
1251                            right_out[right_cursor] = doc;
1252                            right_cursor += 1;
1253                        }
1254
1255                        let was_left = start + offset < mid;
1256                        if was_left != now_left {
1257                            swaps += 1;
1258                            let left_delta = if was_left { -1 } else { 1 };
1259                            for &term in fwd.doc_terms(doc as usize) {
1260                                *deltas.entry_mut(term as usize) += left_delta;
1261                            }
1262                        }
1263                    }
1264                    debug_assert_eq!(left_cursor, left_out.len());
1265                    debug_assert_eq!(right_cursor, right_out.len());
1266                    (swaps, deltas)
1267                },
1268            )
1269            .reduce_with(|(left_swaps, mut left), (right_swaps, right)| {
1270                left.merge_from(&right);
1271                (left_swaps + right_swaps, left)
1272            })
1273            .unwrap_or_else(|| (0, TermDeltas::new(fwd.num_terms)));
1274
1275        return PartitionOutcome {
1276            swap_count,
1277            degree_update: PartitionDegreeUpdate::Deltas(deltas),
1278        };
1279    }
1280
1281    if docs.len() < PARALLEL_BP_MIN_ENTITIES {
1282        ranked_scratch.clear();
1283        ranked_scratch.extend(0..docs.len());
1284        ranked_scratch.select_nth_unstable_by(mid, |&left, &right| {
1285            gains[left]
1286                .total_cmp(&gains[right])
1287                .then_with(|| left.cmp(&right))
1288        });
1289
1290        let mut swaps = 0usize;
1291        for (rank, &old_index) in ranked_scratch.iter().enumerate() {
1292            output[rank] = docs[old_index];
1293            swaps += usize::from((old_index < mid) != (rank < mid));
1294        }
1295        return PartitionOutcome {
1296            swap_count: swaps,
1297            degree_update: PartitionDegreeUpdate::Ranked,
1298        };
1299    }
1300
1301    let (threshold_key, strictly_lower) = select_gain_threshold(gains, mid);
1302    let ties_left = mid - strictly_lower;
1303    let mut equal_seen = 0usize;
1304    let mut left_cursor = 0usize;
1305    let mut right_cursor = mid;
1306    let mut swaps = 0usize;
1307    for (idx, (&doc, &gain)) in docs.iter().zip(gains).enumerate() {
1308        let key = gain_order_key(gain);
1309        let now_left = select_left(key, threshold_key, &mut equal_seen, ties_left);
1310        if now_left {
1311            output[left_cursor] = doc;
1312            left_cursor += 1;
1313        } else {
1314            output[right_cursor] = doc;
1315            right_cursor += 1;
1316        }
1317        swaps += usize::from((idx < mid) != now_left);
1318    }
1319    debug_assert_eq!(left_cursor, mid);
1320    debug_assert_eq!(right_cursor, docs.len());
1321
1322    PartitionOutcome {
1323        swap_count: swaps,
1324        degree_update: PartitionDegreeUpdate::Threshold {
1325            threshold_key,
1326            ties_left,
1327        },
1328    }
1329}
1330
1331/// Apply degree changes for a bounded-memory serial radix partition.
1332fn update_degrees_for_threshold_partition(
1333    docs: &[u32],
1334    gains: &[f32],
1335    mid: usize,
1336    threshold_key: u32,
1337    ties_left: usize,
1338    fwd: &ForwardIndex,
1339    degrees: &mut TermDegrees,
1340) {
1341    let mut equal_seen = 0usize;
1342    for (idx, (&doc, &gain)) in docs.iter().zip(gains).enumerate() {
1343        let key = gain_order_key(gain);
1344        let now_left = select_left(key, threshold_key, &mut equal_seen, ties_left);
1345        let was_left = idx < mid;
1346        if was_left == now_left {
1347            continue;
1348        }
1349        let left_delta = if was_left { -1i64 } else { 1i64 };
1350        for &term in fwd.doc_terms(doc as usize) {
1351            let degree = degrees.entry_mut(term as usize);
1352            let new_left = degree[0] as i64 + left_delta;
1353            let new_right = degree[1] as i64 - left_delta;
1354            debug_assert!(new_left >= 0 && new_right >= 0);
1355            degree[0] = new_left as u32;
1356            degree[1] = new_right as u32;
1357        }
1358    }
1359}
1360
1361/// Apply degree changes using the exact unstable rank order selected for a
1362/// small partition.
1363fn update_degrees_for_ranked_partition(
1364    docs: &[u32],
1365    ranked: &[usize],
1366    mid: usize,
1367    fwd: &ForwardIndex,
1368    degrees: &mut TermDegrees,
1369) {
1370    for (rank, &old_index) in ranked.iter().enumerate() {
1371        let was_left = old_index < mid;
1372        let now_left = rank < mid;
1373        if was_left == now_left {
1374            continue;
1375        }
1376        let left_delta = if was_left { -1i64 } else { 1i64 };
1377        for &term in fwd.doc_terms(docs[old_index] as usize) {
1378            let degree = degrees.entry_mut(term as usize);
1379            let new_left = degree[0] as i64 + left_delta;
1380            let new_right = degree[1] as i64 - left_delta;
1381            debug_assert!(new_left >= 0 && new_right >= 0);
1382            degree[0] = new_left as u32;
1383            degree[1] = new_right as u32;
1384        }
1385    }
1386}
1387
1388#[derive(Clone, Copy)]
1389pub(crate) struct BpProgressLabel<'a> {
1390    pub index: &'a str,
1391    pub field: &'a str,
1392    pub entity_kind: &'static str,
1393}
1394
1395#[cfg(test)]
1396impl BpProgressLabel<'static> {
1397    fn anonymous() -> Self {
1398        Self {
1399            index: "unknown",
1400            field: "unknown",
1401            entity_kind: "entities",
1402        }
1403    }
1404}
1405
1406#[cfg(feature = "native")]
1407struct BpProgress<'a> {
1408    label: BpProgressLabel<'a>,
1409    start: std::time::Instant,
1410    total_entities: usize,
1411    total_postings: u64,
1412    expected_depth: usize,
1413    next_log_ms: std::sync::atomic::AtomicU64,
1414    active_partitions: std::sync::atomic::AtomicU64,
1415    partitions_started: std::sync::atomic::AtomicU64,
1416    partitions_completed: std::sync::atomic::AtomicU64,
1417    iterations: std::sync::atomic::AtomicU64,
1418    entity_passes: std::sync::atomic::AtomicU64,
1419    swaps: std::sync::atomic::AtomicU64,
1420    deepest_level: std::sync::atomic::AtomicU64,
1421    objective_stops: std::sync::atomic::AtomicU64,
1422    last_objective_delta_bits: std::sync::atomic::AtomicU64,
1423    last_relative_delta_bits: std::sync::atomic::AtomicU64,
1424    active_metric_released: std::sync::atomic::AtomicBool,
1425}
1426
1427#[cfg(feature = "native")]
1428impl<'a> BpProgress<'a> {
1429    fn new(
1430        label: BpProgressLabel<'a>,
1431        total_entities: usize,
1432        total_postings: u64,
1433        expected_depth: usize,
1434    ) -> Self {
1435        log::info!(
1436            "[reorder][bp] started: index={} field={} entity_kind={} entities={} postings={} expected_depth={} objective_stall_threshold={:.1e}x{} min_objective_iterations={}",
1437            label.index,
1438            label.field,
1439            label.entity_kind,
1440            total_entities,
1441            total_postings,
1442            expected_depth,
1443            MIN_RELATIVE_OBJECTIVE_IMPROVEMENT,
1444            OBJECTIVE_STALL_ITERATIONS,
1445            MIN_OBJECTIVE_ITERATIONS,
1446        );
1447        crate::observe::reorder_bp_started(label.index, label.field, label.entity_kind);
1448        Self {
1449            label,
1450            start: std::time::Instant::now(),
1451            total_entities,
1452            total_postings,
1453            expected_depth,
1454            next_log_ms: std::sync::atomic::AtomicU64::new(30_000),
1455            active_partitions: std::sync::atomic::AtomicU64::new(0),
1456            partitions_started: std::sync::atomic::AtomicU64::new(0),
1457            partitions_completed: std::sync::atomic::AtomicU64::new(0),
1458            iterations: std::sync::atomic::AtomicU64::new(0),
1459            entity_passes: std::sync::atomic::AtomicU64::new(0),
1460            swaps: std::sync::atomic::AtomicU64::new(0),
1461            deepest_level: std::sync::atomic::AtomicU64::new(0),
1462            objective_stops: std::sync::atomic::AtomicU64::new(0),
1463            last_objective_delta_bits: std::sync::atomic::AtomicU64::new(0f64.to_bits()),
1464            last_relative_delta_bits: std::sync::atomic::AtomicU64::new(0f64.to_bits()),
1465            active_metric_released: std::sync::atomic::AtomicBool::new(false),
1466        }
1467    }
1468
1469    fn partition_started(&self, level: usize) {
1470        self.active_partitions
1471            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1472        self.partitions_started
1473            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1474        self.deepest_level
1475            .fetch_max(level as u64, std::sync::atomic::Ordering::Relaxed);
1476    }
1477
1478    fn partition_finished(&self) {
1479        self.partitions_completed
1480            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1481        self.active_partitions
1482            .fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
1483    }
1484
1485    fn iteration(&self, entities: usize, swaps: usize, objective_delta: f64, relative_delta: f64) {
1486        self.iterations
1487            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1488        self.entity_passes
1489            .fetch_add(entities as u64, std::sync::atomic::Ordering::Relaxed);
1490        self.swaps
1491            .fetch_add(swaps as u64, std::sync::atomic::Ordering::Relaxed);
1492        self.last_objective_delta_bits.store(
1493            objective_delta.to_bits(),
1494            std::sync::atomic::Ordering::Relaxed,
1495        );
1496        self.last_relative_delta_bits.store(
1497            relative_delta.to_bits(),
1498            std::sync::atomic::Ordering::Relaxed,
1499        );
1500        self.maybe_log();
1501    }
1502
1503    fn objective_stop(&self) {
1504        self.objective_stops
1505            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1506    }
1507
1508    fn maybe_log(&self) {
1509        let elapsed_ms = self.start.elapsed().as_millis().min(u64::MAX as u128) as u64;
1510        let next = self.next_log_ms.load(std::sync::atomic::Ordering::Relaxed);
1511        if elapsed_ms < next
1512            || self
1513                .next_log_ms
1514                .compare_exchange(
1515                    next,
1516                    elapsed_ms.saturating_add(30_000),
1517                    std::sync::atomic::Ordering::Relaxed,
1518                    std::sync::atomic::Ordering::Relaxed,
1519                )
1520                .is_err()
1521        {
1522            return;
1523        }
1524
1525        let active = self
1526            .active_partitions
1527            .load(std::sync::atomic::Ordering::Relaxed);
1528        let started = self
1529            .partitions_started
1530            .load(std::sync::atomic::Ordering::Relaxed);
1531        let completed = self
1532            .partitions_completed
1533            .load(std::sync::atomic::Ordering::Relaxed);
1534        let iterations = self.iterations.load(std::sync::atomic::Ordering::Relaxed);
1535        let entity_passes = self
1536            .entity_passes
1537            .load(std::sync::atomic::Ordering::Relaxed);
1538        let swaps = self.swaps.load(std::sync::atomic::Ordering::Relaxed);
1539        let deepest = self
1540            .deepest_level
1541            .load(std::sync::atomic::Ordering::Relaxed);
1542        let objective_delta = f64::from_bits(
1543            self.last_objective_delta_bits
1544                .load(std::sync::atomic::Ordering::Relaxed),
1545        );
1546        let relative_delta = f64::from_bits(
1547            self.last_relative_delta_bits
1548                .load(std::sync::atomic::Ordering::Relaxed),
1549        );
1550        log::info!(
1551            "[reorder][bp] progress: index={} field={} entity_kind={} elapsed={:.1}s depth={}/{} partitions={}/{} active={} iterations={} entity_passes={} swaps={} last_objective_delta={:.3} relative={:.3e}",
1552            self.label.index,
1553            self.label.field,
1554            self.label.entity_kind,
1555            self.start.elapsed().as_secs_f64(),
1556            deepest,
1557            self.expected_depth,
1558            completed,
1559            started,
1560            active,
1561            iterations,
1562            entity_passes,
1563            swaps,
1564            objective_delta,
1565            relative_delta,
1566        );
1567    }
1568
1569    fn finish(&self, converged: bool, memory_limited: bool, deadline_exhausted: bool) {
1570        let elapsed = self.start.elapsed().as_secs_f64();
1571        let partitions = self
1572            .partitions_completed
1573            .load(std::sync::atomic::Ordering::Relaxed);
1574        let iterations = self.iterations.load(std::sync::atomic::Ordering::Relaxed);
1575        let entity_passes = self
1576            .entity_passes
1577            .load(std::sync::atomic::Ordering::Relaxed);
1578        let swaps = self.swaps.load(std::sync::atomic::Ordering::Relaxed);
1579        let deepest = self
1580            .deepest_level
1581            .load(std::sync::atomic::Ordering::Relaxed);
1582        let objective_stops = self
1583            .objective_stops
1584            .load(std::sync::atomic::Ordering::Relaxed);
1585        let stop_reason = if memory_limited {
1586            "memory_budget"
1587        } else if deadline_exhausted {
1588            "time_budget"
1589        } else if objective_stops > 0 {
1590            "objective"
1591        } else {
1592            "complete"
1593        };
1594        log::info!(
1595            "[reorder][bp] completed: index={} field={} entity_kind={} entities={} postings={} elapsed={:.1}s depth={}/{} partitions={} iterations={} entity_passes={} swaps={} objective_stops={} converged={} stop_reason={}",
1596            self.label.index,
1597            self.label.field,
1598            self.label.entity_kind,
1599            self.total_entities,
1600            self.total_postings,
1601            elapsed,
1602            deepest,
1603            self.expected_depth,
1604            partitions,
1605            iterations,
1606            entity_passes,
1607            swaps,
1608            objective_stops,
1609            converged,
1610            stop_reason,
1611        );
1612        crate::observe::reorder_bp_pass(
1613            self.label.index,
1614            self.label.field,
1615            self.label.entity_kind,
1616            stop_reason,
1617            elapsed,
1618            self.total_entities,
1619            self.total_postings,
1620            partitions,
1621            iterations,
1622            entity_passes,
1623            swaps,
1624            converged,
1625        );
1626        self.release_active_metric();
1627    }
1628
1629    fn release_active_metric(&self) {
1630        if !self
1631            .active_metric_released
1632            .swap(true, std::sync::atomic::Ordering::AcqRel)
1633        {
1634            crate::observe::reorder_bp_finished(
1635                self.label.index,
1636                self.label.field,
1637                self.label.entity_kind,
1638            );
1639        }
1640    }
1641}
1642
1643#[cfg(feature = "native")]
1644impl Drop for BpProgress<'_> {
1645    fn drop(&mut self) {
1646        self.release_active_metric();
1647    }
1648}
1649
1650#[cfg(not(feature = "native"))]
1651struct BpProgress<'a>(std::marker::PhantomData<&'a ()>);
1652
1653#[cfg(not(feature = "native"))]
1654impl BpProgress<'_> {
1655    fn new(_: BpProgressLabel<'_>, _: usize, _: u64, _: usize) -> Self {
1656        Self(std::marker::PhantomData)
1657    }
1658    fn partition_started(&self, _: usize) {}
1659    fn partition_finished(&self) {}
1660    fn iteration(&self, _: usize, _: usize, _: f64, _: f64) {}
1661    fn objective_stop(&self) {}
1662    fn finish(&self, _: bool, _: bool, _: bool) {}
1663}
1664
1665/// Recursive graph bisection. Returns `(perm, converged)` where
1666/// `perm[new_pos] = old_index` and `converged` is false iff the wall-clock
1667/// budget ended the pass before it finished (a depth cap alone is a chosen
1668/// target, not an interruption — it reports converged).
1669///
1670/// `min_partition_size` should be the BMP block_size (64).
1671/// `max_iters` controls convergence (20 is standard).
1672///
1673/// Term IDs in the forward index must be compact (0..num_terms) so we can
1674/// use flat arrays for O(1) degree lookups instead of hash maps.
1675#[cfg(test)]
1676pub(crate) fn graph_bisection(
1677    fwd: &ForwardIndex,
1678    min_partition_size: usize,
1679    max_iters: usize,
1680    budget: BpBudget,
1681) -> (Vec<u32>, bool) {
1682    graph_bisection_with_progress(
1683        fwd,
1684        min_partition_size,
1685        max_iters,
1686        budget,
1687        BpProgressLabel::anonymous(),
1688    )
1689}
1690
1691pub(crate) fn graph_bisection_with_progress(
1692    fwd: &ForwardIndex,
1693    min_partition_size: usize,
1694    max_iters: usize,
1695    budget: BpBudget,
1696    progress_label: BpProgressLabel<'_>,
1697) -> (Vec<u32>, bool) {
1698    let n = fwd.num_docs();
1699    if n == 0 {
1700        return (Vec::new(), !fwd.budget_limited);
1701    }
1702
1703    let effective_min_partition = budget
1704        .min_partition_docs
1705        .unwrap_or(0)
1706        .max(min_partition_size);
1707
1708    let mut docs: Vec<u32> = (0..n as u32).collect();
1709    let depth = if effective_min_partition > 0 {
1710        ((n as f64) / (effective_min_partition as f64))
1711            .log2()
1712            .ceil() as usize
1713    } else {
1714        0
1715    };
1716    let log_table = build_log_table(4096);
1717    let progress = BpProgress::new(progress_label, n, fwd.total_postings(), depth);
1718
1719    log::debug!(
1720        "BP graph_bisection: n={}, min_partition={}, max_iters={}, depth=~{}, time_budget={:?}",
1721        n,
1722        effective_min_partition,
1723        max_iters,
1724        depth,
1725        budget.time_budget,
1726    );
1727
1728    #[cfg(feature = "native")]
1729    let deadline = budget.time_budget.map(|duration| {
1730        let now = std::time::Instant::now();
1731        now.checked_add(duration).unwrap_or(now)
1732    });
1733    #[cfg(not(feature = "native"))]
1734    let deadline: Option<()> = None;
1735
1736    let exhausted = std::sync::atomic::AtomicBool::new(false);
1737    let context = BisectContext {
1738        fwd,
1739        min_partition_size: effective_min_partition,
1740        max_iters,
1741        log_table: &log_table,
1742        #[cfg(feature = "native")]
1743        deadline,
1744        #[cfg(not(feature = "native"))]
1745        deadline,
1746        exhausted: &exhausted,
1747        progress: &progress,
1748    };
1749    #[cfg(feature = "native")]
1750    bisect(&mut docs, fwd.parallel_bisect_depth, 0, &context);
1751    #[cfg(not(feature = "native"))]
1752    bisect(&mut docs, 0, 0, &context);
1753
1754    let deadline_exhausted = exhausted.load(std::sync::atomic::Ordering::Relaxed);
1755    let converged = !fwd.budget_limited && !deadline_exhausted;
1756    progress.finish(converged, fwd.budget_limited, deadline_exhausted);
1757    if !converged {
1758        log::info!(
1759            "BP graph_bisection: budget incomplete at n={} (time={:?}, memory_limited={}) — emitting partial (still valid) permutation",
1760            n,
1761            budget.time_budget,
1762            fwd.budget_limited,
1763        );
1764    }
1765    (docs, converged)
1766}
1767
1768/// Recursive bisection of a document slice.
1769///
1770/// Uses flat `Vec<u32>` degree arrays indexed by compact term_id for cache-friendly
1771/// O(1) lookups (vs FxHashMap which has poor cache locality at scale).
1772///
1773/// Gain computation is parallelized via rayon for large partitions (n > 4096).
1774/// Adaptive iteration count reduces work at top levels where coarse splits
1775/// converge faster and dominate total runtime.
1776struct BisectContext<'a> {
1777    fwd: &'a ForwardIndex,
1778    min_partition_size: usize,
1779    max_iters: usize,
1780    log_table: &'a [f32],
1781    #[cfg(feature = "native")]
1782    deadline: Option<std::time::Instant>,
1783    #[cfg(not(feature = "native"))]
1784    deadline: Option<()>,
1785    exhausted: &'a std::sync::atomic::AtomicBool,
1786    progress: &'a BpProgress<'a>,
1787}
1788
1789fn bisect(docs: &mut [u32], parallel_depth: usize, level: usize, context: &BisectContext<'_>) {
1790    #[cfg(not(feature = "native"))]
1791    let _ = parallel_depth;
1792    let n = docs.len();
1793    if n <= context.min_partition_size {
1794        return;
1795    }
1796    // Anytime cutoff: leave this subtree in its current (valid) order.
1797    if context.exhausted.load(std::sync::atomic::Ordering::Relaxed) {
1798        return;
1799    }
1800    #[cfg(feature = "native")]
1801    if let Some(dl) = context.deadline
1802        && std::time::Instant::now() >= dl
1803    {
1804        context
1805            .exhausted
1806            .store(true, std::sync::atomic::Ordering::Relaxed);
1807        return;
1808    }
1809    #[cfg(not(feature = "native"))]
1810    let _ = context.deadline;
1811
1812    context.progress.partition_started(level);
1813    let mid = n / 2;
1814
1815    // Adaptive iteration count: large partitions converge faster with
1816    // coarse splits, so fewer refinement passes suffice. The fine-grained
1817    // clustering is handled by deeper recursion levels with full iterations.
1818    let effective_iters = if n > 100_000 {
1819        context.max_iters.min(12)
1820    } else {
1821        context.max_iters
1822    };
1823    let degree_lanes = 1usize
1824        .checked_shl(parallel_depth as u32)
1825        .unwrap_or(usize::MAX)
1826        .max(1);
1827
1828    // Compact term IDs permit direct indexing. Slots are initialized lazily so
1829    // deep partitions do not zero the full vocabulary on every recursive node.
1830    // Coarse partitions use memory-budgeted thread-local reductions; recursion
1831    // splits the same lane allowance between children.
1832    let mut degrees = build_term_degrees(docs, mid, context.fwd, degree_lanes);
1833    // A full-vocabulary objective scan pays off only on coarse partitions,
1834    // where one avoided refinement skips millions of postings. At fine levels
1835    // it cost more than the work it could save, so retain the cheap gain
1836    // cooling condition there.
1837    let track_objective = n >= PARALLEL_BP_MIN_ENTITIES;
1838    let mut previous_objective = if track_objective {
1839        degrees.bisection_objective(mid, n - mid, context.log_table)
1840    } else {
1841        0.0
1842    };
1843    let mut best_objective = previous_objective;
1844    let mut objective_stalls = 0usize;
1845
1846    // Scratch buffers reused across iterations
1847    let mut gains: Vec<f32> = vec![0.0; n];
1848    let mut partitioned: Vec<u32> = vec![0; n];
1849    let mut ranked_scratch: Vec<usize> = Vec::new();
1850
1851    for iter in 0..effective_iters {
1852        // Anytime cutoff between refinement passes: keep the current split.
1853        #[cfg(feature = "native")]
1854        if let Some(dl) = context.deadline
1855            && std::time::Instant::now() >= dl
1856        {
1857            context
1858                .exhausted
1859                .store(true, std::sync::atomic::Ordering::Relaxed);
1860            break;
1861        }
1862        // Compute gain for each document (approx_1 from Dhulipala et al.)
1863        // Parallelized for large partitions where per-doc work dominates.
1864        compute_gains(
1865            docs,
1866            context.fwd,
1867            mid,
1868            &degrees,
1869            context.log_table,
1870            &mut gains,
1871        );
1872
1873        // Exact median selection and stable partition. The old path allocated
1874        // `Vec<usize>` (8 bytes/entity) and selected/applied it serially; the
1875        // radix path is O(4n), parallel, and accumulates moved-term deltas in
1876        // the same bounded worker lanes used by degree construction.
1877        let partition = partition_by_gain(
1878            docs,
1879            &gains,
1880            mid,
1881            context.fwd,
1882            degree_lanes,
1883            &mut partitioned,
1884            &mut ranked_scratch,
1885        );
1886
1887        if partition.swap_count == 0 {
1888            context.progress.iteration(n, 0, 0.0, 0.0);
1889            // Preserve the selector's within-half order before recursing.
1890            // Small partitions intentionally retain quickselect's established
1891            // symmetry-breaking permutation.
1892            docs.copy_from_slice(&partitioned);
1893            break;
1894        }
1895
1896        match &partition.degree_update {
1897            PartitionDegreeUpdate::Deltas(deltas) => deltas.apply_to(&mut degrees),
1898            PartitionDegreeUpdate::Ranked => update_degrees_for_ranked_partition(
1899                docs,
1900                &ranked_scratch,
1901                mid,
1902                context.fwd,
1903                &mut degrees,
1904            ),
1905            PartitionDegreeUpdate::Threshold {
1906                threshold_key,
1907                ties_left,
1908            } => update_degrees_for_threshold_partition(
1909                docs,
1910                &gains,
1911                mid,
1912                *threshold_key,
1913                *ties_left,
1914                context.fwd,
1915                &mut degrees,
1916            ),
1917        }
1918
1919        let (new_objective, objective_improvement, relative_improvement) = if track_objective {
1920            let new_objective = degrees.bisection_objective(mid, n - mid, context.log_table);
1921            let objective_improvement = new_objective - previous_objective;
1922            let relative_improvement = objective_improvement / previous_objective.abs().max(1.0);
1923            (new_objective, objective_improvement, relative_improvement)
1924        } else {
1925            (0.0, 0.0, 0.0)
1926        };
1927        context.progress.iteration(
1928            n,
1929            partition.swap_count,
1930            objective_improvement,
1931            relative_improvement,
1932        );
1933
1934        // Keep the existing approximate-BP semantics: one median refinement
1935        // can temporarily reduce the exact objective before the reciprocal
1936        // move settles. Rejecting that first step made valid clustered inputs
1937        // no-op. Instead, accept refinements and stop only after a warm-up plus
1938        // consecutive iterations that fail to improve the best exact
1939        // objective by a meaningful relative amount.
1940        docs.copy_from_slice(&partitioned);
1941        if track_objective {
1942            previous_objective = new_objective;
1943            let relative_best_improvement =
1944                (new_objective - best_objective) / best_objective.abs().max(1.0);
1945            if relative_best_improvement >= MIN_RELATIVE_OBJECTIVE_IMPROVEMENT {
1946                best_objective = new_objective;
1947                objective_stalls = 0;
1948            } else if iter + 1 >= MIN_OBJECTIVE_ITERATIONS {
1949                objective_stalls += 1;
1950            }
1951            if objective_stalls >= OBJECTIVE_STALL_ITERATIONS {
1952                context.progress.objective_stop();
1953                break;
1954            }
1955        }
1956
1957        // Early termination: if < 0.5% of docs swapped, partition is stable
1958        if iter > 2 && partition.swap_count < n / 200 {
1959            break;
1960        }
1961
1962        // Fine partitions retain the previous cheap cooling rule. Computing
1963        // an exact vocabulary objective here costs more than the posting work
1964        // it can avoid.
1965        if !track_objective && iter > 5 {
1966            let max_abs_gain = gains
1967                .iter()
1968                .copied()
1969                .fold(0.0f32, |max_gain, gain| max_gain.max(gain.abs()));
1970            if max_abs_gain < 0.001 {
1971                break;
1972            }
1973        }
1974    }
1975
1976    // Drop scratch before recursion to free memory for sub-problems
1977    drop(degrees);
1978    drop(gains);
1979    drop(partitioned);
1980    context.progress.partition_finished();
1981
1982    let (left, right) = docs.split_at_mut(mid);
1983    #[cfg(feature = "native")]
1984    if parallel_depth > 0 {
1985        rayon::join(
1986            || bisect(left, parallel_depth - 1, level + 1, context),
1987            || bisect(right, parallel_depth - 1, level + 1, context),
1988        );
1989    } else {
1990        // Gain computation inside each node remains parallel, so serializing
1991        // recursion here bounds vocabulary-sized degree arrays without leaving
1992        // the Rayon pool idle.
1993        bisect(left, 0, level + 1, context);
1994        bisect(right, 0, level + 1, context);
1995    }
1996    #[cfg(not(feature = "native"))]
1997    {
1998        bisect(left, 0, level + 1, context);
1999        bisect(right, 0, level + 1, context);
2000    }
2001}
2002
2003/// Compute gains for all documents, parallelized via rayon for large partitions.
2004///
2005/// Each doc's gain is independent: iterate its terms, accumulate the log-gap
2006/// cost delta of moving it to the other side. Read-only access to degree arrays
2007/// makes this embarrassingly parallel.
2008#[inline(never)]
2009fn compute_gains(
2010    docs: &[u32],
2011    fwd: &ForwardIndex,
2012    mid: usize,
2013    degrees: &TermDegrees,
2014    log_table: &[f32],
2015    gains: &mut [f32],
2016) {
2017    // Single coherent key: HIGH = belongs in the RIGHT half.
2018    // Left docs get +approx_one(from=left, to=right) — a misplaced left doc
2019    // (terms concentrated right) scores high. Right docs get
2020    // -approx_one(from=right, to=left) — a misplaced right doc scores low.
2021    // This matches the reference two-sided formulation (compute_gains_left /
2022    // compute_gains_right with negation); ranking both halves by raw
2023    // "move gain" instead made both sides' misplaced docs rank identically,
2024    // so the partition step could never exchange them.
2025    let gain_for_doc = |i: usize| -> f32 {
2026        let doc = docs[i] as usize;
2027        let in_left = i < mid;
2028        let mut g = 0.0f32;
2029        for &term in fwd.doc_terms(doc) {
2030            let [left, right] = degrees.get(term as usize);
2031            let (from, to) = if in_left {
2032                (left, right)
2033            } else {
2034                (right, left)
2035            };
2036            let move_gain = fast_log2_lookup(to as usize + 2, log_table)
2037                - fast_log2_lookup(from as usize, log_table)
2038                - std::f32::consts::LOG2_E / (1.0 + to as f32);
2039            g += if in_left { move_gain } else { -move_gain };
2040        }
2041        g
2042    };
2043
2044    #[cfg(feature = "native")]
2045    {
2046        if docs.len() > 4096 {
2047            gains
2048                .par_iter_mut()
2049                .enumerate()
2050                .for_each(|(i, gain)| *gain = gain_for_doc(i));
2051        } else {
2052            for (i, gain) in gains.iter_mut().enumerate().take(docs.len()) {
2053                *gain = gain_for_doc(i);
2054            }
2055        }
2056    }
2057    #[cfg(not(feature = "native"))]
2058    {
2059        for (i, gain) in gains.iter_mut().enumerate().take(docs.len()) {
2060            *gain = gain_for_doc(i);
2061        }
2062    }
2063}
2064
2065// ── Helpers ──────────────────────────────────────────────────────────────
2066
2067/// Build precomputed log2 table for values 0..size.
2068fn build_log_table(size: usize) -> Vec<f32> {
2069    let mut table = vec![0.0f32; size];
2070    // log2(0) is undefined; use a large negative value
2071    table[0] = -10.0;
2072    for (i, entry) in table.iter_mut().enumerate().skip(1) {
2073        *entry = (i as f32).log2();
2074    }
2075    table
2076}
2077
2078/// Fast log2 with precomputed table lookup.
2079#[inline]
2080fn fast_log2_lookup(val: usize, table: &[f32]) -> f32 {
2081    if val < table.len() {
2082        table[val]
2083    } else {
2084        (val as f32).log2()
2085    }
2086}
2087
2088#[cfg(test)]
2089mod tests {
2090    use super::*;
2091
2092    #[test]
2093    fn lazy_term_degrees_initialize_only_on_first_write() {
2094        let mut degrees = TermDegrees::new(130);
2095        assert_eq!(degrees.get(65), [0, 0]);
2096        degrees.entry_mut(65)[0] += 3;
2097        degrees.entry_mut(65)[1] += 2;
2098        assert_eq!(degrees.get(65), [3, 2]);
2099        assert_eq!(degrees.get(64), [0, 0]);
2100        assert_eq!(
2101            degrees
2102                .initialized
2103                .iter()
2104                .map(|w| w.count_ones())
2105                .sum::<u32>(),
2106            1
2107        );
2108    }
2109
2110    #[test]
2111    fn gain_radix_key_matches_total_cmp() {
2112        let values = [
2113            f32::from_bits(0xffc0_0001),
2114            f32::NEG_INFINITY,
2115            -42.0,
2116            -0.0,
2117            0.0,
2118            42.0,
2119            f32::INFINITY,
2120            f32::from_bits(0x7fc0_0001),
2121        ];
2122        let mut by_cmp = values;
2123        by_cmp.sort_by(f32::total_cmp);
2124        let mut by_key = values;
2125        by_key.sort_by_key(|value| gain_order_key(*value));
2126        assert_eq!(
2127            by_cmp.map(f32::to_bits),
2128            by_key.map(f32::to_bits),
2129            "radix selection must preserve the former total_cmp order"
2130        );
2131    }
2132
2133    #[test]
2134    fn radix_threshold_matches_exact_rank_with_ties() {
2135        let gains = [3.0, -1.0, 7.0, -1.0, 0.0, -0.0, 3.0, 9.0, 3.0, 2.0, 2.0];
2136        let mut sorted: Vec<(u32, usize)> = gains
2137            .iter()
2138            .enumerate()
2139            .map(|(idx, &gain)| (gain_order_key(gain), idx))
2140            .collect();
2141        sorted.sort_unstable();
2142
2143        for left_count in 1..=gains.len() {
2144            let (threshold, lower) = select_gain_threshold(&gains, left_count);
2145            assert_eq!(threshold, sorted[left_count - 1].0);
2146            assert_eq!(lower, sorted.partition_point(|&(key, _)| key < threshold),);
2147        }
2148    }
2149
2150    #[cfg(feature = "native")]
2151    #[test]
2152    fn parallel_partition_matches_exact_selection_and_degree_rebuild() {
2153        const N: usize = PARALLEL_BP_MIN_ENTITIES + 1;
2154        const TERMS: usize = 101;
2155        let mut terms = Vec::with_capacity(N * 3);
2156        let mut offsets = Vec::with_capacity(N + 1);
2157        offsets.push(0);
2158        for doc in 0..N {
2159            terms.extend_from_slice(&[
2160                (doc % TERMS) as u32,
2161                ((doc / 7) % TERMS) as u32,
2162                ((doc * 13) % TERMS) as u32,
2163            ]);
2164            offsets.push(terms.len() as u64);
2165        }
2166        let fwd = ForwardIndex {
2167            terms,
2168            offsets,
2169            num_terms: TERMS,
2170            parallel_bisect_depth: 2,
2171            budget_limited: false,
2172        };
2173        let docs: Vec<u32> = (0..N as u32)
2174            .map(|idx| ((idx as usize * 7_919) % N) as u32)
2175            .collect();
2176        let gains: Vec<f32> = docs
2177            .iter()
2178            .map(|&doc| ((doc as usize * 37) % 257) as f32 - 128.0)
2179            .collect();
2180        let mid = N / 2;
2181
2182        let mut ranked: Vec<usize> = (0..N).collect();
2183        ranked.sort_unstable_by(|&left, &right| {
2184            gains[left]
2185                .total_cmp(&gains[right])
2186                .then_with(|| left.cmp(&right))
2187        });
2188        let mut selected_left = vec![false; N];
2189        for &idx in &ranked[..mid] {
2190            selected_left[idx] = true;
2191        }
2192        let expected: Vec<u32> = docs
2193            .iter()
2194            .enumerate()
2195            .filter(|(idx, _)| selected_left[*idx])
2196            .chain(
2197                docs.iter()
2198                    .enumerate()
2199                    .filter(|(idx, _)| !selected_left[*idx]),
2200            )
2201            .map(|(_, &doc)| doc)
2202            .collect();
2203
2204        let mut output = vec![0; N];
2205        let mut ranked_scratch = Vec::new();
2206        let outcome = partition_by_gain(
2207            &docs,
2208            &gains,
2209            mid,
2210            &fwd,
2211            4,
2212            &mut output,
2213            &mut ranked_scratch,
2214        );
2215        assert_eq!(output, expected);
2216        assert!(
2217            matches!(&outcome.degree_update, PartitionDegreeUpdate::Deltas(_)),
2218            "test must exercise parallel deltas"
2219        );
2220
2221        let mut updated = build_term_degrees(&docs, mid, &fwd, 4);
2222        let PartitionDegreeUpdate::Deltas(deltas) = outcome.degree_update else {
2223            unreachable!("assertion above verifies the parallel path")
2224        };
2225        deltas.apply_to(&mut updated);
2226        let rebuilt = build_term_degrees(&output, mid, &fwd, 4);
2227        for term in 0..TERMS {
2228            assert_eq!(
2229                updated.get(term),
2230                rebuilt.get(term),
2231                "parallel moved-term delta mismatch for term {term}"
2232            );
2233        }
2234    }
2235
2236    /// Regression: CSR offsets were u32 and wrapped past 4.29B postings —
2237    /// a 58M-doc / ~85-dims-per-doc prod reorder pass (~4.9B postings)
2238    /// panicked with "mid > len" in the terms carving. The old 8 GB memory
2239    /// budget masked the overflow by dropping dims; raising the budget
2240    /// exposed it. Offsets must be u64.
2241    #[test]
2242    fn test_csr_offsets_do_not_wrap_past_u32() {
2243        let counts = [1_500_000_000u32; 3]; // 4.5B total > u32::MAX
2244        let offsets = build_csr_offsets(&counts);
2245        assert_eq!(
2246            offsets,
2247            vec![0, 1_500_000_000, 3_000_000_000, 4_500_000_000]
2248        );
2249        assert!(*offsets.last().unwrap() > u32::MAX as u64);
2250    }
2251
2252    /// Build a simple forward index from (doc_id, terms) pairs.
2253    fn make_fwd(docs: &[&[u32]], num_terms: usize) -> ForwardIndex {
2254        let mut terms = Vec::new();
2255        let mut offsets = vec![0u64];
2256        for doc_terms in docs {
2257            terms.extend_from_slice(doc_terms);
2258            offsets.push(terms.len() as u64);
2259        }
2260        ForwardIndex {
2261            terms,
2262            offsets,
2263            num_terms,
2264            parallel_bisect_depth: 0,
2265            budget_limited: false,
2266        }
2267    }
2268
2269    #[test]
2270    fn test_bp_empty() {
2271        let fwd = ForwardIndex {
2272            terms: Vec::new(),
2273            offsets: Vec::new(),
2274            num_terms: 0,
2275            parallel_bisect_depth: 0,
2276            budget_limited: false,
2277        };
2278        let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
2279        assert!(perm.is_empty());
2280    }
2281
2282    #[test]
2283    fn test_bp_small() {
2284        // 4 docs, min_partition_size=4 → no bisection, identity
2285        let fwd = make_fwd(&[&[0, 1], &[0, 2], &[1, 3], &[2, 3]], 4);
2286        let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
2287        assert_eq!(perm.len(), 4);
2288        // All docs present
2289        let mut sorted = perm.clone();
2290        sorted.sort();
2291        assert_eq!(sorted, vec![0, 1, 2, 3]);
2292    }
2293
2294    #[test]
2295    fn test_bp_clusters() {
2296        // 8 docs in 2 clear clusters:
2297        // Cluster A (docs 0-3): share terms 0, 1
2298        // Cluster B (docs 4-7): share terms 2, 3
2299        let fwd = make_fwd(
2300            &[
2301                &[0, 1],
2302                &[0, 1],
2303                &[0, 1],
2304                &[0, 1],
2305                &[2, 3],
2306                &[2, 3],
2307                &[2, 3],
2308                &[2, 3],
2309            ],
2310            4,
2311        );
2312        let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
2313        assert_eq!(perm.len(), 8);
2314
2315        // After bisection, docs from same cluster should be in same half
2316        let left: Vec<u32> = perm[..4].to_vec();
2317
2318        // Either all of cluster A is in left and B in right, or vice versa
2319        let a_in_left = left.iter().filter(|&&d| d < 4).count();
2320        let b_in_left = left.iter().filter(|&&d| d >= 4).count();
2321        assert!(
2322            (a_in_left == 4 && b_in_left == 0) || (a_in_left == 0 && b_in_left == 4),
2323            "Clusters should be separated: a_left={}, b_left={}",
2324            a_in_left,
2325            b_in_left,
2326        );
2327    }
2328
2329    #[test]
2330    fn test_bp_permutation_valid() {
2331        // 16 docs with mixed terms: terms range from 0..4 and 10..18
2332        let docs: Vec<Vec<u32>> = (0..16).map(|i| vec![i / 4, 10 + i / 2]).collect();
2333        let doc_refs: Vec<&[u32]> = docs.iter().map(|v| v.as_slice()).collect();
2334        let fwd = make_fwd(&doc_refs, 18); // max term = 10 + 15/2 = 17, so need 18
2335        let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
2336
2337        assert_eq!(perm.len(), 16);
2338        // Must be a valid permutation
2339        let mut sorted = perm.clone();
2340        sorted.sort();
2341        let expected: Vec<u32> = (0..16).collect();
2342        assert_eq!(sorted, expected);
2343    }
2344
2345    /// Depth-capped BP: with min_partition_docs above the cluster size, only
2346    /// the top-level split happens — clusters still separate (coarse
2347    /// clustering), the permutation stays valid, and the pass converges
2348    /// (a depth cap is a chosen target, not an interruption).
2349    #[test]
2350    fn test_bp_depth_cap_separates_clusters_and_converges() {
2351        // Mostly-separated clusters with one misplaced doc per half — the
2352        // top-level swap pass must exchange docs 3 and 4.
2353        let fwd = make_fwd(
2354            &[
2355                &[0, 1],
2356                &[0, 1],
2357                &[0, 1],
2358                &[2, 3],
2359                &[0, 1],
2360                &[2, 3],
2361                &[2, 3],
2362                &[2, 3],
2363            ],
2364            4,
2365        );
2366        let budget = BpBudget {
2367            min_partition_docs: Some(4),
2368            time_budget: None,
2369        };
2370        let (perm, converged) = graph_bisection(&fwd, 2, 20, budget);
2371        assert!(converged, "depth cap must report converged");
2372        assert_eq!(perm.len(), 8);
2373        let mut sorted = perm.clone();
2374        sorted.sort();
2375        assert_eq!(
2376            sorted,
2377            (0..8).collect::<Vec<u32>>(),
2378            "must stay a valid permutation"
2379        );
2380        // Top-level split separates the clusters (docs {0,1,2,4} share terms
2381        // 0/1; docs {3,5,6,7} share terms 2/3)
2382        let cluster_a = [0u32, 1, 2, 4];
2383        let a_in_left = perm[..4].iter().filter(|d| cluster_a.contains(d)).count();
2384        assert!(
2385            a_in_left == 4 || a_in_left == 0,
2386            "clusters should separate at the top level: {:?}",
2387            perm
2388        );
2389    }
2390
2391    /// Zero wall-clock budget: the pass ends immediately, reports
2392    /// converged=false, and still emits a valid (identity) permutation.
2393    #[test]
2394    fn test_bp_zero_time_budget_emits_valid_partial_permutation() {
2395        let docs: Vec<Vec<u32>> = (0..64).map(|i| vec![i % 4]).collect();
2396        let doc_refs: Vec<&[u32]> = docs.iter().map(|v| v.as_slice()).collect();
2397        let fwd = make_fwd(&doc_refs, 4);
2398        let budget = BpBudget {
2399            min_partition_docs: None,
2400            time_budget: Some(std::time::Duration::ZERO),
2401        };
2402        let (perm, converged) = graph_bisection(&fwd, 4, 20, budget);
2403        assert!(!converged, "zero budget must report unconverged");
2404        assert_eq!(perm.len(), 64);
2405        let mut sorted = perm.clone();
2406        sorted.sort();
2407        assert_eq!(sorted, (0..64).collect::<Vec<u32>>());
2408    }
2409
2410    #[test]
2411    fn test_memory_limited_graph_never_reports_converged() {
2412        let mut fwd = make_fwd(&[&[0], &[0], &[1], &[1]], 2);
2413        fwd.budget_limited = true;
2414
2415        let (perm, converged) = graph_bisection(&fwd, 2, 20, BpBudget::full());
2416
2417        assert!(!converged);
2418        let mut sorted = perm;
2419        sorted.sort_unstable();
2420        assert_eq!(sorted, vec![0, 1, 2, 3]);
2421    }
2422
2423    #[test]
2424    fn test_fast_log2() {
2425        let table = build_log_table(4096);
2426        assert!((table[1] - 0.0).abs() < 0.001);
2427        assert!((table[2] - 1.0).abs() < 0.001);
2428        assert!((table[4] - 2.0).abs() < 0.001);
2429        assert!((table[1024] - 10.0).abs() < 0.001);
2430        // Fallback for values beyond table
2431        let val = fast_log2_lookup(8192, &table);
2432        assert!((val - 13.0).abs() < 0.001);
2433    }
2434}