Skip to main content

plugmem_core/index/
postings.rs

1//! Posting storage shared by every id-list index (–3).
2//!
3//! One shape serves BM25 postings, tag → facts and entity → facts: a
4//! [`plugmem_arena::Arena`] of per-key handle slots plus one
5//! [`ChunkPool`] holding the lists. Entries are appended in ascending
6//! fact-id order (ids are assigned monotonically, so appends keep lists
7//! sorted for free) and encoded as varint deltas — `[delta]` for plain id
8//! lists, `[delta][tf u8]` for BM25 (`TF = true`).
9//!
10//! Each entry is pushed as one `ChunkPool` value, so entries never
11//! straddle chunk boundaries and decoding walks whole entries per chunk
12//! slice.
13
14use plugmem_arena::{
15    Arena, ArenaCfg, ChunkIter, ChunkPool, ChunkPoolCfg, ListHandle, ShardMode, Slot, key,
16};
17
18use crate::error::Error;
19use crate::id::FactId;
20use crate::index::varint::{MAX_VARINT, decode_u32, encode_u32};
21
22/// Per-key slot: `[key 4 | ListHandle 12 | count u32 | last u32]`, 24
23/// bytes, Uniform arena. `count` doubles as the document frequency for
24/// BM25; `last` is the highest id in the list (the delta base).
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
27pub struct IdListSlot {
28    /// The indexed key (a `TermId`, a tag's `TermId`, an `EntityId` — the
29    /// owner decides; raw here to serve them all).
30    pub key: u32,
31    /// The list's chunk chain.
32    pub handle: ListHandle,
33    /// Number of entries in the list.
34    pub count: u32,
35    /// Highest id appended so far (delta base for the next append).
36    pub last: u32,
37}
38
39impl Slot for IdListSlot {
40    const SIZE: usize = 24;
41    const KEY_LEN: usize = 4;
42
43    fn write(&self, out: &mut [u8]) {
44        key::write_u32(out, self.key);
45        out[4..16].copy_from_slice(&self.handle.to_bytes());
46        key::write_u32(&mut out[16..], self.count);
47        key::write_u32(&mut out[20..], self.last);
48    }
49
50    fn read(bytes: &[u8]) -> Self {
51        Self {
52            key: key::read_u32(bytes),
53            handle: ListHandle::from_bytes(bytes[4..16].try_into().unwrap()),
54            count: key::read_u32(&bytes[16..]),
55            last: key::read_u32(&bytes[20..]),
56        }
57    }
58}
59
60/// Key → sorted id list storage; `TF` adds a one-byte term frequency to
61/// every entry (the BM25 flavor).
62#[derive(Debug)]
63pub struct PostingStore<'a, const TF: bool> {
64    handles: Arena<'a, IdListSlot>,
65    pool: ChunkPool<'a>,
66}
67
68impl<'a, const TF: bool> PostingStore<'a, TF> {
69    /// Creates an empty store. `shards` follows the owning index's config
70    /// (power of two); `max_bytes` bounds the chunk pool.
71    pub fn new(shards: usize, max_bytes: usize) -> Result<Self, Error> {
72        Ok(Self {
73            handles: Arena::new(
74                ArenaCfg::new(shards, ShardMode::Uniform).with_max_bytes(max_bytes),
75            )?,
76            pool: ChunkPool::new(ChunkPoolCfg::new().with_max_bytes(max_bytes)),
77        })
78    }
79
80    /// Appends `(id, tf)` to `key`'s list. Ids must arrive in strictly
81    /// ascending order per key — the caller indexes facts in id order, so
82    /// a violation is a caller bug and panics (debug) / corrupts the one
83    /// list (release), never the store.
84    ///
85    /// # Errors
86    ///
87    /// [`Error::Arena`] when a pool hits its byte ceiling.
88    pub fn push(&mut self, raw_key: u32, id: FactId, tf: u8) -> Result<(), Error> {
89        let mut kb = [0u8; 4];
90        key::write_u32(&mut kb, raw_key);
91        let slot = self.handles.get(&kb);
92        let (mut handle, count, last) = match slot {
93            Some(s) => (s.handle, s.count, s.last),
94            None => (ListHandle::EMPTY, 0, 0),
95        };
96        debug_assert!(
97            count == 0 || id.0 > last,
98            "posting ids must be appended in ascending order"
99        );
100        let delta = id.0.wrapping_sub(if count == 0 { 0 } else { last });
101        let mut buf = [0u8; MAX_VARINT + 1];
102        let mut head = [0u8; MAX_VARINT];
103        let mut n = encode_u32(delta, &mut head);
104        buf[..n].copy_from_slice(&head[..n]);
105        if TF {
106            buf[n] = tf;
107            n += 1;
108        }
109        self.pool.push(&mut handle, &buf[..n])?;
110        let updated = IdListSlot {
111            key: raw_key,
112            handle,
113            count: count + 1,
114            last: id.0,
115        };
116        if slot.is_some() {
117            let payload = self
118                .handles
119                .payload_mut(&kb)
120                .expect("slot existed just above");
121            let mut full = [0u8; IdListSlot::SIZE];
122            updated.write(&mut full);
123            payload.copy_from_slice(&full[IdListSlot::KEY_LEN..]);
124        } else {
125            self.handles.insert(&updated)?;
126        }
127        Ok(())
128    }
129
130    /// Number of entries in `key`'s list (0 when absent). This is the
131    /// document frequency for BM25 keys.
132    pub fn count(&self, raw_key: u32) -> u32 {
133        let mut kb = [0u8; 4];
134        key::write_u32(&mut kb, raw_key);
135        self.handles.get(&kb).map_or(0, |s| s.count)
136    }
137
138    /// Iterates `key`'s list as `(id, tf)` pairs in ascending id order
139    /// (`tf` is 1 for `TF = false` stores).
140    pub fn entries(&self, raw_key: u32) -> Entries<'_, TF> {
141        let mut kb = [0u8; 4];
142        key::write_u32(&mut kb, raw_key);
143        let handle = self
144            .handles
145            .get(&kb)
146            .map_or(ListHandle::EMPTY, |s| s.handle);
147        Entries {
148            chunks: self.pool.iter(&handle),
149            cur: &[],
150            prev: 0,
151            first: true,
152        }
153    }
154
155    /// Total bytes held by the two underlying pools.
156    pub fn pool_bytes(&self) -> usize {
157        self.handles.pool_bytes() + self.pool.pool_bytes()
158    }
159
160    /// Number of distinct keys.
161    pub fn keys(&self) -> usize {
162        self.handles.len()
163    }
164
165    /// Section dumps: handle-arena meta.
166    pub(crate) fn handles_meta(&self) -> alloc::vec::Vec<u8> {
167        let mut out = alloc::vec::Vec::new();
168        self.handles.dump_meta(&mut out);
169        out
170    }
171
172    /// Section dumps: handle-arena pool.
173    pub(crate) fn handles_pool(&self) -> alloc::vec::Vec<u8> {
174        let mut out = alloc::vec::Vec::new();
175        self.handles.dump_pool(&mut out);
176        out
177    }
178
179    /// Section dumps: chunk-pool meta.
180    pub(crate) fn chunks_meta(&self) -> alloc::vec::Vec<u8> {
181        let mut out = alloc::vec::Vec::new();
182        self.pool.dump_meta(&mut out);
183        out
184    }
185
186    /// Section dumps: chunk-pool bytes.
187    pub(crate) fn chunks_pool(&self) -> alloc::vec::Vec<u8> {
188        let mut out = alloc::vec::Vec::new();
189        self.pool.dump_pool(&mut out);
190        out
191    }
192
193    /// Assembles a store from already-validated parts (the load path).
194    pub(crate) fn from_parts(handles: Arena<'a, IdListSlot>, pool: ChunkPool<'a>) -> Self {
195        Self { handles, pool }
196    }
197}
198
199/// Iterator over one list's `(id, tf)` entries; see
200/// [`PostingStore::entries`].
201pub struct Entries<'a, const TF: bool> {
202    chunks: ChunkIter<'a>,
203    cur: &'a [u8],
204    prev: u32,
205    first: bool,
206}
207
208impl<const TF: bool> Iterator for Entries<'_, TF> {
209    type Item = (FactId, u8);
210
211    fn next(&mut self) -> Option<(FactId, u8)> {
212        while self.cur.is_empty() {
213            self.cur = self.chunks.next()?;
214        }
215        // Entries never straddle chunks, so a non-empty slice starts a
216        // whole entry; the store only ever appends well-formed varints.
217        let (delta, used) = decode_u32(self.cur).expect("posting entry is well-formed");
218        let mut at = used;
219        let tf = if TF {
220            let tf = self.cur[at];
221            at += 1;
222            tf
223        } else {
224            1
225        };
226        self.cur = &self.cur[at..];
227        let id = if self.first {
228            self.first = false;
229            delta
230        } else {
231            self.prev + delta
232        };
233        self.prev = id;
234        Some((FactId(id), tf))
235    }
236}