Skip to main content

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}