Skip to main content

greplm_core/
segment.rs

1//! On-disk index segments.
2//!
3//! Each segment is a set of files:
4//!   * `seg-N.fst`  - an FST mapping each trigram (3 bytes) to a packed value
5//!     holding the posting-list byte offset *and* its cardinality (see
6//!     [`pack_entry`]), so query planning can pick the rarest trigrams without
7//!     touching the postings blob
8//!   * `seg-N.post` - concatenated roaring bitmaps (posting lists) at those offsets
9//!   * `seg-N.docs` - postcard-encoded `Vec<`[`DocMeta`]`>` (one per document)
10//!   * `seg-N.syms` - postcard-encoded `Vec<`[`SymbolEntry`]`>`
11//!   * `seg-N.refs` - postcard-encoded `Vec<`[`RefEntry`]`>` (call sites + imports)
12//!   * `seg-N.live` - a roaring bitmap of live (non-tombstoned) doc IDs
13//!
14//! The FST and postings blob are mmap'd for zero-copy, page-cache-backed reads.
15//! Doc and symbol tables are small relative to content and loaded into memory.
16//!
17//! Every segment file carries an 8-byte xxh3 checksum footer, verified at open
18//! so silent corruption surfaces as [`Error::Corrupt`] (triggering the
19//! self-healing rebuild) instead of garbage results or a panic. The FST
20//! additionally has its own internal checksum.
21//!
22//! Everything except the live bitmap is immutable once written, so the loaded
23//! tables and derived lookup maps live in an [`Arc<SegmentData>`] that a
24//! reloading searcher can share instead of re-parsing (see [`Segment::reopen`]).
25
26use std::io::BufWriter;
27use std::ops::Deref;
28use std::sync::Arc;
29
30use memmap2::Mmap;
31use roaring::RoaringBitmap;
32use serde::{Deserialize, Serialize};
33
34use crate::error::{Error, Result};
35use crate::fsutil::{write_atomic, AtomicFile};
36use crate::paths::Paths;
37use crate::trigram::{self, Trigram, TrigramDnf, TrigramQuery};
38
39/// Metadata for one indexed document.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct DocMeta {
42    /// Path relative to the project root.
43    pub path: String,
44    /// Language id (see [`crate::lang::Language::id`]).
45    pub lang: String,
46    pub size: u64,
47    /// Fast content hash at index time (xxh3).
48    pub hash: u64,
49    pub lines: u32,
50}
51
52/// A symbol definition extracted from a document.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SymbolEntry {
55    pub doc_id: u32,
56    pub name: String,
57    /// Kind, e.g. "function", "class", "struct".
58    pub kind: String,
59    pub line_start: u32,
60    pub line_end: u32,
61    /// Enclosing named container (e.g. the class a method belongs to).
62    ///
63    /// No `skip_serializing_if`: the side tables use postcard, a
64    /// non-self-describing format where every field must be encoded
65    /// unconditionally or the byte stream desyncs from the reader's schema.
66    pub container: Option<String>,
67    /// Compact one-line signature.
68    pub signature: Option<String>,
69}
70
71/// A symbol before a document id is assigned.
72#[derive(Debug, Clone)]
73pub struct RawSymbol {
74    pub name: String,
75    pub kind: String,
76    pub line_start: u32,
77    pub line_end: u32,
78    pub container: Option<String>,
79    pub signature: Option<String>,
80}
81
82/// The kind of a structural reference. Stored as a 1-byte enum (rather than a
83/// heap `String`) since refs are the most numerous index records; serializes to
84/// the same `"call"`/`"import"` tokens on disk.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "lowercase")]
87pub enum RefKind {
88    Call,
89    Import,
90}
91
92impl RefKind {
93    pub fn as_str(self) -> &'static str {
94        match self {
95            RefKind::Call => "call",
96            RefKind::Import => "import",
97        }
98    }
99}
100
101/// A structural reference (call site or import) extracted from a document.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct RefEntry {
104    pub doc_id: u32,
105    pub name: String,
106    pub kind: RefKind,
107    pub line: u32,
108    pub column: u32,
109}
110
111/// A reference before a document id is assigned.
112#[derive(Debug, Clone)]
113pub struct RawRef {
114    pub name: String,
115    pub kind: RefKind,
116    pub line: u32,
117    pub column: u32,
118}
119
120// ---------------------------------------------------------------------------
121// FST value packing
122// ---------------------------------------------------------------------------
123
124/// Bits of the packed FST value reserved for the posting-list byte offset.
125/// 40 bits addresses a 1 TiB postings blob, far beyond any real segment.
126const OFFSET_BITS: u32 = 40;
127const OFFSET_MASK: u64 = (1 << OFFSET_BITS) - 1;
128/// The cardinality saturates at 2^24-1; beyond that the exact count no longer
129/// affects rarest-first ordering meaningfully.
130const CARD_CAP: u64 = (1 << (64 - OFFSET_BITS)) - 1;
131
132/// Maximum trigrams intersected per AND-group, rarest first. Each additional
133/// intersection costs a full posting-list deserialize for rapidly diminishing
134/// selectivity, so long literals only pay for their most selective trigrams;
135/// the exact matcher verifies whatever the looser filter lets through.
136const MAX_GROUP_TRIGRAMS: usize = 4;
137
138/// Pack a posting-list offset and its cardinality into one FST value.
139fn pack_entry(offset: u64, cardinality: u64) -> Result<u64> {
140    if offset > OFFSET_MASK {
141        return Err(Error::other(format!(
142            "postings blob offset {offset} exceeds the packable maximum"
143        )));
144    }
145    Ok((cardinality.min(CARD_CAP) << OFFSET_BITS) | offset)
146}
147
148fn unpack_offset(value: u64) -> u64 {
149    value & OFFSET_MASK
150}
151
152fn unpack_card(value: u64) -> u64 {
153    value >> OFFSET_BITS
154}
155
156// ---------------------------------------------------------------------------
157// Writing
158// ---------------------------------------------------------------------------
159
160/// Accumulates documents and builds a segment on disk.
161///
162/// Symbols and refs stream straight into the columnar table builders as docs
163/// are added — the writer never materializes per-row structs, so peak memory
164/// during a build is the packed table bytes plus the postings pairs.
165pub struct SegmentWriter {
166    docs: Vec<DocMeta>,
167    syms: crate::table::SymTableBuilder,
168    refs: crate::table::RefTableBuilder,
169    /// Flat postings pairs, `(trigram key << 32) | doc_id`, inverted by one
170    /// parallel sort at write time (Lucene-style sort-based inversion) instead
171    /// of millions of cache-hostile tree probes during the build.
172    pairs: Vec<u64>,
173}
174
175impl Default for SegmentWriter {
176    fn default() -> Self {
177        Self::new()
178    }
179}
180
181impl SegmentWriter {
182    pub fn new() -> Self {
183        SegmentWriter {
184            docs: Vec::new(),
185            syms: crate::table::SymTableBuilder::new(),
186            refs: crate::table::RefTableBuilder::new(),
187            pairs: Vec::new(),
188        }
189    }
190
191    pub fn is_empty(&self) -> bool {
192        self.docs.is_empty()
193    }
194
195    pub fn doc_count(&self) -> usize {
196        self.docs.len()
197    }
198
199    pub fn symbol_count(&self) -> usize {
200        self.syms.len()
201    }
202
203    /// Add a document and return its assigned doc id. `trigrams` must be the
204    /// document's distinct trigrams (any order; typically sorted from
205    /// [`trigram::extract`]).
206    pub fn add_doc(
207        &mut self,
208        meta: DocMeta,
209        trigrams: &[Trigram],
210        symbols: Vec<RawSymbol>,
211        refs: Vec<RawRef>,
212    ) -> u32 {
213        let doc_id = self.docs.len() as u32;
214        self.docs.push(meta);
215        self.pairs.extend(
216            trigrams
217                .iter()
218                .map(|t| (u64::from(trigram::key_of(t)) << 32) | u64::from(doc_id)),
219        );
220        for s in &symbols {
221            self.syms
222                .push(
223                    doc_id,
224                    &s.name,
225                    &s.kind,
226                    s.line_start,
227                    s.line_end,
228                    s.container.as_deref(),
229                    s.signature.as_deref(),
230                )
231                .expect("writer doc ids are ascending");
232        }
233        for r in &refs {
234            self.refs
235                .push(doc_id, &r.name, r.kind, r.line, r.column)
236                .expect("writer doc ids are ascending");
237        }
238        doc_id
239    }
240
241    /// Serialize this segment to disk under the given segment id.
242    pub fn write(self, paths: &Paths, seg_id: u64) -> Result<()> {
243        let SegmentWriter {
244            docs,
245            syms,
246            refs,
247            pairs,
248        } = self;
249        // The postings inversion and the two table finishes (each ending in a
250        // parallel name sort) are independent; overlap them.
251        let (postings, tables) = rayon::join(
252            || build_postings_blob(pairs),
253            || rayon::join(|| syms.finish(docs.len()), || refs.finish(docs.len())),
254        );
255        let (post_blob, fst_entries) = postings?;
256        let (syms_enc, refs_enc) = tables;
257        write_segment_files(
258            paths,
259            seg_id,
260            &docs,
261            syms_enc?,
262            refs_enc?,
263            &fst_entries,
264            post_blob,
265        )
266    }
267}
268
269/// Append the 8-byte xxh3 checksum footer carried by every segment file.
270fn append_checksum(buf: &mut Vec<u8>) {
271    let h = xxhash_rust::xxh3::xxh3_64(buf);
272    buf.extend_from_slice(&h.to_le_bytes());
273}
274
275/// Verify a checksum footer and return the payload it covers.
276fn verify_checksum<'a>(bytes: &'a [u8], what: &str) -> Result<&'a [u8]> {
277    if bytes.len() < 8 {
278        return Err(Error::Corrupt(format!(
279            "{what}: too short for checksum footer"
280        )));
281    }
282    let (payload, footer) = bytes.split_at(bytes.len() - 8);
283    let want = u64::from_le_bytes(footer.try_into().expect("8-byte footer"));
284    if xxhash_rust::xxh3::xxh3_64(payload) != want {
285        return Err(Error::Corrupt(format!("{what}: checksum mismatch")));
286    }
287    Ok(payload)
288}
289
290/// Serialize and checksum a postcard side table.
291fn encode_table<T: Serialize>(rows: &[T]) -> Result<Vec<u8>> {
292    let mut buf = postcard::to_allocvec(rows)?;
293    append_checksum(&mut buf);
294    Ok(buf)
295}
296
297/// Read and decode a checksummed postcard side table.
298fn read_table<T: serde::de::DeserializeOwned>(
299    path: &std::path::Path,
300    what: &str,
301) -> Result<Vec<T>> {
302    let bytes = std::fs::read(path).map_err(|e| Error::io(path, e))?;
303    Ok(postcard::from_bytes(verify_checksum(&bytes, what)?)?)
304}
305
306/// Serialize the components of a segment to disk atomically. Shared by the
307/// incremental writer and by compaction's merge path. Takes the postings blob
308/// by value to append its checksum footer without copying; the side tables
309/// arrive pre-encoded and are streamed to disk section by section.
310pub(crate) fn write_segment_files(
311    paths: &Paths,
312    seg_id: u64,
313    docs: &[DocMeta],
314    syms: crate::table::EncodedTable,
315    refs: crate::table::EncodedTable,
316    fst_entries: &[(Trigram, u64)],
317    mut post_blob: Vec<u8>,
318) -> Result<()> {
319    std::fs::create_dir_all(paths.segments_dir())
320        .map_err(|e| Error::io(paths.segments_dir(), e))?;
321    // FST keys must be inserted in lexicographic order; callers pass entries
322    // sorted by trigram (sort-based inversion and the k-way merge both yield
323    // that order). The FST carries its own internal checksum.
324    let fst_path = paths.fst_file(seg_id);
325    let mut fst_out = AtomicFile::create(&fst_path)?;
326    let mut builder = fst::MapBuilder::new(BufWriter::new(fst_out.file()))?;
327    for (tri, value) in fst_entries {
328        builder.insert(tri, *value)?;
329    }
330    builder.finish()?;
331    fst_out.commit()?;
332
333    append_checksum(&mut post_blob);
334    write_atomic(&paths.post_file(seg_id), &post_blob)?;
335    // The doc table is small (one row per file) and eagerly decoded at open.
336    write_atomic(&paths.docs_file(seg_id), &encode_table(docs)?)?;
337    syms.write_atomic(&paths.syms_file(seg_id))?;
338    refs.write_atomic(&paths.refs_file(seg_id))?;
339
340    // Initially every doc is live.
341    let mut live = RoaringBitmap::new();
342    live.insert_range(0..docs.len() as u32);
343    write_bitmap(&paths.live_file(seg_id), &live)?;
344
345    Ok(())
346}
347
348/// A serialized postings blob paired with the (trigram, packed value) entries
349/// that index into it for the FST.
350pub(crate) type PostingsBlob = (Vec<u8>, Vec<(Trigram, u64)>);
351
352/// Build the postings blob and the (trigram, packed offset+cardinality) FST
353/// entries from flat `(trigram key << 32) | doc_id` pairs: one parallel sort,
354/// then a parallel serialization pass over chunks split at trigram-run
355/// boundaries (each chunk serializes whole runs into a local buffer with
356/// local offsets; the chunks are then concatenated with one offset fix-up).
357fn build_postings_blob(mut pairs: Vec<u64>) -> Result<PostingsBlob> {
358    use rayon::prelude::*;
359    pairs.par_sort_unstable();
360    if pairs.is_empty() {
361        return Ok((Vec::new(), Vec::new()));
362    }
363
364    // Chunk boundaries, advanced to the next run boundary so no trigram's
365    // postings straddle two chunks.
366    let n = pairs.len();
367    let parts = rayon::current_num_threads().clamp(1, 64);
368    let mut bounds: Vec<usize> = vec![0];
369    for p in 1..parts {
370        // `max(1)` keeps the look-behind in bounds when n < parts.
371        let mut at = (n * p / parts).max(1);
372        while at < n && (pairs[at - 1] >> 32) == (pairs[at] >> 32) {
373            at += 1;
374        }
375        if at > *bounds.last().expect("non-empty") && at < n {
376            bounds.push(at);
377        }
378    }
379    bounds.push(n);
380
381    // Per chunk: a local blob plus (key, local offset, cardinality) entries.
382    type Chunk = (Vec<u8>, Vec<(u32, u64, u64)>);
383    let chunks: Vec<Chunk> = bounds
384        .par_windows(2)
385        .map(|w| {
386            let span = &pairs[w[0]..w[1]];
387            let mut blob: Vec<u8> = Vec::new();
388            let mut entries: Vec<(u32, u64, u64)> = Vec::new();
389            let mut i = 0usize;
390            while i < span.len() {
391                let key = (span[i] >> 32) as u32;
392                let start = i;
393                while i < span.len() && (span[i] >> 32) as u32 == key {
394                    i += 1;
395                }
396                // Within a run, doc ids are strictly ascending: the run is a
397                // sorted u64 range sharing its high 32 bits, and each doc
398                // contributes a trigram at most once (extract() deduplicates).
399                let mut bm =
400                    RoaringBitmap::from_sorted_iter(span[start..i].iter().map(|&p| p as u32))
401                        .map_err(|e| Error::other(format!("postings pairs not sorted: {e}")))?;
402                bm.optimize();
403                let offset = blob.len() as u64;
404                bm.serialize_into(&mut blob)
405                    .map_err(|e| Error::other(format!("roaring serialize: {e}")))?;
406                entries.push((key, offset, bm.len()));
407            }
408            Ok((blob, entries))
409        })
410        .collect::<Result<_>>()?;
411    drop(pairs);
412
413    // Stitch: concatenate blobs and rebase each chunk's offsets.
414    let total: usize = chunks.iter().map(|(b, _)| b.len()).sum();
415    let mut post_blob: Vec<u8> = Vec::with_capacity(total);
416    let mut fst_entries: Vec<(Trigram, u64)> =
417        Vec::with_capacity(chunks.iter().map(|(_, e)| e.len()).sum());
418    for (blob, entries) in chunks {
419        let base = post_blob.len() as u64;
420        post_blob.extend_from_slice(&blob);
421        for (key, offset, card) in entries {
422            fst_entries.push((trigram::tri_of(key), pack_entry(base + offset, card)?));
423        }
424    }
425    Ok((post_blob, fst_entries))
426}
427
428/// Stream-merge the postings of several segments into one blob, remapping doc
429/// ids via `remaps` (one table per segment, indexed by old doc id; `u32::MAX`
430/// marks a dropped/tombstoned doc).
431///
432/// A k-way union over the segments' FST term dictionaries visits trigrams in
433/// lexicographic order, so memory stays at O(segments) plus a single output
434/// posting list — the merged index's postings are never materialized at once.
435pub(crate) fn merge_postings(segments: &[Segment], remaps: &[Vec<u32>]) -> Result<PostingsBlob> {
436    use fst::Streamer;
437    let mut op = fst::map::OpBuilder::new();
438    for seg in segments {
439        op.push(seg.data.fst.stream());
440    }
441    let mut union = op.union();
442    let mut post_blob: Vec<u8> = Vec::new();
443    let mut fst_entries: Vec<(Trigram, u64)> = Vec::new();
444    while let Some((key, vals)) = union.next() {
445        if key.len() != 3 {
446            continue;
447        }
448        let tri: Trigram = [key[0], key[1], key[2]];
449        let mut out = RoaringBitmap::new();
450        for iv in vals {
451            let seg = &segments[iv.index];
452            let remap = &remaps[iv.index];
453            let bm = seg.data.posting_at(unpack_offset(iv.value))?;
454            for old in bm {
455                if let Some(&new_id) = remap.get(old as usize) {
456                    if new_id != u32::MAX {
457                        out.insert(new_id);
458                    }
459                }
460            }
461        }
462        if out.is_empty() {
463            continue;
464        }
465        out.optimize();
466        let offset = post_blob.len() as u64;
467        out.serialize_into(&mut post_blob)
468            .map_err(|e| Error::other(format!("roaring serialize: {e}")))?;
469        fst_entries.push((tri, pack_entry(offset, out.len())?));
470    }
471    Ok((post_blob, fst_entries))
472}
473
474// ---------------------------------------------------------------------------
475// Reading
476// ---------------------------------------------------------------------------
477
478/// The immutable, shareable portion of an opened segment: mmaps, decoded side
479/// tables, and the derived lookup structures. Wrapped in an `Arc` so a daemon
480/// reloading its searcher after an incremental index can reuse unchanged
481/// segments instead of re-parsing and re-deriving everything.
482pub struct SegmentData {
483    fst: fst::Map<Mmap>,
484    post: Mmap,
485    /// Logical length of the postings blob (the mmap minus its checksum
486    /// footer); posting offsets must never slice past this.
487    post_len: usize,
488    pub docs: Vec<DocMeta>,
489    /// Columnar mmap-backed symbol table: rows decode on demand, name lookups
490    /// go through a persisted FST, and the fuzzy-scan path walks packed name
491    /// columns — nothing is materialized at open.
492    syms: crate::table::SymTable,
493    /// Columnar reference table; `None` when the segment has no refs file.
494    refs: Option<crate::table::RefTable>,
495}
496
497/// A read-only, mmap-backed view of a segment: shared immutable data plus this
498/// open's snapshot of the live bitmap (the only part that changes on disk).
499pub struct Segment {
500    pub id: u64,
501    data: Arc<SegmentData>,
502    live: RoaringBitmap,
503}
504
505impl Deref for Segment {
506    type Target = SegmentData;
507    fn deref(&self) -> &SegmentData {
508        &self.data
509    }
510}
511
512impl Segment {
513    pub fn open(paths: &Paths, seg_id: u64) -> Result<Segment> {
514        let fst_path = paths.fst_file(seg_id);
515        let fst_file = std::fs::File::open(&fst_path).map_err(|e| Error::io(&fst_path, e))?;
516        let fst_mmap = unsafe { Mmap::map(&fst_file).map_err(|e| Error::io(&fst_path, e))? };
517        let fst = fst::Map::new(fst_mmap)?;
518        // `Map::new` only validates the FST header/length, not the node graph.
519        // A corrupt or truncated `.fst` whose header survives would otherwise
520        // panic with an out-of-bounds index deep in the fst crate's traversal
521        // (`Node::new`) on the first query. Verify the stored checksum up front
522        // so corruption surfaces as `Corrupt` (triggering the self-healing
523        // rebuild) instead of an abort.
524        fst.as_fst()
525            .verify()
526            .map_err(|e| Error::Corrupt(format!("fst checksum: {e}")))?;
527
528        let post_path = paths.post_file(seg_id);
529        let post_file = std::fs::File::open(&post_path).map_err(|e| Error::io(&post_path, e))?;
530        let post = unsafe { Mmap::map(&post_file).map_err(|e| Error::io(&post_path, e))? };
531        // Verify the blob's checksum footer up front (one sequential pass, same
532        // policy as the FST verification above) so a flipped bit surfaces as
533        // `Corrupt` at open instead of a wrong or undecodable posting later.
534        let post_len = verify_checksum(&post, "postings blob")?.len();
535
536        let docs: Vec<DocMeta> = read_table(&paths.docs_file(seg_id), "docs table")?;
537        let syms = crate::table::SymTable::open(&paths.syms_file(seg_id))?;
538
539        // Tolerate a missing refs file (treat as no refs) so a segment written
540        // without any references can still be opened read-only.
541        let refs_path = paths.refs_file(seg_id);
542        let refs = match crate::table::RefTable::open(&refs_path) {
543            Ok(t) => Some(t),
544            Err(Error::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => None,
545            Err(e) => return Err(e),
546        };
547
548        // The side tables and the docs table are separate files; make sure
549        // they belong to the same generation, otherwise per-doc CSR lookups
550        // would silently return the wrong rows.
551        if syms.doc_count() != docs.len()
552            || refs.as_ref().is_some_and(|r| r.doc_count() != docs.len())
553        {
554            return Err(Error::Corrupt(format!(
555                "segment {seg_id}: side-table doc count does not match docs table"
556            )));
557        }
558
559        let live = read_bitmap(&paths.live_file(seg_id))?;
560
561        Ok(Segment {
562            id: seg_id,
563            data: Arc::new(SegmentData {
564                fst,
565                post,
566                post_len,
567                docs,
568                syms,
569                refs,
570            }),
571            live,
572        })
573    }
574
575    /// Re-open this segment cheaply: share the immutable data and reload only
576    /// the live bitmap (the single mutable file). Sound because segment ids are
577    /// never reused — the same id always names the same immutable content.
578    pub fn reopen(&self, paths: &Paths) -> Result<Segment> {
579        let live = read_bitmap(&paths.live_file(self.id))?;
580        Ok(Segment {
581            id: self.id,
582            data: self.data.clone(),
583            live,
584        })
585    }
586
587    pub fn is_live(&self, doc_id: u32) -> bool {
588        self.live.contains(doc_id)
589    }
590
591    /// Subtract pending tombstones from this open's live snapshot. Readers use
592    /// this to honor deletes that are already published in the manifest but
593    /// not yet applied to the on-disk live bitmap (see
594    /// [`crate::meta::Meta::pending_tombstones`]).
595    pub fn subtract_live(&mut self, doc_ids: &[u32]) {
596        for &id in doc_ids {
597            self.live.remove(id);
598        }
599    }
600
601    pub fn live_count(&self) -> u64 {
602        self.live.len()
603    }
604
605    /// All live doc ids in this segment.
606    pub fn all_live(&self) -> RoaringBitmap {
607        self.live.clone()
608    }
609
610    /// Compute candidate doc ids satisfying the trigram query, intersected with
611    /// the live set. An unconstrained query yields all live docs.
612    pub fn candidates(&self, query: &TrigramQuery) -> Result<RoaringBitmap> {
613        let mut filtering = query
614            .dnfs
615            .iter()
616            .filter(|d| trigram::dnf_filters(d))
617            .peekable();
618        if filtering.peek().is_none() {
619            return Ok(self.all_live());
620        }
621        let mut result: Option<RoaringBitmap> = None;
622        for dnf in filtering {
623            let bm = self.data.dnf_bitmap(dnf)?;
624            result = Some(match result.take() {
625                None => bm,
626                Some(a) => a & bm,
627            });
628            if result.as_ref().is_some_and(|b| b.is_empty()) {
629                break;
630            }
631        }
632        let mut out = result.unwrap_or_default();
633        out &= &self.live;
634        Ok(out)
635    }
636}
637
638impl SegmentData {
639    pub fn doc(&self, doc_id: u32) -> Option<&DocMeta> {
640        self.docs.get(doc_id as usize)
641    }
642
643    /// Number of symbol rows in this segment (live or not).
644    pub fn sym_count(&self) -> usize {
645        self.syms.len()
646    }
647
648    /// Decode symbol row `i`. `None` for out-of-range ids or a corrupt row.
649    pub fn sym(&self, i: u32) -> Option<SymbolEntry> {
650        self.syms.get(i)
651    }
652
653    /// `(row id, name, lowercased name)` of every symbol — the fuzzy-scan
654    /// path. Walks the packed name columns only; rows are decoded lazily by
655    /// the caller for actual matches.
656    pub fn sym_names(&self) -> impl Iterator<Item = (u32, &str, &str)> {
657        self.syms.names()
658    }
659
660    /// The symbols defined in `doc_id`, in storage order. O(1) range lookup.
661    pub fn doc_syms(&self, doc_id: u32) -> impl Iterator<Item = SymbolEntry> + '_ {
662        self.syms
663            .doc_range(doc_id)
664            .filter_map(move |i| self.syms.get(i))
665    }
666
667    /// Number of symbols defined in `doc_id`, without decoding any row.
668    pub fn doc_sym_count(&self, doc_id: u32) -> u32 {
669        let r = self.syms.doc_range(doc_id);
670        r.end - r.start
671    }
672
673    /// The references (calls + imports) originating in `doc_id`.
674    pub fn doc_refs(&self, doc_id: u32) -> impl Iterator<Item = RefEntry> + '_ {
675        self.refs
676            .iter()
677            .flat_map(move |t| t.doc_range(doc_id).filter_map(move |i| t.get(i)))
678    }
679
680    /// References (calls and imports) whose name is exactly `name`, via the
681    /// persisted name index (O(results), no full scan). Liveness is not
682    /// filtered here; callers that care should check [`Segment::is_live`].
683    pub fn refs_named<'s>(&'s self, name: &'s str) -> impl Iterator<Item = RefEntry> + 's {
684        self.refs
685            .iter()
686            .flat_map(move |t| t.rows_named(name).filter_map(move |i| t.get(i)))
687    }
688
689    /// Call sites whose callee is exactly `name` (see [`Self::refs_named`]).
690    pub fn calls_to<'s>(&'s self, name: &'s str) -> impl Iterator<Item = RefEntry> + 's {
691        self.refs_named(name).filter(|r| r.kind == RefKind::Call)
692    }
693
694    /// Symbol row ids whose lowercased name is exactly `lower`. O(results)
695    /// via the persisted name FST.
696    pub fn syms_by_lower<'s>(&'s self, lower: &'s str) -> impl Iterator<Item = u32> + 's {
697        self.syms.rows_named(lower)
698    }
699
700    /// Name of symbol row `i` (borrowed from the name column).
701    pub fn sym_name(&self, i: u32) -> &str {
702        self.syms.name(i)
703    }
704
705    /// Lowercased name of symbol row `i` (borrowed from the name column).
706    pub fn sym_name_lower(&self, i: u32) -> &str {
707        self.syms.name_lower(i)
708    }
709
710    /// The packed FST entry for a trigram: posting offset + cardinality.
711    fn posting_entry(&self, tri: Trigram) -> Option<u64> {
712        self.fst.get(tri)
713    }
714
715    /// Deserialize the posting list stored at `offset` in the postings blob.
716    ///
717    /// `offset` comes from the FST term dictionary, which on a corrupt or
718    /// truncated index can point past the end of the mmap'd blob. Bounds-check
719    /// it so a bad offset is reported as `Corrupt` (letting the self-healing
720    /// path rebuild) instead of panicking with an out-of-range slice index.
721    fn posting_at(&self, offset: u64) -> Result<RoaringBitmap> {
722        let start = offset as usize;
723        let slice = self.post.get(start..self.post_len).ok_or_else(|| {
724            Error::Corrupt(format!(
725                "posting offset {start} out of range for postings blob of length {}",
726                self.post_len
727            ))
728        })?;
729        RoaringBitmap::deserialize_from(slice)
730            .map_err(|e| Error::Corrupt(format!("posting list: {e}")))
731    }
732
733    /// OR together the AND-groups of one DNF.
734    fn dnf_bitmap(&self, dnf: &TrigramDnf) -> Result<RoaringBitmap> {
735        let mut acc = RoaringBitmap::new();
736        for group in dnf {
737            acc |= self.group_bitmap(group)?;
738        }
739        Ok(acc)
740    }
741
742    /// AND together a group of trigrams, deserializing only the
743    /// [`MAX_GROUP_TRIGRAMS`] rarest posting lists. The cardinality packed in
744    /// the FST value orders the trigrams *before* any posting list is touched,
745    /// and a trigram absent from the index empties the group immediately.
746    /// Skipping the commoner trigrams only widens the candidate set, never
747    /// narrows it, so this is sound.
748    fn group_bitmap(&self, group: &[Trigram]) -> Result<RoaringBitmap> {
749        let mut entries: Vec<u64> = Vec::with_capacity(group.len());
750        for tri in group {
751            match self.posting_entry(*tri) {
752                Some(v) => entries.push(v),
753                // No document contains this trigram => the AND is empty.
754                None => return Ok(RoaringBitmap::new()),
755            }
756        }
757        entries.sort_unstable_by_key(|&v| unpack_card(v));
758        entries.truncate(MAX_GROUP_TRIGRAMS);
759
760        let mut acc: Option<RoaringBitmap> = None;
761        for v in entries {
762            let bm = self.posting_at(unpack_offset(v))?;
763            acc = Some(match acc.take() {
764                None => bm,
765                Some(a) => a & bm,
766            });
767            if acc.as_ref().is_some_and(|b| b.is_empty()) {
768                break;
769            }
770        }
771        Ok(acc.unwrap_or_default())
772    }
773}
774
775pub(crate) fn write_bitmap(path: &std::path::Path, bm: &RoaringBitmap) -> Result<()> {
776    let mut buf = Vec::with_capacity(bm.serialized_size() + 8);
777    bm.serialize_into(&mut buf)
778        .map_err(|e| Error::other(format!("roaring serialize: {e}")))?;
779    append_checksum(&mut buf);
780    write_atomic(path, &buf)
781}
782
783pub(crate) fn read_bitmap(path: &std::path::Path) -> Result<RoaringBitmap> {
784    let bytes = std::fs::read(path).map_err(|e| Error::io(path, e))?;
785    RoaringBitmap::deserialize_from(verify_checksum(&bytes, "live bitmap")?)
786        .map_err(|e| Error::Corrupt(format!("live bitmap: {e}")))
787}