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}