Skip to main content

kevy_index/
view.rs

1//! Views: named composition trees over declared indexes.
2//!
3//! Pure logic: [`ViewSpec`] (the declaration), [`eval_tree`] (the
4//! virtual-mode evaluator over segment closures), and
5//! [`MaterializedSet`] (the incremental ordered result set with the
6//! bounded top-K discipline). The runtime supplies segment access and
7//! wires maintenance to its write hook — nothing here does I/O.
8//!
9//! Locked structural rules: components are NAMED indexes (leaves carry
10//! a shape; the view layer holds no predicates of its own); a view
11//! stores MEMBERSHIP + ORDER only (never field values); AND/OR
12//! subtrees may be re-ordered by the engine (DIFF is fixed
13//! left-right).
14
15use crate::segment::Segment;
16use crate::value::IndexValue;
17
18pub use crate::view_sidecar::{MAX_VIEWS, ViewCatalog};
19
20/// One leaf: a declared index + the shape it contributes.
21#[derive(Debug, Clone, PartialEq)]
22pub struct Leaf {
23    /// Index name (resolved by the runtime).
24    pub index: Vec<u8>,
25    /// Inclusive bounds (EQ = same min/max), already coerced to the
26    /// index's type by the runtime at CREATE time.
27    pub min: IndexValue,
28    /// Upper bound.
29    pub max: IndexValue,
30}
31
32/// The composition tree. Depth ≤ 3, leaves ≤ 4 (declarative caps,
33/// enforced at CREATE).
34#[derive(Debug, Clone, PartialEq)]
35pub enum Tree {
36    /// A single index shape.
37    Leaf(Leaf),
38    /// Intersection.
39    And(Box<Tree>, Box<Tree>),
40    /// Union.
41    Or(Box<Tree>, Box<Tree>),
42    /// Left minus right (NOT commutative — order is fixed).
43    Diff(Box<Tree>, Box<Tree>),
44}
45
46impl Tree {
47    /// Number of leaves.
48    pub fn leaves(&self) -> usize {
49        match self {
50            Tree::Leaf(_) => 1,
51            Tree::And(a, b) | Tree::Or(a, b) | Tree::Diff(a, b) => a.leaves() + b.leaves(),
52        }
53    }
54
55    /// Depth (a leaf is 1).
56    pub fn depth(&self) -> usize {
57        match self {
58            Tree::Leaf(_) => 1,
59            Tree::And(a, b) | Tree::Or(a, b) | Tree::Diff(a, b) => 1 + a.depth().max(b.depth()),
60        }
61    }
62
63    /// Visit every leaf.
64    pub fn each_leaf<F: FnMut(&Leaf)>(&self, f: &mut F) {
65        match self {
66            Tree::Leaf(l) => f(l),
67            Tree::And(a, b) | Tree::Or(a, b) | Tree::Diff(a, b) => {
68                a.each_leaf(f);
69                b.each_leaf(f);
70            }
71        }
72    }
73}
74
75/// View mode.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ViewMode {
78    /// Evaluate the tree at query time.
79    Virtual,
80    /// Maintain an incremental result set; `top_k = 0` = unbounded.
81    Materialized {
82        /// Bounded size (0 = keep every member).
83        top_k: u32,
84    },
85}
86
87/// A declared view.
88#[derive(Debug, Clone, PartialEq)]
89pub struct ViewSpec {
90    /// Catalog name.
91    pub name: Vec<u8>,
92    /// The composition.
93    pub tree: Tree,
94    /// Index whose coerced value orders the view (a row absent from
95    /// this index is excluded — declaratively, counted).
96    pub order_by: Vec<u8>,
97    /// Descending order?
98    pub desc: bool,
99    /// Virtual or materialized.
100    pub mode: ViewMode,
101    /// Optional `VIA` hydration byte-template (`{key}` / `{key.N}`
102    /// placeholders; pure dereference, one template hop).
103    pub via: Option<Vec<u8>>,
104}
105
106/// Declarative caps (RFC §1).
107pub const MAX_TREE_DEPTH: usize = 3;
108/// Max leaves per tree.
109pub const MAX_TREE_LEAVES: usize = 4;
110
111impl ViewSpec {
112    /// Validate the structural caps.
113    pub fn validate(&self) -> Result<(), &'static str> {
114        if self.tree.depth() > MAX_TREE_DEPTH {
115            return Err("ERR view tree deeper than 3");
116        }
117        if self.tree.leaves() > MAX_TREE_LEAVES {
118            return Err("ERR view tree has more than 4 leaves");
119        }
120        Ok(())
121    }
122}
123
124/// Evaluate `tree` against one shard's segments: `seg` resolves an
125/// index name to its [`Segment`] (None = unknown index → empty leaf —
126/// the runtime validates names at CREATE, so this is defensive).
127/// Returns the member keys (unordered set semantics).
128pub fn eval_tree<'a>(
129    tree: &Tree,
130    seg: &impl Fn(&[u8]) -> Option<&'a Segment>,
131) -> Vec<Vec<u8>> {
132    match tree {
133        Tree::Leaf(l) => match seg(&l.index) {
134            Some(s) => {
135                let (hits, _) = s.range(&l.min, &l.max, None, usize::MAX);
136                hits.into_iter().map(|(k, _)| k).collect()
137            }
138            None => Vec::new(),
139        },
140        Tree::And(a, b) => {
141            // Engine may re-order (locked clause): drive the smaller
142            // side, probe the larger.
143            let (xa, xb) = (eval_tree(a, seg), eval_tree(b, seg));
144            let (mut drive, probe) = if xa.len() <= xb.len() { (xa, xb) } else { (xb, xa) };
145            let set: std::collections::HashSet<&[u8]> =
146                probe.iter().map(Vec::as_slice).collect();
147            drive.retain(|k| set.contains(k.as_slice()));
148            drive
149        }
150        Tree::Or(a, b) => {
151            let mut xa = eval_tree(a, seg);
152            xa.extend(eval_tree(b, seg));
153            xa.sort();
154            xa.dedup();
155            xa
156        }
157        Tree::Diff(a, b) => {
158            let mut xa = eval_tree(a, seg);
159            let xb = eval_tree(b, seg);
160            let set: std::collections::HashSet<&[u8]> = xb.iter().map(Vec::as_slice).collect();
161            xa.retain(|k| !set.contains(k.as_slice()));
162            xa
163        }
164    }
165}
166
167/// Re-evaluate ONE key's membership (the materialized write hook):
168/// every leaf is a point probe via the segment's reverse map.
169pub fn key_in_tree<'a>(
170    tree: &Tree,
171    key: &[u8],
172    seg: &impl Fn(&[u8]) -> Option<&'a Segment>,
173) -> bool {
174    match tree {
175        Tree::Leaf(l) => seg(&l.index)
176            .and_then(|s| s.verify_entry(key))
177            .is_some_and(|v| *v >= l.min && *v <= l.max),
178        Tree::And(a, b) => key_in_tree(a, key, seg) && key_in_tree(b, key, seg),
179        Tree::Or(a, b) => key_in_tree(a, key, seg) || key_in_tree(b, key, seg),
180        Tree::Diff(a, b) => key_in_tree(a, key, seg) && !key_in_tree(b, key, seg),
181    }
182}
183
184/// [`key_in_tree`] variant over PRE-FETCHED per-index values — the
185/// write hook probes each referenced index ONCE per key and evaluates
186/// every view against the same small table (bounds compares only; no
187/// per-view re-hashing).
188pub fn key_in_tree_vals(
189    tree: &Tree,
190    vals: &impl Fn(&[u8]) -> Option<IndexValue>,
191) -> bool {
192    match tree {
193        Tree::Leaf(l) => vals(&l.index).is_some_and(|v| v >= l.min && v <= l.max),
194        Tree::And(a, b) => key_in_tree_vals(a, vals) && key_in_tree_vals(b, vals),
195        Tree::Or(a, b) => key_in_tree_vals(a, vals) || key_in_tree_vals(b, vals),
196        Tree::Diff(a, b) => key_in_tree_vals(a, vals) && !key_in_tree_vals(b, vals),
197    }
198}
199
200/// One shard's materialized result set: ordered `(order_value, key)`
201/// members with the bounded top-K discipline (keep `K + Δ` where
202/// `Δ = K/4`; underflow requests a local rebuild from the base
203/// indexes — RFC §2).
204#[derive(Debug, Default)]
205pub struct MaterializedSet {
206    set: std::collections::BTreeSet<(IndexValue, Vec<u8>)>,
207    back: std::collections::HashMap<Vec<u8>, IndexValue>,
208    /// 0 = unbounded.
209    top_k: u32,
210    /// DESC view: the bound keeps the LARGEST members (evict the
211    /// smallest past the cap); ASC keeps the smallest.
212    desc: bool,
213    /// Members excluded because they're absent from the order index.
214    pub order_excluded: u64,
215}
216
217impl MaterializedSet {
218    /// New set with the declared bound (0 = unbounded) and order
219    /// direction (the bound evicts from the view's WORST end).
220    pub fn new(top_k: u32, desc: bool) -> Self {
221        Self { top_k, desc, ..Default::default() }
222    }
223
224    fn cap(&self) -> usize {
225        if self.top_k == 0 {
226            usize::MAX
227        } else {
228            (self.top_k + self.top_k / 4) as usize
229        }
230    }
231
232    /// Apply one key's membership verdict + order value. Returns
233    /// `true` if the set UNDERFLOWED below K after a removal (the
234    /// caller must schedule a local rebuild).
235    pub fn apply(&mut self, key: &[u8], member: bool, order: Option<IndexValue>) -> bool {
236        // Bounded fast path: a NON-member of a full top-K set whose
237        // value is worse than the current worst can neither enter nor
238        // change anything — one comparison, no tree ops, no allocs.
239        // This is the write-tax fast path for hot-list views (most
240        // writes touch rows outside the top K).
241        if self.top_k != 0
242            && member
243            && !self.back.contains_key(key)
244            && self.set.len() >= self.cap()
245            && let Some(v) = &order
246        {
247            let enters = if self.desc {
248                self.set.iter().next().is_some_and(|(worst, _)| v > worst)
249            } else {
250                self.set.iter().next_back().is_some_and(|(worst, _)| v < worst)
251            };
252            if !enters {
253                return false;
254            }
255        }
256        if let Some(old) = self.back.remove(key) {
257            self.set.remove(&(old, key.to_vec()));
258        }
259        match (member, order) {
260            (true, Some(v)) => {
261                self.back.insert(key.to_vec(), v.clone());
262                self.set.insert((v, key.to_vec()));
263                self.evict_past_cap();
264                false
265            }
266            (true, None) => {
267                self.order_excluded += 1;
268                false
269            }
270            _ => {
271                self.top_k != 0 && self.set.len() < self.top_k as usize
272            }
273        }
274    }
275
276    /// Bound: evict the view's WORST member past K+Δ — the largest
277    /// for ASC, the SMALLEST for DESC.
278    fn evict_past_cap(&mut self) {
279        if self.set.len() > self.cap() {
280            let worst = if self.desc {
281                self.set.iter().next().cloned()
282            } else {
283                self.set.iter().next_back().cloned()
284            };
285            if let Some(w) = worst {
286                self.set.remove(&w);
287                self.back.remove(&w.1);
288            }
289        }
290    }
291
292    /// Ordered page. `desc = false`: ascending from just past `after`;
293    /// `desc = true`: DESCENDING from just below `after` (a DESC view
294    /// must take each shard's LARGEST members — taking the ascending
295    /// head and reversing at the merge yields the wrong member set).
296    pub fn page(
297        &self,
298        after: Option<&(IndexValue, Vec<u8>)>,
299        limit: usize,
300        desc: bool,
301    ) -> Vec<(IndexValue, Vec<u8>)> {
302        if desc {
303            let iter: Box<dyn Iterator<Item = &(IndexValue, Vec<u8>)>> = match after {
304                Some(c) => Box::new(
305                    self.set
306                        .range((std::ops::Bound::Unbounded, std::ops::Bound::Excluded(c.clone())))
307                        .rev(),
308                ),
309                None => Box::new(self.set.iter().rev()),
310            };
311            return iter.take(limit).cloned().collect();
312        }
313        let iter: Box<dyn Iterator<Item = &(IndexValue, Vec<u8>)>> = match after {
314            Some(c) => Box::new(self.set.range((
315                std::ops::Bound::Excluded(c.clone()),
316                std::ops::Bound::Unbounded,
317            ))),
318            None => Box::new(self.set.iter()),
319        };
320        iter.take(limit).cloned().collect()
321    }
322
323    /// Member count.
324    pub fn len(&self) -> usize {
325        self.set.len()
326    }
327
328    /// Empty?
329    pub fn is_empty(&self) -> bool {
330        self.set.is_empty()
331    }
332
333    /// Wipe (rebuild path).
334    pub fn clear(&mut self) {
335        self.set.clear();
336        self.back.clear();
337    }
338
339    /// Approximate heap bytes (RFC §5 formula's measured side).
340    pub fn approx_bytes(&self) -> u64 {
341        self.set
342            .iter()
343            .map(|(v, k)| (v.approx_bytes() + k.len() + 48) as u64)
344            .sum()
345    }
346}
347
348#[cfg(test)]
349#[path = "view_tests.rs"]
350mod tests;