Skip to main content

kevy_index/
view.rs

1//! v2.6 — views: named composition trees over declared indexes
2//! (RFC 2026-07-04, LOCKED).
3//!
4//! Pure logic: [`ViewSpec`] (the declaration), [`eval_tree`] (the
5//! virtual-mode evaluator over segment closures), and
6//! [`MaterializedSet`] (the incremental ordered result set with the
7//! bounded top-K discipline). The runtime supplies segment access and
8//! wires maintenance to its write hook — nothing here does I/O.
9//!
10//! Locked structural rules: components are NAMED indexes (leaves carry
11//! a shape; the view layer holds no predicates of its own); a view
12//! stores MEMBERSHIP + ORDER only (never field values); AND/OR
13//! subtrees may be re-ordered by the engine (DIFF is fixed
14//! left-right).
15
16use crate::segment::Segment;
17use crate::value::IndexValue;
18
19/// One leaf: a declared index + the shape it contributes.
20#[derive(Debug, Clone, PartialEq)]
21pub struct Leaf {
22    /// Index name (resolved by the runtime).
23    pub index: Vec<u8>,
24    /// Inclusive bounds (EQ = same min/max), already coerced to the
25    /// index's type by the runtime at CREATE time.
26    pub min: IndexValue,
27    /// Upper bound.
28    pub max: IndexValue,
29}
30
31/// The composition tree. Depth ≤ 3, leaves ≤ 4 (declarative caps,
32/// enforced at CREATE).
33#[derive(Debug, Clone, PartialEq)]
34pub enum Tree {
35    /// A single index shape.
36    Leaf(Leaf),
37    /// Intersection.
38    And(Box<Tree>, Box<Tree>),
39    /// Union.
40    Or(Box<Tree>, Box<Tree>),
41    /// Left minus right (NOT commutative — order is fixed).
42    Diff(Box<Tree>, Box<Tree>),
43}
44
45impl Tree {
46    /// Number of leaves.
47    pub fn leaves(&self) -> usize {
48        match self {
49            Tree::Leaf(_) => 1,
50            Tree::And(a, b) | Tree::Or(a, b) | Tree::Diff(a, b) => a.leaves() + b.leaves(),
51        }
52    }
53
54    /// Depth (a leaf is 1).
55    pub fn depth(&self) -> usize {
56        match self {
57            Tree::Leaf(_) => 1,
58            Tree::And(a, b) | Tree::Or(a, b) | Tree::Diff(a, b) => 1 + a.depth().max(b.depth()),
59        }
60    }
61
62    /// Visit every leaf.
63    pub fn each_leaf<F: FnMut(&Leaf)>(&self, f: &mut F) {
64        match self {
65            Tree::Leaf(l) => f(l),
66            Tree::And(a, b) | Tree::Or(a, b) | Tree::Diff(a, b) => {
67                a.each_leaf(f);
68                b.each_leaf(f);
69            }
70        }
71    }
72}
73
74/// View mode.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum ViewMode {
77    /// Evaluate the tree at query time.
78    Virtual,
79    /// Maintain an incremental result set; `top_k = 0` = unbounded.
80    Materialized {
81        /// Bounded size (0 = keep every member).
82        top_k: u32,
83    },
84}
85
86/// A declared view.
87#[derive(Debug, Clone, PartialEq)]
88pub struct ViewSpec {
89    /// Catalog name.
90    pub name: Vec<u8>,
91    /// The composition.
92    pub tree: Tree,
93    /// Index whose coerced value orders the view (a row absent from
94    /// this index is excluded — declaratively, counted).
95    pub order_by: Vec<u8>,
96    /// Descending order?
97    pub desc: bool,
98    /// Virtual or materialized.
99    pub mode: ViewMode,
100    /// Optional `VIA` hydration byte-template (`{key}` / `{key.N}`
101    /// placeholders; pure dereference, one template hop).
102    pub via: Option<Vec<u8>>,
103}
104
105/// Declarative caps (RFC §1).
106pub const MAX_TREE_DEPTH: usize = 3;
107/// Max leaves per tree.
108pub const MAX_TREE_LEAVES: usize = 4;
109
110impl ViewSpec {
111    /// Validate the structural caps.
112    pub fn validate(&self) -> Result<(), &'static str> {
113        if self.tree.depth() > MAX_TREE_DEPTH {
114            return Err("ERR view tree deeper than 3");
115        }
116        if self.tree.leaves() > MAX_TREE_LEAVES {
117            return Err("ERR view tree has more than 4 leaves");
118        }
119        Ok(())
120    }
121}
122
123/// Evaluate `tree` against one shard's segments: `seg` resolves an
124/// index name to its [`Segment`] (None = unknown index → empty leaf —
125/// the runtime validates names at CREATE, so this is defensive).
126/// Returns the member keys (unordered set semantics).
127pub fn eval_tree<'a>(
128    tree: &Tree,
129    seg: &impl Fn(&[u8]) -> Option<&'a Segment>,
130) -> Vec<Vec<u8>> {
131    match tree {
132        Tree::Leaf(l) => match seg(&l.index) {
133            Some(s) => {
134                let (hits, _) = s.range(&l.min, &l.max, None, usize::MAX);
135                hits.into_iter().map(|(k, _)| k).collect()
136            }
137            None => Vec::new(),
138        },
139        Tree::And(a, b) => {
140            // Engine may re-order (locked clause): drive the smaller
141            // side, probe the larger.
142            let (xa, xb) = (eval_tree(a, seg), eval_tree(b, seg));
143            let (mut drive, probe) = if xa.len() <= xb.len() { (xa, xb) } else { (xb, xa) };
144            let set: std::collections::HashSet<&[u8]> =
145                probe.iter().map(Vec::as_slice).collect();
146            drive.retain(|k| set.contains(k.as_slice()));
147            drive
148        }
149        Tree::Or(a, b) => {
150            let mut xa = eval_tree(a, seg);
151            xa.extend(eval_tree(b, seg));
152            xa.sort();
153            xa.dedup();
154            xa
155        }
156        Tree::Diff(a, b) => {
157            let mut xa = eval_tree(a, seg);
158            let xb = eval_tree(b, seg);
159            let set: std::collections::HashSet<&[u8]> = xb.iter().map(Vec::as_slice).collect();
160            xa.retain(|k| !set.contains(k.as_slice()));
161            xa
162        }
163    }
164}
165
166/// Re-evaluate ONE key's membership (the materialized write hook):
167/// every leaf is a point probe via the segment's reverse map.
168pub fn key_in_tree<'a>(
169    tree: &Tree,
170    key: &[u8],
171    seg: &impl Fn(&[u8]) -> Option<&'a Segment>,
172) -> bool {
173    match tree {
174        Tree::Leaf(l) => seg(&l.index)
175            .and_then(|s| s.verify_entry(key))
176            .is_some_and(|v| *v >= l.min && *v <= l.max),
177        Tree::And(a, b) => key_in_tree(a, key, seg) && key_in_tree(b, key, seg),
178        Tree::Or(a, b) => key_in_tree(a, key, seg) || key_in_tree(b, key, seg),
179        Tree::Diff(a, b) => key_in_tree(a, key, seg) && !key_in_tree(b, key, seg),
180    }
181}
182
183/// [`key_in_tree`] variant over PRE-FETCHED per-index values — the
184/// write hook probes each referenced index ONCE per key and evaluates
185/// every view against the same small table (bounds compares only; no
186/// per-view re-hashing).
187pub fn key_in_tree_vals(
188    tree: &Tree,
189    vals: &impl Fn(&[u8]) -> Option<IndexValue>,
190) -> bool {
191    match tree {
192        Tree::Leaf(l) => vals(&l.index).is_some_and(|v| v >= l.min && v <= l.max),
193        Tree::And(a, b) => key_in_tree_vals(a, vals) && key_in_tree_vals(b, vals),
194        Tree::Or(a, b) => key_in_tree_vals(a, vals) || key_in_tree_vals(b, vals),
195        Tree::Diff(a, b) => key_in_tree_vals(a, vals) && !key_in_tree_vals(b, vals),
196    }
197}
198
199/// One shard's materialized result set: ordered `(order_value, key)`
200/// members with the bounded top-K discipline (keep `K + Δ` where
201/// `Δ = K/4`; underflow requests a local rebuild from the base
202/// indexes — RFC §2).
203#[derive(Debug, Default)]
204pub struct MaterializedSet {
205    set: std::collections::BTreeSet<(IndexValue, Vec<u8>)>,
206    back: std::collections::HashMap<Vec<u8>, IndexValue>,
207    /// 0 = unbounded.
208    top_k: u32,
209    /// DESC view: the bound keeps the LARGEST members (evict the
210    /// smallest past the cap); ASC keeps the smallest.
211    desc: bool,
212    /// Members excluded because they're absent from the order index.
213    pub order_excluded: u64,
214}
215
216impl MaterializedSet {
217    /// New set with the declared bound (0 = unbounded) and order
218    /// direction (the bound evicts from the view's WORST end).
219    pub fn new(top_k: u32, desc: bool) -> Self {
220        Self { top_k, desc, ..Default::default() }
221    }
222
223    fn cap(&self) -> usize {
224        if self.top_k == 0 {
225            usize::MAX
226        } else {
227            (self.top_k + self.top_k / 4) as usize
228        }
229    }
230
231    /// Apply one key's membership verdict + order value. Returns
232    /// `true` if the set UNDERFLOWED below K after a removal (the
233    /// caller must schedule a local rebuild).
234    pub fn apply(&mut self, key: &[u8], member: bool, order: Option<IndexValue>) -> bool {
235        // Bounded fast path: a NON-member of a full top-K set whose
236        // value is worse than the current worst can neither enter nor
237        // change anything — one comparison, no tree ops, no allocs.
238        // This is the write-tax fast path for hot-list views (most
239        // writes touch rows outside the top K).
240        if self.top_k != 0
241            && member
242            && !self.back.contains_key(key)
243            && self.set.len() >= self.cap()
244            && let Some(v) = &order
245        {
246            let enters = if self.desc {
247                self.set.iter().next().is_some_and(|(worst, _)| v > worst)
248            } else {
249                self.set.iter().next_back().is_some_and(|(worst, _)| v < worst)
250            };
251            if !enters {
252                return false;
253            }
254        }
255        if let Some(old) = self.back.remove(key) {
256            self.set.remove(&(old, key.to_vec()));
257        }
258        match (member, order) {
259            (true, Some(v)) => {
260                self.back.insert(key.to_vec(), v.clone());
261                self.set.insert((v, key.to_vec()));
262                // bound: evict the view's WORST member past K+Δ —
263                // the largest for ASC, the SMALLEST for DESC.
264                if self.set.len() > self.cap() {
265                    let worst = if self.desc {
266                        self.set.iter().next().cloned()
267                    } else {
268                        self.set.iter().next_back().cloned()
269                    };
270                    if let Some(w) = worst {
271                        self.set.remove(&w);
272                        self.back.remove(&w.1);
273                    }
274                }
275                false
276            }
277            (true, None) => {
278                self.order_excluded += 1;
279                false
280            }
281            _ => {
282                self.top_k != 0 && self.set.len() < self.top_k as usize
283            }
284        }
285    }
286
287    /// Ordered page. `desc = false`: ascending from just past `after`;
288    /// `desc = true`: DESCENDING from just below `after` (a DESC view
289    /// must take each shard's LARGEST members — taking the ascending
290    /// head and reversing at the merge yields the wrong member set).
291    pub fn page(
292        &self,
293        after: Option<&(IndexValue, Vec<u8>)>,
294        limit: usize,
295        desc: bool,
296    ) -> Vec<(IndexValue, Vec<u8>)> {
297        if desc {
298            let iter: Box<dyn Iterator<Item = &(IndexValue, Vec<u8>)>> = match after {
299                Some(c) => Box::new(
300                    self.set
301                        .range((std::ops::Bound::Unbounded, std::ops::Bound::Excluded(c.clone())))
302                        .rev(),
303                ),
304                None => Box::new(self.set.iter().rev()),
305            };
306            return iter.take(limit).cloned().collect();
307        }
308        let iter: Box<dyn Iterator<Item = &(IndexValue, Vec<u8>)>> = match after {
309            Some(c) => Box::new(self.set.range((
310                std::ops::Bound::Excluded(c.clone()),
311                std::ops::Bound::Unbounded,
312            ))),
313            None => Box::new(self.set.iter()),
314        };
315        iter.take(limit).cloned().collect()
316    }
317
318    /// Member count.
319    pub fn len(&self) -> usize {
320        self.set.len()
321    }
322
323    /// Empty?
324    pub fn is_empty(&self) -> bool {
325        self.set.is_empty()
326    }
327
328    /// Wipe (rebuild path).
329    pub fn clear(&mut self) {
330        self.set.clear();
331        self.back.clear();
332    }
333
334    /// Approximate heap bytes (RFC §5 formula's measured side).
335    pub fn approx_bytes(&self) -> u64 {
336        self.set
337            .iter()
338            .map(|(v, k)| (v.approx_bytes() + k.len() + 48) as u64)
339            .sum()
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    fn seg_ab() -> (Segment, Segment) {
348        let mut a = Segment::new();
349        let mut b = Segment::new();
350        for i in 0..10 {
351            a.apply(format!("k{i}").as_bytes(), Some(IndexValue::I64(i)));
352            if i % 2 == 0 {
353                b.apply(format!("k{i}").as_bytes(), Some(IndexValue::Str(b"eng".to_vec())));
354            }
355        }
356        (a, b)
357    }
358
359    fn leaf(idx: &str, min: IndexValue, max: IndexValue) -> Tree {
360        Tree::Leaf(Leaf { index: idx.into(), min, max })
361    }
362
363    #[test]
364    fn tree_eval_and_or_diff() {
365        let (a, b) = seg_ab();
366        let seg = |n: &[u8]| -> Option<&Segment> {
367            match n {
368                b"age" => Some(&a),
369                b"dept" => Some(&b),
370                _ => None,
371            }
372        };
373        let age = leaf("age", IndexValue::I64(2), IndexValue::I64(7));
374        let eng = leaf(
375            "dept",
376            IndexValue::Str(b"eng".to_vec()),
377            IndexValue::Str(b"eng".to_vec()),
378        );
379        let and = Tree::And(Box::new(age.clone()), Box::new(eng.clone()));
380        let mut got = eval_tree(&and, &seg);
381        got.sort();
382        assert_eq!(got, vec![b"k2".to_vec(), b"k4".to_vec(), b"k6".to_vec()]);
383
384        let or = Tree::Or(Box::new(age.clone()), Box::new(eng.clone()));
385        assert_eq!(eval_tree(&or, &seg).len(), 8, "2..=7 ∪ evens = 8");
386
387        let diff = Tree::Diff(Box::new(age.clone()), Box::new(eng.clone()));
388        let mut got = eval_tree(&diff, &seg);
389        got.sort();
390        assert_eq!(got, vec![b"k3".to_vec(), b"k5".to_vec(), b"k7".to_vec()]);
391
392        // per-key membership mirrors set eval
393        assert!(key_in_tree(&and, b"k4", &seg));
394        assert!(!key_in_tree(&and, b"k3", &seg));
395        assert!(key_in_tree(&diff, b"k5", &seg));
396        assert!(!key_in_tree(&diff, b"k4", &seg));
397    }
398
399    #[test]
400    fn caps_validate() {
401        let l = leaf("a", IndexValue::I64(0), IndexValue::I64(1));
402        let deep = Tree::And(
403            Box::new(Tree::And(
404                Box::new(Tree::And(Box::new(l.clone()), Box::new(l.clone()))),
405                Box::new(l.clone()),
406            )),
407            Box::new(l.clone()),
408        );
409        let spec = ViewSpec {
410            name: b"v".to_vec(),
411            tree: deep,
412            order_by: b"a".to_vec(),
413            desc: false,
414            mode: ViewMode::Virtual,
415            via: None,
416        };
417        assert!(spec.validate().is_err(), "depth 4 rejected");
418    }
419
420    #[test]
421    fn materialized_bounds_and_underflow() {
422        let mut m = MaterializedSet::new(4, false); // cap = 4 + 1 = 5
423        for i in 0..8 {
424            let under = m.apply(
425                format!("k{i}").as_bytes(),
426                true,
427                Some(IndexValue::I64(i)),
428            );
429            assert!(!under);
430        }
431        assert_eq!(m.len(), 5, "bounded at K+Δ");
432        let page = m.page(None, 10, false);
433        assert_eq!(page[0].1, b"k0".to_vec(), "best kept");
434        assert_eq!(page.last().unwrap().1, b"k4".to_vec(), "worst evicted");
435
436    }
437
438    #[test]
439    fn materialized_desc_bound_keeps_largest() {
440        let mut m = MaterializedSet::new(4, true); // cap 5
441        for i in 0..8 {
442            m.apply(format!("k{i}").as_bytes(), true, Some(IndexValue::I64(i)));
443        }
444        assert_eq!(m.len(), 5);
445        let page = m.page(None, 10, true);
446        assert_eq!(page[0].1, b"k7".to_vec(), "largest kept on top");
447        assert_eq!(page.last().unwrap().1, b"k3".to_vec(), "smallest evicted");
448    }
449
450    #[test]
451    fn view_catalog_sidecar_roundtrip() {
452        let tree = Tree::Diff(
453            Box::new(Tree::And(
454                Box::new(leaf("age", IndexValue::I64(1), IndexValue::I64(9))),
455                Box::new(leaf(
456                    "dept",
457                    IndexValue::Str(b"e )n%g".to_vec()),
458                    IndexValue::Str(b"e )n%g".to_vec()),
459                )),
460            )),
461            Box::new(leaf("flag", IndexValue::F64(-0.5), IndexValue::F64(2.5))),
462        );
463        let spec = ViewSpec {
464            name: b"v one".to_vec(),
465            tree,
466            order_by: b"age".to_vec(),
467            desc: true,
468            mode: ViewMode::Materialized { top_k: 50 },
469            via: Some(b"user:{key.1}".to_vec()),
470        };
471        let mut c = ViewCatalog::new();
472        c.create(spec.clone()).unwrap();
473        c.create(ViewSpec {
474            name: b"v2".to_vec(),
475            tree: leaf("age", IndexValue::I64(0), IndexValue::I64(1)),
476            order_by: b"age".to_vec(),
477            desc: false,
478            mode: ViewMode::Virtual,
479            via: None,
480        })
481        .unwrap();
482        let text = c.to_sidecar();
483        let c2 = ViewCatalog::from_sidecar(&text).expect("parse");
484        assert_eq!(c2.len(), 2);
485        assert_eq!(c2.get(b"v one").unwrap(), &spec);
486        assert!(ViewCatalog::from_sidecar("junk").is_none());
487    }
488
489    #[test]
490    fn materialized_underflow_signals() {
491        let mut m = MaterializedSet::new(4, false);
492        for i in 0..5 {
493            m.apply(format!("k{i}").as_bytes(), true, Some(IndexValue::I64(i)));
494        }
495        assert_eq!(m.len(), 5);
496        assert!(!m.apply(b"k0", false, None), "5→4 = still K");
497        assert!(m.apply(b"k1", false, None), "4→3 < K → underflow signal");
498        // order-index-excluded members are counted, not stored
499        m.apply(b"kx", true, None);
500        assert_eq!(m.order_excluded, 1);
501        // unbounded never underflows
502        let mut u = MaterializedSet::new(0, false);
503        u.apply(b"a", true, Some(IndexValue::I64(1)));
504        assert!(!u.apply(b"a", false, None));
505    }
506}
507
508/// The view registry (mirrors [`crate::Catalog`]): named specs +
509/// sidecar text round-trip. Cap 64.
510#[derive(Debug, Clone, Default)]
511pub struct ViewCatalog {
512    specs: Vec<ViewSpec>,
513}
514
515/// Hard cap on declared views.
516pub const MAX_VIEWS: usize = 64;
517
518impl ViewCatalog {
519    /// Empty catalog.
520    pub fn new() -> Self {
521        Self::default()
522    }
523
524    /// Register; errors on duplicate/cap/structure.
525    pub fn create(&mut self, spec: ViewSpec) -> Result<(), &'static str> {
526        spec.validate()?;
527        if self.specs.len() >= MAX_VIEWS {
528            return Err("ERR view limit reached (64)");
529        }
530        if self.specs.iter().any(|s| s.name == spec.name) {
531            return Err("ERR view already exists");
532        }
533        self.specs.push(spec);
534        Ok(())
535    }
536
537    /// Drop by name.
538    pub fn drop_view(&mut self, name: &[u8]) -> bool {
539        let n = self.specs.len();
540        self.specs.retain(|s| s.name != name);
541        self.specs.len() != n
542    }
543
544    /// Lookup.
545    pub fn get(&self, name: &[u8]) -> Option<&ViewSpec> {
546        self.specs.iter().find(|s| s.name == name)
547    }
548
549    /// Declaration order.
550    pub fn iter(&self) -> impl Iterator<Item = &ViewSpec> {
551        self.specs.iter()
552    }
553
554    /// Count.
555    pub fn len(&self) -> usize {
556        self.specs.len()
557    }
558
559    /// Empty?
560    pub fn is_empty(&self) -> bool {
561        self.specs.is_empty()
562    }
563
564    /// Sidecar text (one line per view).
565    pub fn to_sidecar(&self) -> String {
566        let mut out = String::from("kevy-view-catalog v1\n");
567        for s in &self.specs {
568            out.push_str(&s.to_line());
569            out.push('\n');
570        }
571        out
572    }
573
574    /// Parse the sidecar text.
575    pub fn from_sidecar(text: &str) -> Option<ViewCatalog> {
576        let mut lines = text.lines();
577        if lines.next()? != "kevy-view-catalog v1" {
578            return None;
579        }
580        let mut c = ViewCatalog::new();
581        for line in lines {
582            if line.is_empty() {
583                continue;
584            }
585            c.create(ViewSpec::from_line(line)?).ok()?;
586        }
587        Some(c)
588    }
589}
590
591fn esc(b: &[u8]) -> String {
592    let mut out = String::with_capacity(b.len());
593    for &c in b {
594        if c == b' ' || c == b'\t' || c == b'\n' || c == b'%' || c == b'(' || c == b')' || !(33..127).contains(&c) {
595            out.push_str(&format!("%{c:02X}"));
596        } else {
597            out.push(c as char);
598        }
599    }
600    if out.is_empty() { "%".into() } else { out }
601}
602
603fn unesc(s: &str) -> Option<Vec<u8>> {
604    if s == "%" {
605        return Some(Vec::new());
606    }
607    let mut out = Vec::with_capacity(s.len());
608    let b = s.as_bytes();
609    let mut i = 0;
610    while i < b.len() {
611        if b[i] == b'%' {
612            out.push(u8::from_str_radix(s.get(i + 1..i + 3)?, 16).ok()?);
613            i += 3;
614        } else {
615            out.push(b[i]);
616            i += 1;
617        }
618    }
619    Some(out)
620}
621
622fn val_ser(v: &IndexValue) -> String {
623    match v {
624        IndexValue::I64(i) => format!("i{i}"),
625        IndexValue::F64(f) => format!("f{}", f.to_bits()),
626        IndexValue::Str(s) => format!("s{}", esc(s)),
627    }
628}
629
630fn val_de(s: &str) -> Option<IndexValue> {
631    let (tag, rest) = s.split_at(1);
632    match tag {
633        "i" => rest.parse().ok().map(IndexValue::I64),
634        "f" => rest.parse::<u64>().ok().map(|b| IndexValue::F64(f64::from_bits(b))),
635        "s" => unesc(rest).map(IndexValue::Str),
636        _ => None,
637    }
638}
639
640fn tree_ser(t: &Tree, out: &mut String) {
641    match t {
642        Tree::Leaf(l) => {
643            out.push_str(&format!("(L {} {} {})", esc(&l.index), val_ser(&l.min), val_ser(&l.max)));
644        }
645        Tree::And(a, b) | Tree::Or(a, b) | Tree::Diff(a, b) => {
646            let op = match t {
647                Tree::And(..) => "A",
648                Tree::Or(..) => "O",
649                _ => "D",
650            };
651            out.push_str(&format!("({op} "));
652            tree_ser(a, out);
653            out.push(' ');
654            tree_ser(b, out);
655            out.push(')');
656        }
657    }
658}
659
660fn tree_de(toks: &[&str], pos: &mut usize) -> Option<Tree> {
661    let t = toks.get(*pos)?;
662    *pos += 1;
663    match *t {
664        "(L" => {
665            let idx = unesc(toks.get(*pos)?)?;
666            let min = val_de(toks.get(*pos + 1)?)?;
667            let max = val_de(toks.get(*pos + 2)?.trim_end_matches(')'))?;
668            *pos += 3;
669            Some(Tree::Leaf(Leaf { index: idx, min, max }))
670        }
671        "(A" | "(O" | "(D" => {
672            let a = tree_de(toks, pos)?;
673            let b = tree_de(toks, pos)?;
674            let tree = match *t {
675                "(A" => Tree::And(Box::new(a), Box::new(b)),
676                "(O" => Tree::Or(Box::new(a), Box::new(b)),
677                _ => Tree::Diff(Box::new(a), Box::new(b)),
678            };
679            Some(tree)
680        }
681        _ => None,
682    }
683}
684
685impl ViewSpec {
686    /// One sidecar line: `name order_by desc mode topk via tree…`.
687    pub fn to_line(&self) -> String {
688        let (mode, k) = match self.mode {
689            ViewMode::Virtual => ("v", 0),
690            ViewMode::Materialized { top_k } => ("m", top_k),
691        };
692        let via = self.via.as_deref().map(esc).unwrap_or_else(|| "-".into());
693        let mut tree = String::new();
694        tree_ser(&self.tree, &mut tree);
695        format!(
696            "{} {} {} {} {} {} {}",
697            esc(&self.name),
698            esc(&self.order_by),
699            u8::from(self.desc),
700            mode,
701            k,
702            via,
703            tree
704        )
705    }
706
707    /// Parse [`Self::to_line`].
708    pub fn from_line(line: &str) -> Option<ViewSpec> {
709        let toks: Vec<&str> = line.split(' ').collect();
710        if toks.len() < 7 {
711            return None;
712        }
713        let mode = match toks[3] {
714            "v" => ViewMode::Virtual,
715            "m" => ViewMode::Materialized { top_k: toks[4].parse().ok()? },
716            _ => return None,
717        };
718        let via = if toks[5] == "-" { None } else { Some(unesc(toks[5])?) };
719        let mut pos = 6;
720        // Re-tokenize the tree tail with ')' handling: split keeps
721        // parens attached; tree_de trims them.
722        let tree = tree_de_root(&toks, &mut pos)?;
723        Some(ViewSpec {
724            name: unesc(toks[0])?,
725            order_by: unesc(toks[1])?,
726            desc: toks[2] == "1",
727            mode,
728            via,
729            tree,
730        })
731    }
732}
733
734fn tree_de_root(toks: &[&str], pos: &mut usize) -> Option<Tree> {
735    // Fixed arity makes parens redundant on the way back in: each op
736    // token consumes exactly two subtrees, each leaf exactly three
737    // value tokens (the last with its trailing parens trimmed). The
738    // serializer is the only producer; malformed input answers None.
739    tree_de(toks, pos)
740}