Skip to main content

rete_core/
triples.rs

1//! Integer triple blocks: grouped, delta-coded adjacency with a zone map
2//! (SPEC.md §6.1).
3//!
4//! A block stores triples `(a, b, c)` of dictionary IDs for one permutation,
5//! sorted ascending. `a`/`b`/`c` are the permuted roles — for an SPO block
6//! `a=subject, b=predicate, c=object`; for POS, `a=predicate, b=object,
7//! c=subject`; and so on. The encoding is role-agnostic.
8
9use crate::varint::{read_uvarint, write_uvarint};
10
11/// A triple of dictionary IDs in some permutation's component order.
12pub type Triple = (u32, u32, u32);
13
14/// Per-block summary statistics enabling block-skipping.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct ZoneMap {
17    pub min_a: u32,
18    pub max_a: u32,
19    pub min_b: u32,
20    pub max_b: u32,
21    pub min_c: u32,
22    pub max_c: u32,
23    pub count: u32,
24}
25
26impl ZoneMap {
27    /// Could a triple with the given bound components possibly live in this
28    /// block? `None` means "unbound — don't constrain on this component".
29    pub fn may_contain(&self, a: Option<u32>, b: Option<u32>, c: Option<u32>) -> bool {
30        let in_range = |v: Option<u32>, lo: u32, hi: u32| v.is_none_or(|x| lo <= x && x <= hi);
31        in_range(a, self.min_a, self.max_a)
32            && in_range(b, self.min_b, self.max_b)
33            && in_range(c, self.min_c, self.max_c)
34    }
35}
36
37#[derive(Debug, thiserror::Error)]
38pub enum TripleError {
39    #[error("malformed triple block: {0}")]
40    Malformed(&'static str),
41}
42
43/// Accumulates triples and serializes a block.
44#[derive(Default)]
45pub struct TripleBlockBuilder {
46    triples: Vec<Triple>,
47}
48
49impl TripleBlockBuilder {
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    pub fn push(&mut self, t: Triple) {
55        self.triples.push(t);
56    }
57
58    pub fn len(&self) -> usize {
59        self.triples.len()
60    }
61
62    pub fn is_empty(&self) -> bool {
63        self.triples.is_empty()
64    }
65
66    /// Sort, dedup, and encode. Returns an empty body for zero triples.
67    pub fn build(mut self) -> Vec<u8> {
68        self.triples.sort_unstable();
69        self.triples.dedup();
70        let t = &self.triples;
71
72        let mut out = Vec::new();
73        if t.is_empty() {
74            // zone map of zeros + count 0, no body.
75            for _ in 0..7 {
76                write_uvarint(&mut out, 0);
77            }
78            write_uvarint(&mut out, 0); // num_a
79            return out;
80        }
81
82        // Zone map.
83        let (mut min_a, mut max_a) = (u32::MAX, 0u32);
84        let (mut min_b, mut max_b) = (u32::MAX, 0u32);
85        let (mut min_c, mut max_c) = (u32::MAX, 0u32);
86        for &(a, b, c) in t {
87            min_a = min_a.min(a);
88            max_a = max_a.max(a);
89            min_b = min_b.min(b);
90            max_b = max_b.max(b);
91            min_c = min_c.min(c);
92            max_c = max_c.max(c);
93        }
94        for v in [min_a, max_a, min_b, max_b, min_c, max_c, t.len() as u32] {
95            write_uvarint(&mut out, v as u64);
96        }
97
98        // Body: grouped delta adjacency.
99        // First pass: collect a-groups -> b-groups -> c-list.
100        // (b, c-list) for one b under an a; and (a, its b-groups).
101        type BGroup = (u32, Vec<u32>);
102        type AGroup = (u32, Vec<BGroup>);
103        let mut i = 0;
104        let mut a_groups: Vec<AGroup> = Vec::new();
105        while i < t.len() {
106            let a = t[i].0;
107            let mut b_groups: Vec<(u32, Vec<u32>)> = Vec::new();
108            while i < t.len() && t[i].0 == a {
109                let b = t[i].1;
110                let mut cs = Vec::new();
111                while i < t.len() && t[i].0 == a && t[i].1 == b {
112                    cs.push(t[i].2);
113                    i += 1;
114                }
115                b_groups.push((b, cs));
116            }
117            a_groups.push((a, b_groups));
118        }
119
120        write_uvarint(&mut out, a_groups.len() as u64);
121        let mut prev_a = 0u32;
122        for (a, b_groups) in &a_groups {
123            write_uvarint(&mut out, (a - prev_a) as u64);
124            prev_a = *a;
125            write_uvarint(&mut out, b_groups.len() as u64);
126            let mut prev_b = 0u32;
127            for (b, cs) in b_groups {
128                write_uvarint(&mut out, (b - prev_b) as u64);
129                prev_b = *b;
130                write_uvarint(&mut out, cs.len() as u64);
131                let mut prev_c = 0u32;
132                for c in cs {
133                    write_uvarint(&mut out, (c - prev_c) as u64);
134                    prev_c = *c;
135                }
136            }
137        }
138        out
139    }
140}
141
142/// A parsed triple block.
143pub struct TripleBlock<'a> {
144    bytes: &'a [u8],
145    zone: ZoneMap,
146    body_start: usize,
147}
148
149impl<'a> TripleBlock<'a> {
150    pub fn parse(bytes: &'a [u8]) -> Result<Self, TripleError> {
151        let mut pos = 0;
152        let take = |pos: &mut usize| -> Result<u32, TripleError> {
153            rd(bytes, pos).ok_or(TripleError::Malformed("truncated or out-of-range zone map"))
154        };
155        let zone = ZoneMap {
156            min_a: take(&mut pos)?,
157            max_a: take(&mut pos)?,
158            min_b: take(&mut pos)?,
159            max_b: take(&mut pos)?,
160            min_c: take(&mut pos)?,
161            max_c: take(&mut pos)?,
162            count: take(&mut pos)?,
163        };
164        Ok(Self {
165            bytes,
166            zone,
167            body_start: pos,
168        })
169    }
170
171    pub fn zone(&self) -> &ZoneMap {
172        &self.zone
173    }
174
175    /// Decode all triples in ascending order. The bytes may be corrupt (a block
176    /// from an untrusted file), so decoding is bounds-safe and stops gracefully
177    /// at the first malformed varint rather than panicking — returning whatever
178    /// prefix decoded cleanly.
179    pub fn triples(&self) -> Vec<Triple> {
180        self.try_triples().unwrap_or_default()
181    }
182
183    fn try_triples(&self) -> Option<Vec<Triple>> {
184        // `zone.count` is untrusted; each pushed triple consumes ≥1 byte, so the
185        // buffer length is a safe capacity ceiling (avoids an OOM on a bogus count).
186        let mut out = Vec::with_capacity((self.zone.count as usize).min(self.bytes.len()));
187        let mut pos = self.body_start;
188        let g = |pos: &mut usize| rd(self.bytes, pos);
189        let num_a = g(&mut pos)?;
190        let mut a = 0u32;
191        for _ in 0..num_a {
192            // checked_add: a delta chain that leaves the u32 id space is
193            // corrupt; stop rather than wrap around to some other id.
194            a = a.checked_add(g(&mut pos)?)?;
195            let num_b = g(&mut pos)?;
196            let mut b = 0u32;
197            for _ in 0..num_b {
198                b = b.checked_add(g(&mut pos)?)?;
199                let num_c = g(&mut pos)?;
200                let mut c = 0u32;
201                for _ in 0..num_c {
202                    c = c.checked_add(g(&mut pos)?)?;
203                    out.push((a, b, c));
204                }
205            }
206        }
207        Some(out)
208    }
209
210    /// Build the byte-offset directory of this block's a-groups (one header
211    /// walk), enabling binary-search probes via [`scan_from`](Self::scan_from).
212    /// On corrupt bytes the walk stops early — the directory is a prefix, and
213    /// the bounds-checked cursor degrades gracefully like every other reader.
214    pub fn group_directory(&self) -> GroupDirectory {
215        let bytes = self.bytes;
216        let mut entries = Vec::new();
217        let mut p = self.body_start;
218        let mut walk = || -> Option<()> {
219            let num_a = rd(bytes, &mut p)?;
220            // `num_a` is untrusted; each group consumes ≥2 bytes, so the buffer
221            // length caps the allocation.
222            entries.reserve((num_a as usize).min(bytes.len()));
223            let mut a = 0u32;
224            for i in 0..num_a {
225                a = a.checked_add(rd(bytes, &mut p)?)?;
226                let num_b = rd(bytes, &mut p)?;
227                entries.push(DirEntry {
228                    a,
229                    pos: p,
230                    num_b,
231                    a_rem_after: num_a - 1 - i,
232                });
233                for _ in 0..num_b {
234                    rd(bytes, &mut p)?; // delta_b
235                    let nc = rd(bytes, &mut p)?;
236                    for _ in 0..nc {
237                        rd(bytes, &mut p)?;
238                    }
239                }
240            }
241            Some(())
242        };
243        let _ = walk();
244        GroupDirectory { entries }
245    }
246
247    /// Probe the block for a **bound leading component** `pa`, jumping straight
248    /// to its a-group through the directory (binary search) instead of walking
249    /// every preceding group header. Yields exactly what
250    /// `scan(Some(pa), pb, pc)` would.
251    pub fn scan_from(
252        &self,
253        dir: &GroupDirectory,
254        pa: u32,
255        pb: Option<u32>,
256        pc: Option<u32>,
257    ) -> BlockCursor<'a> {
258        let mut cursor = BlockCursor {
259            bytes: self.bytes,
260            pos: self.body_start,
261            a: 0,
262            b: 0,
263            c: 0,
264            a_rem: 0,
265            b_rem: 0,
266            c_rem: 0,
267            started: true, // a dead cursor unless the probe below arms it
268            pa: Some(pa),
269            pb,
270            pc,
271        };
272        if let Ok(i) = dir.entries.binary_search_by_key(&pa, |e| e.a) {
273            let e = &dir.entries[i];
274            // State as if the main cursor had just consumed this group's
275            // delta_a + num_b header: positioned at the first b-group.
276            cursor.pos = e.pos;
277            cursor.a = e.a;
278            cursor.a_rem = e.a_rem_after;
279            cursor.b_rem = e.num_b;
280        }
281        cursor
282    }
283
284    /// **Resume** a leading-*unbound* scan at the first a-group whose leading id
285    /// is `>= from_a`, jumping there through the directory instead of re-walking
286    /// the groups already consumed. Yields exactly the tail of
287    /// `scan(None, pb, pc)` that begins at that group; `from_a = 0` is the whole
288    /// block.
289    ///
290    /// This is what lets a *batched* cursor stop in the middle of a block and
291    /// pick up where it left off without rescanning — the difference between an
292    /// O(n) resumable scan and an O(n²/batch) skip-and-take. Its bound-leading
293    /// twin is [`scan_from`](Self::scan_from), which pins `pa` to one group;
294    /// here `pa` stays unbound and `from_a` is only a starting point, so the
295    /// cursor runs on through every later group.
296    pub fn scan_resume(
297        &self,
298        dir: &GroupDirectory,
299        from_a: u32,
300        pb: Option<u32>,
301        pc: Option<u32>,
302    ) -> BlockCursor<'a> {
303        let mut cursor = BlockCursor {
304            bytes: self.bytes,
305            pos: self.body_start,
306            a: 0,
307            b: 0,
308            c: 0,
309            a_rem: 0,
310            b_rem: 0,
311            c_rem: 0,
312            started: true, // a dead cursor unless the probe below arms it
313            pa: None,
314            pb,
315            pc,
316        };
317        // Groups are stored ascending by `a`, so the first entry not less than
318        // `from_a` is the resume point (and `Err`/`Ok` of a binary search are the
319        // same answer here — unlike `scan_from`, a miss is not "no matches").
320        let i = dir.entries.partition_point(|e| e.a < from_a);
321        if let Some(e) = dir.entries.get(i) {
322            // State as if the main cursor had just consumed this group's
323            // delta_a + num_b header: positioned at the first b-group.
324            cursor.pos = e.pos;
325            cursor.a = e.a;
326            cursor.a_rem = e.a_rem_after;
327            cursor.b_rem = e.num_b;
328        }
329        cursor
330    }
331
332    /// Stream the triples matching a (permuted) pattern, *without* decoding the
333    /// whole block. `pa`/`pb`/`pc` are the bound components in this block's stored
334    /// order (`None` = wildcard). The cursor walks the grouped body and:
335    ///
336    /// * **range-stops** once the leading component `a` exceeds a bound `pa` — the
337    ///   a-groups are stored ascending, so nothing later can match (the early-out
338    ///   that makes a leading-bound lookup `O(matches + preceding groups)` instead
339    ///   of `O(whole block)`);
340    /// * **group-skips** a/b groups that can't match (decoding their headers to
341    ///   advance, but never building or emitting their triples);
342    /// * **equality-filters** `pb`/`pc` without ever early-breaking inside a
343    ///   c-list, so on a *valid* block the yielded set equals what
344    ///   [`triples`](Self::triples) would yield filtered — even on corrupt bytes
345    ///   it only ever yields fewer, never panics (every read is bounds-checked).
346    ///
347    /// Yields triples in this block's stored `(a, b, c)` order; callers map back
348    /// to canonical `(s, p, o)` themselves.
349    pub fn scan(&self, pa: Option<u32>, pb: Option<u32>, pc: Option<u32>) -> BlockCursor<'a> {
350        BlockCursor {
351            bytes: self.bytes,
352            pos: self.body_start,
353            a: 0,
354            b: 0,
355            c: 0,
356            a_rem: 0,
357            b_rem: 0,
358            c_rem: 0,
359            started: false,
360            pa,
361            pb,
362            pc,
363        }
364    }
365}
366
367/// Read one uvarint at `*pos`, advancing it; `None` if truncated or if the
368/// value does not fit a `u32` (every id, count and delta in a block does).
369/// Panic-free, and the one decoder every block reader uses. Before, `v as u32`
370/// kept the low 32 bits, so a corrupt tile decoded to wrong ids instead of
371/// failing.
372#[inline]
373fn rd(bytes: &[u8], pos: &mut usize) -> Option<u32> {
374    let (v, n) = read_uvarint(bytes.get(*pos..)?)?;
375    let v = u32::try_from(v).ok()?;
376    *pos += n;
377    Some(v)
378}
379
380/// A byte-offset directory of a block's a-groups: one entry per group, sorted
381/// by leading id (the storage order). Built once per block with
382/// [`TripleBlock::group_directory`]; [`TripleBlock::scan_from`] then
383/// binary-searches it to jump a probe straight to its group.
384pub struct GroupDirectory {
385    entries: Vec<DirEntry>,
386}
387
388impl GroupDirectory {
389    /// Number of a-groups indexed.
390    pub fn len(&self) -> usize {
391        self.entries.len()
392    }
393
394    pub fn is_empty(&self) -> bool {
395        self.entries.is_empty()
396    }
397
398    /// Heap bytes held by the directory.
399    pub fn heap_bytes(&self) -> u64 {
400        (self.entries.capacity() * std::mem::size_of::<DirEntry>()) as u64
401    }
402
403    /// The smallest leading id `>= a` this block holds, if any.
404    pub fn first_at_or_after(&self, a: u32) -> Option<u32> {
405        let i = self.entries.partition_point(|e| e.a < a);
406        self.entries.get(i).map(|e| e.a)
407    }
408
409    /// An empty directory, for cache tests that need one without a block.
410    #[cfg(test)]
411    pub(crate) fn empty_for_tests() -> Self {
412        GroupDirectory {
413            entries: Vec::new(),
414        }
415    }
416}
417
418/// One a-group: its leading id, the byte offset of its first b-group header
419/// (right after `num_b`), its b-group count, and how many a-groups follow it.
420struct DirEntry {
421    a: u32,
422    pos: usize,
423    num_b: u32,
424    a_rem_after: u32,
425}
426
427/// A lazy cursor over a [`TripleBlock`] body produced by [`TripleBlock::scan`].
428/// Holds only the block bytes and the delta-decode accumulators, so it borrows
429/// the block's bytes but allocates nothing.
430pub struct BlockCursor<'a> {
431    bytes: &'a [u8],
432    pos: usize,
433    // Running delta accumulators for the current (a, b, c).
434    a: u32,
435    b: u32,
436    c: u32,
437    // Groups/items not yet consumed at each level.
438    a_rem: u32,
439    b_rem: u32,
440    c_rem: u32,
441    started: bool,
442    pa: Option<u32>,
443    pb: Option<u32>,
444    pc: Option<u32>,
445}
446
447impl Iterator for BlockCursor<'_> {
448    type Item = Triple;
449
450    fn next(&mut self) -> Option<Triple> {
451        let bytes = self.bytes;
452        if !self.started {
453            self.a_rem = rd(bytes, &mut self.pos)?; // num_a
454            self.started = true;
455        }
456        loop {
457            // (1) Drain the c-list of the current matched (a, b) group. Every c is
458            // decoded to keep the delta chain correct; only matches are emitted.
459            while self.c_rem > 0 {
460                self.c_rem -= 1;
461                self.c = self.c.checked_add(rd(bytes, &mut self.pos)?)?;
462                if self.pc.is_none_or(|z| z == self.c) {
463                    return Some((self.a, self.b, self.c));
464                }
465            }
466            // (2) Advance to the next b-group within the current a-group.
467            while self.b_rem > 0 {
468                self.b_rem -= 1;
469                self.b = self.b.checked_add(rd(bytes, &mut self.pos)?)?;
470                let num_c = rd(bytes, &mut self.pos)?;
471                if self.pb.is_some_and(|y| y != self.b) {
472                    for _ in 0..num_c {
473                        rd(bytes, &mut self.pos)?; // group-skip: advance, never emit
474                    }
475                    continue;
476                }
477                self.c = 0; // the encoder resets prev_c per b-group
478                self.c_rem = num_c;
479                break;
480            }
481            if self.c_rem > 0 {
482                continue; // re-enter (1) to drain the matched c-list
483            }
484            // (3) Advance to the next a-group.
485            if self.a_rem == 0 {
486                return None;
487            }
488            self.a_rem -= 1;
489            self.a = self.a.checked_add(rd(bytes, &mut self.pos)?)?;
490            let num_b = rd(bytes, &mut self.pos)?;
491            self.b = 0; // the encoder resets prev_b per a-group
492            if let Some(x) = self.pa {
493                if self.a > x {
494                    return None; // range-stop: a-groups are ascending
495                }
496                if self.a < x {
497                    // skip this whole a-group (b-group headers + c-lists)
498                    for _ in 0..num_b {
499                        rd(bytes, &mut self.pos)?; // delta_b
500                        let nc = rd(bytes, &mut self.pos)?;
501                        for _ in 0..nc {
502                            rd(bytes, &mut self.pos)?;
503                        }
504                    }
505                    continue;
506                }
507            }
508            self.b_rem = num_b;
509        }
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    fn sample() -> Vec<Triple> {
518        // Unsorted, with a duplicate, multiple b's per a and c's per b.
519        vec![
520            (5, 2, 9),
521            (1, 1, 1),
522            (1, 1, 4),
523            (1, 3, 2),
524            (5, 2, 7),
525            (1, 1, 1), // dup
526            (2, 9, 9),
527        ]
528    }
529
530    #[test]
531    fn round_trip_sorted_dedup() {
532        let mut b = TripleBlockBuilder::new();
533        for t in sample() {
534            b.push(t);
535        }
536        let bytes = b.build();
537        let blk = TripleBlock::parse(&bytes).unwrap();
538
539        let mut expected = sample();
540        expected.sort_unstable();
541        expected.dedup();
542        assert_eq!(blk.zone().count as usize, expected.len());
543        assert_eq!(blk.triples(), expected);
544    }
545
546    #[test]
547    fn zone_map_bounds_and_skipping() {
548        let mut b = TripleBlockBuilder::new();
549        for t in sample() {
550            b.push(t);
551        }
552        let bytes = b.build();
553        let blk = TripleBlock::parse(&bytes).unwrap();
554        let z = blk.zone();
555        assert_eq!((z.min_a, z.max_a), (1, 5));
556        assert_eq!((z.min_b, z.max_b), (1, 9));
557        assert_eq!((z.min_c, z.max_c), (1, 9));
558        // a=3 is within [1,5] so "maybe"; a=99 is out so skippable.
559        assert!(z.may_contain(Some(3), None, None));
560        assert!(!z.may_contain(Some(99), None, None));
561        assert!(z.may_contain(None, None, None)); // fully unbound
562    }
563
564    #[test]
565    fn empty_block() {
566        let blk_bytes = TripleBlockBuilder::new().build();
567        let blk = TripleBlock::parse(&blk_bytes).unwrap();
568        assert_eq!(blk.zone().count, 0);
569        assert!(blk.triples().is_empty());
570        assert!(blk.scan(None, None, None).next().is_none());
571        assert!(blk.scan(Some(1), None, None).next().is_none());
572    }
573
574    /// The streaming `scan` cursor must, for every bound/unbound shape, yield
575    /// exactly the full-decode result filtered by the same bounds.
576    #[test]
577    fn scan_matches_full_decode_every_shape() {
578        let mut b = TripleBlockBuilder::new();
579        for t in sample() {
580            b.push(t);
581        }
582        let bytes = b.build();
583        let blk = TripleBlock::parse(&bytes).unwrap();
584        let all = blk.triples(); // sorted, deduped, ascending (a, b, c)
585
586        let opt = |v: u32| [None, Some(v)];
587        // Probe present values and an absent one in each position.
588        for pa in opt(1).into_iter().chain([Some(5), Some(99)]) {
589            for pb in opt(1).into_iter().chain([Some(2), Some(99)]) {
590                for pc in opt(1).into_iter().chain([Some(9), Some(99)]) {
591                    let want: Vec<Triple> = all
592                        .iter()
593                        .copied()
594                        .filter(|&(a, bb, c)| {
595                            pa.is_none_or(|x| x == a)
596                                && pb.is_none_or(|x| x == bb)
597                                && pc.is_none_or(|x| x == c)
598                        })
599                        .collect();
600                    // The cursor preserves stored ascending order, so no re-sort.
601                    let got: Vec<Triple> = blk.scan(pa, pb, pc).collect();
602                    assert_eq!(got, want, "scan({pa:?},{pb:?},{pc:?})");
603                }
604            }
605        }
606    }
607
608    /// A range-stop on a bound leading component must not over-read: once `a`
609    /// passes the bound the cursor returns `None` and stops decoding.
610    #[test]
611    fn scan_range_stops_on_leading_bound() {
612        let mut b = TripleBlockBuilder::new();
613        for t in [(1, 1, 1), (1, 2, 2), (3, 1, 1), (5, 1, 1)] {
614            b.push(t);
615        }
616        let bytes = b.build();
617        let blk = TripleBlock::parse(&bytes).unwrap();
618        let got: Vec<Triple> = blk.scan(Some(1), None, None).collect();
619        assert_eq!(got, vec![(1, 1, 1), (1, 2, 2)]);
620        // A bound `a` between stored groups yields nothing (and stops early).
621        assert!(blk.scan(Some(2), None, None).next().is_none());
622        assert!(blk.scan(Some(99), None, None).next().is_none());
623    }
624
625    /// Truncations and byte corruptions must never panic the cursor — it only
626    /// ever yields a clean prefix (every read is bounds-checked).
627    #[test]
628    fn scan_never_panics_on_bad_bytes() {
629        let mut b = TripleBlockBuilder::new();
630        for t in sample() {
631            b.push(t);
632        }
633        let bytes = b.build();
634        for len in 0..bytes.len() {
635            if let Ok(blk) = TripleBlock::parse(&bytes[..len]) {
636                for pat in [(None, None, None), (Some(1u32), Some(1u32), Some(1u32))] {
637                    let _ = blk.scan(pat.0, pat.1, pat.2).count();
638                }
639            }
640        }
641        for i in 0..bytes.len() {
642            for v in [0x00u8, 0xff, 0x80, 0x7f] {
643                let mut bad = bytes.clone();
644                bad[i] = v;
645                if let Ok(blk) = TripleBlock::parse(&bad) {
646                    let _ = blk.scan(None, None, None).count();
647                    let _ = blk.scan(Some(1), None, None).count();
648                }
649            }
650        }
651    }
652}