1use std::{error::Error, fmt};
2
3use serde::{Deserialize, Serialize};
4
5use super::{ConfidenceScore, EvidenceSpan, FactStatus, GraphVersion, GraphVersionRange};
6
7pub const RECIPROCAL_RANK_FUSION_K: f64 = 60.0;
9
10#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum RetrievalMode {
24 Hybrid,
25 GraphOnly,
26}
27
28#[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#[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 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 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#[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#[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#[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 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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202pub struct RerankSignal {
203 pub mode: RerankMode,
204 pub score: f64,
205 pub explanation: String,
206}
207
208#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct ContextEntity {
254 pub id: String,
255 pub label: String,
256}
257
258#[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#[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#[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#[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(rename_all = "snake_case")]
336pub enum CodeGraphArtifactKind {
337 Symbol,
338 Chunk,
339}
340
341#[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#[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#[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}