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>(tree: &Tree, seg: &impl Fn(&[u8]) -> Option<&'a Segment>) -> Vec<Vec<u8>> {
129    match tree {
130        Tree::Leaf(l) => match seg(&l.index) {
131            Some(s) => {
132                let (hits, _) = s.range(&l.min, &l.max, None, usize::MAX);
133                hits.into_iter().map(|(k, _)| k).collect()
134            }
135            None => Vec::new(),
136        },
137        Tree::And(a, b) => {
138            // Engine may re-order (locked clause): drive the smaller
139            // side, probe the larger.
140            let (xa, xb) = (eval_tree(a, seg), eval_tree(b, seg));
141            let (mut drive, probe) = if xa.len() <= xb.len() { (xa, xb) } else { (xb, xa) };
142            let set: std::collections::HashSet<&[u8]> = probe.iter().map(Vec::as_slice).collect();
143            drive.retain(|k| set.contains(k.as_slice()));
144            drive
145        }
146        Tree::Or(a, b) => {
147            let mut xa = eval_tree(a, seg);
148            xa.extend(eval_tree(b, seg));
149            xa.sort();
150            xa.dedup();
151            xa
152        }
153        Tree::Diff(a, b) => {
154            let mut xa = eval_tree(a, seg);
155            let xb = eval_tree(b, seg);
156            let set: std::collections::HashSet<&[u8]> = xb.iter().map(Vec::as_slice).collect();
157            xa.retain(|k| !set.contains(k.as_slice()));
158            xa
159        }
160    }
161}
162
163/// Re-evaluate ONE key's membership (the materialized write hook):
164/// every leaf is a point probe via the segment's reverse map.
165pub fn key_in_tree<'a>(
166    tree: &Tree,
167    key: &[u8],
168    seg: &impl Fn(&[u8]) -> Option<&'a Segment>,
169) -> bool {
170    match tree {
171        Tree::Leaf(l) => seg(&l.index)
172            .and_then(|s| s.verify_entry(key))
173            .is_some_and(|v| *v >= l.min && *v <= l.max),
174        Tree::And(a, b) => key_in_tree(a, key, seg) && key_in_tree(b, key, seg),
175        Tree::Or(a, b) => key_in_tree(a, key, seg) || key_in_tree(b, key, seg),
176        Tree::Diff(a, b) => key_in_tree(a, key, seg) && !key_in_tree(b, key, seg),
177    }
178}
179
180/// [`key_in_tree`] variant over PRE-FETCHED per-index values — the
181/// write hook probes each referenced index ONCE per key and evaluates
182/// every view against the same small table (bounds compares only; no
183/// per-view re-hashing).
184pub fn key_in_tree_vals(tree: &Tree, vals: &impl Fn(&[u8]) -> Option<IndexValue>) -> bool {
185    match tree {
186        Tree::Leaf(l) => vals(&l.index).is_some_and(|v| v >= l.min && v <= l.max),
187        Tree::And(a, b) => key_in_tree_vals(a, vals) && key_in_tree_vals(b, vals),
188        Tree::Or(a, b) => key_in_tree_vals(a, vals) || key_in_tree_vals(b, vals),
189        Tree::Diff(a, b) => key_in_tree_vals(a, vals) && !key_in_tree_vals(b, vals),
190    }
191}
192
193/// One shard's materialized result set: ordered `(order_value, key)`
194/// members with the bounded top-K discipline (keep `K + Δ` where
195/// `Δ = K/4`; underflow requests a local rebuild from the base
196/// indexes — RFC §2).
197#[derive(Debug, Default)]
198pub struct MaterializedSet {
199    set: std::collections::BTreeSet<(IndexValue, Vec<u8>)>,
200    back: std::collections::HashMap<Vec<u8>, IndexValue>,
201    /// 0 = unbounded.
202    top_k: u32,
203    /// DESC view: the bound keeps the LARGEST members (evict the
204    /// smallest past the cap); ASC keeps the smallest.
205    desc: bool,
206    /// Members excluded because they're absent from the order index.
207    pub order_excluded: u64,
208}
209
210impl MaterializedSet {
211    /// New set with the declared bound (0 = unbounded) and order
212    /// direction (the bound evicts from the view's WORST end).
213    pub fn new(top_k: u32, desc: bool) -> Self {
214        Self { top_k, desc, ..Default::default() }
215    }
216
217    fn cap(&self) -> usize {
218        if self.top_k == 0 { usize::MAX } else { (self.top_k + self.top_k / 4) as usize }
219    }
220
221    /// Apply one key's membership verdict + order value. Returns
222    /// `true` if the set UNDERFLOWED below K after a removal (the
223    /// caller must schedule a local rebuild).
224    pub fn apply(&mut self, key: &[u8], member: bool, order: Option<IndexValue>) -> bool {
225        // Bounded fast path: a NON-member of a full top-K set whose
226        // value is worse than the current worst can neither enter nor
227        // change anything — one comparison, no tree ops, no allocs.
228        // This is the write-tax fast path for hot-list views (most
229        // writes touch rows outside the top K).
230        if self.top_k != 0
231            && member
232            && !self.back.contains_key(key)
233            && self.set.len() >= self.cap()
234            && let Some(v) = &order
235        {
236            let enters = if self.desc {
237                self.set.iter().next().is_some_and(|(worst, _)| v > worst)
238            } else {
239                self.set.iter().next_back().is_some_and(|(worst, _)| v < worst)
240            };
241            if !enters {
242                return false;
243            }
244        }
245        if let Some(old) = self.back.remove(key) {
246            self.set.remove(&(old, key.to_vec()));
247        }
248        match (member, order) {
249            (true, Some(v)) => {
250                self.back.insert(key.to_vec(), v.clone());
251                self.set.insert((v, key.to_vec()));
252                self.evict_past_cap();
253                false
254            }
255            (true, None) => {
256                self.order_excluded += 1;
257                false
258            }
259            _ => self.top_k != 0 && self.set.len() < self.top_k as usize,
260        }
261    }
262
263    /// Bound: evict the view's WORST member past K+Δ — the largest
264    /// for ASC, the SMALLEST for DESC.
265    fn evict_past_cap(&mut self) {
266        if self.set.len() > self.cap() {
267            let worst = if self.desc {
268                self.set.iter().next().cloned()
269            } else {
270                self.set.iter().next_back().cloned()
271            };
272            if let Some(w) = worst {
273                self.set.remove(&w);
274                self.back.remove(&w.1);
275            }
276        }
277    }
278
279    /// Ordered page. `desc = false`: ascending from just past `after`;
280    /// `desc = true`: DESCENDING from just below `after` (a DESC view
281    /// must take each shard's LARGEST members — taking the ascending
282    /// head and reversing at the merge yields the wrong member set).
283    pub fn page(
284        &self,
285        after: Option<&(IndexValue, Vec<u8>)>,
286        limit: usize,
287        desc: bool,
288    ) -> Vec<(IndexValue, Vec<u8>)> {
289        if desc {
290            let iter: Box<dyn Iterator<Item = &(IndexValue, Vec<u8>)>> = match after {
291                Some(c) => Box::new(
292                    self.set
293                        .range((std::ops::Bound::Unbounded, std::ops::Bound::Excluded(c.clone())))
294                        .rev(),
295                ),
296                None => Box::new(self.set.iter().rev()),
297            };
298            return iter.take(limit).cloned().collect();
299        }
300        let iter: Box<dyn Iterator<Item = &(IndexValue, Vec<u8>)>> = match after {
301            Some(c) => Box::new(
302                self.set.range((std::ops::Bound::Excluded(c.clone()), std::ops::Bound::Unbounded)),
303            ),
304            None => Box::new(self.set.iter()),
305        };
306        iter.take(limit).cloned().collect()
307    }
308
309    /// Member count.
310    pub fn len(&self) -> usize {
311        self.set.len()
312    }
313
314    /// Empty?
315    pub fn is_empty(&self) -> bool {
316        self.set.is_empty()
317    }
318
319    /// Wipe (rebuild path).
320    pub fn clear(&mut self) {
321        self.set.clear();
322        self.back.clear();
323    }
324
325    /// Approximate heap bytes (RFC §5 formula's measured side).
326    pub fn approx_bytes(&self) -> u64 {
327        self.set.iter().map(|(v, k)| (v.approx_bytes() + k.len() + 48) as u64).sum()
328    }
329}
330
331#[cfg(test)]
332#[path = "view_tests.rs"]
333mod tests;