1use serde::{Deserialize, Serialize};
2
3use super::super::GraphVersion;
4use super::{CodeGraphArtifact, RECIPROCAL_RANK_FUSION_K, RetrievalHit, RetrieverSource};
5
6#[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#[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#[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#[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#[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#[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#[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 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 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 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;