1use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet, VecDeque};
5use std::time::{Duration, Instant};
6
7use code_system_graph_model::{
8 CommunityId, Edge, EdgeId, EdgeKind, EpistemicStatus, Evidence, Node, NodeId, NodeKind, RepoFreshnessState, RepoId, TraceSegment
9};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14const MAX_SEARCH_RESULTS: usize = 100;
15const MAX_SEARCH_OFFSET: usize = 1_000_000;
16const MAX_TRAVERSAL_DEPTH: usize = 128;
17const MAX_TRAVERSAL_NODES: usize = 100_000;
18const MAX_TRAVERSAL_EDGES: usize = 1_000_000;
19const MAX_TRAVERSAL_TIMEOUT_MS: u64 = 60_000;
20const MAX_K_PATHS: usize = 16;
21const MIN_CONFIDENCE_FOR_WEIGHT: f64 = 0.01;
22
23#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
25pub struct SearchFilters {
26 #[serde(default)]
28 pub node_kinds: Vec<NodeKind>,
29 #[serde(default)]
31 pub repo_ids: Vec<RepoId>,
32 #[serde(default)]
34 pub workspace_nodes: Vec<NodeId>,
35 #[serde(default)]
37 pub service_ids: Vec<NodeId>,
38 #[serde(default)]
40 pub community_ids: Vec<CommunityId>,
41}
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
45pub struct SearchRequest {
46 pub query: String,
48 #[serde(default)]
50 pub filters: SearchFilters,
51 #[serde(default)]
53 pub fts_scores: BTreeMap<NodeId, f64>,
54 #[serde(default)]
56 pub centrality_scores: BTreeMap<NodeId, f64>,
57 #[serde(default)]
59 pub service_memberships: BTreeMap<NodeId, Vec<NodeId>>,
60 #[serde(default)]
62 pub community_memberships: BTreeMap<NodeId, Vec<CommunityId>>,
63 #[serde(default)]
65 pub evidence: BTreeMap<NodeId, Vec<Evidence>>,
66 #[serde(default)]
68 pub freshness: BTreeMap<RepoId, RepoFreshnessState>,
69 pub offset: usize,
71 pub limit: usize,
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
77pub struct SearchExplanation {
78 pub matched_fields: Vec<String>,
80 pub exact_score: f64,
82 pub prefix_score: f64,
84 pub suffix_score: f64,
86 pub fts_score: f64,
88 pub type_score: f64,
90 pub scope_score: f64,
92 pub centrality_score: f64,
94 pub community_score: f64,
96 pub evidence_score: f64,
98 pub freshness_penalty: f64,
100}
101
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
104pub struct SearchHit {
105 pub node: Node,
107 pub score: f64,
109 pub explanation: SearchExplanation,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115pub struct SearchCoverage {
116 pub input_nodes: usize,
118 pub eligible_nodes: usize,
120 pub matched_nodes: usize,
122 pub fts_scored_nodes: usize,
124 pub freshness_unknown_repositories: Vec<RepoId>,
126 pub gaps: Vec<String>,
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
132pub struct SearchReport {
133 pub hits: Vec<SearchHit>,
135 pub total_matches: usize,
137 pub offset: usize,
139 pub limit: usize,
141 pub truncated: bool,
143 pub coverage: SearchCoverage,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
149#[serde(rename_all = "snake_case")]
150pub enum TraversalAlgorithm {
151 Bfs,
153 Dijkstra,
155 KShortest,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
161#[serde(rename_all = "snake_case")]
162pub enum TraversalDirection {
163 Outgoing,
165 Incoming,
167 Both,
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
173pub struct TraversalFilters {
174 #[serde(default)]
176 pub edge_kinds: Vec<EdgeKind>,
177 pub min_confidence: f64,
179 pub include_tests: bool,
181 pub include_generated_artifacts: bool,
183 pub environment_namespace: Option<String>,
185}
186
187impl Default for TraversalFilters {
188 fn default() -> Self {
189 Self {
190 edge_kinds: Vec::new(),
191 min_confidence: 0.0,
192 include_tests: true,
193 include_generated_artifacts: true,
194 environment_namespace: None,
195 }
196 }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
201pub struct EdgeKindCost {
202 pub kind: EdgeKind,
204 pub cost: f64,
206}
207
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
210pub struct TraversalOptions {
211 pub algorithm: TraversalAlgorithm,
213 pub direction: TraversalDirection,
215 pub max_depth: usize,
217 pub max_cross_repo_hops: usize,
219 pub node_limit: usize,
221 pub edge_limit: usize,
223 pub timeout_ms: u64,
225 pub k: usize,
227 #[serde(default)]
229 pub edge_kind_costs: Vec<EdgeKindCost>,
230}
231
232impl Default for TraversalOptions {
233 fn default() -> Self {
234 Self {
235 algorithm: TraversalAlgorithm::Bfs,
236 direction: TraversalDirection::Outgoing,
237 max_depth: 8,
238 max_cross_repo_hops: 4,
239 node_limit: 10_000,
240 edge_limit: 50_000,
241 timeout_ms: 1_000,
242 k: 1,
243 edge_kind_costs: Vec::new(),
244 }
245 }
246}
247
248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
250pub struct TraversalRequest {
251 pub start: NodeId,
253 pub target: NodeId,
255 #[serde(default)]
257 pub filters: TraversalFilters,
258 #[serde(default)]
260 pub options: TraversalOptions,
261}
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
265#[serde(rename_all = "snake_case")]
266pub enum PathSegmentScope {
267 Local,
269 CrossRepository,
271}
272
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
275pub struct PathSegment {
276 pub trace: TraceSegment,
278 pub scope: PathSegmentScope,
280 pub reversed: bool,
282 pub weight: f64,
284}
285
286#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
288pub struct TraversalPath {
289 pub segments: Vec<PathSegment>,
291 pub total_weight: f64,
293 pub cross_repo_hops: usize,
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
299pub struct TraversalLimits {
300 pub max_depth: usize,
302 pub max_cross_repo_hops: usize,
304 pub node_limit: usize,
306 pub edge_limit: usize,
308 pub timeout_ms: u64,
310 pub max_paths: usize,
312}
313
314#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
316pub struct TraversalReport {
317 pub paths: Vec<TraversalPath>,
319 pub frontier: Vec<Node>,
321 pub candidate_unresolved_edges: Vec<Edge>,
323 pub coverage_gaps: Vec<String>,
325 pub truncated: bool,
327 pub visited_nodes: usize,
329 pub examined_edges: usize,
331 pub limits: TraversalLimits,
333}
334
335#[derive(Debug, Error, PartialEq)]
337pub enum QueryError {
338 #[error("search query must contain at least one letter or digit")]
340 EmptyQuery,
341 #[error(
343 "search limit must be in 1..={MAX_SEARCH_RESULTS} and offset must not exceed {MAX_SEARCH_OFFSET}"
344 )]
345 InvalidPagination,
346 #[error("invalid {signal} score for node `{node}`")]
348 InvalidScore {
349 signal: &'static str,
351 node: String,
353 },
354 #[error("duplicate node identifier `{0}`")]
356 DuplicateNode(String),
357 #[error("duplicate edge identifier `{0}`")]
359 DuplicateEdge(String),
360 #[error("edge `{edge}` references missing node `{node}`")]
362 DanglingEdge {
363 edge: String,
365 node: String,
367 },
368 #[error("traversal anchor `{0}` is not present")]
370 UnknownAnchor(String),
371 #[error("traversal limits are outside supported bounds")]
373 InvalidTraversalLimits,
374 #[error("minimum confidence must be finite and in the inclusive range 0..=1")]
376 InvalidMinimumConfidence,
377 #[error("edge `{0}` confidence must be finite and in the inclusive range 0..=1")]
379 InvalidEdgeConfidence(String),
380 #[error("edge-kind cost for `{0:?}` must be finite and positive")]
382 InvalidEdgeCost(EdgeKind),
383 #[error("path references missing node `{0}`")]
385 InconsistentPath(String),
386}
387
388#[must_use = "search results and validation errors must be handled"]
398pub fn search(nodes: &[Node], request: &SearchRequest) -> Result<SearchReport, QueryError> {
399 let normalized_query = normalize(&request.query);
400 validate_search(nodes, request, &normalized_query)?;
401 let distinct = distinct_nodes(nodes)?;
402 let mut unknown_freshness = BTreeSet::new();
403 let mut hits = Vec::new();
404 let mut eligible_nodes = 0;
405
406 for node in distinct.values().copied() {
407 if !matches_search_filters(node, request) {
408 continue;
409 }
410 eligible_nodes += 1;
411 if let Some(hit) = score_node(node, request, &normalized_query, &mut unknown_freshness) {
412 hits.push(hit);
413 }
414 }
415 sort_search_hits(&mut hits);
416 let total_matches = hits.len();
417 let page_end = request
418 .offset
419 .saturating_add(request.limit)
420 .min(total_matches);
421 let page = hits
422 .into_iter()
423 .skip(request.offset.min(total_matches))
424 .take(page_end.saturating_sub(request.offset))
425 .collect();
426 let coverage = search_coverage(
427 distinct.len(),
428 eligible_nodes,
429 total_matches,
430 request,
431 unknown_freshness,
432 );
433 Ok(SearchReport {
434 hits: page,
435 total_matches,
436 offset: request.offset,
437 limit: request.limit,
438 truncated: page_end < total_matches,
439 coverage,
440 })
441}
442
443fn validate_search(
444 nodes: &[Node],
445 request: &SearchRequest,
446 normalized_query: &str,
447) -> Result<(), QueryError> {
448 if normalized_query.is_empty() {
449 return Err(QueryError::EmptyQuery);
450 }
451 if request.limit == 0
452 || request.limit > MAX_SEARCH_RESULTS
453 || request.offset > MAX_SEARCH_OFFSET
454 {
455 return Err(QueryError::InvalidPagination);
456 }
457 let _ = distinct_nodes(nodes)?;
458 validate_score_map(&request.fts_scores, "full-text")?;
459 validate_score_map(&request.centrality_scores, "centrality")?;
460 for (node_id, evidence) in &request.evidence {
461 if evidence
462 .iter()
463 .any(|item| !item.confidence.is_finite() || !(0.0..=1.0).contains(&item.confidence))
464 {
465 return Err(QueryError::InvalidScore {
466 signal: "evidence confidence",
467 node: node_id.as_str().to_owned(),
468 });
469 }
470 }
471 Ok(())
472}
473
474fn validate_score_map(
475 scores: &BTreeMap<NodeId, f64>,
476 signal: &'static str,
477) -> Result<(), QueryError> {
478 for (node_id, score) in scores {
479 if !score.is_finite() || *score < 0.0 {
480 return Err(QueryError::InvalidScore {
481 signal,
482 node: node_id.as_str().to_owned(),
483 });
484 }
485 }
486 Ok(())
487}
488
489fn distinct_nodes(nodes: &[Node]) -> Result<BTreeMap<NodeId, &Node>, QueryError> {
490 let mut distinct = BTreeMap::new();
491 for node in nodes {
492 if distinct.insert(node.id.clone(), node).is_some() {
493 return Err(QueryError::DuplicateNode(node.id.as_str().to_owned()));
494 }
495 }
496 Ok(distinct)
497}
498
499fn matches_search_filters(node: &Node, request: &SearchRequest) -> bool {
500 let filters = &request.filters;
501 if !filters.node_kinds.is_empty() && !filters.node_kinds.contains(&node.kind) {
502 return false;
503 }
504 if !filters.repo_ids.is_empty()
505 && node
506 .repo_id
507 .as_ref()
508 .is_none_or(|repo_id| !filters.repo_ids.contains(repo_id))
509 {
510 return false;
511 }
512 if !filters.workspace_nodes.is_empty() && !filters.workspace_nodes.contains(&node.id) {
513 return false;
514 }
515 if !membership_matches(
516 request.service_memberships.get(&node.id),
517 &filters.service_ids,
518 ) {
519 return false;
520 }
521 membership_matches(
522 request.community_memberships.get(&node.id),
523 &filters.community_ids,
524 )
525}
526
527fn membership_matches<T: PartialEq>(memberships: Option<&Vec<T>>, required: &[T]) -> bool {
528 required.is_empty()
529 || memberships.is_some_and(|values| {
530 required
531 .iter()
532 .any(|required_value| values.contains(required_value))
533 })
534}
535
536fn score_node(
537 node: &Node,
538 request: &SearchRequest,
539 query: &str,
540 unknown_freshness: &mut BTreeSet<RepoId>,
541) -> Option<SearchHit> {
542 let stable_key = normalize(&node.stable_key);
543 let label = normalize(&node.label);
544 let kind = node_kind_name(node.kind);
545 let mut explanation = lexical_explanation(query, &stable_key, &label, kind);
546 let supplied_fts = request.fts_scores.get(&node.id).copied().unwrap_or(0.0);
547 if explanation.matched_fields.is_empty() && supplied_fts <= 0.0 {
548 return None;
549 }
550 explanation.fts_score = supplied_fts.min(1.0) * 2.0;
551 explanation.type_score = type_signal(query, kind);
552 explanation.scope_score = scope_signal(node, request);
553 explanation.centrality_score = request
554 .centrality_scores
555 .get(&node.id)
556 .copied()
557 .unwrap_or(0.0)
558 .min(1.0)
559 * 0.5;
560 explanation.community_score = community_signal(node, request);
561 explanation.evidence_score = evidence_signal(node, request);
562 explanation.freshness_penalty = freshness_penalty(node, request, unknown_freshness);
563 let score = explanation_score(&explanation).max(0.0);
564 Some(SearchHit {
565 node: node.clone(),
566 score,
567 explanation,
568 })
569}
570
571fn lexical_explanation(
572 query: &str,
573 stable_key: &str,
574 label: &str,
575 kind: &str,
576) -> SearchExplanation {
577 let mut matched_fields = Vec::new();
578 let exact = stable_key == query || label == query;
579 if stable_key == query {
580 matched_fields.push("stable_key_exact".to_owned());
581 }
582 if label == query {
583 matched_fields.push("label_exact".to_owned());
584 }
585 let prefix = !exact && (stable_key.starts_with(query) || label.starts_with(query));
586 if prefix {
587 matched_fields.push("normalized_prefix".to_owned());
588 }
589 let suffix = !exact && (stable_key.ends_with(query) || label.ends_with(query));
590 if suffix {
591 matched_fields.push("normalized_suffix".to_owned());
592 }
593 if kind == query {
594 matched_fields.push("node_kind".to_owned());
595 }
596 SearchExplanation {
597 matched_fields,
598 exact_score: if exact { 4.0 } else { 0.0 },
599 prefix_score: if prefix { 1.5 } else { 0.0 },
600 suffix_score: if suffix { 1.0 } else { 0.0 },
601 fts_score: 0.0,
602 type_score: 0.0,
603 scope_score: 0.0,
604 centrality_score: 0.0,
605 community_score: 0.0,
606 evidence_score: 0.0,
607 freshness_penalty: 0.0,
608 }
609}
610
611fn type_signal(query: &str, kind: &str) -> f64 {
612 if kind == query {
613 0.5
614 } else if kind.starts_with(query) || kind.ends_with(query) {
615 0.25
616 } else {
617 0.0
618 }
619}
620
621fn scope_signal(node: &Node, request: &SearchRequest) -> f64 {
622 let mut score: f64 = 0.0;
623 if !request.filters.repo_ids.is_empty() {
624 score += 0.15;
625 }
626 if !request.filters.workspace_nodes.is_empty() {
627 score += 0.1;
628 }
629 if !request.filters.service_ids.is_empty()
630 && membership_matches(
631 request.service_memberships.get(&node.id),
632 &request.filters.service_ids,
633 )
634 {
635 score += 0.15;
636 }
637 score.min(0.4)
638}
639
640fn community_signal(node: &Node, request: &SearchRequest) -> f64 {
641 let Some(memberships) = request.community_memberships.get(&node.id) else {
642 return 0.0;
643 };
644 if request.filters.community_ids.is_empty() {
645 if memberships.is_empty() { 0.0 } else { 0.1 }
646 } else if membership_matches(Some(memberships), &request.filters.community_ids) {
647 0.4
648 } else {
649 0.0
650 }
651}
652
653fn evidence_signal(node: &Node, request: &SearchRequest) -> f64 {
654 let Some(items) = request.evidence.get(&node.id) else {
655 return 0.0;
656 };
657 if items.is_empty() {
658 return 0.0;
659 }
660 let (confidence_sum, denominator) = items.iter().fold((0.0, 0.0), |(sum, count), item| {
661 (sum + f64::from(item.confidence), count + 1.0)
662 });
663 (confidence_sum / denominator).min(1.0) * 0.75
664}
665
666fn freshness_penalty(node: &Node, request: &SearchRequest, unknown: &mut BTreeSet<RepoId>) -> f64 {
667 let Some(repo_id) = node.repo_id.as_ref() else {
668 return 0.0;
669 };
670 let Some(state) = request.freshness.get(repo_id) else {
671 unknown.insert(repo_id.clone());
672 return 0.2;
673 };
674 match state {
675 RepoFreshnessState::Fresh => 0.0,
676 RepoFreshnessState::WorkingTreeChanged => 0.05,
677 RepoFreshnessState::CommitsBehind => 0.1,
678 RepoFreshnessState::ConfigChanged
679 | RepoFreshnessState::ExtractorChanged
680 | RepoFreshnessState::CodegraphPending => 0.15,
681 RepoFreshnessState::Partial | RepoFreshnessState::Unknown => 0.2,
682 RepoFreshnessState::Corrupt => 0.4,
683 RepoFreshnessState::Unavailable => 0.5,
684 }
685}
686
687fn explanation_score(explanation: &SearchExplanation) -> f64 {
688 explanation.exact_score
689 + explanation.prefix_score
690 + explanation.suffix_score
691 + explanation.fts_score
692 + explanation.type_score
693 + explanation.scope_score
694 + explanation.centrality_score
695 + explanation.community_score
696 + explanation.evidence_score
697 - explanation.freshness_penalty
698}
699
700fn sort_search_hits(hits: &mut [SearchHit]) {
701 hits.sort_by(|left, right| {
702 right
703 .score
704 .total_cmp(&left.score)
705 .then_with(|| left.node.stable_key.cmp(&right.node.stable_key))
706 .then_with(|| left.node.id.cmp(&right.node.id))
707 });
708}
709
710fn search_coverage(
711 input_nodes: usize,
712 eligible_nodes: usize,
713 matched_nodes: usize,
714 request: &SearchRequest,
715 unknown: BTreeSet<RepoId>,
716) -> SearchCoverage {
717 let mut gaps = Vec::new();
718 if request.fts_scores.is_empty() {
719 gaps.push("Full-text scores were not provided.".to_owned());
720 }
721 if request.centrality_scores.is_empty() {
722 gaps.push("Centrality scores were not provided.".to_owned());
723 }
724 if request.evidence.is_empty() {
725 gaps.push("Node evidence was not provided.".to_owned());
726 }
727 if request.community_memberships.is_empty() {
728 gaps.push("Community memberships were not provided.".to_owned());
729 }
730 if !unknown.is_empty() {
731 gaps.push("Freshness was unavailable for one or more repositories.".to_owned());
732 }
733 SearchCoverage {
734 input_nodes,
735 eligible_nodes,
736 matched_nodes,
737 fts_scored_nodes: request.fts_scores.len(),
738 freshness_unknown_repositories: unknown.into_iter().collect(),
739 gaps,
740 }
741}
742
743fn normalize(value: &str) -> String {
744 let mut result = String::with_capacity(value.len());
745 let mut separator_pending = false;
746 for character in value.chars().flat_map(char::to_lowercase) {
747 if character.is_alphanumeric() {
748 if separator_pending && !result.is_empty() {
749 result.push(' ');
750 }
751 result.push(character);
752 separator_pending = false;
753 } else {
754 separator_pending = true;
755 }
756 }
757 result
758}
759
760fn node_kind_name(kind: NodeKind) -> &'static str {
761 match kind {
762 NodeKind::Repository => "repository",
763 NodeKind::Service => "service",
764 NodeKind::Package => "package",
765 NodeKind::Artifact => "artifact",
766 NodeKind::SymbolRef => "symbol ref",
767 NodeKind::TestCase => "test case",
768 NodeKind::HttpOperation => "http operation",
769 NodeKind::GraphqlOperation => "graphql operation",
770 NodeKind::RpcMethod => "rpc method",
771 NodeKind::EventChannel => "event channel",
772 NodeKind::EventSchema => "event schema",
773 NodeKind::Database => "database",
774 NodeKind::DatabaseTable => "database table",
775 NodeKind::DatabaseColumn => "database column",
776 NodeKind::ConfigKey => "config key",
777 NodeKind::Deployment => "deployment",
778 NodeKind::Document => "document",
779 NodeKind::Adr => "adr",
780 NodeKind::Owner => "owner",
781 NodeKind::ChangeSet => "change set",
782 NodeKind::PullRequest => "pull request",
783 NodeKind::Community => "community",
784 }
785}
786
787#[must_use = "traversal results and validation errors must be handled"]
797pub fn traverse(
798 nodes: &[Node],
799 edges: &[Edge],
800 request: &TraversalRequest,
801) -> Result<TraversalReport, QueryError> {
802 validate_traversal_request(request)?;
803 let graph = TraversalGraph::new(nodes, edges)?;
804 graph.require_anchor(&request.start)?;
805 graph.require_anchor(&request.target)?;
806 let mut runtime = TraversalRuntime::new(request);
807 let paths = if request.start == request.target {
808 vec![TraversalPath {
809 segments: Vec::new(),
810 total_weight: 0.0,
811 cross_repo_hops: 0,
812 }]
813 } else {
814 match request.options.algorithm {
815 TraversalAlgorithm::Bfs => bfs(&graph, request, &mut runtime)?,
816 TraversalAlgorithm::Dijkstra => weighted_paths(&graph, request, &mut runtime, 1)?,
817 TraversalAlgorithm::KShortest => {
818 weighted_paths(&graph, request, &mut runtime, request.options.k)?
819 }
820 }
821 };
822 build_traversal_report(&graph, request, runtime, paths)
823}
824
825fn validate_traversal_request(request: &TraversalRequest) -> Result<(), QueryError> {
826 let options = &request.options;
827 if options.max_depth == 0
828 || options.max_depth > MAX_TRAVERSAL_DEPTH
829 || options.node_limit == 0
830 || options.node_limit > MAX_TRAVERSAL_NODES
831 || options.edge_limit == 0
832 || options.edge_limit > MAX_TRAVERSAL_EDGES
833 || options.timeout_ms > MAX_TRAVERSAL_TIMEOUT_MS
834 || options.k == 0
835 || options.k > MAX_K_PATHS
836 {
837 return Err(QueryError::InvalidTraversalLimits);
838 }
839 let confidence = request.filters.min_confidence;
840 if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
841 return Err(QueryError::InvalidMinimumConfidence);
842 }
843 let mut cost_kinds = Vec::new();
844 for item in &options.edge_kind_costs {
845 if !item.cost.is_finite() || item.cost <= 0.0 || cost_kinds.contains(&item.kind) {
846 return Err(QueryError::InvalidEdgeCost(item.kind));
847 }
848 cost_kinds.push(item.kind);
849 }
850 Ok(())
851}
852
853struct TraversalGraph<'a> {
854 nodes: BTreeMap<NodeId, &'a Node>,
855 outgoing: BTreeMap<NodeId, Vec<&'a Edge>>,
856 incoming: BTreeMap<NodeId, Vec<&'a Edge>>,
857}
858
859impl<'a> TraversalGraph<'a> {
860 fn new(nodes: &'a [Node], edges: &'a [Edge]) -> Result<Self, QueryError> {
861 let nodes = distinct_nodes(nodes)?;
862 let mut edge_ids = BTreeSet::new();
863 let mut outgoing: BTreeMap<NodeId, Vec<&Edge>> = BTreeMap::new();
864 let mut incoming: BTreeMap<NodeId, Vec<&Edge>> = BTreeMap::new();
865 for edge in edges {
866 if !edge_ids.insert(edge.id.clone()) {
867 return Err(QueryError::DuplicateEdge(edge.id.as_str().to_owned()));
868 }
869 validate_graph_edge(edge, &nodes)?;
870 outgoing.entry(edge.source.clone()).or_default().push(edge);
871 incoming.entry(edge.target.clone()).or_default().push(edge);
872 }
873 for adjacent in outgoing.values_mut().chain(incoming.values_mut()) {
874 adjacent.sort_by(|left, right| left.id.cmp(&right.id));
875 }
876 Ok(Self {
877 nodes,
878 outgoing,
879 incoming,
880 })
881 }
882
883 fn require_anchor(&self, node_id: &NodeId) -> Result<(), QueryError> {
884 if self.nodes.contains_key(node_id) {
885 Ok(())
886 } else {
887 Err(QueryError::UnknownAnchor(node_id.as_str().to_owned()))
888 }
889 }
890
891 fn adjacent(&self, node_id: &NodeId, direction: TraversalDirection) -> Vec<Adjacent<'a>> {
892 let mut result = Vec::new();
893 if matches!(
894 direction,
895 TraversalDirection::Outgoing | TraversalDirection::Both
896 ) {
897 result.extend(
898 self.outgoing
899 .get(node_id)
900 .into_iter()
901 .flatten()
902 .map(|edge| Adjacent {
903 edge,
904 next: &edge.target,
905 reversed: false,
906 }),
907 );
908 }
909 if matches!(
910 direction,
911 TraversalDirection::Incoming | TraversalDirection::Both
912 ) {
913 result.extend(
914 self.incoming
915 .get(node_id)
916 .into_iter()
917 .flatten()
918 .map(|edge| Adjacent {
919 edge,
920 next: &edge.source,
921 reversed: true,
922 }),
923 );
924 }
925 result.sort_by(adjacent_order);
926 result.dedup_by(|left, right| {
927 left.edge.id == right.edge.id
928 && left.next == right.next
929 && left.reversed == right.reversed
930 });
931 result
932 }
933}
934
935fn validate_graph_edge(edge: &Edge, nodes: &BTreeMap<NodeId, &Node>) -> Result<(), QueryError> {
936 for endpoint in [&edge.source, &edge.target] {
937 if !nodes.contains_key(endpoint) {
938 return Err(QueryError::DanglingEdge {
939 edge: edge.id.as_str().to_owned(),
940 node: endpoint.as_str().to_owned(),
941 });
942 }
943 }
944 let confidence = f64::from(edge.confidence);
945 if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
946 return Err(QueryError::InvalidEdgeConfidence(
947 edge.id.as_str().to_owned(),
948 ));
949 }
950 Ok(())
951}
952
953#[derive(Clone, Copy)]
954struct Adjacent<'a> {
955 edge: &'a Edge,
956 next: &'a NodeId,
957 reversed: bool,
958}
959
960fn adjacent_order(left: &Adjacent<'_>, right: &Adjacent<'_>) -> Ordering {
961 left.edge
962 .id
963 .cmp(&right.edge.id)
964 .then_with(|| left.next.cmp(right.next))
965 .then_with(|| left.reversed.cmp(&right.reversed))
966}
967
968#[derive(Clone)]
969struct PathState<'a> {
970 nodes: Vec<NodeId>,
971 steps: Vec<Adjacent<'a>>,
972 total_weight: f64,
973 cross_repo_hops: usize,
974}
975
976impl<'a> PathState<'a> {
977 fn start(node_id: &NodeId) -> Self {
978 Self {
979 nodes: vec![node_id.clone()],
980 steps: Vec::new(),
981 total_weight: 0.0,
982 cross_repo_hops: 0,
983 }
984 }
985
986 fn current(&self) -> Option<&NodeId> {
987 self.nodes.last()
988 }
989
990 fn contains(&self, node_id: &NodeId) -> bool {
991 self.nodes.contains(node_id)
992 }
993
994 fn extended(&self, adjacent: Adjacent<'a>, weight: f64, cross_repo: bool) -> Self {
995 let mut result = self.clone();
996 result.nodes.push(adjacent.next.clone());
997 result.steps.push(adjacent);
998 result.total_weight += weight;
999 result.cross_repo_hops += usize::from(cross_repo);
1000 result
1001 }
1002}
1003
1004struct TraversalRuntime {
1005 started: Instant,
1006 timeout: Duration,
1007 observed_nodes: BTreeSet<NodeId>,
1008 examined_edges: usize,
1009 candidate_edges: BTreeMap<EdgeId, Edge>,
1010 frontier_depth: usize,
1011 frontier: BTreeSet<NodeId>,
1012 coverage_gaps: BTreeSet<String>,
1013 truncated: bool,
1014}
1015
1016impl TraversalRuntime {
1017 fn new(request: &TraversalRequest) -> Self {
1018 Self {
1019 started: Instant::now(),
1020 timeout: Duration::from_millis(request.options.timeout_ms),
1021 observed_nodes: BTreeSet::from([request.start.clone()]),
1022 examined_edges: 0,
1023 candidate_edges: BTreeMap::new(),
1024 frontier_depth: 0,
1025 frontier: BTreeSet::from([request.start.clone()]),
1026 coverage_gaps: BTreeSet::new(),
1027 truncated: false,
1028 }
1029 }
1030
1031 fn timed_out(&mut self) -> bool {
1032 if self.started.elapsed() >= self.timeout {
1033 self.truncated = true;
1034 self.coverage_gaps
1035 .insert("Traversal stopped at the timeout bound.".to_owned());
1036 true
1037 } else {
1038 false
1039 }
1040 }
1041
1042 fn examine_edge(&mut self, edge_limit: usize) -> bool {
1043 if self.examined_edges >= edge_limit {
1044 self.truncated = true;
1045 self.coverage_gaps
1046 .insert("Traversal stopped at the edge limit.".to_owned());
1047 false
1048 } else {
1049 self.examined_edges += 1;
1050 true
1051 }
1052 }
1053
1054 fn observe_node(&mut self, node_id: &NodeId, node_limit: usize) -> bool {
1055 if self.observed_nodes.contains(node_id) {
1056 return true;
1057 }
1058 if self.observed_nodes.len() >= node_limit {
1059 self.truncated = true;
1060 self.coverage_gaps
1061 .insert("Traversal stopped at the node limit.".to_owned());
1062 false
1063 } else {
1064 self.observed_nodes.insert(node_id.clone());
1065 true
1066 }
1067 }
1068
1069 fn record_frontier(&mut self, node_id: &NodeId, depth: usize) {
1070 match depth.cmp(&self.frontier_depth) {
1071 Ordering::Greater => {
1072 self.frontier_depth = depth;
1073 self.frontier.clear();
1074 self.frontier.insert(node_id.clone());
1075 }
1076 Ordering::Equal => {
1077 self.frontier.insert(node_id.clone());
1078 }
1079 Ordering::Less => {}
1080 }
1081 }
1082
1083 fn record_candidate(&mut self, edge: &Edge) {
1084 self.candidate_edges
1085 .entry(edge.id.clone())
1086 .or_insert_with(|| edge.clone());
1087 }
1088
1089 fn record_depth_bound(&mut self) {
1090 self.truncated = true;
1091 self.coverage_gaps
1092 .insert("Traversal stopped at the depth limit.".to_owned());
1093 }
1094
1095 fn record_cross_repo_bound(&mut self) {
1096 self.truncated = true;
1097 self.coverage_gaps
1098 .insert("A path branch exceeded the cross-repository hop limit.".to_owned());
1099 }
1100}
1101
1102fn bfs(
1103 graph: &TraversalGraph<'_>,
1104 request: &TraversalRequest,
1105 runtime: &mut TraversalRuntime,
1106) -> Result<Vec<TraversalPath>, QueryError> {
1107 let mut queue = VecDeque::from([PathState::start(&request.start)]);
1108 while let Some(state) = queue.pop_front() {
1109 if runtime.timed_out() {
1110 break;
1111 }
1112 let Some(current) = state.current() else {
1113 continue;
1114 };
1115 runtime.record_frontier(current, state.steps.len());
1116 if current == &request.target {
1117 return Ok(vec![materialize_path(graph, &state, request)?]);
1118 }
1119 if state.steps.len() >= request.options.max_depth {
1120 runtime.record_depth_bound();
1121 continue;
1122 }
1123 expand_bfs_state(graph, request, runtime, &state, &mut queue)?;
1124 if runtime.truncated && queue.is_empty() {
1125 break;
1126 }
1127 }
1128 Ok(Vec::new())
1129}
1130
1131fn expand_bfs_state<'a>(
1132 graph: &TraversalGraph<'a>,
1133 request: &TraversalRequest,
1134 runtime: &mut TraversalRuntime,
1135 state: &PathState<'a>,
1136 queue: &mut VecDeque<PathState<'a>>,
1137) -> Result<(), QueryError> {
1138 let Some(current) = state.current() else {
1139 return Ok(());
1140 };
1141 for adjacent in graph.adjacent(current, request.options.direction) {
1142 if runtime.timed_out() || !runtime.examine_edge(request.options.edge_limit) {
1143 break;
1144 }
1145 if !edge_passes_filters(adjacent.edge, &request.filters) {
1146 continue;
1147 }
1148 if adjacent.edge.status != EpistemicStatus::Confirmed {
1149 runtime.record_candidate(adjacent.edge);
1150 continue;
1151 }
1152 if state.contains(adjacent.next)
1153 || !node_passes_filters(graph, adjacent.next, &request.filters, runtime)
1154 {
1155 continue;
1156 }
1157 let cross_repo = segment_is_cross_repo(graph, current, adjacent.next)?;
1158 if state.cross_repo_hops + usize::from(cross_repo) > request.options.max_cross_repo_hops {
1159 runtime.record_cross_repo_bound();
1160 continue;
1161 }
1162 if !runtime.observe_node(adjacent.next, request.options.node_limit) {
1163 continue;
1164 }
1165 let weight = edge_weight(adjacent.edge, &request.options)?;
1166 queue.push_back(state.extended(adjacent, weight, cross_repo));
1167 }
1168 Ok(())
1169}
1170
1171fn weighted_paths(
1172 graph: &TraversalGraph<'_>,
1173 request: &TraversalRequest,
1174 runtime: &mut TraversalRuntime,
1175 wanted: usize,
1176) -> Result<Vec<TraversalPath>, QueryError> {
1177 let mut candidates = vec![PathState::start(&request.start)];
1178 let mut paths = Vec::new();
1179 while !candidates.is_empty() && paths.len() < wanted {
1180 if runtime.timed_out() {
1181 break;
1182 }
1183 let selected = select_best_state(&candidates);
1184 let state = candidates.remove(selected);
1185 let Some(current) = state.current() else {
1186 continue;
1187 };
1188 runtime.record_frontier(current, state.steps.len());
1189 if current == &request.target {
1190 paths.push(materialize_path(graph, &state, request)?);
1191 continue;
1192 }
1193 if state.steps.len() >= request.options.max_depth {
1194 runtime.record_depth_bound();
1195 continue;
1196 }
1197 expand_weighted_state(graph, request, runtime, &state, &mut candidates)?;
1198 }
1199 paths.sort_by(path_order);
1200 Ok(paths)
1201}
1202
1203fn select_best_state(candidates: &[PathState<'_>]) -> usize {
1204 let mut best = 0;
1205 for index in 1..candidates.len() {
1206 if path_state_order(&candidates[index], &candidates[best]) == Ordering::Less {
1207 best = index;
1208 }
1209 }
1210 best
1211}
1212
1213fn path_state_order(left: &PathState<'_>, right: &PathState<'_>) -> Ordering {
1214 left.total_weight
1215 .total_cmp(&right.total_weight)
1216 .then_with(|| left.nodes.cmp(&right.nodes))
1217 .then_with(|| {
1218 left.steps
1219 .iter()
1220 .map(|step| &step.edge.id)
1221 .cmp(right.steps.iter().map(|step| &step.edge.id))
1222 })
1223 .then_with(|| {
1224 left.steps
1225 .iter()
1226 .map(|step| step.reversed)
1227 .cmp(right.steps.iter().map(|step| step.reversed))
1228 })
1229}
1230
1231fn expand_weighted_state<'a>(
1232 graph: &TraversalGraph<'a>,
1233 request: &TraversalRequest,
1234 runtime: &mut TraversalRuntime,
1235 state: &PathState<'a>,
1236 candidates: &mut Vec<PathState<'a>>,
1237) -> Result<(), QueryError> {
1238 let Some(current) = state.current() else {
1239 return Ok(());
1240 };
1241 for adjacent in graph.adjacent(current, request.options.direction) {
1242 if runtime.timed_out() || !runtime.examine_edge(request.options.edge_limit) {
1243 break;
1244 }
1245 if !edge_passes_filters(adjacent.edge, &request.filters) {
1246 continue;
1247 }
1248 if adjacent.edge.status != EpistemicStatus::Confirmed {
1249 runtime.record_candidate(adjacent.edge);
1250 continue;
1251 }
1252 if state.contains(adjacent.next)
1253 || !node_passes_filters(graph, adjacent.next, &request.filters, runtime)
1254 {
1255 continue;
1256 }
1257 let cross_repo = segment_is_cross_repo(graph, current, adjacent.next)?;
1258 if state.cross_repo_hops + usize::from(cross_repo) > request.options.max_cross_repo_hops {
1259 runtime.record_cross_repo_bound();
1260 continue;
1261 }
1262 if !runtime.observe_node(adjacent.next, request.options.node_limit) {
1263 continue;
1264 }
1265 let weight = edge_weight(adjacent.edge, &request.options)?;
1266 candidates.push(state.extended(adjacent, weight, cross_repo));
1267 }
1268 Ok(())
1269}
1270
1271fn edge_passes_filters(edge: &Edge, filters: &TraversalFilters) -> bool {
1272 (filters.edge_kinds.is_empty() || filters.edge_kinds.contains(&edge.kind))
1273 && f64::from(edge.confidence) >= filters.min_confidence
1274}
1275
1276fn node_passes_filters(
1277 graph: &TraversalGraph<'_>,
1278 node_id: &NodeId,
1279 filters: &TraversalFilters,
1280 runtime: &mut TraversalRuntime,
1281) -> bool {
1282 let Some(node) = graph.nodes.get(node_id).copied() else {
1283 return false;
1284 };
1285 if !filters.include_tests && node.kind == NodeKind::TestCase {
1286 return false;
1287 }
1288 if !filters.include_generated_artifacts
1289 && node.kind == NodeKind::Artifact
1290 && is_generated_artifact(node)
1291 {
1292 return false;
1293 }
1294 let Some(namespace) = filters.environment_namespace.as_deref() else {
1295 return true;
1296 };
1297 if node.kind != NodeKind::Deployment {
1298 return true;
1299 }
1300 if let Some(candidate) = deployment_namespace(&node.stable_key) {
1301 candidate == namespace
1302 } else {
1303 runtime.coverage_gaps.insert(format!(
1304 "Deployment `{}` has no representable environment namespace.",
1305 node.id.as_str()
1306 ));
1307 false
1308 }
1309}
1310
1311fn deployment_namespace(stable_key: &str) -> Option<&str> {
1312 let mut parts = stable_key.splitn(4, ':');
1313 if parts.next()? != "deployment" {
1314 return None;
1315 }
1316 let _technology = parts.next()?;
1317 let namespace = parts.next()?;
1318 let _name = parts.next()?;
1319 if namespace.is_empty() {
1320 None
1321 } else {
1322 Some(namespace)
1323 }
1324}
1325
1326fn is_generated_artifact(node: &Node) -> bool {
1327 let key = node.stable_key.to_ascii_lowercase().replace('\\', "/");
1328 [
1329 "/generated/",
1330 "/target/",
1331 "/dist/",
1332 "/build/",
1333 "/vendor/",
1334 ".generated.",
1335 "_generated.",
1336 "/gen/",
1337 ]
1338 .iter()
1339 .any(|marker| key.contains(marker))
1340}
1341
1342fn segment_is_cross_repo(
1343 graph: &TraversalGraph<'_>,
1344 source: &NodeId,
1345 target: &NodeId,
1346) -> Result<bool, QueryError> {
1347 let source_node = graph
1348 .nodes
1349 .get(source)
1350 .copied()
1351 .ok_or_else(|| QueryError::InconsistentPath(source.as_str().to_owned()))?;
1352 let target_node = graph
1353 .nodes
1354 .get(target)
1355 .copied()
1356 .ok_or_else(|| QueryError::InconsistentPath(target.as_str().to_owned()))?;
1357 Ok(source_node.repo_id != target_node.repo_id)
1358}
1359
1360fn edge_weight(edge: &Edge, options: &TraversalOptions) -> Result<f64, QueryError> {
1361 let base = options
1362 .edge_kind_costs
1363 .iter()
1364 .find(|item| item.kind == edge.kind)
1365 .map_or(1.0, |item| item.cost);
1366 let confidence = f64::from(edge.confidence).max(MIN_CONFIDENCE_FOR_WEIGHT);
1367 let weight = base / confidence;
1368 if weight.is_finite() && weight > 0.0 {
1369 Ok(weight)
1370 } else {
1371 Err(QueryError::InvalidEdgeCost(edge.kind))
1372 }
1373}
1374
1375fn materialize_path(
1376 graph: &TraversalGraph<'_>,
1377 state: &PathState<'_>,
1378 request: &TraversalRequest,
1379) -> Result<TraversalPath, QueryError> {
1380 let mut segments = Vec::with_capacity(state.steps.len());
1381 for (index, step) in state.steps.iter().enumerate() {
1382 let source_id = state
1383 .nodes
1384 .get(index)
1385 .ok_or_else(|| QueryError::InconsistentPath(request.start.as_str().to_owned()))?;
1386 let target_id = state
1387 .nodes
1388 .get(index + 1)
1389 .ok_or_else(|| QueryError::InconsistentPath(request.target.as_str().to_owned()))?;
1390 let source = graph
1391 .nodes
1392 .get(source_id)
1393 .copied()
1394 .ok_or_else(|| QueryError::InconsistentPath(source_id.as_str().to_owned()))?;
1395 let target = graph
1396 .nodes
1397 .get(target_id)
1398 .copied()
1399 .ok_or_else(|| QueryError::InconsistentPath(target_id.as_str().to_owned()))?;
1400 let cross_repo = source.repo_id != target.repo_id;
1401 segments.push(PathSegment {
1402 trace: TraceSegment {
1403 source: source.clone(),
1404 edge: step.edge.clone(),
1405 target: target.clone(),
1406 },
1407 scope: if cross_repo {
1408 PathSegmentScope::CrossRepository
1409 } else {
1410 PathSegmentScope::Local
1411 },
1412 reversed: step.reversed,
1413 weight: edge_weight(step.edge, &request.options)?,
1414 });
1415 }
1416 Ok(TraversalPath {
1417 segments,
1418 total_weight: state.total_weight,
1419 cross_repo_hops: state.cross_repo_hops,
1420 })
1421}
1422
1423fn path_order(left: &TraversalPath, right: &TraversalPath) -> Ordering {
1424 left.total_weight
1425 .total_cmp(&right.total_weight)
1426 .then_with(|| left.segments.len().cmp(&right.segments.len()))
1427 .then_with(|| {
1428 left.segments
1429 .iter()
1430 .map(|segment| &segment.trace.edge.id)
1431 .cmp(right.segments.iter().map(|segment| &segment.trace.edge.id))
1432 })
1433 .then_with(|| {
1434 left.segments
1435 .iter()
1436 .map(|segment| &segment.trace.target.id)
1437 .cmp(
1438 right
1439 .segments
1440 .iter()
1441 .map(|segment| &segment.trace.target.id),
1442 )
1443 })
1444}
1445
1446fn build_traversal_report(
1447 graph: &TraversalGraph<'_>,
1448 request: &TraversalRequest,
1449 mut runtime: TraversalRuntime,
1450 paths: Vec<TraversalPath>,
1451) -> Result<TraversalReport, QueryError> {
1452 if paths.is_empty() {
1453 runtime.coverage_gaps.insert(
1454 "No confirmed path was observed within available coverage and bounds.".to_owned(),
1455 );
1456 }
1457 if !runtime.candidate_edges.is_empty() {
1458 runtime
1459 .coverage_gaps
1460 .insert("Unresolved candidate edges were excluded from confirmed paths.".to_owned());
1461 }
1462 let frontier = runtime
1463 .frontier
1464 .iter()
1465 .map(|node_id| {
1466 graph
1467 .nodes
1468 .get(node_id)
1469 .copied()
1470 .cloned()
1471 .ok_or_else(|| QueryError::InconsistentPath(node_id.as_str().to_owned()))
1472 })
1473 .collect::<Result<Vec<_>, _>>()?;
1474 Ok(TraversalReport {
1475 paths,
1476 frontier,
1477 candidate_unresolved_edges: runtime.candidate_edges.into_values().collect(),
1478 coverage_gaps: runtime.coverage_gaps.into_iter().collect(),
1479 truncated: runtime.truncated,
1480 visited_nodes: runtime.observed_nodes.len(),
1481 examined_edges: runtime.examined_edges,
1482 limits: TraversalLimits {
1483 max_depth: request.options.max_depth,
1484 max_cross_repo_hops: request.options.max_cross_repo_hops,
1485 node_limit: request.options.node_limit,
1486 edge_limit: request.options.edge_limit,
1487 timeout_ms: request.options.timeout_ms,
1488 max_paths: if request.options.algorithm == TraversalAlgorithm::KShortest {
1489 request.options.k
1490 } else {
1491 1
1492 },
1493 },
1494 })
1495}
1496
1497#[cfg(test)]
1498mod tests {
1499 use code_system_graph_model::{EdgeId, EvidenceId, Provenance};
1500
1501 use super::*;
1502
1503 fn node(id: &str, kind: NodeKind, repo: Option<&str>) -> Node {
1504 Node {
1505 id: NodeId::new(id),
1506 kind,
1507 repo_id: repo.map(RepoId::new),
1508 stable_key: id.to_owned(),
1509 label: id.trim_start_matches("node:").to_owned(),
1510 }
1511 }
1512
1513 fn edge(
1514 id: &str,
1515 source: &str,
1516 target: &str,
1517 confidence: f32,
1518 status: EpistemicStatus,
1519 ) -> Edge {
1520 Edge {
1521 id: EdgeId::new(id),
1522 source: NodeId::new(source),
1523 target: NodeId::new(target),
1524 kind: EdgeKind::CallsRemote,
1525 confidence,
1526 status,
1527 evidence: Vec::new(),
1528 }
1529 }
1530
1531 fn evidence(confidence: f32) -> Evidence {
1532 Evidence {
1533 id: EvidenceId::new("evidence:1"),
1534 repo_id: Some(RepoId::new("repo:a")),
1535 file_path: Some("src/lib.rs".to_owned()),
1536 start_line: Some(1),
1537 end_line: Some(1),
1538 extractor: "test".to_owned(),
1539 extractor_version: "1.0.0".to_owned(),
1540 provenance: Provenance::Extracted,
1541 confidence,
1542 observed_at_commit: None,
1543 content_hash: None,
1544 note: None,
1545 }
1546 }
1547
1548 fn search_request(query: &str, offset: usize, limit: usize) -> SearchRequest {
1549 SearchRequest {
1550 query: query.to_owned(),
1551 filters: SearchFilters::default(),
1552 fts_scores: BTreeMap::new(),
1553 centrality_scores: BTreeMap::new(),
1554 service_memberships: BTreeMap::new(),
1555 community_memberships: BTreeMap::new(),
1556 evidence: BTreeMap::new(),
1557 freshness: BTreeMap::new(),
1558 offset,
1559 limit,
1560 }
1561 }
1562
1563 fn traversal_request(
1564 start: &str,
1565 target: &str,
1566 algorithm: TraversalAlgorithm,
1567 ) -> TraversalRequest {
1568 TraversalRequest {
1569 start: NodeId::new(start),
1570 target: NodeId::new(target),
1571 filters: TraversalFilters::default(),
1572 options: TraversalOptions {
1573 algorithm,
1574 ..TraversalOptions::default()
1575 },
1576 }
1577 }
1578
1579 fn report_or_panic(result: Result<TraversalReport, QueryError>) -> TraversalReport {
1580 match result {
1581 Ok(report) => report,
1582 Err(error) => panic!("unexpected traversal error: {error}"),
1583 }
1584 }
1585
1586 fn search_or_panic(result: Result<SearchReport, QueryError>) -> SearchReport {
1587 match result {
1588 Ok(report) => report,
1589 Err(error) => panic!("unexpected search error: {error}"),
1590 }
1591 }
1592
1593 #[test]
1594 fn search_is_deterministic_and_paginates_without_duplicates() {
1595 let nodes = vec![
1596 node("node:alpha-b", NodeKind::Service, Some("repo:a")),
1597 node("node:alpha-a", NodeKind::Service, Some("repo:a")),
1598 node("node:alpha-c", NodeKind::Service, Some("repo:a")),
1599 ];
1600 let first = search_or_panic(search(&nodes, &search_request("alpha", 0, 2)));
1601 let second = search_or_panic(search(&nodes, &search_request("alpha", 2, 2)));
1602 let ids = first
1603 .hits
1604 .iter()
1605 .chain(&second.hits)
1606 .map(|hit| hit.node.id.as_str())
1607 .collect::<BTreeSet<_>>();
1608
1609 assert_eq!(ids.len(), 3);
1610 }
1611
1612 #[test]
1613 fn search_explains_signals_and_freshness_penalty() {
1614 let nodes = vec![
1615 node("node:billing", NodeKind::Service, Some("repo:a")),
1616 node("node:billing-old", NodeKind::Service, Some("repo:b")),
1617 ];
1618 let mut request = search_request("billing", 0, 10);
1619 request.fts_scores.insert(NodeId::new("node:billing"), 0.8);
1620 request
1621 .centrality_scores
1622 .insert(NodeId::new("node:billing"), 0.5);
1623 request
1624 .evidence
1625 .insert(NodeId::new("node:billing"), vec![evidence(0.9)]);
1626 request
1627 .freshness
1628 .insert(RepoId::new("repo:a"), RepoFreshnessState::Fresh);
1629 request
1630 .freshness
1631 .insert(RepoId::new("repo:b"), RepoFreshnessState::Unavailable);
1632 let report = search_or_panic(search(&nodes, &request));
1633
1634 assert!(
1635 report.hits[0].explanation.fts_score > 0.0
1636 && report.hits[0].explanation.centrality_score > 0.0
1637 && report.hits[0].explanation.evidence_score > 0.0
1638 && report.hits[1].explanation.freshness_penalty > 0.0
1639 );
1640 }
1641
1642 #[test]
1643 fn bfs_honors_direction_and_returns_frontier() {
1644 let nodes = vec![
1645 node("node:a", NodeKind::Service, Some("repo:a")),
1646 node("node:b", NodeKind::Service, Some("repo:a")),
1647 node("node:c", NodeKind::Service, Some("repo:a")),
1648 ];
1649 let edges = vec![
1650 edge(
1651 "edge:1",
1652 "node:a",
1653 "node:b",
1654 1.0,
1655 EpistemicStatus::Confirmed,
1656 ),
1657 edge(
1658 "edge:2",
1659 "node:b",
1660 "node:c",
1661 1.0,
1662 EpistemicStatus::Confirmed,
1663 ),
1664 ];
1665 let mut request = traversal_request("node:c", "node:a", TraversalAlgorithm::Bfs);
1666 request.options.direction = TraversalDirection::Incoming;
1667 request.options.max_depth = 1;
1668 let report = report_or_panic(traverse(&nodes, &edges, &request));
1669
1670 assert!(
1671 report.paths.is_empty()
1672 && report.truncated
1673 && report
1674 .frontier
1675 .iter()
1676 .any(|item| item.id == NodeId::new("node:b"))
1677 );
1678 }
1679
1680 #[test]
1681 fn bfs_excludes_tests_and_filters_edge_kinds() {
1682 let nodes = vec![
1683 node("node:a", NodeKind::Service, None),
1684 node("node:test", NodeKind::TestCase, None),
1685 node("node:b", NodeKind::Service, None),
1686 ];
1687 let mut validating = edge(
1688 "edge:1",
1689 "node:a",
1690 "node:test",
1691 1.0,
1692 EpistemicStatus::Confirmed,
1693 );
1694 validating.kind = EdgeKind::Validates;
1695 let edges = vec![
1696 validating,
1697 edge(
1698 "edge:2",
1699 "node:test",
1700 "node:b",
1701 1.0,
1702 EpistemicStatus::Confirmed,
1703 ),
1704 ];
1705 let mut request = traversal_request("node:a", "node:b", TraversalAlgorithm::Bfs);
1706 request.filters.include_tests = false;
1707 request.filters.edge_kinds = vec![EdgeKind::Validates, EdgeKind::CallsRemote];
1708 let report = report_or_panic(traverse(&nodes, &edges, &request));
1709
1710 assert!(report.paths.is_empty());
1711 }
1712
1713 #[test]
1714 fn dijkstra_uses_confidence_weighted_shortest_path() {
1715 let nodes = vec![
1716 node("node:a", NodeKind::Service, None),
1717 node("node:b", NodeKind::Service, None),
1718 node("node:c", NodeKind::Service, None),
1719 ];
1720 let edges = vec![
1721 edge(
1722 "edge:1",
1723 "node:a",
1724 "node:c",
1725 0.2,
1726 EpistemicStatus::Confirmed,
1727 ),
1728 edge(
1729 "edge:2",
1730 "node:a",
1731 "node:b",
1732 1.0,
1733 EpistemicStatus::Confirmed,
1734 ),
1735 edge(
1736 "edge:3",
1737 "node:b",
1738 "node:c",
1739 1.0,
1740 EpistemicStatus::Confirmed,
1741 ),
1742 ];
1743 let request = traversal_request("node:a", "node:c", TraversalAlgorithm::Dijkstra);
1744 let report = report_or_panic(traverse(&nodes, &edges, &request));
1745 let edge_ids = report.paths[0]
1746 .segments
1747 .iter()
1748 .map(|segment| segment.trace.edge.id.as_str())
1749 .collect::<Vec<_>>();
1750
1751 assert_eq!(edge_ids, vec!["edge:2", "edge:3"]);
1752 }
1753
1754 #[test]
1755 fn k_shortest_is_loopless_and_deterministic() {
1756 let nodes = vec![
1757 node("node:a", NodeKind::Service, None),
1758 node("node:b", NodeKind::Service, None),
1759 node("node:c", NodeKind::Service, None),
1760 node("node:d", NodeKind::Service, None),
1761 ];
1762 let edges = vec![
1763 edge(
1764 "edge:1",
1765 "node:a",
1766 "node:b",
1767 1.0,
1768 EpistemicStatus::Confirmed,
1769 ),
1770 edge(
1771 "edge:2",
1772 "node:b",
1773 "node:d",
1774 1.0,
1775 EpistemicStatus::Confirmed,
1776 ),
1777 edge(
1778 "edge:3",
1779 "node:a",
1780 "node:c",
1781 1.0,
1782 EpistemicStatus::Confirmed,
1783 ),
1784 edge(
1785 "edge:4",
1786 "node:c",
1787 "node:d",
1788 1.0,
1789 EpistemicStatus::Confirmed,
1790 ),
1791 edge(
1792 "edge:5",
1793 "node:b",
1794 "node:a",
1795 1.0,
1796 EpistemicStatus::Confirmed,
1797 ),
1798 ];
1799 let mut request = traversal_request("node:a", "node:d", TraversalAlgorithm::KShortest);
1800 request.options.k = 2;
1801 let first = report_or_panic(traverse(&nodes, &edges, &request));
1802 let second = report_or_panic(traverse(&nodes, &edges, &request));
1803
1804 assert_eq!(first.paths, second.paths);
1805 }
1806
1807 #[test]
1808 fn unresolved_candidates_are_never_promoted_to_paths() {
1809 let nodes = vec![
1810 node("node:a", NodeKind::Service, None),
1811 node("node:b", NodeKind::Service, None),
1812 ];
1813 let edges = vec![edge(
1814 "edge:candidate",
1815 "node:a",
1816 "node:b",
1817 1.0,
1818 EpistemicStatus::Incomplete,
1819 )];
1820 let request = traversal_request("node:a", "node:b", TraversalAlgorithm::Bfs);
1821 let report = report_or_panic(traverse(&nodes, &edges, &request));
1822
1823 assert!(report.paths.is_empty() && report.candidate_unresolved_edges.len() == 1);
1824 }
1825
1826 #[test]
1827 fn traversal_reports_node_limit_and_timeout() {
1828 let nodes = vec![
1829 node("node:a", NodeKind::Service, None),
1830 node("node:b", NodeKind::Service, None),
1831 node("node:c", NodeKind::Service, None),
1832 ];
1833 let edges = vec![
1834 edge(
1835 "edge:1",
1836 "node:a",
1837 "node:b",
1838 1.0,
1839 EpistemicStatus::Confirmed,
1840 ),
1841 edge(
1842 "edge:2",
1843 "node:b",
1844 "node:c",
1845 1.0,
1846 EpistemicStatus::Confirmed,
1847 ),
1848 ];
1849 let mut limited = traversal_request("node:a", "node:c", TraversalAlgorithm::Bfs);
1850 limited.options.node_limit = 1;
1851 let limited_report = report_or_panic(traverse(&nodes, &edges, &limited));
1852 let mut timed = traversal_request("node:a", "node:c", TraversalAlgorithm::Bfs);
1853 timed.options.timeout_ms = 0;
1854 let timed_report = report_or_panic(traverse(&nodes, &edges, &timed));
1855
1856 assert!(limited_report.truncated && timed_report.truncated);
1857 }
1858}