Skip to main content

code_system_graph_core/
query.rs

1//! Deterministic ranked search and bounded graph traversal.
2
3use 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/// Filters applied before search candidates are ranked.
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
25pub struct SearchFilters {
26    /// Node kinds eligible for the result; an empty list accepts every kind.
27    #[serde(default)]
28    pub node_kinds: Vec<NodeKind>,
29    /// Repositories eligible for the result; an empty list accepts every repository.
30    #[serde(default)]
31    pub repo_ids: Vec<RepoId>,
32    /// Node identifiers in the selected workspace scope; an empty list accepts every node.
33    #[serde(default)]
34    pub workspace_nodes: Vec<NodeId>,
35    /// Service identifiers that must contain an eligible node.
36    #[serde(default)]
37    pub service_ids: Vec<NodeId>,
38    /// Community identifiers that must contain an eligible node.
39    #[serde(default)]
40    pub community_ids: Vec<CommunityId>,
41}
42
43/// Input for one deterministic ranked search.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
45pub struct SearchRequest {
46    /// User query matched against stable keys, labels, and node kinds.
47    pub query: String,
48    /// Scope and type filters.
49    #[serde(default)]
50    pub filters: SearchFilters,
51    /// Normalized full-text scores keyed by node identifier.
52    #[serde(default)]
53    pub fts_scores: BTreeMap<NodeId, f64>,
54    /// Normalized centrality scores keyed by node identifier.
55    #[serde(default)]
56    pub centrality_scores: BTreeMap<NodeId, f64>,
57    /// Optional service memberships keyed by member node identifier.
58    #[serde(default)]
59    pub service_memberships: BTreeMap<NodeId, Vec<NodeId>>,
60    /// Optional community memberships keyed by member node identifier.
61    #[serde(default)]
62    pub community_memberships: BTreeMap<NodeId, Vec<CommunityId>>,
63    /// Evidence used only to derive bounded quality scores, never returned in hits.
64    #[serde(default)]
65    pub evidence: BTreeMap<NodeId, Vec<Evidence>>,
66    /// Repository freshness used to penalize stale or incomplete results.
67    #[serde(default)]
68    pub freshness: BTreeMap<RepoId, RepoFreshnessState>,
69    /// Zero-based result offset.
70    pub offset: usize,
71    /// Maximum number of results, inclusively bounded by 100.
72    pub limit: usize,
73}
74
75/// Score components and match facts for one search hit.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
77pub struct SearchExplanation {
78    /// Fields that matched the normalized query.
79    pub matched_fields: Vec<String>,
80    /// Score contributed by exact stable-key or label matching.
81    pub exact_score: f64,
82    /// Score contributed by normalized prefix matching.
83    pub prefix_score: f64,
84    /// Score contributed by normalized suffix matching.
85    pub suffix_score: f64,
86    /// Score contributed by the supplied full-text score.
87    pub fts_score: f64,
88    /// Score contributed by matching the node kind.
89    pub type_score: f64,
90    /// Score contributed by explicit service or community scope.
91    pub scope_score: f64,
92    /// Score contributed by supplied centrality.
93    pub centrality_score: f64,
94    /// Score contributed by optional community membership.
95    pub community_score: f64,
96    /// Score contributed by bounded evidence quality.
97    pub evidence_score: f64,
98    /// Score subtracted for stale, partial, unknown, or unavailable inputs.
99    pub freshness_penalty: f64,
100}
101
102/// One ranked search result without source bodies.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
104pub struct SearchHit {
105    /// Matched graph node.
106    pub node: Node,
107    /// Deterministic aggregate score.
108    pub score: f64,
109    /// Auditable score decomposition.
110    pub explanation: SearchExplanation,
111}
112
113/// Coverage and degradation details for a search.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115pub struct SearchCoverage {
116    /// Distinct input nodes considered.
117    pub input_nodes: usize,
118    /// Nodes remaining after scope filters.
119    pub eligible_nodes: usize,
120    /// Eligible nodes with a lexical or full-text match.
121    pub matched_nodes: usize,
122    /// Nodes for which a full-text score was supplied.
123    pub fts_scored_nodes: usize,
124    /// Repositories whose freshness was required but not supplied.
125    pub freshness_unknown_repositories: Vec<RepoId>,
126    /// Missing optional signals or other coverage limitations.
127    pub gaps: Vec<String>,
128}
129
130/// Paginated ranked search result.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
132pub struct SearchReport {
133    /// Hits in deterministic score order.
134    pub hits: Vec<SearchHit>,
135    /// Total matches before pagination.
136    pub total_matches: usize,
137    /// Applied zero-based offset.
138    pub offset: usize,
139    /// Applied page-size limit.
140    pub limit: usize,
141    /// Whether additional matches exist after this page.
142    pub truncated: bool,
143    /// Search coverage and degradation details.
144    pub coverage: SearchCoverage,
145}
146
147/// Traversal strategy used to find confirmed paths.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
149#[serde(rename_all = "snake_case")]
150pub enum TraversalAlgorithm {
151    /// Deterministic shortest path by hop count.
152    Bfs,
153    /// Deterministic shortest path by conservative edge weight.
154    Dijkstra,
155    /// Deterministic bounded enumeration of loopless paths by total weight.
156    KShortest,
157}
158
159/// Direction in which graph relationships may be traversed.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
161#[serde(rename_all = "snake_case")]
162pub enum TraversalDirection {
163    /// Follow relationships from source to target.
164    Outgoing,
165    /// Follow relationships from target to source.
166    Incoming,
167    /// Follow relationships in either direction.
168    Both,
169}
170
171/// Filters applied to nodes and edges during traversal.
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
173pub struct TraversalFilters {
174    /// Eligible relationship kinds; an empty list accepts every kind.
175    #[serde(default)]
176    pub edge_kinds: Vec<EdgeKind>,
177    /// Minimum edge confidence in the inclusive range from zero to one.
178    pub min_confidence: f64,
179    /// Whether paths may include test-case nodes.
180    pub include_tests: bool,
181    /// Whether paths may include artifacts identified as generated.
182    pub include_generated_artifacts: bool,
183    /// Deployment namespace required when a deployment stable key represents one.
184    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/// Positive base cost assigned to one relationship kind.
200#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
201pub struct EdgeKindCost {
202    /// Relationship kind receiving the custom cost.
203    pub kind: EdgeKind,
204    /// Positive finite base cost divided by edge confidence.
205    pub cost: f64,
206}
207
208/// Bounds and costs controlling one graph traversal.
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
210pub struct TraversalOptions {
211    /// Traversal strategy.
212    pub algorithm: TraversalAlgorithm,
213    /// Relationship direction.
214    pub direction: TraversalDirection,
215    /// Maximum number of segments in a path.
216    pub max_depth: usize,
217    /// Maximum number of cross-repository segments in a path.
218    pub max_cross_repo_hops: usize,
219    /// Maximum number of distinct nodes observed.
220    pub node_limit: usize,
221    /// Maximum number of relationship examinations.
222    pub edge_limit: usize,
223    /// Wall-clock budget in milliseconds.
224    pub timeout_ms: u64,
225    /// Number of paths requested for `k_shortest`; inclusively bounded by 16.
226    pub k: usize,
227    /// Optional positive base costs keyed by relationship kind.
228    #[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/// Input for one bounded confirmed-path traversal.
249#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
250pub struct TraversalRequest {
251    /// Starting node identifier.
252    pub start: NodeId,
253    /// Destination node identifier.
254    pub target: NodeId,
255    /// Node and edge filters.
256    #[serde(default)]
257    pub filters: TraversalFilters,
258    /// Traversal limits, algorithm, and costs.
259    #[serde(default)]
260    pub options: TraversalOptions,
261}
262
263/// Whether a path segment remains within one repository.
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
265#[serde(rename_all = "snake_case")]
266pub enum PathSegmentScope {
267    /// Both endpoints have the same repository ownership.
268    Local,
269    /// Endpoint repository ownership differs.
270    CrossRepository,
271}
272
273/// One ordered segment in a confirmed traversal path.
274#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
275pub struct PathSegment {
276    /// Existing model trace segment in traversal order.
277    pub trace: TraceSegment,
278    /// Local or cross-repository classification.
279    pub scope: PathSegmentScope,
280    /// Whether the underlying directed edge was followed in reverse.
281    pub reversed: bool,
282    /// Positive finite cost used by weighted algorithms.
283    pub weight: f64,
284}
285
286/// One loopless confirmed path.
287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
288pub struct TraversalPath {
289    /// Ordered path segments.
290    pub segments: Vec<PathSegment>,
291    /// Sum of positive finite segment weights.
292    pub total_weight: f64,
293    /// Number of cross-repository segments.
294    pub cross_repo_hops: usize,
295}
296
297/// Effective traversal limits echoed in a report.
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
299pub struct TraversalLimits {
300    /// Maximum path depth.
301    pub max_depth: usize,
302    /// Maximum cross-repository hops per path.
303    pub max_cross_repo_hops: usize,
304    /// Maximum distinct observed nodes.
305    pub node_limit: usize,
306    /// Maximum examined edges.
307    pub edge_limit: usize,
308    /// Timeout in milliseconds.
309    pub timeout_ms: u64,
310    /// Maximum returned paths.
311    pub max_paths: usize,
312}
313
314/// Bounded traversal result with explicit uncertainty and frontier data.
315#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
316pub struct TraversalReport {
317    /// Confirmed paths in deterministic algorithm order.
318    pub paths: Vec<TraversalPath>,
319    /// Deepest reachable confirmed nodes observed before completion or truncation.
320    pub frontier: Vec<Node>,
321    /// Encountered non-confirmed edges, excluded from every returned path.
322    pub candidate_unresolved_edges: Vec<Edge>,
323    /// Missing paths, malformed representable scopes, or exhausted bounds.
324    pub coverage_gaps: Vec<String>,
325    /// Whether any configured bound stopped exploration.
326    pub truncated: bool,
327    /// Number of distinct confirmed nodes observed.
328    pub visited_nodes: usize,
329    /// Number of edges examined, including unresolved candidates.
330    pub examined_edges: usize,
331    /// Effective limits used for this traversal.
332    pub limits: TraversalLimits,
333}
334
335/// Validation or consistency error from ranked search or traversal.
336#[derive(Debug, Error, PartialEq)]
337pub enum QueryError {
338    /// Search text is empty after normalization.
339    #[error("search query must contain at least one letter or digit")]
340    EmptyQuery,
341    /// Search pagination is outside the supported bound.
342    #[error(
343        "search limit must be in 1..={MAX_SEARCH_RESULTS} and offset must not exceed {MAX_SEARCH_OFFSET}"
344    )]
345    InvalidPagination,
346    /// A supplied score is not finite or is negative.
347    #[error("invalid {signal} score for node `{node}`")]
348    InvalidScore {
349        /// Name of the invalid signal.
350        signal: &'static str,
351        /// Node carrying the invalid score.
352        node: String,
353    },
354    /// More than one node has the same identifier.
355    #[error("duplicate node identifier `{0}`")]
356    DuplicateNode(String),
357    /// More than one edge has the same identifier.
358    #[error("duplicate edge identifier `{0}`")]
359    DuplicateEdge(String),
360    /// An edge endpoint is absent from the supplied graph.
361    #[error("edge `{edge}` references missing node `{node}`")]
362    DanglingEdge {
363        /// Stable edge identifier.
364        edge: String,
365        /// Missing node identifier.
366        node: String,
367    },
368    /// A traversal anchor is absent from the supplied graph.
369    #[error("traversal anchor `{0}` is not present")]
370    UnknownAnchor(String),
371    /// One or more traversal limits are zero or exceed hard bounds.
372    #[error("traversal limits are outside supported bounds")]
373    InvalidTraversalLimits,
374    /// Minimum confidence is not finite or lies outside zero through one.
375    #[error("minimum confidence must be finite and in the inclusive range 0..=1")]
376    InvalidMinimumConfidence,
377    /// An edge has invalid confidence for conservative weighting.
378    #[error("edge `{0}` confidence must be finite and in the inclusive range 0..=1")]
379    InvalidEdgeConfidence(String),
380    /// An edge-kind cost is not finite and strictly positive.
381    #[error("edge-kind cost for `{0:?}` must be finite and positive")]
382    InvalidEdgeCost(EdgeKind),
383    /// Internal path reconstruction found inconsistent graph state.
384    #[error("path references missing node `{0}`")]
385    InconsistentPath(String),
386}
387
388/// Performs deterministic ranked search over graph nodes.
389///
390/// Duplicate node identifiers and invalid numeric signals are rejected. Returned hits include
391/// node metadata and score explanations only; evidence and source bodies are never returned.
392///
393/// # Errors
394///
395/// Returns [`QueryError`] when the query, pagination, node identities, or numeric signals are
396/// invalid.
397#[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/// Finds confirmed paths using the requested bounded traversal strategy.
788///
789/// Non-confirmed edges are never traversed. Encountered inferred, ambiguous, stale, or incomplete
790/// edges are returned separately as unresolved candidates.
791///
792/// # Errors
793///
794/// Returns [`QueryError`] for invalid graph identities, dangling endpoints, missing anchors,
795/// invalid confidence values, invalid costs, or unsupported limits.
796#[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}