lunaris_retrieve/types.rs
1//! Core types — `Query`, `Hit`, `RawHit`, `SourceOp`.
2//!
3//! `Query` is what the user sends in; `Hit` is the hydrated, post-fusion
4//! result they get back; `RawHit` is the pre-hydration shape that flows
5//! between operators (carries the `SourceOp` tag so `fuse_rrf` can group
6//! by branch and rank per group).
7
8use lunaris_core::Hlc;
9use lunaris_core::storage::types::Filter;
10use serde::{Deserialize, Serialize};
11
12/// What kind of operator produced a [`RawHit`]. RRF fusion groups raw hits by
13/// this tag so per-branch rankings stay isolated when computing `1 / (k + rank_i)`.
14///
15/// Plan 02-03 added `Reranked` (cross-encoder pass output). Plan 03-02 added
16/// `Graph` (anchored graph traversal output). The `Fused` variant marks a
17/// `RawHit` that has already been through a `fuse_rrf` operator (so `fuse_rrf`
18/// of a `fuse_rrf` re-fuses on the fused tag, treating it as a single branch).
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub enum SourceOp {
21 Vector,
22 Keyword,
23 Fused,
24 Reranked,
25 /// Plan 03-02 (D-15): `Graph::anchored` operator output. RRF fusion groups
26 /// Graph hits as a separate branch from Vector/Keyword so reciprocal-rank
27 /// contributions stay isolated. Per-hit score = `1.0 / (1 + bfs_rank)` for
28 /// rank-stability across hops (rank 0 → 1.0, rank 1 → 0.5, …).
29 Graph,
30 /// N5/B2 (RAPTOR tree retrieval): leaf-chunk hits descended from a matched
31 /// community summary node. The operator searches the `"communities"` vector
32 /// index, then walks `Community.members` to collect leaf-chunk IDs. Score
33 /// is inherited from the parent community's cosine similarity. Hydration
34 /// resolves chunk text normally — the id bytes are chunk ULIDs.
35 Tree,
36}
37
38/// One pre-hydration retrieval hit. Operators flow these around between
39/// each other; the final stage hydrates them into [`Hit`]s.
40#[derive(Clone, Debug, Serialize, Deserialize)]
41pub struct RawHit {
42 /// Backend-issued id (typically a ULID's 16 bytes).
43 pub id: Vec<u8>,
44 /// Score from the producing operator. Vector: cosine similarity.
45 /// Keyword: min-max normalized BM25. Fused: sum of `1 / (k + rank_i)`
46 /// across branches. Reranked: cross-encoder logit (replaces upstream
47 /// score, see [`SourceOp::Reranked`]).
48 pub score: f32,
49 /// Whether a cross-encoder reranker has been applied to this hit.
50 /// Plan 02-03's `rerank` operator sets this to the value of
51 /// `Reranker::applies()` after the rerank pass — `true` when the real
52 /// `BgeRerankerV2M3` ran, `false` when the `NoopReranker` passthrough
53 /// fallback ran (degraded model-missing path per RETRIEVE-06).
54 pub rerank_applied: bool,
55 /// Set `true` by Plan 02-03's `degraded_fallback` operator when this
56 /// hit came from the fallback (secondary) retriever after the primary
57 /// errored. Default `false` — every upstream operator (Vector, Keyword,
58 /// fuse_rrf, combinators, rerank) leaves it false; only the
59 /// `degraded_fallback` operator flips it on the fallback path. Hydration
60 /// copies this onto [`Hit::degraded`] so callers can tell whether the
61 /// result came from the primary backend or the fallback.
62 #[serde(default)]
63 pub degraded: bool,
64 /// Free-form metadata from the producing operator. Vector reads it from
65 /// the backend's `__metadata` / payload column. Keyword reads it from
66 /// the backend's payload column.
67 #[serde(default)]
68 pub metadata: serde_json::Value,
69 /// Which operator produced this hit. RRF fusion groups by this tag.
70 pub source_op: SourceOp,
71}
72
73/// Final hydrated retrieval hit returned to the caller.
74///
75/// `text` + `source` come from the chunk's KV row (looked up via
76/// `StoragePort::read_as_of` in [`crate::hydrate::hydrate`]). `valid_from` /
77/// `valid_to` come from the chunk's bi-temporal stamp (so callers can render
78/// "this fact was true on …").
79#[derive(Clone, Debug, Serialize, Deserialize)]
80pub struct Hit {
81 pub id: Vec<u8>,
82 /// 16-byte ULID of the parent Episode this chunk was extracted from
83 /// (`chunk.episode_id`), populated during [`crate::hydrate::hydrate`].
84 ///
85 /// In-process provenance channel ONLY: `#[serde(skip)]` keeps it off the
86 /// wire and out of SDK responses (no payload bloat). Exact-key callers
87 /// such as `WorkingMemory::read` use it to recover the VERBATIM Episode
88 /// `content` instead of the lossy, smart-punctuation-rewritten chunk
89 /// `text`. Empty for hits produced outside the main hydration path.
90 #[serde(skip)]
91 pub episode_id: Vec<u8>,
92 pub score: f32,
93 /// Chunk text body — from the chunk's KV row.
94 pub text: String,
95 /// Episode source (e.g., `helios:fs/notes.md`). Empty when the episode
96 /// row was not found at hydration time (e.g., since-deleted).
97 pub source: String,
98 /// Heading path inherited from the chunk. Empty list = root document.
99 #[serde(default)]
100 pub heading_path: Vec<String>,
101 pub valid_from: Hlc,
102 pub valid_to: Option<Hlc>,
103 /// `true` when a `degraded_fallback` operator (Plan 02-03) flipped to a
104 /// fallback retriever for this branch. Plumbed through hydration from
105 /// [`RawHit::degraded`]. Callers can render "stale result" UI off this
106 /// flag.
107 pub degraded: bool,
108 pub rerank_applied: bool,
109 pub source_op: SourceOp,
110}
111
112/// One retrieval query.
113///
114/// Construct via [`Query::text`] for the common case (text-only with default
115/// k=30 and no filter / as_of); use the struct-literal form when you need to
116/// set every field explicitly.
117#[derive(Clone, Debug, Default)]
118pub struct Query {
119 pub text: String,
120 pub k: usize,
121 pub filter: Option<Filter>,
122 pub as_of: Option<Hlc>,
123}
124
125impl Query {
126 /// Build a default `Query` with the given text, `k = 30`, no filter, no `as_of`.
127 pub fn text(t: impl Into<String>) -> Self {
128 Self { text: t.into(), k: 30, filter: None, as_of: None }
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn query_text_defaults() {
138 let q = Query::text("hello");
139 assert_eq!(q.text, "hello");
140 assert_eq!(q.k, 30);
141 assert!(q.filter.is_none());
142 assert!(q.as_of.is_none());
143 }
144
145 #[test]
146 fn source_op_is_hashable_and_eq() {
147 use std::collections::HashSet;
148 let mut s = HashSet::new();
149 s.insert(SourceOp::Vector);
150 s.insert(SourceOp::Vector);
151 s.insert(SourceOp::Keyword);
152 s.insert(SourceOp::Graph); // Plan 03-02 (D-15): Graph variant is its own bucket.
153 assert_eq!(s.len(), 3);
154 }
155
156 #[test]
157 fn source_op_graph_is_distinct_from_other_variants() {
158 // Plan 03-02 (D-15): the Graph variant must group separately from
159 // Vector/Keyword/Fused/Reranked so RRF fusion can rank graph hits as
160 // their own branch instead of folding them in with vector/keyword
161 // results (which would skew the per-branch reciprocal-rank weights).
162 assert_ne!(SourceOp::Graph, SourceOp::Vector);
163 assert_ne!(SourceOp::Graph, SourceOp::Keyword);
164 assert_ne!(SourceOp::Graph, SourceOp::Fused);
165 assert_ne!(SourceOp::Graph, SourceOp::Reranked);
166 }
167}