Skip to main content

relay_knowledge/domain/graph/
retrieval.rs

1use std::{error::Error, fmt};
2
3use serde::{Deserialize, Serialize};
4
5use super::{ConfidenceScore, EvidenceSpan, FactStatus, GraphVersion, GraphVersionRange};
6
7/// RRF constant used by Phase 1 hybrid retrieval.
8pub const RECIPROCAL_RANK_FUSION_K: f64 = 60.0;
9
10/// Freshness policy for hybrid retrieval.
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum FreshnessPolicy {
14    #[default]
15    AllowStale,
16    WaitUntilFresh,
17    GraphOnly,
18}
19
20/// Retrieval path used to satisfy a query.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum RetrievalMode {
24    Hybrid,
25    GraphOnly,
26}
27
28/// Retrieval source that contributed to a fused context result.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum RetrieverSource {
32    Bm25,
33    GraphEvidence,
34    CodeGraph,
35    Semantic,
36    Vector,
37    GraphPath,
38    Temporal,
39    CommunitySummary,
40}
41
42/// Rerank backend requested for the hybrid retrieval candidate set.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum RerankMode {
46    Local,
47    External,
48    Disabled,
49}
50
51impl RerankMode {
52    /// Parses a stable environment/config value.
53    pub fn parse(value: &str) -> Result<Self, RerankModeError> {
54        match value.trim().to_ascii_lowercase().as_str() {
55            "local" => Ok(Self::Local),
56            "external" => Ok(Self::External),
57            "disabled" => Ok(Self::Disabled),
58            other => Err(RerankModeError {
59                value: other.to_owned(),
60            }),
61        }
62    }
63
64    /// Stable configuration label.
65    pub const fn as_str(self) -> &'static str {
66        match self {
67            Self::Local => "local",
68            Self::External => "external",
69            Self::Disabled => "disabled",
70        }
71    }
72}
73
74/// Invalid rerank backend mode supplied by runtime configuration.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct RerankModeError {
77    pub value: String,
78}
79
80impl fmt::Display for RerankModeError {
81    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
82        write!(
83            formatter,
84            "rerank backend '{}' must be local, external, or disabled",
85            self.value
86        )
87    }
88}
89
90impl Error for RerankModeError {}
91
92/// Availability state for optional retrieval backends.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum RetrievalBackendState {
96    Available,
97    Degraded,
98    Unavailable,
99}
100
101/// Per-backend status preserved so callers can distinguish fallback from
102/// complete hybrid retrieval.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct RetrievalBackendStatus {
105    pub source: RetrieverSource,
106    pub state: RetrievalBackendState,
107    pub scope_post_filter: bool,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub indexed_graph_version: Option<GraphVersion>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub reason: Option<String>,
112}
113
114impl RetrieverSource {
115    /// Stable API representation used in ranking diagnostics.
116    pub const fn as_str(self) -> &'static str {
117        match self {
118            Self::Bm25 => "bm25",
119            Self::GraphEvidence => "graph_evidence",
120            Self::CodeGraph => "code_graph",
121            Self::Semantic => "semantic",
122            Self::Vector => "vector",
123            Self::GraphPath => "graph_path",
124            Self::Temporal => "temporal",
125            Self::CommunitySummary => "community_summary",
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn retriever_source_labels_match_wire_values() {
136        assert_eq!(RetrieverSource::Bm25.as_str(), "bm25");
137        assert_eq!(RetrieverSource::GraphEvidence.as_str(), "graph_evidence");
138        assert_eq!(RetrieverSource::CodeGraph.as_str(), "code_graph");
139        assert_eq!(RetrieverSource::Semantic.as_str(), "semantic");
140        assert_eq!(RetrieverSource::Vector.as_str(), "vector");
141        assert_eq!(RetrieverSource::GraphPath.as_str(), "graph_path");
142        assert_eq!(RetrieverSource::Temporal.as_str(), "temporal");
143        assert_eq!(
144            RetrieverSource::CommunitySummary.as_str(),
145            "community_summary"
146        );
147    }
148
149    #[test]
150    fn rerank_mode_labels_match_wire_values() {
151        assert_eq!(
152            RerankMode::parse("local").expect("local"),
153            RerankMode::Local
154        );
155        assert_eq!(
156            RerankMode::parse("external").expect("external"),
157            RerankMode::External
158        );
159        assert_eq!(
160            RerankMode::parse("disabled").expect("disabled"),
161            RerankMode::Disabled
162        );
163        assert_eq!(RerankMode::Local.as_str(), "local");
164        assert_eq!(RerankMode::External.as_str(), "external");
165        assert_eq!(RerankMode::Disabled.as_str(), "disabled");
166    }
167
168    #[test]
169    fn graph_path_preserves_fact_provenance() {
170        let fact = ContextGraphFact {
171            fact_id: "rel-1".to_owned(),
172            kind: ContextGraphFactKind::Relation,
173            subject: "relay-knowledge".to_owned(),
174            predicate: "uses".to_owned(),
175            object: Some("BM25".to_owned()),
176            evidence_ids: vec!["ev-1".to_owned()],
177            confidence: ConfidenceScore { basis_points: 9000 },
178            status: FactStatus::Accepted,
179            version_range: GraphVersionRange::open_from(GraphVersion::new(1)),
180        };
181
182        let path = ContextGraphPath::from_fact(&fact);
183
184        assert_eq!(path.path_id, "path:rel-1");
185        assert_eq!(path.nodes, ["relay-knowledge", "BM25"]);
186        assert_eq!(path.edges[0].evidence_ids, ["ev-1"]);
187        assert_eq!(path.edges[0].confidence.basis_points, 9000);
188    }
189}
190
191/// Per-retriever ranking signal preserved after fusion.
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
193pub struct RankingSignal {
194    pub source: RetrieverSource,
195    pub rank: usize,
196    pub score: f64,
197    pub explanation: String,
198}
199
200/// Final rerank signal applied after hybrid retrieval fusion.
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202pub struct RerankSignal {
203    pub mode: RerankMode,
204    pub score: f64,
205    pub explanation: String,
206}
207
208/// Budget actually consumed by retrieval context packing.
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210pub struct RetrievalBudgetUsed {
211    pub limit: usize,
212    pub candidate_count: usize,
213    pub returned_count: usize,
214    pub context_bytes: usize,
215}
216
217/// Diagnostics for reciprocal-rank fusion.
218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
219pub struct FusionDiagnostics {
220    pub algorithm: String,
221    pub k: f64,
222    pub candidate_count: usize,
223}
224
225/// Diagnostics for post-fusion reranking.
226#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
227pub struct RerankDiagnostics {
228    pub requested_mode: RerankMode,
229    pub effective_mode: RerankMode,
230    pub algorithm: String,
231    pub candidate_count: usize,
232    pub returned_count: usize,
233    pub degraded: bool,
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub reason: Option<String>,
236}
237
238/// A compact, auditable context pack for agent and UI adapters.
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
240pub struct RetrievedContextPack {
241    pub graph_version: GraphVersion,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub source_scope: Option<String>,
244    pub freshness: FreshnessPolicy,
245    pub truncated: bool,
246    #[serde(default, skip_serializing_if = "Vec::is_empty")]
247    pub backend_statuses: Vec<RetrievalBackendStatus>,
248    pub items: Vec<ContextPackItem>,
249}
250
251/// Entity projection retained with each context item.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct ContextEntity {
254    pub id: String,
255    pub label: String,
256}
257
258/// Structured graph fact kind referenced from a context item.
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(rename_all = "snake_case")]
261pub enum ContextGraphFactKind {
262    Relation,
263    Claim,
264    Event,
265}
266
267/// Structured relation, claim, or event that supports a retrieval hit.
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269pub struct ContextGraphFact {
270    pub fact_id: String,
271    pub kind: ContextGraphFactKind,
272    pub subject: String,
273    pub predicate: String,
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub object: Option<String>,
276    pub evidence_ids: Vec<String>,
277    pub confidence: ConfidenceScore,
278    pub status: FactStatus,
279    pub version_range: GraphVersionRange,
280}
281
282/// Direct graph path evidence derived from a structured graph fact.
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct ContextGraphPath {
285    pub path_id: String,
286    pub nodes: Vec<String>,
287    pub edges: Vec<ContextGraphPathEdge>,
288}
289
290/// One edge in a graph path returned through the context pack.
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292pub struct ContextGraphPathEdge {
293    pub fact_id: String,
294    pub kind: ContextGraphFactKind,
295    pub from: String,
296    pub predicate: String,
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub to: Option<String>,
299    pub evidence_ids: Vec<String>,
300    pub confidence: ConfidenceScore,
301    pub status: FactStatus,
302    pub version_range: GraphVersionRange,
303}
304
305impl ContextGraphPath {
306    /// Builds a one-hop path from a persisted structured fact.
307    pub fn from_fact(fact: &ContextGraphFact) -> Self {
308        let mut nodes = vec![fact.subject.clone()];
309        if let Some(object) = &fact.object
310            && !nodes.contains(object)
311        {
312            nodes.push(object.clone());
313        }
314
315        Self {
316            path_id: format!("path:{}", fact.fact_id),
317            nodes,
318            edges: vec![ContextGraphPathEdge {
319                fact_id: fact.fact_id.clone(),
320                kind: fact.kind,
321                from: fact.subject.clone(),
322                predicate: fact.predicate.clone(),
323                to: fact.object.clone(),
324                evidence_ids: fact.evidence_ids.clone(),
325                confidence: fact.confidence,
326                status: fact.status,
327                version_range: fact.version_range,
328            }],
329        }
330    }
331}
332
333/// Code artifact category returned through the general GraphRAG context pack.
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(rename_all = "snake_case")]
336pub enum CodeGraphArtifactKind {
337    Symbol,
338    Chunk,
339}
340
341/// Code graph artifact tied to a shared retrieval result.
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343pub struct CodeGraphArtifact {
344    pub kind: CodeGraphArtifactKind,
345    pub artifact_id: String,
346    pub path: String,
347}
348
349/// Context-pack item tied to a retrieval hit.
350#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
351pub struct ContextPackItem {
352    pub result_id: String,
353    pub source_scope: String,
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub source_path: Option<String>,
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub source_span: Option<EvidenceSpan>,
358    #[serde(default, skip_serializing_if = "Vec::is_empty")]
359    pub entities: Vec<ContextEntity>,
360    #[serde(default, skip_serializing_if = "Vec::is_empty")]
361    pub graph_facts: Vec<ContextGraphFact>,
362    #[serde(default, skip_serializing_if = "Vec::is_empty")]
363    pub graph_paths: Vec<ContextGraphPath>,
364    #[serde(skip_serializing_if = "Option::is_none")]
365    pub code_artifact: Option<CodeGraphArtifact>,
366    pub retriever_sources: Vec<RetrieverSource>,
367    pub ranking: Vec<RankingSignal>,
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub rerank: Option<RerankSignal>,
370}
371
372/// A context item returned by retrieval.
373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
374pub struct RetrievalHit {
375    pub evidence_id: String,
376    pub source_scope: String,
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub source_path: Option<String>,
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub source_span: Option<EvidenceSpan>,
381    pub content: String,
382    pub entity_labels: Vec<String>,
383    #[serde(default, skip_serializing_if = "Vec::is_empty")]
384    pub entities: Vec<ContextEntity>,
385    #[serde(default, skip_serializing_if = "Vec::is_empty")]
386    pub graph_facts: Vec<ContextGraphFact>,
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub code_artifact: Option<CodeGraphArtifact>,
389    pub retriever_sources: Vec<RetrieverSource>,
390    pub ranking: Vec<RankingSignal>,
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub rerank: Option<RerankSignal>,
393    pub score: f64,
394}