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. `trigram_keys` must be the
204    /// document's distinct trigram keys (any order; typically sorted from
205    /// [`trigram::extract_keys`]).
206    pub fn add_doc(
207        &mut self,
208        meta: DocMeta,
209        trigram_keys: &[u32],
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            trigram_keys
217                .iter()
218                .map(|&k| (u64::from(k) << 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        // Opening a segment is dominated by integrity checks — the FST's node
515        // graph, the postings blob's checksum footer, and every side-table
516        // section's xxh3 — which together hash tens of MB on a large index.
517        // They are over disjoint files and independent of each other, so run
518        // them concurrently; a cold query pays the slowest one rather than the
519        // sum. Results are unwrapped below in the original order so that when
520        // more than one file is damaged, the reported error stays deterministic.
521        let (fst_and_post, tables) = rayon::join(
522            || -> Result<(fst::Map<Mmap>, Mmap, usize)> {
523                let fst_path = paths.fst_file(seg_id);
524                let (fst, post_and_len) = rayon::join(
525                    || -> Result<fst::Map<Mmap>> {
526                        let fst_file =
527                            std::fs::File::open(&fst_path).map_err(|e| Error::io(&fst_path, e))?;
528                        let fst_mmap =
529                            unsafe { Mmap::map(&fst_file).map_err(|e| Error::io(&fst_path, e))? };
530                        let fst = fst::Map::new(fst_mmap)?;
531                        // `Map::new` only validates the FST header/length, not the
532                        // node graph. A corrupt or truncated `.fst` whose header
533                        // survives would otherwise panic with an out-of-bounds
534                        // index deep in the fst crate's traversal (`Node::new`) on
535                        // the first query. Verify the stored checksum up front so
536                        // corruption surfaces as `Corrupt` (triggering the
537                        // self-healing rebuild) instead of an abort.
538                        fst.as_fst()
539                            .verify()
540                            .map_err(|e| Error::Corrupt(format!("fst checksum: {e}")))?;
541                        Ok(fst)
542                    },
543                    || -> Result<(Mmap, usize)> {
544                        let post_path = paths.post_file(seg_id);
545                        let post_file =
546                            std::fs::File::open(&post_path).map_err(|e| Error::io(&post_path, e))?;
547                        let post = unsafe {
548                            Mmap::map(&post_file).map_err(|e| Error::io(&post_path, e))?
549                        };
550                        // Verify the blob's checksum footer up front (one
551                        // sequential pass, same policy as the FST verification
552                        // above) so a flipped bit surfaces as `Corrupt` at open
553                        // instead of a wrong or undecodable posting later.
554                        let post_len = verify_checksum(&post, "postings blob")?.len();
555                        Ok((post, post_len))
556                    },
557                );
558                let (post, post_len) = post_and_len?;
559                Ok((fst?, post, post_len))
560            },
561            || -> Result<(
562                Vec<DocMeta>,
563                crate::table::SymTable,
564                Option<crate::table::RefTable>,
565            )> {
566                let (docs_and_syms, refs) = rayon::join(
567                    || -> Result<(Vec<DocMeta>, crate::table::SymTable)> {
568                        let docs: Vec<DocMeta> =
569                            read_table(&paths.docs_file(seg_id), "docs table")?;
570                        let syms = crate::table::SymTable::open(&paths.syms_file(seg_id))?;
571                        Ok((docs, syms))
572                    },
573                    || -> Result<Option<crate::table::RefTable>> {
574                        // Tolerate a missing refs file (treat as no refs) so a
575                        // segment written without any references can still be
576                        // opened read-only.
577                        let refs_path = paths.refs_file(seg_id);
578                        match crate::table::RefTable::open(&refs_path) {
579                            Ok(t) => Ok(Some(t)),
580                            Err(Error::Io { source, .. })
581                                if source.kind() == std::io::ErrorKind::NotFound =>
582                            {
583                                Ok(None)
584                            }
585                            Err(e) => Err(e),
586                        }
587                    },
588                );
589                let (docs, syms) = docs_and_syms?;
590                Ok((docs, syms, refs?))
591            },
592        );
593        let (fst, post, post_len) = fst_and_post?;
594        let (docs, syms, refs) = tables?;
595
596        // The side tables and the docs table are separate files; make sure
597        // they belong to the same generation, otherwise per-doc CSR lookups
598        // would silently return the wrong rows.
599        if syms.doc_count() != docs.len()
600            || refs.as_ref().is_some_and(|r| r.doc_count() != docs.len())
601        {
602            return Err(Error::Corrupt(format!(
603                "segment {seg_id}: side-table doc count does not match docs table"
604            )));
605        }
606
607        let live = read_bitmap(&paths.live_file(seg_id))?;
608
609        Ok(Segment {
610            id: seg_id,
611            data: Arc::new(SegmentData {
612                fst,
613                post,
614                post_len,
615                docs,
616                syms,
617                refs,
618            }),
619            live,
620        })
621    }
622
623    /// Re-open this segment cheaply: share the immutable data and reload only
624    /// the live bitmap (the single mutable file). Sound because segment ids are
625    /// never reused — the same id always names the same immutable content.
626    pub fn reopen(&self, paths: &Paths) -> Result<Segment> {
627        let live = read_bitmap(&paths.live_file(self.id))?;
628        Ok(Segment {
629            id: self.id,
630            data: self.data.clone(),
631            live,
632        })
633    }
634
635    pub fn is_live(&self, doc_id: u32) -> bool {
636        self.live.contains(doc_id)
637    }
638
639    /// Subtract pending tombstones from this open's live snapshot. Readers use
640    /// this to honor deletes that are already published in the manifest but
641    /// not yet applied to the on-disk live bitmap (see
642    /// [`crate::meta::Meta::pending_tombstones`]).
643    pub fn subtract_live(&mut self, doc_ids: &[u32]) {
644        for &id in doc_ids {
645            self.live.remove(id);
646        }
647    }
648
649    pub fn live_count(&self) -> u64 {
650        self.live.len()
651    }
652
653    /// All live doc ids in this segment.
654    pub fn all_live(&self) -> RoaringBitmap {
655        self.live.clone()
656    }
657
658    /// Compute candidate doc ids satisfying the trigram query, intersected with
659    /// the live set. An unconstrained query yields all live docs.
660    pub fn candidates(&self, query: &TrigramQuery) -> Result<RoaringBitmap> {
661        let mut filtering = query
662            .dnfs
663            .iter()
664            .filter(|d| trigram::dnf_filters(d))
665            .peekable();
666        if filtering.peek().is_none() {
667            return Ok(self.all_live());
668        }
669        let mut result: Option<RoaringBitmap> = None;
670        for dnf in filtering {
671            let bm = self.data.dnf_bitmap(dnf)?;
672            result = Some(match result.take() {
673                None => bm,
674                Some(a) => a & bm,
675            });
676            if result.as_ref().is_some_and(|b| b.is_empty()) {
677                break;
678            }
679        }
680        let mut out = result.unwrap_or_default();
681        out &= &self.live;
682        Ok(out)
683    }
684}
685
686impl SegmentData {
687    pub fn doc(&self, doc_id: u32) -> Option<&DocMeta> {
688        self.docs.get(doc_id as usize)
689    }
690
691    /// Path ranking adjustment for `doc_id`, or `0.0` if there is no such doc.
692    ///
693    /// Computed on demand: [`crate::search::path_score`] allocates nothing, so
694    /// precomputing a column of these at open cost more (one pass over every
695    /// path in the repository, on the critical path of every cold query) than
696    /// the per-hit calls it saved.
697    pub fn doc_path_score(&self, doc_id: u32) -> f32 {
698        self.doc(doc_id)
699            .map(|d| crate::search::path_score(&d.path))
700            .unwrap_or(0.0)
701    }
702
703    /// Number of symbol rows in this segment (live or not).
704    pub fn sym_count(&self) -> usize {
705        self.syms.len()
706    }
707
708    /// Decode symbol row `i`. `None` for out-of-range ids or a corrupt row.
709    pub fn sym(&self, i: u32) -> Option<SymbolEntry> {
710        self.syms.get(i)
711    }
712
713    /// Borrow symbol row `i` without allocating (see
714    /// [`crate::table::SymView`]). For scan paths that inspect many rows and
715    /// keep few.
716    pub(crate) fn sym_view(&self, i: u32) -> Option<crate::table::SymView<'_>> {
717        self.syms.view(i)
718    }
719
720    /// Row-id range of the symbols defined in `doc_id`. O(1).
721    pub(crate) fn doc_sym_rows(&self, doc_id: u32) -> std::ops::Range<u32> {
722        self.syms.doc_range(doc_id)
723    }
724
725    /// The symbols defined in `doc_id` as borrowed views, in storage order.
726    pub(crate) fn doc_sym_views(
727        &self,
728        doc_id: u32,
729    ) -> impl Iterator<Item = crate::table::SymView<'_>> {
730        self.syms
731            .doc_range(doc_id)
732            .filter_map(move |i| self.syms.view(i))
733    }
734
735    /// References originating in `doc_id`, as borrowed views.
736    pub(crate) fn doc_ref_views(
737        &self,
738        doc_id: u32,
739    ) -> impl Iterator<Item = crate::table::RefView<'_>> {
740        self.refs
741            .iter()
742            .flat_map(move |t| t.doc_range(doc_id).filter_map(move |i| t.view(i)))
743    }
744
745    /// References whose name is exactly `name`, as borrowed views (O(results)).
746    pub(crate) fn ref_views_named<'s>(
747        &'s self,
748        name: &'s str,
749    ) -> impl Iterator<Item = crate::table::RefView<'s>> + 's {
750        self.refs
751            .iter()
752            .flat_map(move |t| t.rows_named(name).filter_map(move |i| t.view(i)))
753    }
754
755    /// The symbols defined in `doc_id`, in storage order. O(1) range lookup.
756    pub fn doc_syms(&self, doc_id: u32) -> impl Iterator<Item = SymbolEntry> + '_ {
757        self.syms
758            .doc_range(doc_id)
759            .filter_map(move |i| self.syms.get(i))
760    }
761
762    /// Number of symbols defined in `doc_id`, without decoding any row.
763    pub fn doc_sym_count(&self, doc_id: u32) -> u32 {
764        let r = self.syms.doc_range(doc_id);
765        r.end - r.start
766    }
767
768    /// The references (calls + imports) originating in `doc_id`.
769    pub fn doc_refs(&self, doc_id: u32) -> impl Iterator<Item = RefEntry> + '_ {
770        self.refs
771            .iter()
772            .flat_map(move |t| t.doc_range(doc_id).filter_map(move |i| t.get(i)))
773    }
774
775    /// References (calls and imports) whose name is exactly `name`, via the
776    /// persisted name index (O(results), no full scan). Liveness is not
777    /// filtered here; callers that care should check [`Segment::is_live`].
778    pub fn refs_named<'s>(&'s self, name: &'s str) -> impl Iterator<Item = RefEntry> + 's {
779        self.refs
780            .iter()
781            .flat_map(move |t| t.rows_named(name).filter_map(move |i| t.get(i)))
782    }
783
784    /// Call sites whose callee is exactly `name` (see [`Self::refs_named`]).
785    pub fn calls_to<'s>(&'s self, name: &'s str) -> impl Iterator<Item = RefEntry> + 's {
786        self.refs_named(name).filter(|r| r.kind == RefKind::Call)
787    }
788
789    /// Symbol row ids whose lowercased name is exactly `lower`. O(results)
790    /// via the persisted name FST.
791    pub fn syms_by_lower<'s>(&'s self, lower: &'s str) -> impl Iterator<Item = u32> + 's {
792        self.syms.rows_named(lower)
793    }
794
795    /// Name of symbol row `i` (borrowed from the name column).
796    pub fn sym_name(&self, i: u32) -> &str {
797        self.syms.name(i)
798    }
799
800    /// Lowercased name of symbol row `i` (borrowed from the name column).
801    pub fn sym_name_lower(&self, i: u32) -> &str {
802        self.syms.name_lower(i)
803    }
804
805    /// The packed FST entry for a trigram: posting offset + cardinality.
806    fn posting_entry(&self, tri: Trigram) -> Option<u64> {
807        self.fst.get(tri)
808    }
809
810    /// Deserialize the posting list stored at `offset` in the postings blob.
811    ///
812    /// `offset` comes from the FST term dictionary, which on a corrupt or
813    /// truncated index can point past the end of the mmap'd blob. Bounds-check
814    /// it so a bad offset is reported as `Corrupt` (letting the self-healing
815    /// path rebuild) instead of panicking with an out-of-range slice index.
816    fn posting_at(&self, offset: u64) -> Result<RoaringBitmap> {
817        let start = offset as usize;
818        let slice = self.post.get(start..self.post_len).ok_or_else(|| {
819            Error::Corrupt(format!(
820                "posting offset {start} out of range for postings blob of length {}",
821                self.post_len
822            ))
823        })?;
824        RoaringBitmap::deserialize_from(slice)
825            .map_err(|e| Error::Corrupt(format!("posting list: {e}")))
826    }
827
828    /// OR together the AND-groups of one DNF.
829    fn dnf_bitmap(&self, dnf: &TrigramDnf) -> Result<RoaringBitmap> {
830        let mut acc = RoaringBitmap::new();
831        for group in dnf {
832            acc |= self.group_bitmap(group)?;
833        }
834        Ok(acc)
835    }
836
837    /// AND together a group of trigrams, deserializing only the
838    /// [`MAX_GROUP_TRIGRAMS`] rarest posting lists. The cardinality packed in
839    /// the FST value orders the trigrams *before* any posting list is touched,
840    /// and a trigram absent from the index empties the group immediately.
841    /// Skipping the commoner trigrams only widens the candidate set, never
842    /// narrows it, so this is sound.
843    fn group_bitmap(&self, group: &[Trigram]) -> Result<RoaringBitmap> {
844        let mut entries: Vec<u64> = Vec::with_capacity(group.len());
845        for tri in group {
846            match self.posting_entry(*tri) {
847                Some(v) => entries.push(v),
848                // No document contains this trigram => the AND is empty.
849                None => return Ok(RoaringBitmap::new()),
850            }
851        }
852        entries.sort_unstable_by_key(|&v| unpack_card(v));
853        entries.truncate(MAX_GROUP_TRIGRAMS);
854
855        let mut acc: Option<RoaringBitmap> = None;
856        for v in entries {
857            let bm = self.posting_at(unpack_offset(v))?;
858            acc = Some(match acc.take() {
859                None => bm,
860                Some(a) => a & bm,
861            });
862            if acc.as_ref().is_some_and(|b| b.is_empty()) {
863                break;
864            }
865        }
866        Ok(acc.unwrap_or_default())
867    }
868}
869
870pub(crate) fn write_bitmap(path: &std::path::Path, bm: &RoaringBitmap) -> Result<()> {
871    let mut buf = Vec::with_capacity(bm.serialized_size() + 8);
872    bm.serialize_into(&mut buf)
873        .map_err(|e| Error::other(format!("roaring serialize: {e}")))?;
874    append_checksum(&mut buf);
875    write_atomic(path, &buf)
876}
877
878pub(crate) fn read_bitmap(path: &std::path::Path) -> Result<RoaringBitmap> {
879    let bytes = std::fs::read(path).map_err(|e| Error::io(path, e))?;
880    RoaringBitmap::deserialize_from(verify_checksum(&bytes, "live bitmap")?)
881        .map_err(|e| Error::Corrupt(format!("live bitmap: {e}")))
882}