Skip to main content

relay_knowledge/domain/graph/retrieval/
provenance.rs

1use serde::{Deserialize, Serialize};
2
3use super::super::GraphVersion;
4use super::{CodeGraphArtifact, RECIPROCAL_RANK_FUSION_K, RetrievalHit, RetrieverSource};
5
6/// Bounded explanation of the graph traversal and candidate path used for an answer.
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8pub struct TraversalProvenanceTrace {
9    pub graph_version: GraphVersion,
10    #[serde(skip_serializing_if = "Option::is_none")]
11    pub source_scope: Option<String>,
12    pub routed_intent: String,
13    #[serde(default, skip_serializing_if = "Vec::is_empty")]
14    pub visited_nodes: Vec<TraversalTraceNode>,
15    #[serde(default, skip_serializing_if = "Vec::is_empty")]
16    pub visited_edges: Vec<TraversalTraceEdge>,
17    #[serde(default, skip_serializing_if = "Vec::is_empty")]
18    pub cited_evidence: Vec<TraversalTraceEvidence>,
19    #[serde(default, skip_serializing_if = "Vec::is_empty")]
20    pub visited_but_uncited: Vec<TraversalTraceEvidence>,
21    #[serde(default, skip_serializing_if = "Vec::is_empty")]
22    pub ranking_contributions: Vec<TraversalRankingContribution>,
23    pub truncated: bool,
24    pub stale: bool,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub degraded_reason: Option<String>,
27    pub redaction: TraversalTraceRedaction,
28}
29
30/// Node reached while building a retrieval context pack.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct TraversalTraceNode {
33    pub node_id: String,
34    pub label: String,
35    pub kind: TraversalTraceNodeKind,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub source_scope: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub source_path: Option<String>,
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub evidence_ids: Vec<String>,
42}
43
44/// Stable node categories exposed in traversal provenance traces.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum TraversalTraceNodeKind {
48    Entity,
49    Evidence,
50    CodeArtifact,
51}
52
53/// Edge reached while building a retrieval context pack.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct TraversalTraceEdge {
56    pub edge_id: String,
57    pub from_node_id: String,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub to_node_id: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub predicate: Option<String>,
62    pub source: RetrieverSource,
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub evidence_ids: Vec<String>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub source_scope: Option<String>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub source_path: Option<String>,
69}
70
71/// Evidence candidate reached during retrieval.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct TraversalTraceEvidence {
74    pub evidence_id: String,
75    pub source_scope: String,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub source_path: Option<String>,
78    pub score: f64,
79    pub retriever_sources: Vec<RetrieverSource>,
80}
81
82/// Per-source ranking contribution retained before final context truncation.
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct TraversalRankingContribution {
85    pub result_id: String,
86    pub source: RetrieverSource,
87    pub rank: usize,
88    pub score: f64,
89    pub rrf_contribution: f64,
90    pub cited: bool,
91    pub explanation: String,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub source_scope: Option<String>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub source_path: Option<String>,
96}
97
98/// Authorization and budget redaction summary for a traversal trace.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub struct TraversalTraceRedaction {
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub authorization_scope: Option<String>,
103    pub redacted_count: usize,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub reason: Option<String>,
106}
107
108impl TraversalProvenanceTrace {
109    /// Builds a traversal trace from storage candidates before answer-level citation is known.
110    pub fn from_hits(
111        graph_version: GraphVersion,
112        source_scope: Option<String>,
113        routed_intent: String,
114        hits: &[RetrievalHit],
115    ) -> Self {
116        let mut trace = Self {
117            graph_version,
118            source_scope: source_scope.clone(),
119            routed_intent,
120            visited_nodes: Vec::new(),
121            visited_edges: Vec::new(),
122            cited_evidence: Vec::new(),
123            visited_but_uncited: Vec::new(),
124            ranking_contributions: Vec::new(),
125            truncated: false,
126            stale: false,
127            degraded_reason: None,
128            redaction: TraversalTraceRedaction {
129                authorization_scope: source_scope,
130                redacted_count: 0,
131                reason: None,
132            },
133        };
134
135        for hit in hits {
136            if !trace.trace_scope_allows(&hit.source_scope) {
137                trace.redaction.redacted_count += 1;
138                trace.redaction.reason = Some("source_scope authorization filter".to_owned());
139                continue;
140            }
141            trace.push_evidence(hit);
142            trace.push_hit_nodes(hit);
143            trace.push_hit_edges(hit);
144            trace.push_code_artifact_edge(hit);
145            trace.push_ranking_contributions(hit);
146        }
147
148        trace
149    }
150
151    /// Marks which visited evidence items are cited by the final context pack.
152    pub fn mark_citations<I>(&mut self, cited_result_ids: I)
153    where
154        I: IntoIterator,
155        I::Item: AsRef<str>,
156    {
157        let cited_ids = cited_result_ids
158            .into_iter()
159            .map(|id| id.as_ref().to_owned())
160            .collect::<std::collections::BTreeSet<_>>();
161
162        let visited_evidence = self.all_visited_evidence();
163        self.cited_evidence.clear();
164        self.visited_but_uncited.clear();
165        for contribution in &mut self.ranking_contributions {
166            contribution.cited = cited_ids.contains(contribution.result_id.as_str());
167        }
168        for evidence in visited_evidence {
169            if cited_ids.contains(evidence.evidence_id.as_str()) {
170                self.cited_evidence.push(evidence);
171            } else {
172                self.visited_but_uncited.push(evidence);
173            }
174        }
175    }
176
177    pub(crate) fn mark_citations_for_hits<'a, I>(&mut self, cited_hits: I)
178    where
179        I: IntoIterator<Item = &'a RetrievalHit>,
180    {
181        let cited_keys = cited_hits
182            .into_iter()
183            .map(TraceEvidenceKey::from_hit)
184            .collect::<std::collections::BTreeSet<_>>();
185        let visited_evidence = self.all_visited_evidence();
186        self.cited_evidence.clear();
187        self.visited_but_uncited.clear();
188        for contribution in &mut self.ranking_contributions {
189            contribution.cited = trace_contribution_matches_keys(contribution, &cited_keys);
190        }
191        for evidence in visited_evidence {
192            if cited_keys.contains(&TraceEvidenceKey::from_evidence(&evidence)) {
193                self.cited_evidence.push(evidence);
194            } else {
195                self.visited_but_uncited.push(evidence);
196            }
197        }
198    }
199
200    pub(crate) fn retain_hits<'a, I>(&mut self, retained_hits: I)
201    where
202        I: IntoIterator<Item = &'a RetrievalHit>,
203    {
204        let retained_keys = retained_hits
205            .into_iter()
206            .map(TraceEvidenceKey::from_hit)
207            .collect::<std::collections::BTreeSet<_>>();
208        self.cited_evidence
209            .retain(|evidence| retained_keys.contains(&TraceEvidenceKey::from_evidence(evidence)));
210        self.visited_but_uncited
211            .retain(|evidence| retained_keys.contains(&TraceEvidenceKey::from_evidence(evidence)));
212        self.ranking_contributions
213            .retain(|contribution| trace_contribution_matches_keys(contribution, &retained_keys));
214        self.visited_edges
215            .retain(|edge| trace_edge_matches_keys(edge, &retained_keys));
216        let retained_edge_node_keys = self
217            .visited_edges
218            .iter()
219            .flat_map(trace_edge_endpoint_keys)
220            .collect::<std::collections::BTreeSet<_>>();
221        self.visited_nodes.retain(|node| {
222            retained_edge_node_keys.contains(&TraceNodeKey::from_node(node))
223                || trace_node_matches_keys(node, &retained_keys)
224        });
225    }
226
227    /// Truncates low-priority trace detail without dropping cited evidence first.
228    pub fn apply_budget(&mut self, max_trace_items: usize) {
229        let max_trace_items = max_trace_items.max(1);
230        let cited_keys = self
231            .cited_evidence
232            .iter()
233            .map(TraceEvidenceKey::from_evidence)
234            .collect::<std::collections::BTreeSet<_>>();
235        let cited_edge_node_keys = self
236            .visited_edges
237            .iter()
238            .filter(|edge| trace_edge_matches_keys(edge, &cited_keys))
239            .flat_map(trace_edge_endpoint_keys)
240            .collect::<std::collections::BTreeSet<_>>();
241        self.visited_nodes.sort_by(|left, right| {
242            let left_cited = cited_edge_node_keys.contains(&TraceNodeKey::from_node(left))
243                || trace_node_matches_keys(left, &cited_keys);
244            let right_cited = cited_edge_node_keys.contains(&TraceNodeKey::from_node(right))
245                || trace_node_matches_keys(right, &cited_keys);
246            right_cited
247                .cmp(&left_cited)
248                .then_with(|| left.kind.cmp(&right.kind))
249                .then_with(|| left.source_scope.cmp(&right.source_scope))
250                .then_with(|| left.source_path.cmp(&right.source_path))
251                .then_with(|| left.node_id.cmp(&right.node_id))
252        });
253        self.visited_nodes.dedup_by(|left, right| {
254            left.node_id == right.node_id
255                && left.kind == right.kind
256                && left.source_scope == right.source_scope
257                && left.source_path == right.source_path
258                && left.evidence_ids == right.evidence_ids
259        });
260        self.visited_edges.sort_by(|left, right| {
261            let left_cited = trace_edge_matches_keys(left, &cited_keys);
262            let right_cited = trace_edge_matches_keys(right, &cited_keys);
263            right_cited
264                .cmp(&left_cited)
265                .then_with(|| left.edge_id.cmp(&right.edge_id))
266        });
267        self.visited_edges
268            .dedup_by(|left, right| left.edge_id == right.edge_id);
269        self.visited_but_uncited.sort_by(|left, right| {
270            right
271                .score
272                .total_cmp(&left.score)
273                .then_with(|| left.evidence_id.cmp(&right.evidence_id))
274        });
275        self.cited_evidence.sort_by(|left, right| {
276            right
277                .score
278                .total_cmp(&left.score)
279                .then_with(|| left.evidence_id.cmp(&right.evidence_id))
280        });
281        self.ranking_contributions.sort_by(|left, right| {
282            right
283                .cited
284                .cmp(&left.cited)
285                .then_with(|| right.rrf_contribution.total_cmp(&left.rrf_contribution))
286                .then_with(|| left.result_id.cmp(&right.result_id))
287        });
288
289        self.truncated |= truncate_vec(&mut self.visited_nodes, max_trace_items);
290        self.truncated |= truncate_vec(&mut self.visited_edges, max_trace_items);
291        self.truncated |= truncate_vec(&mut self.cited_evidence, max_trace_items);
292        self.truncated |= truncate_vec(&mut self.visited_but_uncited, max_trace_items);
293        self.truncated |= truncate_vec(&mut self.ranking_contributions, max_trace_items);
294    }
295
296    fn trace_scope_allows(&self, hit_scope: &str) -> bool {
297        self.source_scope
298            .as_deref()
299            .is_none_or(|scope| scope == hit_scope)
300    }
301
302    fn push_evidence(&mut self, hit: &RetrievalHit) {
303        let source_path = trace_source_path(hit);
304        if self.visited_but_uncited.iter().any(|evidence| {
305            evidence.evidence_id == hit.evidence_id
306                && evidence.source_scope == hit.source_scope
307                && evidence.source_path == source_path
308        }) {
309            return;
310        }
311        self.visited_but_uncited.push(TraversalTraceEvidence {
312            evidence_id: hit.evidence_id.clone(),
313            source_scope: hit.source_scope.clone(),
314            source_path,
315            score: hit.score,
316            retriever_sources: hit.retriever_sources.clone(),
317        });
318    }
319
320    fn push_hit_nodes(&mut self, hit: &RetrievalHit) {
321        let source_path = trace_source_path(hit);
322        self.visited_nodes.push(TraversalTraceNode {
323            node_id: format!("evidence:{}", hit.evidence_id),
324            label: hit.evidence_id.clone(),
325            kind: TraversalTraceNodeKind::Evidence,
326            source_scope: Some(hit.source_scope.clone()),
327            source_path: source_path.clone(),
328            evidence_ids: vec![hit.evidence_id.clone()],
329        });
330        if let Some(artifact) = &hit.code_artifact {
331            self.visited_nodes.push(TraversalTraceNode {
332                node_id: code_artifact_node_id(hit, artifact),
333                label: artifact.artifact_id.clone(),
334                kind: TraversalTraceNodeKind::CodeArtifact,
335                source_scope: Some(hit.source_scope.clone()),
336                source_path: trace_artifact_path(artifact),
337                evidence_ids: vec![hit.evidence_id.clone()],
338            });
339        }
340        for entity in &hit.entities {
341            self.visited_nodes.push(TraversalTraceNode {
342                node_id: entity.id.clone(),
343                label: entity.label.clone(),
344                kind: TraversalTraceNodeKind::Entity,
345                source_scope: Some(hit.source_scope.clone()),
346                source_path: source_path.clone(),
347                evidence_ids: vec![hit.evidence_id.clone()],
348            });
349        }
350        for fact in &hit.graph_facts {
351            let evidence_ids = trace_edge_evidence_ids(hit, &fact.evidence_ids);
352            self.visited_nodes.push(TraversalTraceNode {
353                node_id: format!("entity-label:{}", fact.subject),
354                label: fact.subject.clone(),
355                kind: TraversalTraceNodeKind::Entity,
356                source_scope: Some(hit.source_scope.clone()),
357                source_path: source_path.clone(),
358                evidence_ids: evidence_ids.clone(),
359            });
360            if let Some(object) = &fact.object {
361                self.visited_nodes.push(TraversalTraceNode {
362                    node_id: format!("entity-label:{object}"),
363                    label: object.clone(),
364                    kind: TraversalTraceNodeKind::Entity,
365                    source_scope: Some(hit.source_scope.clone()),
366                    source_path: source_path.clone(),
367                    evidence_ids: evidence_ids.clone(),
368                });
369            }
370        }
371    }
372
373    fn push_hit_edges(&mut self, hit: &RetrievalHit) {
374        for fact in &hit.graph_facts {
375            self.visited_edges.push(TraversalTraceEdge {
376                edge_id: format!("{}:{}", fact.kind.as_str(), fact.fact_id),
377                from_node_id: format!("entity-label:{}", fact.subject),
378                to_node_id: fact
379                    .object
380                    .as_ref()
381                    .map(|object| format!("entity-label:{object}")),
382                predicate: Some(fact.predicate.clone()),
383                source: trace_edge_source(hit),
384                evidence_ids: trace_edge_evidence_ids(hit, &fact.evidence_ids),
385                source_scope: Some(hit.source_scope.clone()),
386                source_path: trace_source_path(hit),
387            });
388        }
389    }
390
391    fn push_code_artifact_edge(&mut self, hit: &RetrievalHit) {
392        if let Some(artifact) = &hit.code_artifact {
393            self.visited_edges.push(TraversalTraceEdge {
394                edge_id: format!(
395                    "code-artifact:{}:{}:{}:{}:{}",
396                    hit.source_scope,
397                    artifact.path,
398                    hit.evidence_id,
399                    artifact.kind.as_str(),
400                    artifact.artifact_id
401                ),
402                from_node_id: format!("evidence:{}", hit.evidence_id),
403                to_node_id: Some(code_artifact_node_id(hit, artifact)),
404                predicate: Some("code_artifact".to_owned()),
405                source: trace_edge_source(hit),
406                evidence_ids: vec![hit.evidence_id.clone()],
407                source_scope: Some(hit.source_scope.clone()),
408                source_path: trace_artifact_path(artifact),
409            });
410        }
411    }
412
413    fn push_ranking_contributions(&mut self, hit: &RetrievalHit) {
414        let source_path = trace_source_path(hit);
415        for signal in &hit.ranking {
416            self.ranking_contributions
417                .push(TraversalRankingContribution {
418                    result_id: hit.evidence_id.clone(),
419                    source: signal.source,
420                    rank: signal.rank,
421                    score: signal.score,
422                    rrf_contribution: 1.0 / (RECIPROCAL_RANK_FUSION_K + signal.rank as f64),
423                    cited: false,
424                    explanation: signal.explanation.clone(),
425                    source_scope: Some(hit.source_scope.clone()),
426                    source_path: source_path.clone(),
427                });
428        }
429    }
430
431    fn all_visited_evidence(&self) -> Vec<TraversalTraceEvidence> {
432        let mut evidence = self
433            .cited_evidence
434            .iter()
435            .chain(self.visited_but_uncited.iter())
436            .cloned()
437            .collect::<Vec<_>>();
438        evidence.sort_by(|left, right| {
439            left.evidence_id
440                .cmp(&right.evidence_id)
441                .then_with(|| left.source_scope.cmp(&right.source_scope))
442                .then_with(|| left.source_path.cmp(&right.source_path))
443        });
444        evidence.dedup_by(|left, right| {
445            left.evidence_id == right.evidence_id
446                && left.source_scope == right.source_scope
447                && left.source_path == right.source_path
448        });
449        evidence
450    }
451}
452
453fn truncate_vec<T>(items: &mut Vec<T>, max: usize) -> bool {
454    if items.len() <= max {
455        return false;
456    }
457    items.truncate(max);
458    true
459}
460
461fn trace_edge_evidence_ids(hit: &RetrievalHit, fact_evidence_ids: &[String]) -> Vec<String> {
462    let mut evidence_ids = fact_evidence_ids.to_vec();
463    if !evidence_ids.contains(&hit.evidence_id) {
464        evidence_ids.push(hit.evidence_id.clone());
465    }
466    evidence_ids
467}
468
469fn trace_edge_source(hit: &RetrievalHit) -> RetrieverSource {
470    if hit.retriever_sources.contains(&RetrieverSource::GraphPath) {
471        return RetrieverSource::GraphPath;
472    }
473    hit.retriever_sources
474        .first()
475        .copied()
476        .or_else(|| hit.ranking.first().map(|signal| signal.source))
477        .unwrap_or(RetrieverSource::GraphEvidence)
478}
479
480#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
481struct TraceEvidenceKey {
482    evidence_id: String,
483    source_scope: String,
484    source_path: Option<String>,
485}
486
487impl TraceEvidenceKey {
488    fn from_hit(hit: &RetrievalHit) -> Self {
489        Self {
490            evidence_id: hit.evidence_id.clone(),
491            source_scope: hit.source_scope.clone(),
492            source_path: trace_source_path(hit),
493        }
494    }
495
496    fn from_evidence(evidence: &TraversalTraceEvidence) -> Self {
497        Self {
498            evidence_id: evidence.evidence_id.clone(),
499            source_scope: evidence.source_scope.clone(),
500            source_path: evidence.source_path.clone(),
501        }
502    }
503}
504
505#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
506struct TraceNodeKey {
507    node_id: String,
508    source_scope: Option<String>,
509    source_path: Option<String>,
510}
511
512impl TraceNodeKey {
513    fn from_node(node: &TraversalTraceNode) -> Self {
514        Self {
515            node_id: node.node_id.clone(),
516            source_scope: node.source_scope.clone(),
517            source_path: node.source_path.clone(),
518        }
519    }
520
521    fn from_edge_node(edge: &TraversalTraceEdge, node_id: &str) -> Self {
522        Self {
523            node_id: node_id.to_owned(),
524            source_scope: edge.source_scope.clone(),
525            source_path: edge.source_path.clone(),
526        }
527    }
528}
529
530fn trace_edge_endpoint_keys(edge: &TraversalTraceEdge) -> impl Iterator<Item = TraceNodeKey> + '_ {
531    std::iter::once(TraceNodeKey::from_edge_node(edge, &edge.from_node_id)).chain(
532        edge.to_node_id
533            .iter()
534            .map(|node_id| TraceNodeKey::from_edge_node(edge, node_id)),
535    )
536}
537
538fn trace_source_path(hit: &RetrievalHit) -> Option<String> {
539    hit.source_path
540        .clone()
541        .or_else(|| hit.code_artifact.as_ref().and_then(trace_artifact_path))
542}
543
544fn trace_artifact_path(artifact: &CodeGraphArtifact) -> Option<String> {
545    (!artifact.path.is_empty()).then(|| artifact.path.clone())
546}
547
548fn trace_node_matches_keys(
549    node: &TraversalTraceNode,
550    keys: &std::collections::BTreeSet<TraceEvidenceKey>,
551) -> bool {
552    keys.iter().any(|key| {
553        node.source_scope.as_deref() == Some(key.source_scope.as_str())
554            && node.source_path == key.source_path
555            && (node
556                .evidence_ids
557                .iter()
558                .any(|evidence_id| evidence_id == &key.evidence_id)
559                || node
560                    .node_id
561                    .strip_prefix("evidence:")
562                    .is_some_and(|id| id == key.evidence_id))
563    })
564}
565
566fn trace_edge_matches_keys(
567    edge: &TraversalTraceEdge,
568    keys: &std::collections::BTreeSet<TraceEvidenceKey>,
569) -> bool {
570    keys.iter().any(|key| {
571        edge.source_scope.as_deref() == Some(key.source_scope.as_str())
572            && edge.source_path == key.source_path
573            && edge
574                .evidence_ids
575                .iter()
576                .any(|evidence_id| evidence_id == &key.evidence_id)
577    })
578}
579
580fn trace_contribution_matches_keys(
581    contribution: &TraversalRankingContribution,
582    keys: &std::collections::BTreeSet<TraceEvidenceKey>,
583) -> bool {
584    keys.iter().any(|key| {
585        contribution.result_id == key.evidence_id
586            && contribution.source_scope.as_deref() == Some(key.source_scope.as_str())
587            && contribution.source_path == key.source_path
588    })
589}
590
591fn code_artifact_node_id(hit: &RetrievalHit, artifact: &CodeGraphArtifact) -> String {
592    format!(
593        "code:{}:{}:{}:{}",
594        hit.source_scope,
595        artifact.path,
596        artifact.kind.as_str(),
597        artifact.artifact_id
598    )
599}
600
601#[cfg(test)]
602#[path = "provenance_tests.rs"]
603mod tests;