Skip to main content

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