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)]
131#[path = "retrieval_tests.rs"]
132mod tests;
133
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct RankingSignal {
137 pub source: RetrieverSource,
138 pub rank: usize,
139 pub score: f64,
140 pub explanation: String,
141}
142
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145pub struct RerankSignal {
146 pub mode: RerankMode,
147 pub score: f64,
148 pub explanation: String,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct RetrievalBudgetUsed {
154 pub limit: usize,
155 pub candidate_count: usize,
156 pub returned_count: usize,
157 pub context_bytes: usize,
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162pub struct FusionDiagnostics {
163 pub algorithm: String,
164 pub k: f64,
165 pub candidate_count: usize,
166}
167
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170pub struct RerankDiagnostics {
171 pub requested_mode: RerankMode,
172 pub effective_mode: RerankMode,
173 pub algorithm: String,
174 pub candidate_count: usize,
175 pub returned_count: usize,
176 pub degraded: bool,
177 #[serde(skip_serializing_if = "Option::is_none")]
178 pub reason: Option<String>,
179}
180
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183pub struct RetrievedContextPack {
184 pub graph_version: GraphVersion,
185 #[serde(skip_serializing_if = "Option::is_none")]
186 pub source_scope: Option<String>,
187 pub freshness: FreshnessPolicy,
188 pub truncated: bool,
189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
190 pub backend_statuses: Vec<RetrievalBackendStatus>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub provenance_trace: Option<TraversalProvenanceTrace>,
193 pub items: Vec<ContextPackItem>,
194}
195
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198pub struct TraversalProvenanceTrace {
199 pub graph_version: GraphVersion,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 pub source_scope: Option<String>,
202 pub routed_intent: String,
203 #[serde(default, skip_serializing_if = "Vec::is_empty")]
204 pub visited_nodes: Vec<TraversalTraceNode>,
205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
206 pub visited_edges: Vec<TraversalTraceEdge>,
207 #[serde(default, skip_serializing_if = "Vec::is_empty")]
208 pub cited_evidence: Vec<TraversalTraceEvidence>,
209 #[serde(default, skip_serializing_if = "Vec::is_empty")]
210 pub visited_but_uncited: Vec<TraversalTraceEvidence>,
211 #[serde(default, skip_serializing_if = "Vec::is_empty")]
212 pub ranking_contributions: Vec<TraversalRankingContribution>,
213 pub truncated: bool,
214 pub stale: bool,
215 #[serde(skip_serializing_if = "Option::is_none")]
216 pub degraded_reason: Option<String>,
217 pub redaction: TraversalTraceRedaction,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct TraversalTraceNode {
223 pub node_id: String,
224 pub label: String,
225 pub kind: TraversalTraceNodeKind,
226 #[serde(skip_serializing_if = "Option::is_none")]
227 pub source_scope: Option<String>,
228 #[serde(skip_serializing_if = "Option::is_none")]
229 pub source_path: Option<String>,
230 #[serde(default, skip_serializing_if = "Vec::is_empty")]
231 pub evidence_ids: Vec<String>,
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum TraversalTraceNodeKind {
238 Entity,
239 Evidence,
240 CodeArtifact,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct TraversalTraceEdge {
246 pub edge_id: String,
247 pub from_node_id: String,
248 #[serde(skip_serializing_if = "Option::is_none")]
249 pub to_node_id: Option<String>,
250 #[serde(skip_serializing_if = "Option::is_none")]
251 pub predicate: Option<String>,
252 pub source: RetrieverSource,
253 #[serde(default, skip_serializing_if = "Vec::is_empty")]
254 pub evidence_ids: Vec<String>,
255 #[serde(skip_serializing_if = "Option::is_none")]
256 pub source_scope: Option<String>,
257 #[serde(skip_serializing_if = "Option::is_none")]
258 pub source_path: Option<String>,
259}
260
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263pub struct TraversalTraceEvidence {
264 pub evidence_id: String,
265 pub source_scope: String,
266 #[serde(skip_serializing_if = "Option::is_none")]
267 pub source_path: Option<String>,
268 pub score: f64,
269 pub retriever_sources: Vec<RetrieverSource>,
270}
271
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274pub struct TraversalRankingContribution {
275 pub result_id: String,
276 pub source: RetrieverSource,
277 pub rank: usize,
278 pub score: f64,
279 pub rrf_contribution: f64,
280 pub cited: bool,
281 pub explanation: String,
282 #[serde(skip_serializing_if = "Option::is_none")]
283 pub source_scope: Option<String>,
284 #[serde(skip_serializing_if = "Option::is_none")]
285 pub source_path: Option<String>,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct TraversalTraceRedaction {
291 #[serde(skip_serializing_if = "Option::is_none")]
292 pub authorization_scope: Option<String>,
293 pub redacted_count: usize,
294 #[serde(skip_serializing_if = "Option::is_none")]
295 pub reason: Option<String>,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub struct ContextEntity {
301 pub id: String,
302 pub label: String,
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(rename_all = "snake_case")]
308pub enum ContextGraphFactKind {
309 Relation,
310 Claim,
311 Event,
312}
313
314impl ContextGraphFactKind {
315 pub const fn as_str(self) -> &'static str {
316 match self {
317 Self::Relation => "relation",
318 Self::Claim => "claim",
319 Self::Event => "event",
320 }
321 }
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
326pub struct ContextGraphFact {
327 pub fact_id: String,
328 pub kind: ContextGraphFactKind,
329 pub subject: String,
330 pub predicate: String,
331 #[serde(skip_serializing_if = "Option::is_none")]
332 pub object: Option<String>,
333 pub evidence_ids: Vec<String>,
334 pub confidence: ConfidenceScore,
335 pub status: FactStatus,
336 pub version_range: GraphVersionRange,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct ContextGraphPath {
342 pub path_id: String,
343 pub nodes: Vec<String>,
344 pub edges: Vec<ContextGraphPathEdge>,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349pub struct ContextGraphPathEdge {
350 pub fact_id: String,
351 pub kind: ContextGraphFactKind,
352 pub from: String,
353 pub predicate: String,
354 #[serde(skip_serializing_if = "Option::is_none")]
355 pub to: Option<String>,
356 pub evidence_ids: Vec<String>,
357 pub confidence: ConfidenceScore,
358 pub status: FactStatus,
359 pub version_range: GraphVersionRange,
360}
361
362impl ContextGraphPath {
363 pub fn from_fact(fact: &ContextGraphFact) -> Self {
365 let mut nodes = vec![fact.subject.clone()];
366 if let Some(object) = &fact.object
367 && !nodes.contains(object)
368 {
369 nodes.push(object.clone());
370 }
371
372 Self {
373 path_id: format!("path:{}", fact.fact_id),
374 nodes,
375 edges: vec![ContextGraphPathEdge {
376 fact_id: fact.fact_id.clone(),
377 kind: fact.kind,
378 from: fact.subject.clone(),
379 predicate: fact.predicate.clone(),
380 to: fact.object.clone(),
381 evidence_ids: fact.evidence_ids.clone(),
382 confidence: fact.confidence,
383 status: fact.status,
384 version_range: fact.version_range,
385 }],
386 }
387 }
388}
389
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(rename_all = "snake_case")]
393pub enum CodeGraphArtifactKind {
394 Symbol,
395 Chunk,
396}
397
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400pub struct CodeGraphArtifact {
401 pub kind: CodeGraphArtifactKind,
402 pub artifact_id: String,
403 pub path: String,
404}
405
406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
408pub struct ContextPackItem {
409 pub result_id: String,
410 pub source_scope: String,
411 #[serde(skip_serializing_if = "Option::is_none")]
412 pub source_path: Option<String>,
413 #[serde(skip_serializing_if = "Option::is_none")]
414 pub source_span: Option<EvidenceSpan>,
415 #[serde(default, skip_serializing_if = "Vec::is_empty")]
416 pub entities: Vec<ContextEntity>,
417 #[serde(default, skip_serializing_if = "Vec::is_empty")]
418 pub graph_facts: Vec<ContextGraphFact>,
419 #[serde(default, skip_serializing_if = "Vec::is_empty")]
420 pub graph_paths: Vec<ContextGraphPath>,
421 #[serde(skip_serializing_if = "Option::is_none")]
422 pub code_artifact: Option<CodeGraphArtifact>,
423 pub retriever_sources: Vec<RetrieverSource>,
424 pub ranking: Vec<RankingSignal>,
425 #[serde(skip_serializing_if = "Option::is_none")]
426 pub rerank: Option<RerankSignal>,
427}
428
429#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
431pub struct RetrievalHit {
432 pub evidence_id: String,
433 pub source_scope: String,
434 #[serde(skip_serializing_if = "Option::is_none")]
435 pub source_path: Option<String>,
436 #[serde(skip_serializing_if = "Option::is_none")]
437 pub source_span: Option<EvidenceSpan>,
438 pub content: String,
439 pub entity_labels: Vec<String>,
440 #[serde(default, skip_serializing_if = "Vec::is_empty")]
441 pub entities: Vec<ContextEntity>,
442 #[serde(default, skip_serializing_if = "Vec::is_empty")]
443 pub graph_facts: Vec<ContextGraphFact>,
444 #[serde(skip_serializing_if = "Option::is_none")]
445 pub code_artifact: Option<CodeGraphArtifact>,
446 pub retriever_sources: Vec<RetrieverSource>,
447 pub ranking: Vec<RankingSignal>,
448 #[serde(skip_serializing_if = "Option::is_none")]
449 pub rerank: Option<RerankSignal>,
450 pub score: f64,
451}
452
453impl TraversalProvenanceTrace {
454 pub fn from_hits(
456 graph_version: GraphVersion,
457 source_scope: Option<String>,
458 routed_intent: String,
459 hits: &[RetrievalHit],
460 ) -> Self {
461 let mut trace = Self {
462 graph_version,
463 source_scope: source_scope.clone(),
464 routed_intent,
465 visited_nodes: Vec::new(),
466 visited_edges: Vec::new(),
467 cited_evidence: Vec::new(),
468 visited_but_uncited: Vec::new(),
469 ranking_contributions: Vec::new(),
470 truncated: false,
471 stale: false,
472 degraded_reason: None,
473 redaction: TraversalTraceRedaction {
474 authorization_scope: source_scope,
475 redacted_count: 0,
476 reason: None,
477 },
478 };
479
480 for hit in hits {
481 if !trace.trace_scope_allows(&hit.source_scope) {
482 trace.redaction.redacted_count += 1;
483 trace.redaction.reason = Some("source_scope authorization filter".to_owned());
484 continue;
485 }
486 trace.push_evidence(hit);
487 trace.push_hit_nodes(hit);
488 trace.push_hit_edges(hit);
489 trace.push_code_artifact_edge(hit);
490 trace.push_ranking_contributions(hit);
491 }
492
493 trace
494 }
495
496 pub fn mark_citations<I>(&mut self, cited_result_ids: I)
498 where
499 I: IntoIterator,
500 I::Item: AsRef<str>,
501 {
502 let cited_ids = cited_result_ids
503 .into_iter()
504 .map(|id| id.as_ref().to_owned())
505 .collect::<std::collections::BTreeSet<_>>();
506
507 let visited_evidence = self.all_visited_evidence();
508 self.cited_evidence.clear();
509 self.visited_but_uncited.clear();
510 for contribution in &mut self.ranking_contributions {
511 contribution.cited = cited_ids.contains(contribution.result_id.as_str());
512 }
513 for evidence in visited_evidence {
514 if cited_ids.contains(evidence.evidence_id.as_str()) {
515 self.cited_evidence.push(evidence);
516 } else {
517 self.visited_but_uncited.push(evidence);
518 }
519 }
520 }
521
522 pub(crate) fn mark_citations_for_hits<'a, I>(&mut self, cited_hits: I)
523 where
524 I: IntoIterator<Item = &'a RetrievalHit>,
525 {
526 let cited_keys = cited_hits
527 .into_iter()
528 .map(TraceEvidenceKey::from_hit)
529 .collect::<std::collections::BTreeSet<_>>();
530 let visited_evidence = self.all_visited_evidence();
531 self.cited_evidence.clear();
532 self.visited_but_uncited.clear();
533 for contribution in &mut self.ranking_contributions {
534 contribution.cited = trace_contribution_matches_keys(contribution, &cited_keys);
535 }
536 for evidence in visited_evidence {
537 if cited_keys.contains(&TraceEvidenceKey::from_evidence(&evidence)) {
538 self.cited_evidence.push(evidence);
539 } else {
540 self.visited_but_uncited.push(evidence);
541 }
542 }
543 }
544
545 pub(crate) fn retain_hits<'a, I>(&mut self, retained_hits: I)
546 where
547 I: IntoIterator<Item = &'a RetrievalHit>,
548 {
549 let retained_keys = retained_hits
550 .into_iter()
551 .map(TraceEvidenceKey::from_hit)
552 .collect::<std::collections::BTreeSet<_>>();
553 self.cited_evidence
554 .retain(|evidence| retained_keys.contains(&TraceEvidenceKey::from_evidence(evidence)));
555 self.visited_but_uncited
556 .retain(|evidence| retained_keys.contains(&TraceEvidenceKey::from_evidence(evidence)));
557 self.ranking_contributions
558 .retain(|contribution| trace_contribution_matches_keys(contribution, &retained_keys));
559 self.visited_edges
560 .retain(|edge| trace_edge_matches_keys(edge, &retained_keys));
561 let retained_edge_node_keys = self
562 .visited_edges
563 .iter()
564 .flat_map(trace_edge_endpoint_keys)
565 .collect::<std::collections::BTreeSet<_>>();
566 self.visited_nodes.retain(|node| {
567 retained_edge_node_keys.contains(&TraceNodeKey::from_node(node))
568 || trace_node_matches_keys(node, &retained_keys)
569 });
570 }
571
572 pub fn apply_budget(&mut self, max_trace_items: usize) {
574 let max_trace_items = max_trace_items.max(1);
575 let cited_keys = self
576 .cited_evidence
577 .iter()
578 .map(TraceEvidenceKey::from_evidence)
579 .collect::<std::collections::BTreeSet<_>>();
580 let cited_edge_node_keys = self
581 .visited_edges
582 .iter()
583 .filter(|edge| trace_edge_matches_keys(edge, &cited_keys))
584 .flat_map(trace_edge_endpoint_keys)
585 .collect::<std::collections::BTreeSet<_>>();
586 self.visited_nodes.sort_by(|left, right| {
587 let left_cited = cited_edge_node_keys.contains(&TraceNodeKey::from_node(left))
588 || trace_node_matches_keys(left, &cited_keys);
589 let right_cited = cited_edge_node_keys.contains(&TraceNodeKey::from_node(right))
590 || trace_node_matches_keys(right, &cited_keys);
591 right_cited
592 .cmp(&left_cited)
593 .then_with(|| left.kind.cmp(&right.kind))
594 .then_with(|| left.source_scope.cmp(&right.source_scope))
595 .then_with(|| left.source_path.cmp(&right.source_path))
596 .then_with(|| left.node_id.cmp(&right.node_id))
597 });
598 self.visited_nodes.dedup_by(|left, right| {
599 left.node_id == right.node_id
600 && left.kind == right.kind
601 && left.source_scope == right.source_scope
602 && left.source_path == right.source_path
603 && left.evidence_ids == right.evidence_ids
604 });
605 self.visited_edges.sort_by(|left, right| {
606 let left_cited = trace_edge_matches_keys(left, &cited_keys);
607 let right_cited = trace_edge_matches_keys(right, &cited_keys);
608 right_cited
609 .cmp(&left_cited)
610 .then_with(|| left.edge_id.cmp(&right.edge_id))
611 });
612 self.visited_edges
613 .dedup_by(|left, right| left.edge_id == right.edge_id);
614 self.visited_but_uncited.sort_by(|left, right| {
615 right
616 .score
617 .total_cmp(&left.score)
618 .then_with(|| left.evidence_id.cmp(&right.evidence_id))
619 });
620 self.cited_evidence.sort_by(|left, right| {
621 right
622 .score
623 .total_cmp(&left.score)
624 .then_with(|| left.evidence_id.cmp(&right.evidence_id))
625 });
626 self.ranking_contributions.sort_by(|left, right| {
627 right
628 .cited
629 .cmp(&left.cited)
630 .then_with(|| right.rrf_contribution.total_cmp(&left.rrf_contribution))
631 .then_with(|| left.result_id.cmp(&right.result_id))
632 });
633
634 self.truncated |= truncate_vec(&mut self.visited_nodes, max_trace_items);
635 self.truncated |= truncate_vec(&mut self.visited_edges, max_trace_items);
636 self.truncated |= truncate_vec(&mut self.cited_evidence, max_trace_items);
637 self.truncated |= truncate_vec(&mut self.visited_but_uncited, max_trace_items);
638 self.truncated |= truncate_vec(&mut self.ranking_contributions, max_trace_items);
639 }
640
641 fn trace_scope_allows(&self, hit_scope: &str) -> bool {
642 self.source_scope
643 .as_deref()
644 .is_none_or(|scope| scope == hit_scope)
645 }
646
647 fn push_evidence(&mut self, hit: &RetrievalHit) {
648 let source_path = trace_source_path(hit);
649 if self.visited_but_uncited.iter().any(|evidence| {
650 evidence.evidence_id == hit.evidence_id
651 && evidence.source_scope == hit.source_scope
652 && evidence.source_path == source_path
653 }) {
654 return;
655 }
656 self.visited_but_uncited.push(TraversalTraceEvidence {
657 evidence_id: hit.evidence_id.clone(),
658 source_scope: hit.source_scope.clone(),
659 source_path,
660 score: hit.score,
661 retriever_sources: hit.retriever_sources.clone(),
662 });
663 }
664
665 fn push_hit_nodes(&mut self, hit: &RetrievalHit) {
666 let source_path = trace_source_path(hit);
667 self.visited_nodes.push(TraversalTraceNode {
668 node_id: format!("evidence:{}", hit.evidence_id),
669 label: hit.evidence_id.clone(),
670 kind: TraversalTraceNodeKind::Evidence,
671 source_scope: Some(hit.source_scope.clone()),
672 source_path: source_path.clone(),
673 evidence_ids: vec![hit.evidence_id.clone()],
674 });
675 if let Some(artifact) = &hit.code_artifact {
676 self.visited_nodes.push(TraversalTraceNode {
677 node_id: code_artifact_node_id(hit, artifact),
678 label: artifact.artifact_id.clone(),
679 kind: TraversalTraceNodeKind::CodeArtifact,
680 source_scope: Some(hit.source_scope.clone()),
681 source_path: trace_artifact_path(artifact),
682 evidence_ids: vec![hit.evidence_id.clone()],
683 });
684 }
685 for entity in &hit.entities {
686 self.visited_nodes.push(TraversalTraceNode {
687 node_id: entity.id.clone(),
688 label: entity.label.clone(),
689 kind: TraversalTraceNodeKind::Entity,
690 source_scope: Some(hit.source_scope.clone()),
691 source_path: source_path.clone(),
692 evidence_ids: vec![hit.evidence_id.clone()],
693 });
694 }
695 for fact in &hit.graph_facts {
696 let evidence_ids = trace_edge_evidence_ids(hit, &fact.evidence_ids);
697 self.visited_nodes.push(TraversalTraceNode {
698 node_id: format!("entity-label:{}", fact.subject),
699 label: fact.subject.clone(),
700 kind: TraversalTraceNodeKind::Entity,
701 source_scope: Some(hit.source_scope.clone()),
702 source_path: source_path.clone(),
703 evidence_ids: evidence_ids.clone(),
704 });
705 if let Some(object) = &fact.object {
706 self.visited_nodes.push(TraversalTraceNode {
707 node_id: format!("entity-label:{object}"),
708 label: object.clone(),
709 kind: TraversalTraceNodeKind::Entity,
710 source_scope: Some(hit.source_scope.clone()),
711 source_path: source_path.clone(),
712 evidence_ids: evidence_ids.clone(),
713 });
714 }
715 }
716 }
717
718 fn push_hit_edges(&mut self, hit: &RetrievalHit) {
719 for fact in &hit.graph_facts {
720 self.visited_edges.push(TraversalTraceEdge {
721 edge_id: format!("{}:{}", fact.kind.as_str(), fact.fact_id),
722 from_node_id: format!("entity-label:{}", fact.subject),
723 to_node_id: fact
724 .object
725 .as_ref()
726 .map(|object| format!("entity-label:{object}")),
727 predicate: Some(fact.predicate.clone()),
728 source: trace_edge_source(hit),
729 evidence_ids: trace_edge_evidence_ids(hit, &fact.evidence_ids),
730 source_scope: Some(hit.source_scope.clone()),
731 source_path: trace_source_path(hit),
732 });
733 }
734 }
735
736 fn push_code_artifact_edge(&mut self, hit: &RetrievalHit) {
737 if let Some(artifact) = &hit.code_artifact {
738 self.visited_edges.push(TraversalTraceEdge {
739 edge_id: format!(
740 "code-artifact:{}:{}:{}:{}:{}",
741 hit.source_scope,
742 artifact.path,
743 hit.evidence_id,
744 artifact.kind.as_str(),
745 artifact.artifact_id
746 ),
747 from_node_id: format!("evidence:{}", hit.evidence_id),
748 to_node_id: Some(code_artifact_node_id(hit, artifact)),
749 predicate: Some("code_artifact".to_owned()),
750 source: trace_edge_source(hit),
751 evidence_ids: vec![hit.evidence_id.clone()],
752 source_scope: Some(hit.source_scope.clone()),
753 source_path: trace_artifact_path(artifact),
754 });
755 }
756 }
757
758 fn push_ranking_contributions(&mut self, hit: &RetrievalHit) {
759 let source_path = trace_source_path(hit);
760 for signal in &hit.ranking {
761 self.ranking_contributions
762 .push(TraversalRankingContribution {
763 result_id: hit.evidence_id.clone(),
764 source: signal.source,
765 rank: signal.rank,
766 score: signal.score,
767 rrf_contribution: 1.0 / (RECIPROCAL_RANK_FUSION_K + signal.rank as f64),
768 cited: false,
769 explanation: signal.explanation.clone(),
770 source_scope: Some(hit.source_scope.clone()),
771 source_path: source_path.clone(),
772 });
773 }
774 }
775
776 fn all_visited_evidence(&self) -> Vec<TraversalTraceEvidence> {
777 let mut evidence = self
778 .cited_evidence
779 .iter()
780 .chain(self.visited_but_uncited.iter())
781 .cloned()
782 .collect::<Vec<_>>();
783 evidence.sort_by(|left, right| {
784 left.evidence_id
785 .cmp(&right.evidence_id)
786 .then_with(|| left.source_scope.cmp(&right.source_scope))
787 .then_with(|| left.source_path.cmp(&right.source_path))
788 });
789 evidence.dedup_by(|left, right| {
790 left.evidence_id == right.evidence_id
791 && left.source_scope == right.source_scope
792 && left.source_path == right.source_path
793 });
794 evidence
795 }
796}
797
798impl CodeGraphArtifactKind {
799 pub const fn as_str(self) -> &'static str {
800 match self {
801 Self::Symbol => "symbol",
802 Self::Chunk => "chunk",
803 }
804 }
805}
806
807fn truncate_vec<T>(items: &mut Vec<T>, max: usize) -> bool {
808 if items.len() <= max {
809 return false;
810 }
811 items.truncate(max);
812 true
813}
814
815fn trace_edge_evidence_ids(hit: &RetrievalHit, fact_evidence_ids: &[String]) -> Vec<String> {
816 let mut evidence_ids = fact_evidence_ids.to_vec();
817 if !evidence_ids.contains(&hit.evidence_id) {
818 evidence_ids.push(hit.evidence_id.clone());
819 }
820 evidence_ids
821}
822
823fn trace_edge_source(hit: &RetrievalHit) -> RetrieverSource {
824 if hit.retriever_sources.contains(&RetrieverSource::GraphPath) {
825 return RetrieverSource::GraphPath;
826 }
827 hit.retriever_sources
828 .first()
829 .copied()
830 .or_else(|| hit.ranking.first().map(|signal| signal.source))
831 .unwrap_or(RetrieverSource::GraphEvidence)
832}
833
834#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
835struct TraceEvidenceKey {
836 evidence_id: String,
837 source_scope: String,
838 source_path: Option<String>,
839}
840
841impl TraceEvidenceKey {
842 fn from_hit(hit: &RetrievalHit) -> Self {
843 Self {
844 evidence_id: hit.evidence_id.clone(),
845 source_scope: hit.source_scope.clone(),
846 source_path: trace_source_path(hit),
847 }
848 }
849
850 fn from_evidence(evidence: &TraversalTraceEvidence) -> Self {
851 Self {
852 evidence_id: evidence.evidence_id.clone(),
853 source_scope: evidence.source_scope.clone(),
854 source_path: evidence.source_path.clone(),
855 }
856 }
857}
858
859#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
860struct TraceNodeKey {
861 node_id: String,
862 source_scope: Option<String>,
863 source_path: Option<String>,
864}
865
866impl TraceNodeKey {
867 fn from_node(node: &TraversalTraceNode) -> Self {
868 Self {
869 node_id: node.node_id.clone(),
870 source_scope: node.source_scope.clone(),
871 source_path: node.source_path.clone(),
872 }
873 }
874
875 fn from_edge_node(edge: &TraversalTraceEdge, node_id: &str) -> Self {
876 Self {
877 node_id: node_id.to_owned(),
878 source_scope: edge.source_scope.clone(),
879 source_path: edge.source_path.clone(),
880 }
881 }
882}
883
884fn trace_edge_endpoint_keys(edge: &TraversalTraceEdge) -> impl Iterator<Item = TraceNodeKey> + '_ {
885 std::iter::once(TraceNodeKey::from_edge_node(edge, &edge.from_node_id)).chain(
886 edge.to_node_id
887 .iter()
888 .map(|node_id| TraceNodeKey::from_edge_node(edge, node_id)),
889 )
890}
891
892fn trace_source_path(hit: &RetrievalHit) -> Option<String> {
893 hit.source_path
894 .clone()
895 .or_else(|| hit.code_artifact.as_ref().and_then(trace_artifact_path))
896}
897
898fn trace_artifact_path(artifact: &CodeGraphArtifact) -> Option<String> {
899 (!artifact.path.is_empty()).then(|| artifact.path.clone())
900}
901
902fn trace_node_matches_keys(
903 node: &TraversalTraceNode,
904 keys: &std::collections::BTreeSet<TraceEvidenceKey>,
905) -> bool {
906 keys.iter().any(|key| {
907 node.source_scope.as_deref() == Some(key.source_scope.as_str())
908 && node.source_path == key.source_path
909 && (node
910 .evidence_ids
911 .iter()
912 .any(|evidence_id| evidence_id == &key.evidence_id)
913 || node
914 .node_id
915 .strip_prefix("evidence:")
916 .is_some_and(|id| id == key.evidence_id))
917 })
918}
919
920fn trace_edge_matches_keys(
921 edge: &TraversalTraceEdge,
922 keys: &std::collections::BTreeSet<TraceEvidenceKey>,
923) -> bool {
924 keys.iter().any(|key| {
925 edge.source_scope.as_deref() == Some(key.source_scope.as_str())
926 && edge.source_path == key.source_path
927 && edge
928 .evidence_ids
929 .iter()
930 .any(|evidence_id| evidence_id == &key.evidence_id)
931 })
932}
933
934fn trace_contribution_matches_keys(
935 contribution: &TraversalRankingContribution,
936 keys: &std::collections::BTreeSet<TraceEvidenceKey>,
937) -> bool {
938 keys.iter().any(|key| {
939 contribution.result_id == key.evidence_id
940 && contribution.source_scope.as_deref() == Some(key.source_scope.as_str())
941 && contribution.source_path == key.source_path
942 })
943}
944
945fn code_artifact_node_id(hit: &RetrievalHit, artifact: &CodeGraphArtifact) -> String {
946 format!(
947 "code:{}:{}:{}:{}",
948 hit.source_scope,
949 artifact.path,
950 artifact.kind.as_str(),
951 artifact.artifact_id
952 )
953}