Skip to main content

lunaris_retrieve/operators/
tree.rs

1//! `Tree` operator — RAPTOR hierarchical retrieval over the `communities` index.
2//!
3//! ## What it does
4//!
5//! 1. Embeds the query (shared via [`super::QueryContext::embed_once`]).
6//! 2. Vector-searches the `"communities"` FT index for the `k` closest
7//!    community summaries.
8//! 3. For each community hit, reads the [`lunaris_core::Community`] KV row
9//!    (via `StoragePort::read_as_of`) to get `Community.members`.
10//! 4. Partitions `members` into **leaf chunks** vs **sub-communities** by
11//!    probing: if `community_key(scope, id)` exists in the KV store, it's a
12//!    sub-community; otherwise treat it as a leaf chunk.
13//! 5. Emits `RawHit { id: chunk_id_bytes, source_op: SourceOp::Tree }` for
14//!    every resolved leaf chunk so downstream `hydrate()` can resolve the full
15//!    chunk text (chunk keys are looked up by `hydrate` in the normal path).
16//!
17//! ## Why this matters (RAPTOR discrimination)
18//!
19//! Flat chunk retrieval at small `k` can only return individual chunks. A
20//! whole-document or multi-hop question whose answer spans several chunks
21//! benefits from the community summary node, which *semantically aggregates*
22//! those chunks. The `Tree` operator finds the nearest community summary node
23//! first, then exposes ALL leaf chunks under it — including chunks that would
24//! be ranked outside the flat search's top-k budget.
25//!
26//! ## BFS depth and DoS guard
27//!
28//! `Tree::new(index, k)` performs **one level of descent** (community →
29//! direct members) by default. `Tree::with_depth(d)` expands BFS up to `d`
30//! levels deep (capped at [`MAX_TREE_DEPTH`]).  Each level fetches only the
31//! community rows from the KV store (no new vector search is issued), so
32//! latency scales with depth × fan-out, not with index size.
33//!
34//! ## Seam constraints
35//!
36//! Per the DAG execution context, `operators/mod.rs` and `builder.rs` are
37//! **orchestrator-owned seams**. The `mod tree;` declaration and `.tree(...)`
38//! builder method are returned as additive snippets — the orchestrator applies
39//! them. This file is a standalone new file, safe to commit on the worktree
40//! branch.
41
42use std::any::Any;
43use std::collections::{HashSet, VecDeque};
44
45use async_trait::async_trait;
46use lunaris_core::keyspace::{chunk_key, community_key};
47use lunaris_core::{Community, HlcClock, LunarisError};
48
49use super::{QueryContext, Retriever, clamp_k};
50use crate::types::{RawHit, SourceOp};
51
52/// Hard cap on BFS descent depth to prevent DoS via a degenerate community tree
53/// with very large fan-out at each level.
54pub const MAX_TREE_DEPTH: usize = 4;
55
56/// Default BFS descent depth when not specified. One level means: find the
57/// nearest communities, then collect their direct member chunks. Deeper levels
58/// recurse into sub-communities.
59pub const DEFAULT_TREE_DEPTH: usize = 1;
60
61/// RAPTOR tree retrieval operator.
62///
63/// Searches the `communities` vector index, then descends the community→members
64/// hierarchy to collect leaf-chunk IDs that `hydrate()` can resolve into `Hit`s.
65///
66/// Construction is fluent. Pass it to [`crate::RetrievalBuilder::with_root`] or
67/// compose with other operators via `.and()` / `.or()` / `.fuse_rrf()`.
68///
69/// ```no_run
70/// use lunaris_core::LunarisError;
71/// use lunaris_retrieve::{Query, RetrievalBuilder};
72/// use lunaris_retrieve::operators::tree::Tree;
73///
74/// # async fn demo(builder: RetrievalBuilder) -> Result<(), LunarisError> {
75/// let hits = builder
76///     .with_root(Tree::new("communities", 5))
77///     .execute(Query::text("What are the main themes across both reports?"))
78///     .await?;
79/// # Ok(()) }
80/// ```
81#[derive(Clone, Debug)]
82#[must_use = "Tree is a query node — pass it to RetrievalBuilder::with_root() or chain via .and/.or/.fuse_rrf, otherwise it never executes"]
83pub struct Tree {
84    /// Vector index to search for community summaries. Typically `"communities"`.
85    pub index: String,
86    /// Number of top community nodes to retrieve from the vector index.
87    pub k: usize,
88    /// BFS descent depth: 1 = direct members only, 2 = members + their members, etc.
89    pub depth: usize,
90}
91
92impl Tree {
93    /// Build a `Tree` operator targeting the given community index with `k`
94    /// top-level nodes and the default descent depth of [`DEFAULT_TREE_DEPTH`].
95    ///
96    /// `k` is clamped to [`super::MAX_K`].
97    pub fn new(index: impl Into<String>, k: usize) -> Self {
98        Self { index: index.into(), k: clamp_k(k), depth: DEFAULT_TREE_DEPTH }
99    }
100
101    /// Override the BFS descent depth (clamped to [`MAX_TREE_DEPTH`]).
102    ///
103    /// `depth = 1` (default): collect direct `Community.members` as leaf chunks.
104    /// `depth = 2`: also expand sub-community members one additional level, etc.
105    pub fn with_depth(mut self, depth: usize) -> Self {
106        self.depth = depth.clamp(1, MAX_TREE_DEPTH);
107        self
108    }
109
110    /// Convenience: wrap into the standard combinator builder.
111    pub fn and<R: Retriever + 'static>(self, other: R) -> super::combinators::AndRetriever {
112        super::combinators::AndRetriever::new(Box::new(self), Box::new(other))
113    }
114
115    pub fn or<R: Retriever + 'static>(self, other: R) -> super::combinators::OrRetriever {
116        super::combinators::OrRetriever::new(Box::new(self), Box::new(other))
117    }
118
119    pub fn then<R: Retriever + 'static>(self, other: R) -> super::combinators::ThenRetriever {
120        super::combinators::ThenRetriever::new(Box::new(self), Box::new(other))
121    }
122
123    /// Cap the final result set after the operator tree resolves.
124    pub fn top(self, n: usize) -> super::modifiers::TopRetriever {
125        super::modifiers::TopRetriever::new(Box::new(self), n)
126    }
127}
128
129#[async_trait]
130impl Retriever for Tree {
131    async fn retrieve(&self, ctx: &QueryContext) -> Result<Vec<RawHit>, LunarisError> {
132        // Step 1: embed query (shared via OnceCell — no re-embed if chained with Vector).
133        let q_emb = ctx.embed_once().await?;
134
135        // Step 2: vector-search the communities index for the k closest summary nodes.
136        //
137        // Graceful-empty for missing index: if no data has been ingested under this scope
138        // the communities FT index may not yet exist (Moon creates indexes lazily on first
139        // VectorUpsert). A missing-index error from the backend surfaces as
140        // `StorageError::Backend` containing "Index name" — treat that as "no communities"
141        // rather than propagating a hard error to the caller. The Vector operator uses the
142        // same degradation pattern: sparse indexes are a normal production state when RAPTOR
143        // has not yet built any community nodes for a freshly-created scope.
144        let community_hits = match ctx
145            .storage
146            .vector_search(
147                &ctx.scope,
148                &self.index,
149                &q_emb,
150                self.k,
151                ctx.query.filter.as_ref(),
152                ctx.query.as_of,
153                false,
154            )
155            .await
156        {
157            Ok(hits) => hits,
158            // Graceful-empty for a missing index — the normal state for a scope
159            // that has not been ingested into yet. F1 moved the predicate to
160            // `crate::missing_index`, which recognises all three spellings Moon
161            // uses; this site had been matching only `Index name`, so the same
162            // condition reported through the hybrid path's wording sailed
163            // straight past it.
164            Err(ref e) if crate::missing_index::is_index_absent(e) => {
165                // F8: WARN, not debug. Returning empty is correct (F1: an
166                // absent index means nothing has been written under this
167                // scope), but the caller ASKED for tree retrieval, so silence
168                // leaves them with flat results and no way to learn why.
169                // RAPTOR is opt-in and does not backfill, so the usual cause is
170                // a corpus ingested before it was enabled — which no amount of
171                // re-querying will fix.
172                //
173                // The message names `raptor` and `re-ingest` deliberately;
174                // `tree_missing_index_is_loud.rs` asserts on both, because a
175                // warning a reader cannot act on is only marginally better than
176                // no warning. Its sibling test pins that a present-but-empty
177                // index stays quiet, so this stays rare enough to be worth
178                // reading.
179                tracing::warn!(
180                    scope = %ctx.scope,
181                    index = %self.index,
182                    "Tree: no communities index for this scope, so `.tree(..)` \
183                     matched nothing and recall fell back to flat results. \
184                     RAPTOR is opt-in and does not backfill: enable it and \
185                     re-ingest this scope's documents to build the tree."
186                );
187                return Ok(vec![]);
188            }
189            Err(e) => return Err(LunarisError::Storage(e)),
190        };
191
192        if community_hits.is_empty() {
193            return Ok(vec![]);
194        }
195
196        // Use a live clock for hydration when as_of is not set.
197        let live_clock = HlcClock::new(0);
198        let snapshot = ctx.query.as_of.unwrap_or_else(|| live_clock.tick());
199
200        // Step 3–5: BFS descent from matched community nodes → leaf chunk IDs.
201        //
202        // Invariant: we never emit a community ULID as a RawHit.id — those are NOT
203        // chunk keys and hydrate() would silently drop them. We only emit chunk ULIDs.
204        let mut leaf_ids: Vec<(Vec<u8>, f32)> = Vec::new();
205        let mut seen: HashSet<Vec<u8>> = HashSet::new();
206
207        for community_hit in community_hits {
208            // `community_hit.id` is the ULID bytes of the community node.
209            let community_id_bytes = &community_hit.id;
210            let community_ulid = match ulid_from_bytes(community_id_bytes) {
211                Some(u) => u,
212                None => continue,
213            };
214
215            // BFS queue: (ulid_bytes, score_from_parent, current_depth)
216            let mut queue: VecDeque<(ulid::Ulid, f32, usize)> = VecDeque::new();
217            queue.push_back((community_ulid, community_hit.score, 0));
218
219            while let Some((ulid, score, depth)) = queue.pop_front() {
220                // Read the community KV row.
221                let key = community_key(&ctx.scope, ulid);
222                let row = ctx.storage.read_as_of(&ctx.scope, &key, snapshot).await?;
223
224                let community: Community = match row
225                    .and_then(|r| serde_json::from_slice::<Community>(&r.value).ok())
226                {
227                    Some(c) => c,
228                    None => {
229                        // KV row missing or malformed — treat this id as a leaf chunk
230                        // (it may be a chunk id that was stored in members as a peer;
231                        // the BFS only starts from valid community hits so this is
232                        // normally a data-integrity edge case, not the hot path).
233                        let id_bytes = ulid.to_bytes().to_vec();
234                        if seen.insert(id_bytes.clone()) {
235                            // Verify it is actually a chunk before emitting.
236                            let chunk_kv_key = chunk_key(&ctx.scope, ulid);
237                            if let Ok(Some(_)) =
238                                ctx.storage.read_as_of(&ctx.scope, &chunk_kv_key, snapshot).await
239                            {
240                                leaf_ids.push((id_bytes, score));
241                            }
242                        }
243                        continue;
244                    }
245                };
246
247                // Process Community.members.
248                for member_id in &community.members {
249                    let member_bytes = member_id.to_bytes().to_vec();
250                    if !seen.insert(member_bytes.clone()) {
251                        continue; // already visited
252                    }
253
254                    // Probe: is this member a sub-community or a leaf chunk?
255                    let comm_key = community_key(&ctx.scope, *member_id);
256                    let is_community = ctx
257                        .storage
258                        .read_as_of(&ctx.scope, &comm_key, snapshot)
259                        .await
260                        .ok()
261                        .and_then(|r| r)
262                        .and_then(|r| serde_json::from_slice::<Community>(&r.value).ok())
263                        .is_some();
264
265                    if is_community && depth + 1 < self.depth {
266                        // Sub-community within descent budget — recurse.
267                        queue.push_back((*member_id, score, depth + 1));
268                    } else if !is_community {
269                        // Leaf chunk: emit as a RawHit with the parent community's score.
270                        leaf_ids.push((member_bytes, score));
271                    }
272                    // If is_community but depth budget exhausted: silently skip
273                    // (the community summary is not a chunk, and we can't descend further).
274                }
275            }
276        }
277
278        // Dedup by id (a chunk can appear under multiple communities via
279        // `members` cross-listing; keep the highest score).
280        let mut by_id: std::collections::HashMap<Vec<u8>, f32> =
281            std::collections::HashMap::with_capacity(leaf_ids.len());
282        for (id, score) in leaf_ids {
283            by_id.entry(id).and_modify(|s| *s = s.max(score)).or_insert(score);
284        }
285
286        Ok(by_id
287            .into_iter()
288            .map(|(id, score)| RawHit {
289                id,
290                score,
291                rerank_applied: false,
292                degraded: false,
293                metadata: serde_json::Value::Null,
294                source_op: SourceOp::Tree,
295            })
296            .collect())
297    }
298
299    fn as_any(&self) -> &dyn Any {
300        self
301    }
302}
303
304/// Parse a ULID from its 16-byte big-endian representation.
305#[inline]
306fn ulid_from_bytes(bytes: &[u8]) -> Option<ulid::Ulid> {
307    let arr: [u8; 16] = bytes.try_into().ok()?;
308    Some(ulid::Ulid::from_bytes(arr))
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn tree_new_records_index_and_clamps_k() {
317        let t = Tree::new("communities", 5);
318        assert_eq!(t.index, "communities");
319        assert_eq!(t.k, 5);
320        assert_eq!(t.depth, DEFAULT_TREE_DEPTH);
321    }
322
323    #[test]
324    fn tree_k_is_clamped_to_max_k() {
325        let t = Tree::new("communities", 1_000_000);
326        assert_eq!(t.k, super::super::MAX_K);
327    }
328
329    #[test]
330    fn tree_with_depth_clamps_to_max() {
331        let t = Tree::new("communities", 5).with_depth(999);
332        assert_eq!(t.depth, MAX_TREE_DEPTH);
333    }
334
335    #[test]
336    fn tree_with_depth_enforces_min_1() {
337        let t = Tree::new("communities", 5).with_depth(0);
338        assert_eq!(t.depth, 1);
339    }
340
341    #[test]
342    fn ulid_from_bytes_round_trips() {
343        let id = ulid::Ulid::new();
344        let bytes = id.to_bytes().to_vec();
345        assert_eq!(ulid_from_bytes(&bytes), Some(id));
346    }
347
348    #[test]
349    fn ulid_from_bytes_rejects_wrong_len() {
350        assert_eq!(ulid_from_bytes(b"too-short"), None);
351    }
352}