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)]
131#[path = "retrieval_tests.rs"]
132mod tests;
133
134/// Per-retriever ranking signal preserved after fusion.
135#[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/// Final rerank signal applied after hybrid retrieval fusion.
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145pub struct RerankSignal {
146    pub mode: RerankMode,
147    pub score: f64,
148    pub explanation: String,
149}
150
151/// Budget actually consumed by retrieval context packing.
152#[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/// Diagnostics for reciprocal-rank fusion.
161#[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/// Diagnostics for post-fusion reranking.
169#[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/// A compact, auditable context pack for agent and UI adapters.
182#[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/// Bounded explanation of the graph traversal and candidate path used for an answer.
197#[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/// Node reached while building a retrieval context pack.
221#[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/// Stable node categories exposed in traversal provenance traces.
235#[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/// Edge reached while building a retrieval context pack.
244#[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/// Evidence candidate reached during retrieval.
262#[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/// Per-source ranking contribution retained before final context truncation.
273#[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/// Authorization and budget redaction summary for a traversal trace.
289#[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/// Entity projection retained with each context item.
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub struct ContextEntity {
301    pub id: String,
302    pub label: String,
303}
304
305/// Structured graph fact kind referenced from a context item.
306#[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/// Structured relation, claim, or event that supports a retrieval hit.
325#[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/// Direct graph path evidence derived from a structured graph fact.
340#[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/// One edge in a graph path returned through the context pack.
348#[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    /// Builds a one-hop path from a persisted structured fact.
364    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/// Code artifact category returned through the general GraphRAG context pack.
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(rename_all = "snake_case")]
393pub enum CodeGraphArtifactKind {
394    Symbol,
395    Chunk,
396}
397
398/// Code graph artifact tied to a shared retrieval result.
399#[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/// Context-pack item tied to a retrieval hit.
407#[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/// A context item returned by retrieval.
430#[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    /// Builds a traversal trace from storage candidates before answer-level citation is known.
455    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    /// Marks which visited evidence items are cited by the final context pack.
497    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    /// Truncates low-priority trace detail without dropping cited evidence first.
573    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}