Skip to main content

summa_core/segment/builder/
graph_bisection.rs

1//! Level-synchronized 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//! Scheduling follows Mackenzie et al.'s level-synchronized iterative variant:
10//! all partitions at one depth finish before the next depth begins.
11//!
12//! Memory is budgeted: CSR terms plus roughly 32 bytes/document of graph
13//! scratch, with lazily initialized direct-index term-degree arrays.
14
15#[cfg(feature = "native")]
16use rayon::prelude::*;
17const TERM_DEGREE_VALUE_BYTES: usize = std::mem::size_of::<[u32; 2]>();
18const CANDIDATE_ENTRY_BYTES: usize = std::mem::size_of::<(usize, u32)>();
19// Radix selection scans the gain array four times and parallel degree updates
20// require private vocabulary arrays. Quickselect wins decisively below this
21// scale; above it, bounded memory and worker utilization dominate.
22const PARALLEL_BP_MIN_ENTITIES: usize = 1_048_576;
23const MIN_RELATIVE_OBJECTIVE_IMPROVEMENT: f64 = 1e-6;
24const MIN_OBJECTIVE_ITERATIONS: usize = 4;
25const OBJECTIVE_STALL_ITERATIONS: usize = 2;
26const LOG_TABLE_SIZE: usize = 4096;
27
28fn term_degree_bytes(num_terms: usize) -> usize {
29    let bitmap_words = num_terms.div_ceil(64);
30    num_terms
31        .saturating_mul(TERM_DEGREE_VALUE_BYTES)
32        .saturating_add(bitmap_words.saturating_mul(std::mem::size_of::<u64>()))
33        // A reusable lane remembers only bitmap words touched by its previous
34        // partition, so reset is proportional to active terms rather than the
35        // complete vocabulary.
36        .saturating_add(bitmap_words.saturating_mul(std::mem::size_of::<u32>()))
37}
38
39/// Count a dense vocabulary without a cache-line-contended atomic increment
40/// for every posting. Workers own private tables, and the number of tables is
41/// capped by the caller's remaining memory budget. A single plain table is
42/// used when the budget cannot afford useful parallelism.
43#[cfg(feature = "native")]
44trait FrequencyParallelSafe: Sync {}
45#[cfg(feature = "native")]
46impl<T: Sync + ?Sized> FrequencyParallelSafe for T {}
47
48#[cfg(not(feature = "native"))]
49trait FrequencyParallelSafe {}
50#[cfg(not(feature = "native"))]
51impl<T: ?Sized> FrequencyParallelSafe for T {}
52
53fn count_frequencies_bounded<T: FrequencyParallelSafe>(
54    items: &[T],
55    num_terms: usize,
56    available_bytes: usize,
57    count_item: impl Fn(&T, &mut [u32]) -> crate::Result<()> + FrequencyParallelSafe,
58) -> crate::Result<Option<Vec<u32>>> {
59    if num_terms == 0 {
60        return Ok(Some(Vec::new()));
61    }
62    let Some(table_bytes) = num_terms
63        .checked_mul(std::mem::size_of::<u32>())
64        .and_then(|bytes| bytes.checked_add(std::mem::size_of::<Vec<u32>>()))
65    else {
66        return Ok(None);
67    };
68    let affordable_tables = available_bytes / table_bytes;
69    if affordable_tables == 0 {
70        return Ok(None);
71    }
72
73    #[cfg(feature = "native")]
74    {
75        let lanes = affordable_tables
76            .min(rayon::current_num_threads().max(1))
77            .min(items.len().max(1));
78        if lanes > 1 {
79            let chunk_len = items.len().div_ceil(lanes);
80            return items
81                .par_chunks(chunk_len)
82                .map(|chunk| {
83                    let mut counts = vec![0u32; num_terms];
84                    for item in chunk {
85                        count_item(item, &mut counts)?;
86                    }
87                    Ok(counts)
88                })
89                .try_reduce_with(|mut left, right| {
90                    for (total, count) in left.iter_mut().zip(right) {
91                        *total = total.saturating_add(count);
92                    }
93                    Ok(left)
94                })
95                .transpose();
96        }
97    }
98
99    let mut counts = vec![0u32; num_terms];
100    for item in items {
101        count_item(item, &mut counts)?;
102    }
103    Ok(Some(counts))
104}
105
106/// Retain the lowest-frequency eligible dimensions while the frequency table
107/// is live. Both record- and block-level builders use the same bounded policy.
108pub(crate) fn select_frequency_candidates(
109    frequencies: &[u32],
110    min_frequency: usize,
111    max_frequency: usize,
112    candidate_budget_bytes: usize,
113) -> (Vec<(u32, usize)>, bool) {
114    let eligible_count = frequencies
115        .iter()
116        .filter(|&&frequency| {
117            let frequency = frequency as usize;
118            frequency >= min_frequency && frequency <= max_frequency
119        })
120        .count();
121    let capacity = candidate_budget_bytes
122        .checked_div(CANDIDATE_ENTRY_BYTES)
123        .unwrap_or(0)
124        .min(eligible_count);
125    let mut candidates = std::collections::BinaryHeap::with_capacity(capacity);
126    for (term_id, &frequency) in frequencies.iter().enumerate() {
127        let frequency = frequency as usize;
128        if frequency < min_frequency || frequency > max_frequency {
129            continue;
130        }
131        let candidate = (frequency, term_id as u32);
132        if candidates.len() < capacity {
133            candidates.push(candidate);
134        } else if capacity > 0 && candidate < *candidates.peek().unwrap() {
135            candidates.pop();
136            candidates.push(candidate);
137        }
138    }
139    let selected = candidates
140        .into_vec()
141        .into_iter()
142        .map(|(frequency, term_id)| (term_id, frequency))
143        .collect();
144    (selected, capacity < eligible_count)
145}
146
147pub(crate) struct CandidateFit {
148    pub(crate) estimated_bytes: usize,
149    pub(crate) retained_postings: usize,
150    pub(crate) dropped: usize,
151}
152
153/// Apply the shared forward-index memory model, preferring low-frequency
154/// dimensions because they add the least CSR storage and the strongest
155/// clustering signal.
156pub(crate) fn fit_candidates_to_budget(
157    candidates: &mut Vec<(u32, usize)>,
158    fixed_bytes: usize,
159    memory_budget_bytes: usize,
160    posting_bytes: usize,
161) -> CandidateFit {
162    let total_postings = candidates.iter().fold(0usize, |total, (_, frequency)| {
163        total.saturating_add(*frequency)
164    });
165    let estimated_bytes = total_postings
166        .saturating_mul(posting_bytes)
167        .saturating_add(fixed_bytes)
168        .saturating_add(candidates.len().saturating_mul(CANDIDATE_ENTRY_BYTES))
169        .saturating_add(term_degree_bytes(candidates.len()));
170    if estimated_bytes <= memory_budget_bytes || candidates.is_empty() {
171        return CandidateFit {
172            estimated_bytes,
173            retained_postings: total_postings,
174            dropped: 0,
175        };
176    }
177
178    candidates.sort_by_key(|&(_, frequency)| frequency);
179    let mut used_bytes = fixed_bytes;
180    let mut retained_postings = 0usize;
181    let mut keep = 0usize;
182    for &(_, frequency) in candidates.iter() {
183        let term_bytes = frequency
184            .saturating_mul(posting_bytes)
185            .saturating_add(TERM_DEGREE_VALUE_BYTES + 1)
186            .saturating_add(CANDIDATE_ENTRY_BYTES);
187        if term_bytes > memory_budget_bytes.saturating_sub(used_bytes) {
188            break;
189        }
190        used_bytes = used_bytes.saturating_add(term_bytes);
191        retained_postings = retained_postings.saturating_add(frequency);
192        keep += 1;
193    }
194    let dropped = candidates.len() - keep;
195    candidates.truncate(keep);
196    // BinaryHeap::into_vec retains its original capacity. Release dropped
197    // candidates before allocating the remap, CSR, and graph scratch.
198    candidates.shrink_to_fit();
199    CandidateFit {
200        estimated_bytes,
201        retained_postings,
202        dropped,
203    }
204}
205
206fn parallel_bisect_lanes(
207    memory_budget_bytes: usize,
208    non_degree_bytes: usize,
209    num_terms: usize,
210) -> usize {
211    let per_node = term_degree_bytes(num_terms).max(1);
212    let affordable_nodes = memory_budget_bytes
213        .saturating_sub(non_degree_bytes)
214        .checked_div(per_node)
215        .unwrap_or(0)
216        .max(1);
217    #[cfg(feature = "native")]
218    let worker_limit = rayon::current_num_threads().max(1);
219    #[cfg(not(feature = "native"))]
220    let worker_limit = 1usize;
221    affordable_nodes.min(worker_limit)
222}
223
224/// Spend only spare graph memory on gain caching. In particular, this must
225/// not change candidate selection or the number of degree lanes admitted by
226/// the existing policy. Tight budgets keep the direct arithmetic path.
227fn gain_cache_fits(budget: usize, non_degree_bytes: usize, terms: usize, lanes: usize) -> bool {
228    let required = terms
229        .checked_mul(std::mem::size_of::<[f32; 2]>())
230        .and_then(|cache| cache.checked_add(term_degree_bytes(terms)))
231        .and_then(|lane| lane.checked_add(std::mem::size_of::<TermDegrees>()))
232        .and_then(|lane| lane.checked_mul(lanes))
233        .and_then(|all_lanes| all_lanes.checked_add(non_degree_bytes))
234        .and_then(|graph| graph.checked_add(LOG_TABLE_SIZE * std::mem::size_of::<f32>()));
235    terms > 0 && required.is_some_and(|bytes| bytes <= budget)
236}
237
238/// Per-partition left/right term degrees with direct compact-term indexing.
239///
240/// Recursive BP used to zero two `num_terms`-long vectors at every node. At
241/// 100k vocabulary terms and hundreds of thousands of fine partitions, that
242/// turns into a large amount of memory traffic unrelated to actual postings.
243/// A one-bit initialization map lets us retain array-speed lookups while only
244/// touching degree slots present in the current partition.
245struct TermDegrees {
246    values: Vec<std::mem::MaybeUninit<[u32; 2]>>,
247    initialized: Vec<u64>,
248    touched_words: Vec<u32>,
249    /// Optional, budgeted scratch. Refreshed for active terms before scoring,
250    /// reused across all refinements and partitions on this lane.
251    gain_cache: Vec<[f32; 2]>,
252}
253
254impl TermDegrees {
255    fn new(num_terms: usize) -> Self {
256        let bitmap_words = num_terms.div_ceil(64);
257        let mut values = Vec::with_capacity(num_terms);
258        values.resize_with(num_terms, std::mem::MaybeUninit::uninit);
259        Self {
260            values,
261            initialized: vec![0; bitmap_words],
262            touched_words: Vec::with_capacity(bitmap_words),
263            gain_cache: Vec::new(),
264        }
265    }
266
267    /// Reuse this vocabulary-sized lane for another partition without
268    /// clearing the complete initialization bitmap.
269    fn reset(&mut self) {
270        for word in self.touched_words.drain(..) {
271            self.initialized[word as usize] = 0;
272        }
273    }
274
275    fn sort_touched_words(&mut self) {
276        self.touched_words.sort_unstable();
277    }
278
279    #[inline]
280    fn entry_mut(&mut self, term: usize) -> &mut [u32; 2] {
281        let word = term / 64;
282        let mask = 1u64 << (term % 64);
283        if self.initialized[word] & mask == 0 {
284            if self.initialized[word] == 0 {
285                self.touched_words.push(word as u32);
286            }
287            self.values[term].write([0, 0]);
288            self.initialized[word] |= mask;
289        }
290        // SAFETY: the bit above is set only after writing this exact slot.
291        unsafe { self.values[term].assume_init_mut() }
292    }
293
294    #[inline]
295    fn get(&self, term: usize) -> [u32; 2] {
296        let word = term / 64;
297        let mask = 1u64 << (term % 64);
298        if self.initialized[word] & mask == 0 {
299            return [0, 0];
300        }
301        // SAFETY: an initialized bit is published only after the slot write;
302        // scoring reads degrees after construction, with no concurrent writes.
303        unsafe { *self.values[term].assume_init_ref() }
304    }
305
306    fn refresh_gain_cache(&mut self, log_table: &[f32]) {
307        if self.gain_cache.is_empty() {
308            return;
309        }
310        for &word_idx in &self.touched_words {
311            let word_idx = word_idx as usize;
312            let mut pending = self.initialized[word_idx];
313            while pending != 0 {
314                let bit = pending.trailing_zeros() as usize;
315                let term = word_idx * 64 + bit;
316                // SAFETY: pending contains only initialized degree slots.
317                let [left, right] = unsafe { *self.values[term].assume_init_ref() };
318                // Preserve both subtraction operations, division, and final
319                // sign exactly. Combining the logs/penalty or reassociating
320                // the document reduction would change median ties.
321                self.gain_cache[term] = [
322                    if left == 0 {
323                        0.0
324                    } else {
325                        fast_log2_lookup(right as usize + 2, log_table)
326                            - fast_log2_lookup(left as usize, log_table)
327                            - std::f32::consts::LOG2_E / (1.0 + right as f32)
328                    },
329                    if right == 0 {
330                        0.0
331                    } else {
332                        -(fast_log2_lookup(left as usize + 2, log_table)
333                            - fast_log2_lookup(right as usize, log_table)
334                            - std::f32::consts::LOG2_E / (1.0 + left as f32))
335                    },
336                ];
337                pending &= pending - 1;
338            }
339        }
340    }
341
342    fn merge_from(&mut self, other: &Self) {
343        for &word_idx in &other.touched_words {
344            let word_idx = word_idx as usize;
345            let mut pending = other.initialized[word_idx];
346            while pending != 0 {
347                let bit = pending.trailing_zeros() as usize;
348                let term = word_idx * 64 + bit;
349                // SAFETY: `pending` is derived from the initialized bitmap.
350                let [left, right] = unsafe { *other.values[term].assume_init_ref() };
351                let entry = self.entry_mut(term);
352                entry[0] += left;
353                entry[1] += right;
354                pending &= pending - 1;
355            }
356        }
357    }
358
359    /// Apply directional movement counts accumulated in a reusable lane.
360    ///
361    /// Each entry is `[right_to_left, left_to_right]`. Keeping two unsigned
362    /// counters lets partition workers reuse the exact same storage as degree
363    /// construction instead of allocating a separate signed delta table.
364    fn apply_moves_to(&self, degrees: &mut Self) {
365        for &word_idx in &self.touched_words {
366            let word_idx = word_idx as usize;
367            let mut pending = self.initialized[word_idx];
368            while pending != 0 {
369                let bit = pending.trailing_zeros() as usize;
370                let term = word_idx * 64 + bit;
371                // SAFETY: `pending` is derived from the initialized bitmap.
372                let [right_to_left, left_to_right] =
373                    unsafe { *self.values[term].assume_init_ref() };
374                debug_assert!(
375                    degrees.initialized[word_idx] & (1u64 << bit) != 0,
376                    "a moved term must already exist in the partition degrees"
377                );
378                let degree = degrees.entry_mut(term);
379                let new_left =
380                    i64::from(degree[0]) + i64::from(right_to_left) - i64::from(left_to_right);
381                let new_right =
382                    i64::from(degree[1]) + i64::from(left_to_right) - i64::from(right_to_left);
383                debug_assert!(new_left >= 0 && new_right >= 0);
384                debug_assert!(new_left <= i64::from(u32::MAX));
385                debug_assert!(new_right <= i64::from(u32::MAX));
386                degree[0] = new_left as u32;
387                degree[1] = new_right as u32;
388                pending &= pending - 1;
389            }
390        }
391    }
392
393    /// Exact bisection objective optimized by the BP gain approximation.
394    ///
395    /// This returns the negative assignment-dependent BiMLogA bisection cost
396    /// from Dhulipala et al., so a larger value means lower cost. Keeping the
397    /// partition-size term matters for odd-sized partitions, whose halves
398    /// differ by one entity.
399    fn bisection_objective(&self, left_size: usize, right_size: usize, log_table: &[f32]) -> f64 {
400        let mut objective = 0.0f64;
401        let side_log = [
402            fast_log2_lookup(left_size, log_table) as f64,
403            fast_log2_lookup(right_size, log_table) as f64,
404        ];
405        for &word_idx in &self.touched_words {
406            let word_idx = word_idx as usize;
407            let mut pending = self.initialized[word_idx];
408            while pending != 0 {
409                let bit = pending.trailing_zeros() as usize;
410                let term = word_idx * 64 + bit;
411                // SAFETY: `pending` is derived from the initialized bitmap.
412                let [left, right] = unsafe { *self.values[term].assume_init_ref() };
413                for (side, count) in [left, right].into_iter().enumerate() {
414                    if count > 0 {
415                        objective += count as f64
416                            * (fast_log2_lookup(count as usize + 1, log_table) as f64
417                                - side_log[side]);
418                    }
419                }
420                pending &= pending - 1;
421            }
422        }
423        objective
424    }
425}
426
427// ── Forward index (CSR) ──────────────────────────────────────────────────
428
429/// Forward index in CSR format: doc `d`'s terms are `terms[offsets[d]..offsets[d+1]]`.
430///
431/// Term IDs are remapped to compact range `0..num_terms` for flat-array degree tracking.
432pub(crate) struct ForwardIndex {
433    terms: Vec<u32>,
434    /// u64, not u32: a 58M-doc / ~85-dims-per-doc reorder pass carries ~4.9B
435    /// postings — u32 prefix sums wrapped and the CSR carving panicked
436    /// (prod 2026-07-14, "mid > len"). The old 8 GB memory budget masked it
437    /// by dropping dims below the u32 limit.
438    offsets: Vec<u64>,
439    pub num_terms: usize,
440    /// Exact number of simultaneous vocabulary-sized degree-array lanes
441    /// allowed by the memory budget. Recursion divides this allowance between
442    /// children without rounding non-power-of-two worker pools down.
443    parallel_bisect_lanes: usize,
444    cache_gains: bool,
445    /// True when the configured memory limit forced graph signal to be
446    /// discarded. Callers must not report the resulting order as fully
447    /// converged: a later pass with a larger budget may still improve it.
448    budget_limited: bool,
449}
450
451/// Build CSR offsets (prefix sums) from per-entity counts. u64 output — the
452/// sum of counts legitimately exceeds u32::MAX on large reorder passes.
453fn build_csr_offsets(
454    counts: &[u32],
455    check_cancel: &(impl Fn() -> crate::Result<()> + Sync),
456) -> crate::Result<Vec<u64>> {
457    let mut offsets = Vec::with_capacity(counts.len() + 1);
458    offsets.push(0u64);
459    for (i, &c) in counts.iter().enumerate() {
460        if i.is_multiple_of(256) {
461            check_cancel()?;
462        }
463        offsets.push(offsets.last().unwrap() + c as u64);
464    }
465    Ok(offsets)
466}
467
468impl ForwardIndex {
469    /// Build from a ready CSR (`terms[offsets[d]..offsets[d + 1]]` are the
470    /// compact term ids of entity `d`), e.g. the postings of a chunked text
471    /// field keyed by virtual chunk id.
472    pub(crate) fn from_csr(
473        terms: Vec<u32>,
474        offsets: Vec<u64>,
475        num_terms: usize,
476        memory_budget_bytes: usize,
477        budget_limited: bool,
478    ) -> Self {
479        let non_degree_bytes = terms
480            .len()
481            .saturating_mul(std::mem::size_of::<u32>())
482            .saturating_add(offsets.len().saturating_sub(1).saturating_mul(32));
483        let lanes = parallel_bisect_lanes(memory_budget_bytes, non_degree_bytes, num_terms);
484        Self {
485            terms,
486            offsets,
487            num_terms,
488            parallel_bisect_lanes: lanes.max(1),
489            // The text caller retains additional plans/construction buffers
490            // outside this graph's accounting. It cannot safely admit the
491            // optional cache until it supplies a complete remaining budget.
492            cache_gains: false,
493            budget_limited,
494        }
495    }
496
497    #[inline]
498    pub fn num_docs(&self) -> usize {
499        if self.offsets.is_empty() {
500            0
501        } else {
502            self.offsets.len() - 1
503        }
504    }
505
506    #[inline]
507    fn doc_terms(&self, doc: usize) -> &[u32] {
508        let start = self.offsets[doc] as usize;
509        let end = self.offsets[doc + 1] as usize;
510        &self.terms[start..end]
511    }
512
513    /// Total postings in the forward index.
514    pub fn total_postings(&self) -> u64 {
515        self.offsets.last().copied().unwrap_or(0)
516    }
517
518    #[inline]
519    pub fn budget_limited(&self) -> bool {
520        self.budget_limited
521    }
522}
523
524/// Build virtual→real and real→virtual vid maps from a BMP doc map.
525///
526/// A virtual slot is real iff its doc-map entry is not the `u32::MAX` padding
527/// sentinel. Realness must come from the doc map itself: block-copy merged
528/// segments carry each source's tail padding as *interior* padding, so
529/// `vid < num_real_docs` does NOT identify real docs there.
530///
531/// Returns `(virtual_to_real, real_to_virtual)` where `virtual_to_real[vid]`
532/// is the dense real index or `u32::MAX` for padding.
533pub(crate) fn build_vid_maps(
534    bmp: &crate::segment::reader::bmp::BmpIndex,
535    check_cancel: &(impl Fn() -> crate::Result<()> + Sync),
536) -> crate::Result<(Vec<u32>, Vec<u32>)> {
537    check_cancel()?;
538    let ids = bmp.doc_map_ids_slice();
539    let num_virtual = bmp.num_virtual_docs as usize;
540    let expected_real = bmp.num_real_docs() as usize;
541    let mut virtual_to_real = vec![u32::MAX; num_virtual];
542    let mut real_to_virtual = Vec::with_capacity(expected_real);
543    debug_assert_eq!(ids.len(), virtual_to_real.len() * 4);
544    bmp.visit_real_slots_for_rewrite(check_cancel, |vid| {
545        virtual_to_real[vid] = real_to_virtual.len() as u32;
546        real_to_virtual.push(vid as u32);
547    })?;
548    Ok((virtual_to_real, real_to_virtual))
549}
550
551/// One (source, block) unit of forward-index construction. Because
552/// [`build_vid_maps`] assigns real ids in ascending vid order, a block's real
553/// docs form the contiguous per-source range
554/// `real_start..real_start + real_len` — blocks can be processed in parallel
555/// with disjoint output slices.
556struct BlockJob {
557    src: u32,
558    block_id: u32,
559    /// Per-source real index of the block's first real doc.
560    real_start: u32,
561    /// Number of real (non-padding) docs in the block.
562    real_len: u32,
563}
564
565/// Enumerate jobs in (source, block) order — cumulative `real_len` tiles the
566/// global real-id space `0..total_docs` exactly.
567fn build_block_jobs(
568    bmps: &[&crate::segment::reader::bmp::BmpIndex],
569    vid_maps: &[(Vec<u32>, Vec<u32>)],
570    check_cancel: &(impl Fn() -> crate::Result<()> + Sync),
571) -> crate::Result<Vec<BlockJob>> {
572    let total_blocks: usize = bmps.iter().map(|b| b.num_blocks as usize).sum();
573    let mut jobs = Vec::with_capacity(total_blocks);
574    for (src, (bmp, (v2r, _))) in bmps.iter().zip(vid_maps).enumerate() {
575        let block_size = bmp.bmp_block_size as usize;
576        let mut real_cursor = 0u32;
577        for block_id in 0..bmp.num_blocks as usize {
578            check_cancel()?;
579            let vid_start = block_id * block_size;
580            let vid_end = ((block_id + 1) * block_size).min(v2r.len());
581            let real_len = v2r[vid_start..vid_end]
582                .iter()
583                .filter(|&&r| r != u32::MAX)
584                .count() as u32;
585            jobs.push(BlockJob {
586                src: src as u32,
587                block_id: block_id as u32,
588                real_start: real_cursor,
589                real_len,
590            });
591            real_cursor += real_len;
592        }
593    }
594    Ok(jobs)
595}
596
597fn visit_bmp_job(
598    job: &BlockJob,
599    bmps: &[&crate::segment::reader::bmp::BmpIndex],
600    vid_maps: &[(Vec<u32>, Vec<u32>)],
601    forward_views: &[Option<crate::segment::bmp_forward::ValidatedForward<'_>>],
602    mut visit: impl FnMut(u32, u32),
603    check_cancel: &(impl Fn() -> crate::Result<()> + Sync),
604) -> crate::Result<()> {
605    check_cancel()?;
606    let bmp = bmps[job.src as usize];
607    let (v2r, r2v) = &vid_maps[job.src as usize];
608    if let Some(view) = &forward_views[job.src as usize] {
609        let forward = bmp.forward().expect("validated forward source");
610        for real in job.real_start..job.real_start + job.real_len {
611            check_cancel()?;
612            let (doc, ordinal) = bmp.virtual_to_doc(r2v[real as usize]);
613            let index = forward
614                .find(crate::segment::logical_address::LogicalUnit { doc, ordinal })
615                .expect("validated forward key");
616            for (dim, _) in view.vector(index).iter() {
617                visit(real, dim);
618            }
619        }
620    } else {
621        for (dim, _, postings) in bmp.iter_block_terms(job.block_id) {
622            check_cancel()?;
623            for posting in postings {
624                let vid = job.block_id as usize * bmp.bmp_block_size as usize
625                    + posting.local_slot as usize;
626                if let Some(&real) = v2r.get(vid)
627                    && real != u32::MAX
628                    && posting.impact > 0
629                {
630                    visit(real, dim);
631                }
632            }
633        }
634    }
635    Ok(())
636}
637
638/// Build forward index from BmpIndex sources (single or multi-source).
639///
640/// Documents are identified by dense *real* indices assigned sequentially
641/// across sources: source 0 gets 0..n0, source 1 gets n0..n0+n1, etc., where
642/// each n is the source's real (non-padding) doc count derived from its doc
643/// map via [`build_vid_maps`].
644///
645/// Filters dims with doc_freq outside `[min_doc_freq, max_doc_freq]`.
646/// If the estimated forward index memory exceeds `memory_budget_bytes`, the
647/// highest-frequency dims are dropped to stay within budget. This prevents OOM
648/// for huge segments at the cost of slightly reduced reorder quality.
649///
650/// Remaps term IDs to compact range for flat-array degree tracking.
651#[cfg(test)]
652pub(crate) fn build_forward_index_from_bmps(
653    bmps: &[&crate::segment::reader::bmp::BmpIndex],
654    min_doc_freq: usize,
655    max_doc_freq: usize,
656    memory_budget_bytes: usize,
657) -> crate::Result<ForwardIndex> {
658    let vid_maps: Vec<(Vec<u32>, Vec<u32>)> = bmps
659        .iter()
660        .map(|bmp| build_vid_maps(bmp, &|| Ok(())))
661        .collect::<crate::Result<_>>()?;
662    build_forward_index_from_bmps_with_maps(
663        bmps,
664        &vid_maps,
665        min_doc_freq,
666        max_doc_freq,
667        memory_budget_bytes,
668        &|| Ok(()),
669    )
670}
671
672/// Variant for reorder callers that already need the virtual/real maps during
673/// output encoding. Reusing them avoids a second full document-map scan and a
674/// duplicate real-to-virtual allocation on very large segments.
675pub(crate) fn build_forward_index_from_bmps_with_maps(
676    bmps: &[&crate::segment::reader::bmp::BmpIndex],
677    vid_maps: &[(Vec<u32>, Vec<u32>)],
678    min_doc_freq: usize,
679    max_doc_freq: usize,
680    memory_budget_bytes: usize,
681    check_cancel: &(impl Fn() -> crate::Result<()> + Sync),
682) -> crate::Result<ForwardIndex> {
683    check_cancel()?;
684    debug_assert_eq!(bmps.len(), vid_maps.len());
685    let total_docs: usize = vid_maps.iter().map(|(_, r2v)| r2v.len()).sum();
686
687    if total_docs == 0 {
688        return Ok(ForwardIndex {
689            terms: Vec::new(),
690            offsets: Vec::new(),
691            num_terms: 0,
692            parallel_bisect_lanes: 1,
693            cache_gains: false,
694            budget_limited: false,
695        });
696    }
697
698    // Job list: one entry per (source, block). Real ids are assigned in
699    // ascending vid order (see build_vid_maps), so each block owns a
700    // contiguous real-id range — every phase below can process blocks in
701    // parallel, writing disjoint slices.
702    let jobs = build_block_jobs(bmps, vid_maps, check_cancel)?;
703    check_cancel()?;
704    let forward_views = bmps
705        .iter()
706        .zip(vid_maps)
707        .map(|(bmp, (_, r2v))| {
708            let Some(forward) = bmp.forward() else {
709                return Ok(None);
710            };
711            for &vid in r2v {
712                check_cancel()?;
713                let (doc, ordinal) = bmp.virtual_to_doc(vid);
714                if forward
715                    .find(crate::segment::logical_address::LogicalUnit { doc, ordinal })
716                    .is_none()
717                {
718                    return Err(crate::Error::Corruption(
719                        "BMP physical vector missing from forward values".into(),
720                    ));
721                }
722            }
723            forward.validate_payload(check_cancel).map(Some)
724        })
725        .collect::<crate::Result<Vec<_>>>()?;
726
727    // Phase 1: count document frequencies in bounded worker-local tables.
728    // Shared atomics made popular dimensions a cache-coherence bottleneck;
729    // private dense tables avoid that while retaining an exact memory cap.
730    let max_dims = bmps
731        .iter()
732        .map(|bmp| bmp.dims() as usize)
733        .max()
734        .unwrap_or(0);
735    let jobs_bytes = jobs
736        .len()
737        .saturating_mul(std::mem::size_of::<BlockJob>().saturating_add(40));
738    let frequency_bytes = max_dims.saturating_mul(std::mem::size_of::<u32>());
739    if frequency_bytes > memory_budget_bytes.saturating_sub(jobs_bytes) {
740        log::warn!(
741            "[reorder] memory budget {} cannot hold the {} dimension-frequency table; using identity order",
742            crate::format_bytes(memory_budget_bytes as u64),
743            crate::format_bytes(frequency_bytes as u64),
744        );
745        return Ok(ForwardIndex {
746            terms: Vec::new(),
747            offsets: Vec::new(),
748            num_terms: 0,
749            parallel_bisect_lanes: 1,
750            cache_gains: false,
751            budget_limited: true,
752        });
753    }
754    let Some(dim_df) = count_frequencies_bounded(
755        &jobs,
756        max_dims,
757        memory_budget_bytes.saturating_sub(jobs_bytes),
758        |job, counts| {
759            visit_bmp_job(
760                job,
761                bmps,
762                vid_maps,
763                &forward_views,
764                |_, dim| {
765                    if let Some(total) = counts.get_mut(dim as usize) {
766                        *total = total.saturating_add(1);
767                    }
768                },
769                check_cancel,
770            )
771        },
772    )?
773    else {
774        log::warn!(
775            "[reorder] memory budget {} cannot hold a bounded dimension-frequency table; using identity order",
776            crate::format_bytes(memory_budget_bytes as u64),
777        );
778        return Ok(ForwardIndex {
779            terms: Vec::new(),
780            offsets: Vec::new(),
781            num_terms: 0,
782            parallel_bisect_lanes: 1,
783            cache_gains: false,
784            budget_limited: true,
785        });
786    };
787
788    // Retain the lowest-frequency candidates in a bounded heap while the
789    // frequency table is live. This makes candidate discovery itself obey the
790    // configured limit even for extremely large vocabularies.
791    let (mut eligible, mut budget_limited) = select_frequency_candidates(
792        &dim_df,
793        min_doc_freq,
794        max_doc_freq,
795        memory_budget_bytes
796            .saturating_sub(jobs_bytes)
797            .saturating_sub(frequency_bytes),
798    );
799    drop(dim_df);
800
801    // Memory budget: estimate forward index + bisection scratch.
802    // Includes jobs/slice descriptors, dense remap, all per-document scratch,
803    // and at least one exact TermDegrees allocation.
804    let entity_scratch_bytes = total_docs.saturating_mul(32);
805    let remap_bytes = max_dims.saturating_mul(4);
806    let fixed_bytes = entity_scratch_bytes
807        .saturating_add(remap_bytes)
808        .saturating_add(jobs_bytes);
809    let fit = fit_candidates_to_budget(
810        &mut eligible,
811        fixed_bytes,
812        memory_budget_bytes,
813        std::mem::size_of::<u32>(),
814    );
815    if fit.dropped > 0 {
816        budget_limited = true;
817        log::warn!(
818            "[reorder] memory budget {}: estimated {}, dropped {} highest-df dims, keeping {} ({} postings)",
819            crate::format_bytes(memory_budget_bytes as u64),
820            crate::format_bytes(fit.estimated_bytes as u64),
821            fit.dropped,
822            eligible.len(),
823            fit.retained_postings,
824        );
825    }
826
827    if eligible.is_empty() {
828        // The caller emits an identity permutation when there is no graph
829        // signal. Avoid allocating per-document counts and u64 offsets only
830        // to discover that the terms array is empty, especially when the
831        // configured budget is below the fixed document scratch cost.
832        return Ok(ForwardIndex {
833            terms: Vec::new(),
834            offsets: Vec::new(),
835            num_terms: 0,
836            parallel_bisect_lanes: 1,
837            cache_gains: false,
838            budget_limited,
839        });
840    }
841
842    check_cancel()?;
843    let mut term_remap = vec![u32::MAX; max_dims];
844    for (compact_id, &(dim_id, _)) in eligible.iter().enumerate() {
845        term_remap[dim_id as usize] = compact_id as u32;
846    }
847    let num_active_terms = eligible.len();
848    // Jobs, candidate metadata, and the dense remap are construction scratch
849    // and are gone before graph recursion. Admit lanes against graph-resident
850    // CSR/entity scratch so non-power-of-two pools are not needlessly rounded
851    // down under a tight budget.
852    let non_degree_bytes = entity_scratch_bytes.saturating_add(
853        fit.retained_postings
854            .saturating_mul(std::mem::size_of::<u32>()),
855    );
856    let parallel_bisect_lanes =
857        parallel_bisect_lanes(memory_budget_bytes, non_degree_bytes, num_active_terms);
858    drop(eligible);
859
860    // Phase 2: count terms per doc (filtered) — per-block disjoint slices
861    let mut counts = vec![0u32; total_docs];
862    let fill_block_counts = |job: &BlockJob, out: &mut [u32]| {
863        visit_bmp_job(
864            job,
865            bmps,
866            vid_maps,
867            &forward_views,
868            |real, dim| {
869                if term_remap.get(dim as usize).copied().unwrap_or(u32::MAX) != u32::MAX {
870                    out[(real - job.real_start) as usize] += 1;
871                }
872            },
873            check_cancel,
874        )
875    };
876    {
877        let mut slices: Vec<(&BlockJob, &mut [u32])> = Vec::with_capacity(jobs.len());
878        let mut rest: &mut [u32] = &mut counts;
879        for job in &jobs {
880            check_cancel()?;
881            let (head, tail) = rest.split_at_mut(job.real_len as usize);
882            slices.push((job, head));
883            rest = tail;
884        }
885        #[cfg(feature = "native")]
886        slices
887            .into_par_iter()
888            .try_for_each(|(job, out)| fill_block_counts(job, out))?;
889        #[cfg(not(feature = "native"))]
890        for (job, out) in slices {
891            fill_block_counts(job, out)?;
892        }
893    }
894
895    // Phase 3: build CSR offsets (u64 — sums exceed u32::MAX at scale)
896    check_cancel()?;
897    let offsets = build_csr_offsets(&counts, check_cancel)?;
898    check_cancel()?;
899    let total = *offsets.last().unwrap() as usize;
900    drop(counts);
901
902    // Phase 4: fill terms (compact IDs) — each block writes the contiguous
903    // terms range covering its real docs; per-doc write cursors are local.
904    let mut terms = vec![0u32; total];
905    let fill_block_terms = |job: &BlockJob, global_real_start: usize, out: &mut [u32]| {
906        let mut cursor = [0u32; 256];
907        let base = offsets[global_real_start] as usize;
908        visit_bmp_job(
909            job,
910            bmps,
911            vid_maps,
912            &forward_views,
913            |real, dim| {
914                let compact = term_remap.get(dim as usize).copied().unwrap_or(u32::MAX);
915                if compact != u32::MAX {
916                    let local = (real - job.real_start) as usize;
917                    let pos =
918                        offsets[global_real_start + local] as usize - base + cursor[local] as usize;
919                    out[pos] = compact;
920                    cursor[local] += 1;
921                }
922            },
923            check_cancel,
924        )
925    };
926    {
927        let mut slices: Vec<(&BlockJob, usize, &mut [u32])> = Vec::with_capacity(jobs.len());
928        let mut rest: &mut [u32] = &mut terms;
929        let mut global_real = 0usize;
930        for job in &jobs {
931            check_cancel()?;
932            let len =
933                (offsets[global_real + job.real_len as usize] - offsets[global_real]) as usize;
934            let (head, tail) = rest.split_at_mut(len);
935            slices.push((job, global_real, head));
936            rest = tail;
937            global_real += job.real_len as usize;
938        }
939        #[cfg(feature = "native")]
940        slices
941            .into_par_iter()
942            .try_for_each(|(job, g, out)| fill_block_terms(job, g, out))?;
943        #[cfg(not(feature = "native"))]
944        for (job, g, out) in slices {
945            fill_block_terms(job, g, out)?;
946        }
947    }
948
949    check_cancel()?;
950    Ok(ForwardIndex {
951        terms,
952        offsets,
953        num_terms: num_active_terms,
954        parallel_bisect_lanes,
955        cache_gains: gain_cache_fits(
956            memory_budget_bytes,
957            non_degree_bytes,
958            num_active_terms,
959            parallel_bisect_lanes,
960        ),
961        budget_limited,
962    })
963}
964
965/// Build a forward index over BLOCKS (one entity per block, its terms = the
966/// block's header dim list). Used by block-level reorder: BP over blocks is
967/// ~block_size× cheaper than over records and only needs to decide superblock
968/// assignment. Blocks are numbered globally across sources in source order.
969///
970/// Dims appearing in fewer than 2 blocks carry no clustering signal and are
971/// dropped; the memory budget applies as in the record-level builder.
972pub(crate) fn build_forward_index_from_blocks(
973    bmps: &[&crate::segment::reader::bmp::BmpIndex],
974    memory_budget_bytes: usize,
975    check_cancel: &(impl Fn() -> crate::Result<()> + Sync),
976) -> crate::Result<ForwardIndex> {
977    check_cancel()?;
978    let total_blocks: usize = bmps.iter().map(|b| b.num_blocks as usize).sum();
979    if total_blocks == 0 {
980        return Ok(ForwardIndex {
981            terms: Vec::new(),
982            offsets: Vec::new(),
983            num_terms: 0,
984            parallel_bisect_lanes: 1,
985            cache_gains: false,
986            budget_limited: false,
987        });
988    }
989
990    // (source, block) pairs in global block order — the parallel unit.
991    let blocks: Vec<(u32, u32)> = bmps
992        .iter()
993        .enumerate()
994        .flat_map(|(src, bmp)| {
995            (0..bmp.num_blocks).map(move |b| {
996                check_cancel()?;
997                Ok((src as u32, b))
998            })
999        })
1000        .collect::<crate::Result<_>>()?;
1001
1002    // Phase 1: bounded worker-local frequency tables. This is the same policy
1003    // as record-level BP and avoids hot atomic increments on common terms.
1004    let max_dims = bmps
1005        .iter()
1006        .map(|bmp| bmp.dims() as usize)
1007        .max()
1008        .unwrap_or(0);
1009    let blocks_bytes = blocks
1010        .len()
1011        .saturating_mul(std::mem::size_of::<(u32, u32)>().saturating_add(32));
1012    let frequency_bytes = max_dims.saturating_mul(std::mem::size_of::<u32>());
1013    if frequency_bytes > memory_budget_bytes.saturating_sub(blocks_bytes) {
1014        log::warn!(
1015            "[reorder] block-level frequency table exceeds memory budget; using identity order"
1016        );
1017        return Ok(ForwardIndex {
1018            terms: Vec::new(),
1019            offsets: Vec::new(),
1020            num_terms: 0,
1021            parallel_bisect_lanes: 1,
1022            cache_gains: false,
1023            budget_limited: true,
1024        });
1025    }
1026    let Some(dim_bf) = count_frequencies_bounded(
1027        &blocks,
1028        max_dims,
1029        memory_budget_bytes.saturating_sub(blocks_bytes),
1030        |&(src, block_id), counts| {
1031            check_cancel()?;
1032            for (dim_id, _, _) in bmps[src as usize].iter_block_terms(block_id) {
1033                check_cancel()?;
1034                if let Some(count) = counts.get_mut(dim_id as usize) {
1035                    *count = count.saturating_add(1);
1036                }
1037            }
1038            Ok(())
1039        },
1040    )?
1041    else {
1042        log::warn!(
1043            "[reorder] block-level frequency table cannot fit its bounded allocation; using identity order"
1044        );
1045        return Ok(ForwardIndex {
1046            terms: Vec::new(),
1047            offsets: Vec::new(),
1048            num_terms: 0,
1049            parallel_bisect_lanes: 1,
1050            cache_gains: false,
1051            budget_limited: true,
1052        });
1053    };
1054
1055    let max_bf = (total_blocks as f64 * 0.9) as usize;
1056    let (mut eligible, mut budget_limited) = select_frequency_candidates(
1057        &dim_bf,
1058        2,
1059        max_bf.max(2),
1060        memory_budget_bytes
1061            .saturating_sub(blocks_bytes)
1062            .saturating_sub(frequency_bytes),
1063    );
1064    drop(dim_bf);
1065
1066    let entity_scratch_bytes = total_blocks.saturating_mul(32);
1067    let remap_bytes = max_dims.saturating_mul(4);
1068    let fixed_bytes = entity_scratch_bytes
1069        .saturating_add(remap_bytes)
1070        .saturating_add(blocks_bytes);
1071    let fit = fit_candidates_to_budget(
1072        &mut eligible,
1073        fixed_bytes,
1074        memory_budget_bytes,
1075        std::mem::size_of::<u32>(),
1076    );
1077    if fit.dropped > 0 {
1078        budget_limited = true;
1079        log::warn!(
1080            "[reorder] block-level fwd index over budget — dropped {} highest-bf dims",
1081            fit.dropped,
1082        );
1083    }
1084
1085    if eligible.is_empty() {
1086        return Ok(ForwardIndex {
1087            terms: Vec::new(),
1088            offsets: Vec::new(),
1089            num_terms: 0,
1090            parallel_bisect_lanes: 1,
1091            cache_gains: false,
1092            budget_limited,
1093        });
1094    }
1095
1096    let mut term_remap = vec![u32::MAX; max_dims];
1097    for (compact, &(dim_id, _)) in eligible.iter().enumerate() {
1098        term_remap[dim_id as usize] = compact as u32;
1099    }
1100    let num_terms = eligible.len();
1101    let non_degree_bytes = entity_scratch_bytes.saturating_add(
1102        fit.retained_postings
1103            .saturating_mul(std::mem::size_of::<u32>()),
1104    );
1105    let parallel_bisect_lanes =
1106        parallel_bisect_lanes(memory_budget_bytes, non_degree_bytes, num_terms);
1107    drop(eligible);
1108
1109    // Phase 2+3: counts and CSR fill — one entity per block, so each block
1110    // maps to a single count cell and a contiguous terms range.
1111    let count_remapped = |&(src, block_id): &(u32, u32)| -> crate::Result<u32> {
1112        check_cancel()?;
1113        let mut count = 0;
1114        for (dim_id, _, _) in bmps[src as usize].iter_block_terms(block_id) {
1115            check_cancel()?;
1116            check_cancel()?;
1117            if term_remap.get(dim_id as usize).copied().unwrap_or(u32::MAX) != u32::MAX {
1118                count += 1;
1119            }
1120        }
1121        Ok(count)
1122    };
1123    #[cfg(feature = "native")]
1124    let counts: Vec<u32> = blocks
1125        .par_iter()
1126        .map(count_remapped)
1127        .collect::<crate::Result<_>>()?;
1128    #[cfg(not(feature = "native"))]
1129    let counts: Vec<u32> = blocks
1130        .iter()
1131        .map(count_remapped)
1132        .collect::<crate::Result<_>>()?;
1133
1134    let offsets = build_csr_offsets(&counts, check_cancel)?;
1135    let total = *offsets.last().unwrap() as usize;
1136    drop(counts);
1137
1138    let mut terms = vec![0u32; total];
1139    let fill_block = |&(src, block_id): &(u32, u32), out: &mut [u32]| -> crate::Result<()> {
1140        check_cancel()?;
1141        let mut n = 0usize;
1142        for (dim_id, _, _) in bmps[src as usize].iter_block_terms(block_id) {
1143            check_cancel()?;
1144            let compact = term_remap.get(dim_id as usize).copied().unwrap_or(u32::MAX);
1145            if compact != u32::MAX {
1146                out[n] = compact;
1147                n += 1;
1148            }
1149        }
1150        Ok(())
1151    };
1152    {
1153        let mut slices: Vec<(&(u32, u32), &mut [u32])> = Vec::with_capacity(blocks.len());
1154        let mut rest: &mut [u32] = &mut terms;
1155        for (gb, b) in blocks.iter().enumerate() {
1156            check_cancel()?;
1157            let len = (offsets[gb + 1] - offsets[gb]) as usize;
1158            let (head, tail) = rest.split_at_mut(len);
1159            slices.push((b, head));
1160            rest = tail;
1161        }
1162        #[cfg(feature = "native")]
1163        slices
1164            .into_par_iter()
1165            .try_for_each(|(b, out)| fill_block(b, out))?;
1166        #[cfg(not(feature = "native"))]
1167        for (b, out) in slices {
1168            fill_block(b, out)?;
1169        }
1170    }
1171
1172    check_cancel()?;
1173    Ok(ForwardIndex {
1174        terms,
1175        offsets,
1176        num_terms,
1177        parallel_bisect_lanes,
1178        cache_gains: gain_cache_fits(
1179            memory_budget_bytes,
1180            non_degree_bytes,
1181            num_terms,
1182            parallel_bisect_lanes,
1183        ),
1184        budget_limited,
1185    })
1186}
1187
1188// ── Recursive Graph Bisection ────────────────────────────────────────────
1189
1190/// CPU/depth budget for a BP pass. BP is an anytime algorithm: stopping at
1191/// any depth or deadline still yields a valid permutation, and because the
1192/// output layout becomes the next pass's input order, repeated budgeted
1193/// passes warm-start and deepen (top levels converge in ~0 swaps, the budget
1194/// flows to deeper levels).
1195#[derive(Clone, Copy, Debug, Default)]
1196pub struct BpBudget {
1197    /// Stop recursion at partitions of at most this many docs instead of
1198    /// descending to block granularity. `None` = full depth. Capping at
1199    /// superblock granularity (superblock_size × block_size docs) keeps most
1200    /// of the superblock-pruning win at ~⅓ less depth.
1201    pub min_partition_docs: Option<usize>,
1202    /// Wall-clock cap for the whole BP computation. The pass ends cleanly at
1203    /// the deadline with whatever depth it reached (`converged = false`).
1204    /// Ignored on wasm (no monotonic clock).
1205    pub time_budget: Option<std::time::Duration>,
1206}
1207
1208impl BpBudget {
1209    /// Unbudgeted: full depth, no deadline.
1210    pub fn full() -> Self {
1211        Self::default()
1212    }
1213}
1214
1215/// Build one partition's term degrees in preallocated lanes.
1216///
1217/// At coarse levels each worker scans a contiguous document range into a
1218/// private lane, then the lanes are reduced into `workspaces[0]`. Recursive
1219/// siblings receive disjoint workspace slices, so the complete pass performs
1220/// exactly the vocabulary-sized allocations admitted before BP starts.
1221fn build_term_degrees(
1222    docs: &[u32],
1223    mid: usize,
1224    fwd: &ForwardIndex,
1225    workspaces: &mut [TermDegrees],
1226    cancellation: Option<&std::sync::atomic::AtomicBool>,
1227) {
1228    debug_assert!(!workspaces.is_empty());
1229    let build_range = |degrees: &mut TermDegrees, start: usize, chunk: &[u32]| {
1230        degrees.reset();
1231        for (offset, &doc) in chunk.iter().enumerate() {
1232            if offset.is_multiple_of(1024)
1233                && cancellation
1234                    .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Relaxed))
1235            {
1236                break;
1237            }
1238            let side = usize::from(start + offset >= mid);
1239            for &term in fwd.doc_terms(doc as usize) {
1240                degrees.entry_mut(term as usize)[side] += 1;
1241            }
1242        }
1243    };
1244
1245    #[cfg(feature = "native")]
1246    {
1247        let workers = workspaces
1248            .len()
1249            .min(docs.len().div_ceil(PARALLEL_BP_MIN_ENTITIES).max(1));
1250        if workers > 1 {
1251            let chunk_len = docs.len().div_ceil(workers);
1252            workspaces[..workers]
1253                .par_iter_mut()
1254                .enumerate()
1255                .for_each(|(worker, degrees)| {
1256                    let start = worker * chunk_len;
1257                    let end = (start + chunk_len).min(docs.len());
1258                    build_range(degrees, start, &docs[start..end]);
1259                });
1260            let (degrees, partials) = workspaces[..workers]
1261                .split_first_mut()
1262                .expect("at least one BP degree workspace");
1263            for partial in partials {
1264                degrees.merge_from(partial);
1265            }
1266            return;
1267        }
1268        build_range(&mut workspaces[0], 0, docs);
1269    }
1270
1271    #[cfg(not(feature = "native"))]
1272    build_range(&mut workspaces[0], 0, docs);
1273}
1274
1275/// Convert `f32::total_cmp` ordering into an unsigned radix key.
1276///
1277/// BP gains are finite, but preserving the complete IEEE total order makes
1278/// the parallel selector exactly equivalent to the former comparator even if
1279/// malformed input ever produces a signed zero, infinity, or NaN.
1280#[inline]
1281fn gain_order_key(gain: f32) -> u32 {
1282    let bits = gain.to_bits();
1283    if bits & 0x8000_0000 != 0 {
1284        !bits
1285    } else {
1286        bits ^ 0x8000_0000
1287    }
1288}
1289
1290/// Find the gain key of the last entity admitted to the left half and the
1291/// number of strictly lower keys. Four byte-histogram passes replace the old
1292/// serial `select_nth_unstable_by` over an 8-byte index per entity.
1293fn select_gain_threshold(
1294    gains: &[f32],
1295    left_count: usize,
1296    allow_inner_parallelism: bool,
1297) -> (u32, usize) {
1298    #[cfg(not(feature = "native"))]
1299    let _ = allow_inner_parallelism;
1300
1301    debug_assert!(left_count > 0 && left_count <= gains.len());
1302    let mut rank_within_prefix = left_count - 1;
1303    let mut strictly_lower = 0usize;
1304    let mut prefix = 0u32;
1305    let mut prefix_mask = 0u32;
1306
1307    for shift in [24u32, 16, 8, 0] {
1308        let histogram = {
1309            #[cfg(feature = "native")]
1310            {
1311                if allow_inner_parallelism && gains.len() >= PARALLEL_BP_MIN_ENTITIES {
1312                    gains
1313                        .par_iter()
1314                        .fold(
1315                            || Box::new([0usize; 256]),
1316                            |mut counts, &gain| {
1317                                let key = gain_order_key(gain);
1318                                if key & prefix_mask == prefix {
1319                                    counts[((key >> shift) & 0xff) as usize] += 1;
1320                                }
1321                                counts
1322                            },
1323                        )
1324                        .reduce(
1325                            || Box::new([0usize; 256]),
1326                            |mut left, right| {
1327                                for (dst, &count) in left.iter_mut().zip(right.iter()) {
1328                                    *dst += count;
1329                                }
1330                                left
1331                            },
1332                        )
1333                } else {
1334                    let mut counts = [0usize; 256];
1335                    for &gain in gains {
1336                        let key = gain_order_key(gain);
1337                        if key & prefix_mask == prefix {
1338                            counts[((key >> shift) & 0xff) as usize] += 1;
1339                        }
1340                    }
1341                    Box::new(counts)
1342                }
1343            }
1344            #[cfg(not(feature = "native"))]
1345            {
1346                let mut counts = [0usize; 256];
1347                for &gain in gains {
1348                    let key = gain_order_key(gain);
1349                    if key & prefix_mask == prefix {
1350                        counts[((key >> shift) & 0xff) as usize] += 1;
1351                    }
1352                }
1353                counts
1354            }
1355        };
1356
1357        let mut before_bucket = 0usize;
1358        let mut selected_bucket = None;
1359        for (bucket, count) in histogram.iter().copied().enumerate() {
1360            if rank_within_prefix < before_bucket + count {
1361                selected_bucket = Some(bucket as u32);
1362                rank_within_prefix -= before_bucket;
1363                strictly_lower += before_bucket;
1364                break;
1365            }
1366            before_bucket += count;
1367        }
1368        let selected_bucket = selected_bucket.expect("BP radix selection lost the requested rank");
1369        prefix |= selected_bucket << shift;
1370        prefix_mask |= 0xffu32 << shift;
1371    }
1372
1373    (prefix, strictly_lower)
1374}
1375
1376enum PartitionDegreeUpdate {
1377    /// Parallel workers accumulated directional movement counts in the first
1378    /// reusable movement workspace.
1379    Moves,
1380    /// Small partitions use the former unstable selection order. Keep its
1381    /// reusable rank map long enough to update only records that crossed the
1382    /// cut.
1383    Ranked,
1384    /// A large partition with only one affordable degree lane still uses the
1385    /// bounded-memory radix selector, then applies deltas serially.
1386    Threshold {
1387        threshold_key: u32,
1388        ties_left: usize,
1389    },
1390}
1391
1392struct PartitionOutcome {
1393    swap_count: usize,
1394    degree_update: PartitionDegreeUpdate,
1395}
1396
1397#[derive(Clone, Copy)]
1398struct PartitionChunk {
1399    start: usize,
1400    end: usize,
1401    strictly_lower: usize,
1402    equal: usize,
1403    ties_left: usize,
1404}
1405
1406#[inline]
1407fn select_left(key: u32, threshold_key: u32, equal_seen: &mut usize, ties_left: usize) -> bool {
1408    if key < threshold_key {
1409        true
1410    } else if key == threshold_key {
1411        let selected = *equal_seen < ties_left;
1412        *equal_seen += 1;
1413        selected
1414    } else {
1415        false
1416    }
1417}
1418
1419/// Exact, deterministic parallel partition by `(gain.total_cmp(), old_index)`.
1420///
1421/// Output is stable within each half. Parallel workers also accumulate
1422/// per-term degree deltas for moved entities, eliminating the former serial
1423/// postings update without rescanning every posting after each iteration.
1424/// Small partitions retain the old unstable selector in their preallocated
1425/// rank slice: it is faster than four radix scans, and its within-half
1426/// permutation supplies the graph algorithm's established symmetry breaking.
1427#[allow(clippy::too_many_arguments)]
1428fn partition_by_gain(
1429    docs: &[u32],
1430    gains: &[f32],
1431    mid: usize,
1432    fwd: &ForwardIndex,
1433    movement_workspaces: &mut [TermDegrees],
1434    output: &mut [u32],
1435    ranked_scratch: &mut [usize],
1436    allow_inner_parallelism: bool,
1437) -> PartitionOutcome {
1438    #[cfg(not(feature = "native"))]
1439    let _ = (fwd, &movement_workspaces);
1440
1441    #[cfg(feature = "native")]
1442    if allow_inner_parallelism
1443        && !movement_workspaces.is_empty()
1444        && docs.len() >= PARALLEL_BP_MIN_ENTITIES
1445    {
1446        let (threshold_key, strictly_lower) =
1447            select_gain_threshold(gains, mid, allow_inner_parallelism);
1448        let ties_left = mid - strictly_lower;
1449
1450        // `degrees` remains live while these deltas are built. Reserving one
1451        // lane for it keeps total vocabulary arrays within the admitted set.
1452        let chunk_count = movement_workspaces
1453            .len()
1454            .min(docs.len().div_ceil(PARALLEL_BP_MIN_ENTITIES).max(1));
1455        let chunk_len = docs.len().div_ceil(chunk_count);
1456        let mut chunks: Vec<PartitionChunk> = gains
1457            .par_chunks(chunk_len)
1458            .enumerate()
1459            .map(|(chunk_id, chunk)| {
1460                let mut lower = 0usize;
1461                let mut equal = 0usize;
1462                for &gain in chunk {
1463                    match gain_order_key(gain).cmp(&threshold_key) {
1464                        std::cmp::Ordering::Less => lower += 1,
1465                        std::cmp::Ordering::Equal => equal += 1,
1466                        std::cmp::Ordering::Greater => {}
1467                    }
1468                }
1469                let start = chunk_id * chunk_len;
1470                PartitionChunk {
1471                    start,
1472                    end: start + chunk.len(),
1473                    strictly_lower: lower,
1474                    equal,
1475                    ties_left: 0,
1476                }
1477            })
1478            .collect();
1479
1480        let mut remaining_ties = ties_left;
1481        for chunk in &mut chunks {
1482            chunk.ties_left = remaining_ties.min(chunk.equal);
1483            remaining_ties -= chunk.ties_left;
1484        }
1485        debug_assert_eq!(remaining_ties, 0);
1486
1487        let (mut left_rest, mut right_rest) = output.split_at_mut(mid);
1488        let mut jobs = Vec::with_capacity(chunks.len());
1489        for chunk in chunks {
1490            let left_len = chunk.strictly_lower + chunk.ties_left;
1491            let right_len = chunk.end - chunk.start - left_len;
1492            let (left_out, next_left) = left_rest.split_at_mut(left_len);
1493            let (right_out, next_right) = right_rest.split_at_mut(right_len);
1494            jobs.push((
1495                chunk.start,
1496                &docs[chunk.start..chunk.end],
1497                &gains[chunk.start..chunk.end],
1498                chunk.ties_left,
1499                left_out,
1500                right_out,
1501            ));
1502            left_rest = next_left;
1503            right_rest = next_right;
1504        }
1505        debug_assert!(left_rest.is_empty() && right_rest.is_empty());
1506
1507        let swap_count = movement_workspaces[..chunk_count]
1508            .par_iter_mut()
1509            .zip(jobs.into_par_iter())
1510            .map(
1511                |(moves, (start, docs, gains, ties_for_chunk, left_out, right_out))| {
1512                    moves.reset();
1513                    let mut equal_seen = 0usize;
1514                    let mut left_cursor = 0usize;
1515                    let mut right_cursor = 0usize;
1516                    let mut swaps = 0usize;
1517
1518                    for (offset, (&doc, &gain)) in docs.iter().zip(gains).enumerate() {
1519                        let key = gain_order_key(gain);
1520                        let now_left =
1521                            select_left(key, threshold_key, &mut equal_seen, ties_for_chunk);
1522                        if now_left {
1523                            left_out[left_cursor] = doc;
1524                            left_cursor += 1;
1525                        } else {
1526                            right_out[right_cursor] = doc;
1527                            right_cursor += 1;
1528                        }
1529
1530                        let was_left = start + offset < mid;
1531                        if was_left != now_left {
1532                            swaps += 1;
1533                            // [right→left, left→right]
1534                            let direction = usize::from(was_left);
1535                            for &term in fwd.doc_terms(doc as usize) {
1536                                moves.entry_mut(term as usize)[direction] += 1;
1537                            }
1538                        }
1539                    }
1540                    debug_assert_eq!(left_cursor, left_out.len());
1541                    debug_assert_eq!(right_cursor, right_out.len());
1542                    swaps
1543                },
1544            )
1545            .sum();
1546
1547        let (moves, partials) = movement_workspaces[..chunk_count]
1548            .split_first_mut()
1549            .expect("parallel BP partition must use at least one movement workspace");
1550        for partial in partials {
1551            moves.merge_from(partial);
1552        }
1553
1554        return PartitionOutcome {
1555            swap_count,
1556            degree_update: PartitionDegreeUpdate::Moves,
1557        };
1558    }
1559
1560    if docs.len() < PARALLEL_BP_MIN_ENTITIES {
1561        debug_assert_eq!(ranked_scratch.len(), docs.len());
1562        for (index, rank) in ranked_scratch.iter_mut().enumerate() {
1563            *rank = index;
1564        }
1565        ranked_scratch.select_nth_unstable_by(mid, |&left, &right| {
1566            gains[left]
1567                .total_cmp(&gains[right])
1568                .then_with(|| left.cmp(&right))
1569        });
1570
1571        let mut swaps = 0usize;
1572        for (rank, &old_index) in ranked_scratch.iter().enumerate() {
1573            output[rank] = docs[old_index];
1574            swaps += usize::from((old_index < mid) != (rank < mid));
1575        }
1576        return PartitionOutcome {
1577            swap_count: swaps,
1578            degree_update: PartitionDegreeUpdate::Ranked,
1579        };
1580    }
1581
1582    let (threshold_key, strictly_lower) =
1583        select_gain_threshold(gains, mid, allow_inner_parallelism);
1584    let ties_left = mid - strictly_lower;
1585    let mut equal_seen = 0usize;
1586    let mut left_cursor = 0usize;
1587    let mut right_cursor = mid;
1588    let mut swaps = 0usize;
1589    for (idx, (&doc, &gain)) in docs.iter().zip(gains).enumerate() {
1590        let key = gain_order_key(gain);
1591        let now_left = select_left(key, threshold_key, &mut equal_seen, ties_left);
1592        if now_left {
1593            output[left_cursor] = doc;
1594            left_cursor += 1;
1595        } else {
1596            output[right_cursor] = doc;
1597            right_cursor += 1;
1598        }
1599        swaps += usize::from((idx < mid) != now_left);
1600    }
1601    debug_assert_eq!(left_cursor, mid);
1602    debug_assert_eq!(right_cursor, docs.len());
1603
1604    PartitionOutcome {
1605        swap_count: swaps,
1606        degree_update: PartitionDegreeUpdate::Threshold {
1607            threshold_key,
1608            ties_left,
1609        },
1610    }
1611}
1612
1613/// Apply degree changes for a bounded-memory serial radix partition.
1614fn update_degrees_for_threshold_partition(
1615    docs: &[u32],
1616    gains: &[f32],
1617    mid: usize,
1618    threshold_key: u32,
1619    ties_left: usize,
1620    fwd: &ForwardIndex,
1621    degrees: &mut TermDegrees,
1622) {
1623    let mut equal_seen = 0usize;
1624    for (idx, (&doc, &gain)) in docs.iter().zip(gains).enumerate() {
1625        let key = gain_order_key(gain);
1626        let now_left = select_left(key, threshold_key, &mut equal_seen, ties_left);
1627        let was_left = idx < mid;
1628        if was_left == now_left {
1629            continue;
1630        }
1631        let left_delta = if was_left { -1i64 } else { 1i64 };
1632        for &term in fwd.doc_terms(doc as usize) {
1633            let degree = degrees.entry_mut(term as usize);
1634            let new_left = degree[0] as i64 + left_delta;
1635            let new_right = degree[1] as i64 - left_delta;
1636            debug_assert!(new_left >= 0 && new_right >= 0);
1637            degree[0] = new_left as u32;
1638            degree[1] = new_right as u32;
1639        }
1640    }
1641}
1642
1643/// Apply degree changes using the exact unstable rank order selected for a
1644/// small partition.
1645fn update_degrees_for_ranked_partition(
1646    docs: &[u32],
1647    ranked: &[usize],
1648    mid: usize,
1649    fwd: &ForwardIndex,
1650    degrees: &mut TermDegrees,
1651) {
1652    for (rank, &old_index) in ranked.iter().enumerate() {
1653        let was_left = old_index < mid;
1654        let now_left = rank < mid;
1655        if was_left == now_left {
1656            continue;
1657        }
1658        let left_delta = if was_left { -1i64 } else { 1i64 };
1659        for &term in fwd.doc_terms(docs[old_index] as usize) {
1660            let degree = degrees.entry_mut(term as usize);
1661            let new_left = degree[0] as i64 + left_delta;
1662            let new_right = degree[1] as i64 - left_delta;
1663            debug_assert!(new_left >= 0 && new_right >= 0);
1664            degree[0] = new_left as u32;
1665            degree[1] = new_right as u32;
1666        }
1667    }
1668}
1669
1670#[derive(Clone, Copy)]
1671pub(crate) struct BpProgressLabel<'a> {
1672    pub index: &'a str,
1673    pub field: &'a str,
1674    pub entity_kind: &'static str,
1675}
1676
1677#[cfg(test)]
1678impl BpProgressLabel<'static> {
1679    fn anonymous() -> Self {
1680        Self {
1681            index: "unknown",
1682            field: "unknown",
1683            entity_kind: "entities",
1684        }
1685    }
1686}
1687
1688#[cfg(feature = "native")]
1689struct BpProgress<'a> {
1690    label: BpProgressLabel<'a>,
1691    start: std::time::Instant,
1692    total_entities: usize,
1693    total_postings: u64,
1694    expected_depth: usize,
1695    next_log_ms: std::sync::atomic::AtomicU64,
1696    active_partitions: std::sync::atomic::AtomicU64,
1697    partitions_started: std::sync::atomic::AtomicU64,
1698    partitions_completed: std::sync::atomic::AtomicU64,
1699    iterations: std::sync::atomic::AtomicU64,
1700    entity_passes: std::sync::atomic::AtomicU64,
1701    swaps: std::sync::atomic::AtomicU64,
1702    deepest_level: std::sync::atomic::AtomicU64,
1703    objective_stops: std::sync::atomic::AtomicU64,
1704    last_objective_delta_bits: std::sync::atomic::AtomicU64,
1705    last_relative_delta_bits: std::sync::atomic::AtomicU64,
1706    active_metric_released: std::sync::atomic::AtomicBool,
1707}
1708
1709#[cfg(feature = "native")]
1710impl<'a> BpProgress<'a> {
1711    fn new(
1712        label: BpProgressLabel<'a>,
1713        total_entities: usize,
1714        total_postings: u64,
1715        expected_depth: usize,
1716        degree_lanes: usize,
1717    ) -> Self {
1718        log::info!(
1719            "[reorder][bp] started: index={} field={} entity_kind={} scheduler=level_synchronized degree_lanes={} entities={} postings={} expected_depth={} objective_stall_threshold={:.1e}x{} min_objective_iterations={}",
1720            label.index,
1721            label.field,
1722            label.entity_kind,
1723            degree_lanes,
1724            total_entities,
1725            total_postings,
1726            expected_depth,
1727            MIN_RELATIVE_OBJECTIVE_IMPROVEMENT,
1728            OBJECTIVE_STALL_ITERATIONS,
1729            MIN_OBJECTIVE_ITERATIONS,
1730        );
1731        crate::observe::reorder_bp_started(label.index, label.field, label.entity_kind);
1732        Self {
1733            label,
1734            start: std::time::Instant::now(),
1735            total_entities,
1736            total_postings,
1737            expected_depth,
1738            next_log_ms: std::sync::atomic::AtomicU64::new(30_000),
1739            active_partitions: std::sync::atomic::AtomicU64::new(0),
1740            partitions_started: std::sync::atomic::AtomicU64::new(0),
1741            partitions_completed: std::sync::atomic::AtomicU64::new(0),
1742            iterations: std::sync::atomic::AtomicU64::new(0),
1743            entity_passes: std::sync::atomic::AtomicU64::new(0),
1744            swaps: std::sync::atomic::AtomicU64::new(0),
1745            deepest_level: std::sync::atomic::AtomicU64::new(0),
1746            objective_stops: std::sync::atomic::AtomicU64::new(0),
1747            last_objective_delta_bits: std::sync::atomic::AtomicU64::new(0f64.to_bits()),
1748            last_relative_delta_bits: std::sync::atomic::AtomicU64::new(0f64.to_bits()),
1749            active_metric_released: std::sync::atomic::AtomicBool::new(false),
1750        }
1751    }
1752
1753    fn partition_started(&self, level: usize) {
1754        self.active_partitions
1755            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1756        self.partitions_started
1757            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1758        self.deepest_level
1759            .fetch_max(level as u64, std::sync::atomic::Ordering::Relaxed);
1760    }
1761
1762    fn partition_finished(&self) {
1763        self.partitions_completed
1764            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1765        self.active_partitions
1766            .fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
1767    }
1768
1769    fn iteration(&self, entities: usize, swaps: usize, objective_delta: f64, relative_delta: f64) {
1770        self.iterations
1771            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1772        self.entity_passes
1773            .fetch_add(entities as u64, std::sync::atomic::Ordering::Relaxed);
1774        self.swaps
1775            .fetch_add(swaps as u64, std::sync::atomic::Ordering::Relaxed);
1776        self.last_objective_delta_bits.store(
1777            objective_delta.to_bits(),
1778            std::sync::atomic::Ordering::Relaxed,
1779        );
1780        self.last_relative_delta_bits.store(
1781            relative_delta.to_bits(),
1782            std::sync::atomic::Ordering::Relaxed,
1783        );
1784        self.maybe_log();
1785    }
1786
1787    fn objective_stop(&self) {
1788        self.objective_stops
1789            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1790    }
1791
1792    fn maybe_log(&self) {
1793        let elapsed_ms = self.start.elapsed().as_millis().min(u64::MAX as u128) as u64;
1794        let next = self.next_log_ms.load(std::sync::atomic::Ordering::Relaxed);
1795        if elapsed_ms < next
1796            || self
1797                .next_log_ms
1798                .compare_exchange(
1799                    next,
1800                    elapsed_ms.saturating_add(30_000),
1801                    std::sync::atomic::Ordering::Relaxed,
1802                    std::sync::atomic::Ordering::Relaxed,
1803                )
1804                .is_err()
1805        {
1806            return;
1807        }
1808
1809        let active = self
1810            .active_partitions
1811            .load(std::sync::atomic::Ordering::Relaxed);
1812        let started = self
1813            .partitions_started
1814            .load(std::sync::atomic::Ordering::Relaxed);
1815        let completed = self
1816            .partitions_completed
1817            .load(std::sync::atomic::Ordering::Relaxed);
1818        let iterations = self.iterations.load(std::sync::atomic::Ordering::Relaxed);
1819        let entity_passes = self
1820            .entity_passes
1821            .load(std::sync::atomic::Ordering::Relaxed);
1822        let swaps = self.swaps.load(std::sync::atomic::Ordering::Relaxed);
1823        let deepest = self
1824            .deepest_level
1825            .load(std::sync::atomic::Ordering::Relaxed);
1826        let objective_delta = f64::from_bits(
1827            self.last_objective_delta_bits
1828                .load(std::sync::atomic::Ordering::Relaxed),
1829        );
1830        let relative_delta = f64::from_bits(
1831            self.last_relative_delta_bits
1832                .load(std::sync::atomic::Ordering::Relaxed),
1833        );
1834        log::info!(
1835            "[reorder][bp] progress: index={} field={} entity_kind={} elapsed={:.1}s depth={}/{} partitions={}/{} active={} iterations={} entity_passes={} swaps={} last_objective_delta={:.3} relative={:.3e}",
1836            self.label.index,
1837            self.label.field,
1838            self.label.entity_kind,
1839            self.start.elapsed().as_secs_f64(),
1840            deepest,
1841            self.expected_depth,
1842            completed,
1843            started,
1844            active,
1845            iterations,
1846            entity_passes,
1847            swaps,
1848            objective_delta,
1849            relative_delta,
1850        );
1851    }
1852
1853    fn finish(
1854        &self,
1855        converged: bool,
1856        memory_limited: bool,
1857        deadline_exhausted: bool,
1858        cancelled: bool,
1859    ) {
1860        let elapsed = self.start.elapsed().as_secs_f64();
1861        let partitions = self
1862            .partitions_completed
1863            .load(std::sync::atomic::Ordering::Relaxed);
1864        let iterations = self.iterations.load(std::sync::atomic::Ordering::Relaxed);
1865        let entity_passes = self
1866            .entity_passes
1867            .load(std::sync::atomic::Ordering::Relaxed);
1868        let swaps = self.swaps.load(std::sync::atomic::Ordering::Relaxed);
1869        let deepest = self
1870            .deepest_level
1871            .load(std::sync::atomic::Ordering::Relaxed);
1872        let objective_stops = self
1873            .objective_stops
1874            .load(std::sync::atomic::Ordering::Relaxed);
1875        let stop_reason = if cancelled {
1876            "shutdown"
1877        } else if memory_limited {
1878            "memory_budget"
1879        } else if deadline_exhausted {
1880            "time_budget"
1881        } else if objective_stops > 0 {
1882            "objective"
1883        } else {
1884            "complete"
1885        };
1886        log::info!(
1887            "[reorder][bp] completed: index={} field={} entity_kind={} entities={} postings={} elapsed={:.1}s depth={}/{} partitions={} iterations={} entity_passes={} swaps={} objective_stops={} converged={} stop_reason={}",
1888            self.label.index,
1889            self.label.field,
1890            self.label.entity_kind,
1891            self.total_entities,
1892            self.total_postings,
1893            elapsed,
1894            deepest,
1895            self.expected_depth,
1896            partitions,
1897            iterations,
1898            entity_passes,
1899            swaps,
1900            objective_stops,
1901            converged,
1902            stop_reason,
1903        );
1904        crate::observe::reorder_bp_pass(
1905            self.label.index,
1906            self.label.field,
1907            self.label.entity_kind,
1908            stop_reason,
1909            elapsed,
1910            self.total_entities,
1911            self.total_postings,
1912            partitions,
1913            iterations,
1914            entity_passes,
1915            swaps,
1916            converged,
1917        );
1918        self.release_active_metric();
1919    }
1920
1921    fn release_active_metric(&self) {
1922        if !self
1923            .active_metric_released
1924            .swap(true, std::sync::atomic::Ordering::AcqRel)
1925        {
1926            crate::observe::reorder_bp_finished(
1927                self.label.index,
1928                self.label.field,
1929                self.label.entity_kind,
1930            );
1931        }
1932    }
1933}
1934
1935#[cfg(feature = "native")]
1936impl Drop for BpProgress<'_> {
1937    fn drop(&mut self) {
1938        self.release_active_metric();
1939    }
1940}
1941
1942#[cfg(not(feature = "native"))]
1943struct BpProgress<'a>(std::marker::PhantomData<&'a ()>);
1944
1945#[cfg(not(feature = "native"))]
1946impl BpProgress<'_> {
1947    fn new(_: BpProgressLabel<'_>, _: usize, _: u64, _: usize, _: usize) -> Self {
1948        Self(std::marker::PhantomData)
1949    }
1950    fn partition_started(&self, _: usize) {}
1951    fn partition_finished(&self) {}
1952    fn iteration(&self, _: usize, _: usize, _: f64, _: f64) {}
1953    fn objective_stop(&self) {}
1954    fn finish(&self, _: bool, _: bool, _: bool, _: bool) {}
1955}
1956
1957/// Level-synchronized graph bisection. Returns `(perm, converged)` where
1958/// `perm[new_pos] = old_index`. Convergence is false when the wall-clock or
1959/// memory budget prevents the requested work from finishing; a configured
1960/// depth cap is a chosen target and reports converged.
1961///
1962/// `min_partition_size` should be the configured BMP block size.
1963/// `max_iters` controls convergence (20 is standard).
1964///
1965/// Term IDs in the forward index must be compact (0..num_terms) so we can
1966/// use flat arrays for O(1) degree lookups instead of hash maps.
1967#[cfg(test)]
1968pub(crate) fn graph_bisection(
1969    fwd: &ForwardIndex,
1970    min_partition_size: usize,
1971    max_iters: usize,
1972    budget: BpBudget,
1973) -> (Vec<u32>, bool) {
1974    graph_bisection_with_progress(
1975        fwd,
1976        min_partition_size,
1977        max_iters,
1978        budget,
1979        None,
1980        BpProgressLabel::anonymous(),
1981    )
1982}
1983
1984pub(crate) fn graph_bisection_with_progress(
1985    fwd: &ForwardIndex,
1986    min_partition_size: usize,
1987    max_iters: usize,
1988    budget: BpBudget,
1989    cancellation: Option<&std::sync::atomic::AtomicBool>,
1990    progress_label: BpProgressLabel<'_>,
1991) -> (Vec<u32>, bool) {
1992    let n = fwd.num_docs();
1993    if n == 0 {
1994        return (Vec::new(), !fwd.budget_limited);
1995    }
1996
1997    let effective_min_partition = budget
1998        .min_partition_docs
1999        .unwrap_or(0)
2000        .max(min_partition_size)
2001        // A singleton cannot be bisected. The public callers use a positive
2002        // BMP block size, but enforcing the structural minimum here also
2003        // prevents an accidental zero configuration from walking empty tree
2004        // levels until the partition counter overflows.
2005        .max(1);
2006
2007    let mut docs: Vec<u32> = (0..n as u32).collect();
2008    let depth = if effective_min_partition > 0 {
2009        ((n as f64) / (effective_min_partition as f64))
2010            .log2()
2011            .ceil() as usize
2012    } else {
2013        0
2014    };
2015    #[cfg(feature = "native")]
2016    let degree_lanes = fwd.parallel_bisect_lanes.max(1);
2017    #[cfg(not(feature = "native"))]
2018    let degree_lanes = 1usize;
2019    let log_table = build_log_table(LOG_TABLE_SIZE);
2020    let progress = BpProgress::new(progress_label, n, fwd.total_postings(), depth, degree_lanes);
2021
2022    log::debug!(
2023        "BP graph_bisection: n={}, min_partition={}, max_iters={}, depth=~{}, time_budget={:?}, gain_cache={}",
2024        n,
2025        effective_min_partition,
2026        max_iters,
2027        depth,
2028        budget.time_budget,
2029        fwd.cache_gains,
2030    );
2031
2032    #[cfg(feature = "native")]
2033    let deadline = budget.time_budget.map(|duration| {
2034        let now = std::time::Instant::now();
2035        now.checked_add(duration).unwrap_or(now)
2036    });
2037    #[cfg(not(feature = "native"))]
2038    let deadline: Option<()> = None;
2039
2040    let exhausted = std::sync::atomic::AtomicBool::new(false);
2041    let context = BisectContext {
2042        fwd,
2043        min_partition_size: effective_min_partition,
2044        max_iters,
2045        log_table: &log_table,
2046        #[cfg(feature = "native")]
2047        deadline,
2048        #[cfg(not(feature = "native"))]
2049        deadline,
2050        exhausted: &exhausted,
2051        cancellation,
2052        progress: &progress,
2053    };
2054    let immediately_exhausted = {
2055        #[cfg(feature = "native")]
2056        {
2057            deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline)
2058        }
2059        #[cfg(not(feature = "native"))]
2060        {
2061            false
2062        }
2063    };
2064    let initially_cancelled =
2065        cancellation.is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire));
2066    if immediately_exhausted || initially_cancelled {
2067        exhausted.store(true, std::sync::atomic::Ordering::Relaxed);
2068    } else if n > effective_min_partition {
2069        // Entity scratch and vocabulary-sized degree lanes are allocated
2070        // exactly once. At each depth, workers dynamically claim disjoint
2071        // partitions and reuse their lane for the whole level.
2072        let mut gains = vec![0.0f32; n];
2073        let mut partitioned = vec![0u32; n];
2074        let mut ranked = vec![0usize; n];
2075        let mut degree_workspaces: Vec<TermDegrees> = (0..degree_lanes)
2076            .map(|_| {
2077                let mut lane = TermDegrees::new(fwd.num_terms);
2078                if fwd.cache_gains {
2079                    lane.gain_cache = vec![[0.0; 2]; fwd.num_terms];
2080                }
2081                lane
2082            })
2083            .collect();
2084        bisect_level_synchronized(
2085            &mut docs,
2086            &mut gains,
2087            &mut partitioned,
2088            &mut ranked,
2089            &mut degree_workspaces,
2090            &context,
2091        );
2092    }
2093
2094    let stopped_early = exhausted.load(std::sync::atomic::Ordering::Relaxed);
2095    let cancelled =
2096        cancellation.is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire));
2097    let deadline_exhausted = stopped_early && !cancelled;
2098    let converged = !fwd.budget_limited && !stopped_early && !cancelled;
2099    progress.finish(converged, fwd.budget_limited, deadline_exhausted, cancelled);
2100    if !converged {
2101        log::info!(
2102            "BP graph_bisection: pass incomplete at n={} (time={:?}, memory_limited={}, cancelled={}) — emitting partial (still valid) permutation",
2103            n,
2104            budget.time_budget,
2105            fwd.budget_limited,
2106            cancelled,
2107        );
2108    }
2109    (docs, converged)
2110}
2111
2112/// Context shared by all partitions in a level-synchronized BP pass.
2113///
2114/// Uses flat `Vec<u32>` degree arrays indexed by compact term_id for cache-friendly
2115/// O(1) lookups (vs FxHashMap which has poor cache locality at scale).
2116///
2117struct BisectContext<'a> {
2118    fwd: &'a ForwardIndex,
2119    min_partition_size: usize,
2120    max_iters: usize,
2121    log_table: &'a [f32],
2122    #[cfg(feature = "native")]
2123    deadline: Option<std::time::Instant>,
2124    #[cfg(not(feature = "native"))]
2125    deadline: Option<()>,
2126    exhausted: &'a std::sync::atomic::AtomicBool,
2127    cancellation: Option<&'a std::sync::atomic::AtomicBool>,
2128    progress: &'a BpProgress<'a>,
2129}
2130
2131/// Return the range of partition `partition_id` at a fixed recursion `level`.
2132///
2133/// Replaying the path bits produces exactly the same floor-left/ceil-right
2134/// boundaries as recursive `split_at_mut(len / 2)`, including for non-power
2135/// of-two collection sizes.
2136fn partition_range(total: usize, level: usize, partition_id: usize) -> std::ops::Range<usize> {
2137    debug_assert!(level < usize::BITS as usize);
2138    debug_assert!(partition_id < (1usize << level));
2139    let mut start = 0usize;
2140    let mut len = total;
2141    for bit in (0..level).rev() {
2142        let left_len = len / 2;
2143        if partition_id & (1usize << bit) == 0 {
2144            len = left_len;
2145        } else {
2146            start += left_len;
2147            len -= left_len;
2148        }
2149    }
2150    start..start + len
2151}
2152
2153/// Collect all active partitions when they fit in `limit` lanes. Returning
2154/// `None` means there are more active partitions than lanes, so the caller
2155/// should use dynamic claiming instead of assigning multiple lanes per node.
2156fn small_active_partition_set(
2157    total: usize,
2158    level: usize,
2159    min_partition_size: usize,
2160    limit: usize,
2161) -> Option<Vec<usize>> {
2162    let partition_count = 1usize.checked_shl(level as u32)?;
2163    let mut active = Vec::with_capacity(partition_count.min(limit));
2164    for partition_id in 0..partition_count {
2165        if partition_range(total, level, partition_id).len() <= min_partition_size {
2166            continue;
2167        }
2168        active.push(partition_id);
2169        if active.len() > limit {
2170            return None;
2171        }
2172    }
2173    Some(active)
2174}
2175
2176/// Mutable pass buffers shared by native level workers.
2177///
2178/// The scheduler hands each claimed partition ID to exactly one worker, and
2179/// `partition_range` yields non-overlapping ranges at a fixed level. The level
2180/// barrier completes before any buffer is accessed again or the next level is
2181/// started. Those invariants make concurrent slice construction sound.
2182#[cfg(feature = "native")]
2183#[derive(Clone, Copy)]
2184struct LevelBuffers {
2185    docs: *mut u32,
2186    gains: *mut f32,
2187    partitioned: *mut u32,
2188    ranked: *mut usize,
2189    len: usize,
2190}
2191
2192#[cfg(feature = "native")]
2193unsafe impl Send for LevelBuffers {}
2194#[cfg(feature = "native")]
2195unsafe impl Sync for LevelBuffers {}
2196
2197#[cfg(feature = "native")]
2198impl LevelBuffers {
2199    fn new(
2200        docs: &mut [u32],
2201        gains: &mut [f32],
2202        partitioned: &mut [u32],
2203        ranked: &mut [usize],
2204    ) -> Self {
2205        debug_assert_eq!(docs.len(), gains.len());
2206        debug_assert_eq!(docs.len(), partitioned.len());
2207        debug_assert_eq!(docs.len(), ranked.len());
2208        Self {
2209            docs: docs.as_mut_ptr(),
2210            gains: gains.as_mut_ptr(),
2211            partitioned: partitioned.as_mut_ptr(),
2212            ranked: ranked.as_mut_ptr(),
2213            len: docs.len(),
2214        }
2215    }
2216
2217    /// # Safety
2218    ///
2219    /// During one level, every requested range must be in bounds and disjoint
2220    /// from every range concurrently handed out from this `LevelBuffers`.
2221    unsafe fn with_slices<R>(
2222        &self,
2223        range: std::ops::Range<usize>,
2224        use_slices: impl for<'a> FnOnce(
2225            &'a mut [u32],
2226            &'a mut [f32],
2227            &'a mut [u32],
2228            &'a mut [usize],
2229        ) -> R,
2230    ) -> R {
2231        debug_assert!(range.start <= range.end && range.end <= self.len);
2232        let len = range.len();
2233        // SAFETY: upheld by the caller as documented above. All four backing
2234        // allocations remain live and immovable until the level barrier.
2235        unsafe {
2236            use_slices(
2237                std::slice::from_raw_parts_mut(self.docs.add(range.start), len),
2238                std::slice::from_raw_parts_mut(self.gains.add(range.start), len),
2239                std::slice::from_raw_parts_mut(self.partitioned.add(range.start), len),
2240                std::slice::from_raw_parts_mut(self.ranked.add(range.start), len),
2241            )
2242        }
2243    }
2244}
2245
2246/// Process the recursion tree breadth-first. Same-depth partitions have
2247/// similar sizes and dynamically claim bounded degree lanes, avoiding the
2248/// inter-level contention and permanently imbalanced descendant ownership of
2249/// recursive fork-join BP.
2250fn bisect_level_synchronized(
2251    docs: &mut [u32],
2252    gains: &mut [f32],
2253    partitioned: &mut [u32],
2254    ranked_scratch: &mut [usize],
2255    degree_workspaces: &mut [TermDegrees],
2256    context: &BisectContext<'_>,
2257) {
2258    debug_assert_eq!(docs.len(), gains.len());
2259    debug_assert_eq!(docs.len(), partitioned.len());
2260    debug_assert_eq!(docs.len(), ranked_scratch.len());
2261    debug_assert!(!degree_workspaces.is_empty());
2262
2263    #[cfg(feature = "native")]
2264    {
2265        let buffers = LevelBuffers::new(docs, gains, partitioned, ranked_scratch);
2266        let mut level = 0usize;
2267        while let Some(partition_count) = 1usize.checked_shl(level as u32) {
2268            let small_set = small_active_partition_set(
2269                docs.len(),
2270                level,
2271                context.min_partition_size,
2272                degree_workspaces.len(),
2273            );
2274
2275            match small_set {
2276                Some(active) if active.is_empty() => break,
2277                Some(active) => {
2278                    // With fewer partitions than degree lanes, give each node
2279                    // a lane group so its frequency/gain work can still use
2280                    // the whole pool during BP's startup levels.
2281                    let active_count = active.len();
2282                    let allow_inner_parallelism =
2283                        active_count < rayon::current_num_threads().max(1);
2284                    let mut rest: &mut [TermDegrees] = &mut *degree_workspaces;
2285                    let mut groups = Vec::with_capacity(active_count);
2286                    for remaining_groups in (1..=active_count).rev() {
2287                        let group_len = rest.len().div_ceil(remaining_groups);
2288                        let (group, tail) = rest.split_at_mut(group_len);
2289                        groups.push(group);
2290                        rest = tail;
2291                    }
2292                    debug_assert!(rest.is_empty());
2293                    groups.into_par_iter().zip(active.into_par_iter()).for_each(
2294                        |(workspaces, partition_id)| {
2295                            let range = partition_range(docs.len(), level, partition_id);
2296                            // SAFETY: active IDs are unique at one fixed level,
2297                            // hence their ranges are disjoint. `for_each` is
2298                            // the barrier before buffers are reused.
2299                            unsafe {
2300                                buffers.with_slices(
2301                                    range,
2302                                    |part_docs, part_gains, part_output, part_ranked| {
2303                                        bisect_partition(
2304                                            part_docs,
2305                                            part_gains,
2306                                            part_output,
2307                                            part_ranked,
2308                                            workspaces,
2309                                            level,
2310                                            allow_inner_parallelism,
2311                                            context,
2312                                        );
2313                                    },
2314                                );
2315                            }
2316                        },
2317                    );
2318                }
2319                None => {
2320                    // More partitions than degree lanes: one long-lived worker
2321                    // per admitted lane repeatedly claims the next partition.
2322                    // This preserves the memory bound and lets short/deep work
2323                    // steal naturally instead of pinning a whole subtree to a
2324                    // lane for the remainder of the pass.
2325                    let next = std::sync::atomic::AtomicUsize::new(0);
2326                    let allow_inner_parallelism =
2327                        degree_workspaces.len() < rayon::current_num_threads().max(1);
2328                    degree_workspaces.par_iter_mut().for_each(|workspace| {
2329                        loop {
2330                            let partition_id =
2331                                next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2332                            if partition_id >= partition_count {
2333                                break;
2334                            }
2335                            let range = partition_range(docs.len(), level, partition_id);
2336                            if range.len() <= context.min_partition_size {
2337                                continue;
2338                            }
2339                            // SAFETY: `fetch_add` assigns every partition ID
2340                            // once, and fixed-level partition ranges are
2341                            // pairwise disjoint.
2342                            unsafe {
2343                                buffers.with_slices(
2344                                    range,
2345                                    |part_docs, part_gains, part_output, part_ranked| {
2346                                        bisect_partition(
2347                                            part_docs,
2348                                            part_gains,
2349                                            part_output,
2350                                            part_ranked,
2351                                            std::slice::from_mut(workspace),
2352                                            level,
2353                                            allow_inner_parallelism,
2354                                            context,
2355                                        );
2356                                    },
2357                                );
2358                            }
2359                        }
2360                    });
2361                }
2362            }
2363
2364            if context.exhausted.load(std::sync::atomic::Ordering::Relaxed) {
2365                break;
2366            }
2367            level += 1;
2368        }
2369    }
2370
2371    #[cfg(not(feature = "native"))]
2372    {
2373        let mut level = 0usize;
2374        while let Some(partition_count) = 1usize.checked_shl(level as u32) {
2375            let mut active = false;
2376            for partition_id in 0..partition_count {
2377                let range = partition_range(docs.len(), level, partition_id);
2378                if range.len() <= context.min_partition_size {
2379                    continue;
2380                }
2381                active = true;
2382                bisect_partition(
2383                    &mut docs[range.clone()],
2384                    &mut gains[range.clone()],
2385                    &mut partitioned[range.clone()],
2386                    &mut ranked_scratch[range],
2387                    degree_workspaces,
2388                    level,
2389                    false,
2390                    context,
2391                );
2392            }
2393            if !active || context.exhausted.load(std::sync::atomic::Ordering::Relaxed) {
2394                break;
2395            }
2396            level += 1;
2397        }
2398    }
2399}
2400
2401/// Bisection of one document slice, without scheduling its children.
2402///
2403/// Uses flat `Vec<u32>` degree arrays indexed by compact term_id for
2404/// cache-friendly O(1) lookups. Adaptive iteration count reduces work at top
2405/// levels where coarse splits converge faster and dominate total runtime.
2406#[allow(clippy::too_many_arguments)]
2407fn bisect_partition(
2408    docs: &mut [u32],
2409    gains: &mut [f32],
2410    partitioned: &mut [u32],
2411    ranked_scratch: &mut [usize],
2412    degree_workspaces: &mut [TermDegrees],
2413    level: usize,
2414    allow_inner_parallelism: bool,
2415    context: &BisectContext<'_>,
2416) {
2417    let n = docs.len();
2418    debug_assert_eq!(gains.len(), n);
2419    debug_assert_eq!(partitioned.len(), n);
2420    debug_assert_eq!(ranked_scratch.len(), n);
2421    debug_assert!(!degree_workspaces.is_empty());
2422    if n <= context.min_partition_size {
2423        return;
2424    }
2425    // Anytime cutoff: leave this subtree in its current (valid) order.
2426    if context.exhausted.load(std::sync::atomic::Ordering::Relaxed)
2427        || context
2428            .cancellation
2429            .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire))
2430    {
2431        context
2432            .exhausted
2433            .store(true, std::sync::atomic::Ordering::Relaxed);
2434        return;
2435    }
2436    #[cfg(feature = "native")]
2437    if let Some(dl) = context.deadline
2438        && std::time::Instant::now() >= dl
2439    {
2440        context
2441            .exhausted
2442            .store(true, std::sync::atomic::Ordering::Relaxed);
2443        return;
2444    }
2445    #[cfg(not(feature = "native"))]
2446    let _ = context.deadline;
2447
2448    context.progress.partition_started(level);
2449    let mid = n / 2;
2450
2451    // Adaptive iteration count: large partitions converge faster with
2452    // coarse splits, so fewer refinement passes suffice. The fine-grained
2453    // clustering is handled by deeper recursion levels with full iterations.
2454    let effective_iters = if n > 100_000 {
2455        context.max_iters.min(12)
2456    } else {
2457        context.max_iters
2458    };
2459
2460    // Compact term IDs permit direct indexing. Slots are initialized lazily so
2461    // deep partitions touch only their active terms. Coarse partitions use
2462    // multiple preallocated lanes; fine levels dynamically reuse one lane per
2463    // active partition.
2464    build_term_degrees(
2465        docs,
2466        mid,
2467        context.fwd,
2468        degree_workspaces,
2469        context.cancellation,
2470    );
2471    if context
2472        .cancellation
2473        .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire))
2474    {
2475        context
2476            .exhausted
2477            .store(true, std::sync::atomic::Ordering::Relaxed);
2478        context.progress.partition_finished();
2479        return;
2480    }
2481    // A full-vocabulary objective scan pays off only on coarse partitions,
2482    // where one avoided refinement skips millions of postings. At fine levels
2483    // it cost more than the work it could save, so retain the cheap gain
2484    // cooling condition there.
2485    let track_objective = n >= PARALLEL_BP_MIN_ENTITIES;
2486    if track_objective {
2487        // The old bitmap scan accumulated terms in ascending order. Sorting
2488        // once preserves that exact floating-point order while subsequent
2489        // objective evaluations visit only words active in this partition.
2490        degree_workspaces[0].sort_touched_words();
2491    }
2492    let mut previous_objective = if track_objective {
2493        degree_workspaces[0].bisection_objective(mid, n - mid, context.log_table)
2494    } else {
2495        0.0
2496    };
2497    let mut best_objective = previous_objective;
2498    let mut objective_stalls = 0usize;
2499
2500    for iter in 0..effective_iters {
2501        // Anytime cutoff between refinement passes: keep the current split.
2502        if context
2503            .cancellation
2504            .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire))
2505        {
2506            context
2507                .exhausted
2508                .store(true, std::sync::atomic::Ordering::Relaxed);
2509            break;
2510        }
2511        #[cfg(feature = "native")]
2512        if let Some(dl) = context.deadline
2513            && std::time::Instant::now() >= dl
2514        {
2515            context
2516                .exhausted
2517                .store(true, std::sync::atomic::Ordering::Relaxed);
2518            break;
2519        }
2520        // Compute gain for each document (approx_1 from Dhulipala et al.)
2521        // Parallelized for large partitions where per-doc work dominates.
2522        compute_gains(
2523            docs,
2524            context.fwd,
2525            mid,
2526            &mut degree_workspaces[0],
2527            context.log_table,
2528            gains,
2529            allow_inner_parallelism,
2530        );
2531        if context
2532            .cancellation
2533            .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire))
2534        {
2535            context
2536                .exhausted
2537                .store(true, std::sync::atomic::Ordering::Relaxed);
2538            break;
2539        }
2540
2541        // Exact median selection and stable partition. The old path allocated
2542        // `Vec<usize>` (8 bytes/entity) and selected/applied it serially; the
2543        // radix path is O(4n), parallel, and accumulates moved-term deltas in
2544        // the same bounded worker lanes used by degree construction.
2545        let partition = partition_by_gain(
2546            docs,
2547            gains,
2548            mid,
2549            context.fwd,
2550            &mut degree_workspaces[1..],
2551            partitioned,
2552            ranked_scratch,
2553            allow_inner_parallelism,
2554        );
2555
2556        if partition.swap_count == 0 {
2557            context.progress.iteration(n, 0, 0.0, 0.0);
2558            // Preserve the selector's within-half order for the next level.
2559            // Small partitions intentionally retain quickselect's established
2560            // symmetry-breaking permutation.
2561            docs.copy_from_slice(partitioned);
2562            break;
2563        }
2564
2565        match &partition.degree_update {
2566            PartitionDegreeUpdate::Moves => {
2567                let (degrees, movement_workspaces) = degree_workspaces
2568                    .split_first_mut()
2569                    .expect("BP always has one degree workspace");
2570                movement_workspaces
2571                    .first()
2572                    .expect("parallel BP movement update requires a spare workspace")
2573                    .apply_moves_to(degrees);
2574            }
2575            PartitionDegreeUpdate::Ranked => update_degrees_for_ranked_partition(
2576                docs,
2577                ranked_scratch,
2578                mid,
2579                context.fwd,
2580                &mut degree_workspaces[0],
2581            ),
2582            PartitionDegreeUpdate::Threshold {
2583                threshold_key,
2584                ties_left,
2585            } => update_degrees_for_threshold_partition(
2586                docs,
2587                gains,
2588                mid,
2589                *threshold_key,
2590                *ties_left,
2591                context.fwd,
2592                &mut degree_workspaces[0],
2593            ),
2594        }
2595
2596        let (new_objective, objective_improvement, relative_improvement) = if track_objective {
2597            let new_objective =
2598                degree_workspaces[0].bisection_objective(mid, n - mid, context.log_table);
2599            let objective_improvement = new_objective - previous_objective;
2600            let relative_improvement = objective_improvement / previous_objective.abs().max(1.0);
2601            (new_objective, objective_improvement, relative_improvement)
2602        } else {
2603            (0.0, 0.0, 0.0)
2604        };
2605        context.progress.iteration(
2606            n,
2607            partition.swap_count,
2608            objective_improvement,
2609            relative_improvement,
2610        );
2611
2612        // Keep the existing approximate-BP semantics: one median refinement
2613        // can temporarily reduce the exact objective before the reciprocal
2614        // move settles. Rejecting that first step made valid clustered inputs
2615        // no-op. Instead, accept refinements and stop only after a warm-up plus
2616        // consecutive iterations that fail to improve the best exact
2617        // objective by a meaningful relative amount.
2618        docs.copy_from_slice(partitioned);
2619        if track_objective {
2620            previous_objective = new_objective;
2621            let relative_best_improvement =
2622                (new_objective - best_objective) / best_objective.abs().max(1.0);
2623            if relative_best_improvement >= MIN_RELATIVE_OBJECTIVE_IMPROVEMENT {
2624                best_objective = new_objective;
2625                objective_stalls = 0;
2626            } else if iter + 1 >= MIN_OBJECTIVE_ITERATIONS {
2627                objective_stalls += 1;
2628            }
2629            if objective_stalls >= OBJECTIVE_STALL_ITERATIONS {
2630                context.progress.objective_stop();
2631                break;
2632            }
2633        }
2634
2635        // Early termination: if < 0.5% of docs swapped, partition is stable
2636        if iter > 2 && partition.swap_count < n / 200 {
2637            break;
2638        }
2639
2640        // Fine partitions retain the previous cheap cooling rule. Computing
2641        // an exact vocabulary objective here costs more than the posting work
2642        // it can avoid.
2643        if !track_objective && iter > 5 {
2644            let max_abs_gain = gains
2645                .iter()
2646                .copied()
2647                .fold(0.0f32, |max_gain, gain| max_gain.max(gain.abs()));
2648            if max_abs_gain < 0.001 {
2649                break;
2650            }
2651        }
2652    }
2653
2654    context.progress.partition_finished();
2655}
2656
2657/// Compute gains for all documents, parallelized via rayon for large partitions.
2658///
2659/// Each doc's gain is independent: iterate its terms, accumulate the log-gap
2660/// cost delta of moving it to the other side. Read-only access to degree arrays
2661/// makes this embarrassingly parallel.
2662#[inline(never)]
2663fn compute_gains(
2664    docs: &[u32],
2665    fwd: &ForwardIndex,
2666    mid: usize,
2667    degrees: &mut TermDegrees,
2668    log_table: &[f32],
2669    gains: &mut [f32],
2670    allow_inner_parallelism: bool,
2671) {
2672    degrees.refresh_gain_cache(log_table);
2673    debug_assert_eq!(docs.len(), gains.len());
2674    if !degrees.gain_cache.is_empty() {
2675        // Dispatch once per refinement, keeping this small gather/reduction
2676        // callback separate from the logarithmic fallback. This avoids its
2677        // branch and large spill frame in sequential and Rayon leaf workers.
2678        let cache = degrees.gain_cache.as_slice();
2679        fill_gains(gains, allow_inner_parallelism, |i| {
2680            let side = usize::from(i >= mid);
2681            let mut gain = 0.0f32;
2682            for &term in fwd.doc_terms(docs[i] as usize) {
2683                gain += cache[term as usize][side];
2684            }
2685            gain
2686        });
2687        return;
2688    }
2689
2690    // Single coherent key: HIGH = belongs in the RIGHT half.
2691    // Left docs get +approx_one(from=left, to=right) — a misplaced left doc
2692    // (terms concentrated right) scores high. Right docs get
2693    // -approx_one(from=right, to=left) — a misplaced right doc scores low.
2694    // This matches the reference two-sided formulation (compute_gains_left /
2695    // compute_gains_right with negation); ranking both halves by raw
2696    // "move gain" instead made both sides' misplaced docs rank identically,
2697    // so the partition step could never exchange them.
2698    let gain_for_doc = |i: usize| -> f32 {
2699        let doc = docs[i] as usize;
2700        let in_left = i < mid;
2701        let mut g = 0.0f32;
2702        for &term in fwd.doc_terms(doc) {
2703            let [left, right] = degrees.get(term as usize);
2704            let (from, to) = if in_left {
2705                (left, right)
2706            } else {
2707                (right, left)
2708            };
2709            let move_gain = fast_log2_lookup(to as usize + 2, log_table)
2710                - fast_log2_lookup(from as usize, log_table)
2711                - std::f32::consts::LOG2_E / (1.0 + to as f32);
2712            g += if in_left { move_gain } else { -move_gain };
2713        }
2714        g
2715    };
2716
2717    fill_gains(gains, allow_inner_parallelism, gain_for_doc);
2718}
2719
2720fn fill_gains(
2721    gains: &mut [f32],
2722    allow_inner_parallelism: bool,
2723    gain_for_doc: impl Fn(usize) -> f32 + FrequencyParallelSafe,
2724) {
2725    #[cfg(not(feature = "native"))]
2726    let _ = allow_inner_parallelism;
2727
2728    #[cfg(feature = "native")]
2729    {
2730        if allow_inner_parallelism && gains.len() > 4096 {
2731            gains
2732                .par_iter_mut()
2733                .enumerate()
2734                .for_each(|(i, gain)| *gain = gain_for_doc(i));
2735        } else {
2736            for (i, gain) in gains.iter_mut().enumerate() {
2737                *gain = gain_for_doc(i);
2738            }
2739        }
2740    }
2741    #[cfg(not(feature = "native"))]
2742    {
2743        for (i, gain) in gains.iter_mut().enumerate() {
2744            *gain = gain_for_doc(i);
2745        }
2746    }
2747}
2748
2749// ── Helpers ──────────────────────────────────────────────────────────────
2750
2751/// Build precomputed log2 table for values 0..size.
2752fn build_log_table(size: usize) -> Vec<f32> {
2753    let mut table = vec![0.0f32; size];
2754    // log2(0) is undefined; use a large negative value
2755    table[0] = -10.0;
2756    for (i, entry) in table.iter_mut().enumerate().skip(1) {
2757        *entry = (i as f32).log2();
2758    }
2759    table
2760}
2761
2762/// Fast log2 with precomputed table lookup.
2763#[inline]
2764fn fast_log2_lookup(val: usize, table: &[f32]) -> f32 {
2765    if val < table.len() {
2766        table[val]
2767    } else {
2768        (val as f32).log2()
2769    }
2770}
2771
2772#[cfg(test)]
2773mod gain_tests;
2774
2775#[cfg(test)]
2776mod tests {
2777    use super::*;
2778
2779    #[test]
2780    fn bounded_frequency_count_and_candidate_selection_are_exact() {
2781        let items: Vec<u32> = (0..10_000).collect();
2782        let frequencies = count_frequencies_bounded(&items, 7, 16 * 1024, |item, counts| {
2783            let term = *item as usize % counts.len();
2784            counts[term] += 1;
2785            Ok(())
2786        })
2787        .unwrap()
2788        .unwrap();
2789        assert_eq!(frequencies.iter().sum::<u32>(), items.len() as u32);
2790        for (term, &frequency) in frequencies.iter().enumerate() {
2791            let expected = (term..items.len()).step_by(frequencies.len()).count() as u32;
2792            assert_eq!(frequency, expected);
2793        }
2794
2795        let (selected, limited) =
2796            select_frequency_candidates(&[8, 3, 0, 5, 3], 1, 10, 2 * CANDIDATE_ENTRY_BYTES);
2797        assert!(limited);
2798        let mut selected = selected;
2799        selected.sort_unstable();
2800        assert_eq!(selected, vec![(1, 3), (4, 3)]);
2801    }
2802
2803    #[test]
2804    fn bounded_frequency_count_rejects_an_undersized_budget() {
2805        assert!(
2806            count_frequencies_bounded(&[0u32], 32, 32, |_, _| Ok(()))
2807                .unwrap()
2808                .is_none(),
2809            "one complete dense frequency table must fit before counting"
2810        );
2811    }
2812
2813    #[test]
2814    fn lazy_term_degrees_initialize_only_on_first_write() {
2815        let mut degrees = TermDegrees::new(130);
2816        let values_ptr = degrees.values.as_ptr();
2817        let bitmap_ptr = degrees.initialized.as_ptr();
2818        assert_eq!(degrees.get(65), [0, 0]);
2819        degrees.entry_mut(65)[0] += 3;
2820        degrees.entry_mut(65)[1] += 2;
2821        assert_eq!(degrees.get(65), [3, 2]);
2822        assert_eq!(degrees.get(64), [0, 0]);
2823        assert_eq!(
2824            degrees
2825                .initialized
2826                .iter()
2827                .map(|w| w.count_ones())
2828                .sum::<u32>(),
2829            1
2830        );
2831
2832        degrees.reset();
2833        assert_eq!(degrees.values.as_ptr(), values_ptr);
2834        assert_eq!(degrees.initialized.as_ptr(), bitmap_ptr);
2835        assert_eq!(degrees.get(65), [0, 0]);
2836        assert!(degrees.touched_words.is_empty());
2837        degrees.entry_mut(129)[1] = 7;
2838        assert_eq!(degrees.get(129), [0, 7]);
2839        assert_eq!(degrees.get(65), [0, 0]);
2840    }
2841
2842    #[test]
2843    fn sparse_objective_keeps_the_original_ascending_term_order() {
2844        let mut degrees = TermDegrees::new(130);
2845        *degrees.entry_mut(129) = [5, 2];
2846        *degrees.entry_mut(1) = [3, 7];
2847        *degrees.entry_mut(65) = [11, 13];
2848        assert_eq!(degrees.touched_words, vec![2, 0, 1]);
2849        degrees.sort_touched_words();
2850        assert_eq!(degrees.touched_words, vec![0, 1, 2]);
2851
2852        let log_table = build_log_table(4096);
2853        let actual = degrees.bisection_objective(31, 32, &log_table);
2854        let side_log = [
2855            fast_log2_lookup(31, &log_table) as f64,
2856            fast_log2_lookup(32, &log_table) as f64,
2857        ];
2858        let mut original_bitmap_scan = 0.0f64;
2859        for (word_idx, &initialized) in degrees.initialized.iter().enumerate() {
2860            let mut pending = initialized;
2861            while pending != 0 {
2862                let bit = pending.trailing_zeros() as usize;
2863                let term = word_idx * 64 + bit;
2864                let [left, right] = degrees.get(term);
2865                for (side, count) in [left, right].into_iter().enumerate() {
2866                    if count > 0 {
2867                        original_bitmap_scan += count as f64
2868                            * (fast_log2_lookup(count as usize + 1, &log_table) as f64
2869                                - side_log[side]);
2870                    }
2871                }
2872                pending &= pending - 1;
2873            }
2874        }
2875        assert_eq!(actual.to_bits(), original_bitmap_scan.to_bits());
2876    }
2877
2878    #[test]
2879    fn gain_radix_key_matches_total_cmp() {
2880        let values = [
2881            f32::from_bits(0xffc0_0001),
2882            f32::NEG_INFINITY,
2883            -42.0,
2884            -0.0,
2885            0.0,
2886            42.0,
2887            f32::INFINITY,
2888            f32::from_bits(0x7fc0_0001),
2889        ];
2890        let mut by_cmp = values;
2891        by_cmp.sort_by(f32::total_cmp);
2892        let mut by_key = values;
2893        by_key.sort_by_key(|value| gain_order_key(*value));
2894        assert_eq!(
2895            by_cmp.map(f32::to_bits),
2896            by_key.map(f32::to_bits),
2897            "radix selection must preserve the former total_cmp order"
2898        );
2899    }
2900
2901    #[test]
2902    fn radix_threshold_matches_exact_rank_with_ties() {
2903        let gains = [3.0, -1.0, 7.0, -1.0, 0.0, -0.0, 3.0, 9.0, 3.0, 2.0, 2.0];
2904        let mut sorted: Vec<(u32, usize)> = gains
2905            .iter()
2906            .enumerate()
2907            .map(|(idx, &gain)| (gain_order_key(gain), idx))
2908            .collect();
2909        sorted.sort_unstable();
2910
2911        for left_count in 1..=gains.len() {
2912            let (threshold, lower) = select_gain_threshold(&gains, left_count, true);
2913            assert_eq!(threshold, sorted[left_count - 1].0);
2914            assert_eq!(lower, sorted.partition_point(|&(key, _)| key < threshold),);
2915        }
2916    }
2917
2918    #[cfg(feature = "native")]
2919    #[test]
2920    fn parallel_partition_matches_exact_selection_and_degree_rebuild() {
2921        const N: usize = PARALLEL_BP_MIN_ENTITIES + 1;
2922        const TERMS: usize = 101;
2923        let mut terms = Vec::with_capacity(N * 3);
2924        let mut offsets = Vec::with_capacity(N + 1);
2925        offsets.push(0);
2926        for doc in 0..N {
2927            terms.extend_from_slice(&[
2928                (doc % TERMS) as u32,
2929                ((doc / 7) % TERMS) as u32,
2930                ((doc * 13) % TERMS) as u32,
2931            ]);
2932            offsets.push(terms.len() as u64);
2933        }
2934        let fwd = ForwardIndex {
2935            terms,
2936            offsets,
2937            num_terms: TERMS,
2938            parallel_bisect_lanes: 4,
2939            cache_gains: false,
2940            budget_limited: false,
2941        };
2942        let docs: Vec<u32> = (0..N as u32)
2943            .map(|idx| ((idx as usize * 7_919) % N) as u32)
2944            .collect();
2945        let gains: Vec<f32> = docs
2946            .iter()
2947            .map(|&doc| ((doc as usize * 37) % 257) as f32 - 128.0)
2948            .collect();
2949        let mid = N / 2;
2950
2951        let mut ranked: Vec<usize> = (0..N).collect();
2952        ranked.sort_unstable_by(|&left, &right| {
2953            gains[left]
2954                .total_cmp(&gains[right])
2955                .then_with(|| left.cmp(&right))
2956        });
2957        let mut selected_left = vec![false; N];
2958        for &idx in &ranked[..mid] {
2959            selected_left[idx] = true;
2960        }
2961        let expected: Vec<u32> = docs
2962            .iter()
2963            .enumerate()
2964            .filter(|(idx, _)| selected_left[*idx])
2965            .chain(
2966                docs.iter()
2967                    .enumerate()
2968                    .filter(|(idx, _)| !selected_left[*idx]),
2969            )
2970            .map(|(_, &doc)| doc)
2971            .collect();
2972
2973        let mut output = vec![0; N];
2974        let mut ranked_scratch = Vec::new();
2975        let mut movement_workspaces: Vec<_> = (0..3).map(|_| TermDegrees::new(TERMS)).collect();
2976        let outcome = partition_by_gain(
2977            &docs,
2978            &gains,
2979            mid,
2980            &fwd,
2981            &mut movement_workspaces,
2982            &mut output,
2983            &mut ranked_scratch,
2984            true,
2985        );
2986        assert_eq!(output, expected);
2987        assert!(
2988            matches!(&outcome.degree_update, PartitionDegreeUpdate::Moves),
2989            "test must exercise parallel movement counts"
2990        );
2991
2992        let mut updated_workspaces: Vec<_> = (0..4).map(|_| TermDegrees::new(TERMS)).collect();
2993        build_term_degrees(&docs, mid, &fwd, &mut updated_workspaces, None);
2994        movement_workspaces[0].apply_moves_to(&mut updated_workspaces[0]);
2995
2996        let mut rebuilt_workspaces: Vec<_> = (0..4).map(|_| TermDegrees::new(TERMS)).collect();
2997        build_term_degrees(&output, mid, &fwd, &mut rebuilt_workspaces, None);
2998        for term in 0..TERMS {
2999            assert_eq!(
3000                updated_workspaces[0].get(term),
3001                rebuilt_workspaces[0].get(term),
3002                "parallel movement-count mismatch for term {term}"
3003            );
3004        }
3005    }
3006
3007    /// Regression: CSR offsets were u32 and wrapped past 4.29B postings —
3008    /// a 58M-doc / ~85-dims-per-doc prod reorder pass (~4.9B postings)
3009    /// panicked with "mid > len" in the terms carving. The old 8 GB memory
3010    /// budget masked the overflow by dropping dims; raising the budget
3011    /// exposed it. Offsets must be u64.
3012    #[test]
3013    fn test_csr_offsets_do_not_wrap_past_u32() {
3014        let counts = [1_500_000_000u32; 3]; // 4.5B total > u32::MAX
3015        let offsets = build_csr_offsets(&counts, &|| Ok(())).unwrap();
3016        assert_eq!(
3017            offsets,
3018            vec![0, 1_500_000_000, 3_000_000_000, 4_500_000_000]
3019        );
3020        assert!(*offsets.last().unwrap() > u32::MAX as u64);
3021    }
3022
3023    /// Build a simple forward index from (doc_id, terms) pairs.
3024    fn make_fwd(docs: &[&[u32]], num_terms: usize) -> ForwardIndex {
3025        let mut terms = Vec::new();
3026        let mut offsets = vec![0u64];
3027        for doc_terms in docs {
3028            terms.extend_from_slice(doc_terms);
3029            offsets.push(terms.len() as u64);
3030        }
3031        ForwardIndex {
3032            terms,
3033            offsets,
3034            num_terms,
3035            parallel_bisect_lanes: 1,
3036            cache_gains: false,
3037            budget_limited: false,
3038        }
3039    }
3040
3041    #[test]
3042    fn level_partition_ranges_match_recursive_halving() {
3043        for total in 1..=129 {
3044            let mut expected: Vec<std::ops::Range<usize>> = std::iter::once(0..total).collect();
3045            for level in 0..8 {
3046                let actual: Vec<_> = (0..(1usize << level))
3047                    .map(|partition_id| partition_range(total, level, partition_id))
3048                    .collect();
3049                assert_eq!(actual, expected, "total={total}, level={level}");
3050
3051                expected = expected
3052                    .into_iter()
3053                    .flat_map(|range| {
3054                        let mid = range.start + range.len() / 2;
3055                        [range.start..mid, mid..range.end]
3056                    })
3057                    .collect();
3058            }
3059        }
3060    }
3061
3062    #[cfg(feature = "native")]
3063    #[test]
3064    fn level_synchronized_scheduler_is_thread_count_deterministic() {
3065        let docs: Vec<Vec<u32>> = (0..1_027)
3066            .map(|doc| {
3067                vec![
3068                    (doc % 31) as u32,
3069                    ((doc / 5) % 53) as u32,
3070                    ((doc * 29 + 3) % 97) as u32,
3071                ]
3072            })
3073            .collect();
3074        let doc_refs: Vec<_> = docs.iter().map(Vec::as_slice).collect();
3075        let mut fwd = make_fwd(&doc_refs, 97);
3076        fwd.parallel_bisect_lanes = 8;
3077        let one_thread = rayon::ThreadPoolBuilder::new()
3078            .num_threads(1)
3079            .build()
3080            .unwrap();
3081        let eight_threads = rayon::ThreadPoolBuilder::new()
3082            .num_threads(8)
3083            .build()
3084            .unwrap();
3085
3086        let one = one_thread.install(|| graph_bisection(&fwd, 8, 12, BpBudget::full()).0);
3087        let eight = eight_threads.install(|| graph_bisection(&fwd, 8, 12, BpBudget::full()).0);
3088
3089        assert_eq!(eight, one);
3090    }
3091
3092    /// Scheduler benchmark on a posting-skewed graph. Run manually:
3093    /// `cargo test -p summa-core --release --features native \
3094    ///    bench_level_synchronized_scheduler -- --ignored --nocapture`
3095    #[cfg(feature = "native")]
3096    #[test]
3097    #[ignore]
3098    fn bench_level_synchronized_scheduler() {
3099        const DOCS: usize = 500_000;
3100        const TERMS: usize = 32_768;
3101        const ROUNDS: usize = 3;
3102
3103        let mut terms = Vec::with_capacity(DOCS * 12);
3104        let mut offsets = Vec::with_capacity(DOCS + 1);
3105        offsets.push(0);
3106        for doc in 0..DOCS {
3107            // Make posting work deliberately uneven between neighboring
3108            // neighboring tree regions. Dynamic same-level claiming should
3109            // absorb this skew instead of pinning it to one lane.
3110            let term_count = 4 + ((doc.wrapping_mul(2_654_435_761) >> 12) % 17);
3111            for term in 0..term_count {
3112                terms.push(
3113                    ((doc / 64)
3114                        .wrapping_mul(131)
3115                        .wrapping_add(term.wrapping_mul(7_919))
3116                        % TERMS) as u32,
3117                );
3118            }
3119            offsets.push(terms.len() as u64);
3120        }
3121        let fwd = ForwardIndex {
3122            terms,
3123            offsets,
3124            num_terms: TERMS,
3125            parallel_bisect_lanes: 8,
3126            cache_gains: false,
3127            budget_limited: false,
3128        };
3129        let pool = rayon::ThreadPoolBuilder::new()
3130            .num_threads(8)
3131            .build()
3132            .unwrap();
3133        let mut level_times = Vec::with_capacity(ROUNDS);
3134        let mut expected_output = None;
3135
3136        pool.install(|| {
3137            for _ in 0..ROUNDS {
3138                let started = std::time::Instant::now();
3139                let output = graph_bisection(&fwd, 32, 12, BpBudget::full()).0;
3140                level_times.push(started.elapsed());
3141                if let Some(expected) = &expected_output {
3142                    assert_eq!(&output, expected);
3143                } else {
3144                    expected_output = Some(output);
3145                }
3146            }
3147        });
3148
3149        level_times.sort_unstable();
3150        let level = level_times[ROUNDS / 2];
3151        println!(
3152            "BP level-synchronized scheduler median: {:.3}s",
3153            level.as_secs_f64(),
3154        );
3155    }
3156
3157    #[test]
3158    fn test_bp_empty() {
3159        let fwd = ForwardIndex {
3160            terms: Vec::new(),
3161            offsets: Vec::new(),
3162            num_terms: 0,
3163            parallel_bisect_lanes: 1,
3164            cache_gains: false,
3165            budget_limited: false,
3166        };
3167        let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
3168        assert!(perm.is_empty());
3169    }
3170
3171    #[test]
3172    fn test_bp_small() {
3173        // 4 docs, min_partition_size=4 → no bisection, identity
3174        let fwd = make_fwd(&[&[0, 1], &[0, 2], &[1, 3], &[2, 3]], 4);
3175        let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
3176        assert_eq!(perm.len(), 4);
3177        // All docs present
3178        let mut sorted = perm.clone();
3179        sorted.sort();
3180        assert_eq!(sorted, vec![0, 1, 2, 3]);
3181    }
3182
3183    #[test]
3184    fn test_bp_clusters() {
3185        // 8 docs in 2 clear clusters:
3186        // Cluster A (docs 0-3): share terms 0, 1
3187        // Cluster B (docs 4-7): share terms 2, 3
3188        let fwd = make_fwd(
3189            &[
3190                &[0, 1],
3191                &[0, 1],
3192                &[0, 1],
3193                &[0, 1],
3194                &[2, 3],
3195                &[2, 3],
3196                &[2, 3],
3197                &[2, 3],
3198            ],
3199            4,
3200        );
3201        let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
3202        assert_eq!(perm.len(), 8);
3203
3204        // After bisection, docs from same cluster should be in same half
3205        let left: Vec<u32> = perm[..4].to_vec();
3206
3207        // Either all of cluster A is in left and B in right, or vice versa
3208        let a_in_left = left.iter().filter(|&&d| d < 4).count();
3209        let b_in_left = left.iter().filter(|&&d| d >= 4).count();
3210        assert!(
3211            (a_in_left == 4 && b_in_left == 0) || (a_in_left == 0 && b_in_left == 4),
3212            "Clusters should be separated: a_left={}, b_left={}",
3213            a_in_left,
3214            b_in_left,
3215        );
3216    }
3217
3218    #[test]
3219    fn test_bp_permutation_valid() {
3220        // 16 docs with mixed terms: terms range from 0..4 and 10..18
3221        let docs: Vec<Vec<u32>> = (0..16).map(|i| vec![i / 4, 10 + i / 2]).collect();
3222        let doc_refs: Vec<&[u32]> = docs.iter().map(|v| v.as_slice()).collect();
3223        let fwd = make_fwd(&doc_refs, 18); // max term = 10 + 15/2 = 17, so need 18
3224        let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
3225
3226        assert_eq!(perm.len(), 16);
3227        // Must be a valid permutation
3228        let mut sorted = perm.clone();
3229        sorted.sort();
3230        let expected: Vec<u32> = (0..16).collect();
3231        assert_eq!(sorted, expected);
3232    }
3233
3234    /// Depth-capped BP: with min_partition_docs above the cluster size, only
3235    /// the top-level split happens — clusters still separate (coarse
3236    /// clustering), the permutation stays valid, and the pass converges
3237    /// (a depth cap is a chosen target, not an interruption).
3238    #[test]
3239    fn test_bp_depth_cap_separates_clusters_and_converges() {
3240        // Mostly-separated clusters with one misplaced doc per half — the
3241        // top-level swap pass must exchange docs 3 and 4.
3242        let fwd = make_fwd(
3243            &[
3244                &[0, 1],
3245                &[0, 1],
3246                &[0, 1],
3247                &[2, 3],
3248                &[0, 1],
3249                &[2, 3],
3250                &[2, 3],
3251                &[2, 3],
3252            ],
3253            4,
3254        );
3255        let budget = BpBudget {
3256            min_partition_docs: Some(4),
3257            time_budget: None,
3258        };
3259        let (perm, converged) = graph_bisection(&fwd, 2, 20, budget);
3260        assert!(converged, "depth cap must report converged");
3261        assert_eq!(perm.len(), 8);
3262        let mut sorted = perm.clone();
3263        sorted.sort();
3264        assert_eq!(
3265            sorted,
3266            (0..8).collect::<Vec<u32>>(),
3267            "must stay a valid permutation"
3268        );
3269        // Top-level split separates the clusters (docs {0,1,2,4} share terms
3270        // 0/1; docs {3,5,6,7} share terms 2/3)
3271        let cluster_a = [0u32, 1, 2, 4];
3272        let a_in_left = perm[..4].iter().filter(|d| cluster_a.contains(d)).count();
3273        assert!(
3274            a_in_left == 4 || a_in_left == 0,
3275            "clusters should separate at the top level: {:?}",
3276            perm
3277        );
3278    }
3279
3280    /// Zero wall-clock budget: the pass ends immediately, reports
3281    /// converged=false, and still emits a valid (identity) permutation.
3282    #[test]
3283    fn test_bp_zero_time_budget_emits_valid_partial_permutation() {
3284        let docs: Vec<Vec<u32>> = (0..64).map(|i| vec![i % 4]).collect();
3285        let doc_refs: Vec<&[u32]> = docs.iter().map(|v| v.as_slice()).collect();
3286        let fwd = make_fwd(&doc_refs, 4);
3287        let budget = BpBudget {
3288            min_partition_docs: None,
3289            time_budget: Some(std::time::Duration::ZERO),
3290        };
3291        let (perm, converged) = graph_bisection(&fwd, 4, 20, budget);
3292        assert!(!converged, "zero budget must report unconverged");
3293        assert_eq!(perm.len(), 64);
3294        let mut sorted = perm.clone();
3295        sorted.sort();
3296        assert_eq!(sorted, (0..64).collect::<Vec<u32>>());
3297    }
3298
3299    #[test]
3300    fn test_bp_shutdown_cancellation_emits_valid_partial_permutation() {
3301        let docs: Vec<Vec<u32>> = (0..64).map(|i| vec![i % 4]).collect();
3302        let doc_refs: Vec<&[u32]> = docs.iter().map(Vec::as_slice).collect();
3303        let fwd = make_fwd(&doc_refs, 4);
3304        let cancellation = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
3305        let budget = BpBudget {
3306            min_partition_docs: None,
3307            time_budget: None,
3308        };
3309
3310        let (perm, converged) = graph_bisection_with_progress(
3311            &fwd,
3312            4,
3313            20,
3314            budget,
3315            Some(cancellation.as_ref()),
3316            BpProgressLabel::anonymous(),
3317        );
3318
3319        assert!(!converged, "cancelled BP must report unconverged");
3320        assert_eq!(perm, (0..64).collect::<Vec<u32>>());
3321    }
3322
3323    #[test]
3324    fn test_memory_limited_graph_never_reports_converged() {
3325        let mut fwd = make_fwd(&[&[0], &[0], &[1], &[1]], 2);
3326        fwd.budget_limited = true;
3327
3328        let (perm, converged) = graph_bisection(&fwd, 2, 20, BpBudget::full());
3329
3330        assert!(!converged);
3331        let mut sorted = perm;
3332        sorted.sort_unstable();
3333        assert_eq!(sorted, vec![0, 1, 2, 3]);
3334    }
3335
3336    #[test]
3337    fn test_fast_log2() {
3338        let table = build_log_table(4096);
3339        assert!((table[1] - 0.0).abs() < 0.001);
3340        assert!((table[2] - 1.0).abs() < 0.001);
3341        assert!((table[4] - 2.0).abs() < 0.001);
3342        assert!((table[1024] - 10.0).abs() < 0.001);
3343        // Fallback for values beyond table
3344        let val = fast_log2_lookup(8192, &table);
3345        assert!((val - 13.0).abs() < 0.001);
3346    }
3347}