1use super::{ServiceError, ServiceResult, selected_project_binding};
4use projectatlas_core::graph::{
5 ConfidenceClass, CoverageRecord, CoverageScope, EntitySelector, GraphEntity, GraphEntityKey,
6 GraphLimitKind, GraphLimits, GraphRelationKind, LogicalRelation, RelationOccurrence,
7 RelationResolution, RepositoryFilePath, RepositoryNodePath, SymbolSelector,
8};
9use projectatlas_core::symbols::SymbolKind;
10use projectatlas_core::{
11 IndexCancellation, IndexGeneration, IndexWorkControl, IndexWorkFailure, IndexWorkStage,
12 Purpose, PurposeSource, PurposeStatus,
13};
14use projectatlas_db::{
15 AtlasStore, DbError, MAX_REPOSITORY_GRAPH_FRONTIER, RepositoryGraphAdjacencyContinuation,
16 RepositoryGraphDirection, RepositoryGraphReadBudget, RepositoryGraphReadWork,
17 RepositoryGraphRelationRow,
18};
19use serde::{Deserialize, Serialize};
20use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
21use std::io::{self, Write};
22use std::time::{Duration, Instant};
23
24const ADJACENCY_WORK_ROWS: usize = GraphLimits::MAX_ROWS as usize + 1;
26
27const DETAILED_RELATION_CURSOR_MAX_BYTES: usize = 4 * 1_024 * 1_024;
29
30const DETAILED_RELATION_CURSOR_VERSION: u16 = 1;
32
33const DETAILED_RELATION_ROOT_DOMAIN: &str = "projectatlas:detailed-relation-root:v1";
35
36#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
38#[serde(rename_all = "snake_case")]
39pub enum RelationDirection {
40 Outbound,
42 Inbound,
44}
45
46impl From<RelationDirection> for RepositoryGraphDirection {
47 fn from(value: RelationDirection) -> Self {
48 match value {
49 RelationDirection::Outbound => Self::Outbound,
50 RelationDirection::Inbound => Self::Inbound,
51 }
52 }
53}
54
55#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
57#[serde(tag = "kind", rename_all = "snake_case")]
58pub enum RelationAnchor {
59 File {
61 file: RepositoryFilePath,
63 },
64 Symbol {
66 file: RepositoryFilePath,
68 name: String,
70 symbol_kind: Option<SymbolKind>,
72 parent: Option<String>,
74 signature: Option<String>,
76 },
77}
78
79#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
81#[serde(rename_all = "snake_case")]
82pub enum RelationResolutionFilter {
83 Any,
85 Resolved,
87 Ambiguous,
89 Unresolved,
91 External,
93}
94
95pub fn parse_relation_direction(value: &str) -> ServiceResult<RelationDirection> {
101 match value.trim().to_ascii_lowercase().as_str() {
102 "outbound" => Ok(RelationDirection::Outbound),
103 "inbound" => Ok(RelationDirection::Inbound),
104 _ => Err(ServiceError::InvalidInput(format!(
105 "unsupported relation direction {value:?}"
106 ))),
107 }
108}
109
110pub fn parse_relation_confidence(value: &str) -> ServiceResult<ConfidenceClass> {
116 match value.trim().to_ascii_lowercase().as_str() {
117 "exact" => Ok(ConfidenceClass::Exact),
118 "high" => Ok(ConfidenceClass::High),
119 "medium" => Ok(ConfidenceClass::Medium),
120 "low" => Ok(ConfidenceClass::Low),
121 _ => Err(ServiceError::InvalidInput(format!(
122 "unsupported relation confidence {value:?}"
123 ))),
124 }
125}
126
127pub fn parse_relation_resolution(value: &str) -> ServiceResult<RelationResolutionFilter> {
133 match value.trim().to_ascii_lowercase().as_str() {
134 "any" => Ok(RelationResolutionFilter::Any),
135 "resolved" => Ok(RelationResolutionFilter::Resolved),
136 "ambiguous" => Ok(RelationResolutionFilter::Ambiguous),
137 "unresolved" => Ok(RelationResolutionFilter::Unresolved),
138 "external" => Ok(RelationResolutionFilter::External),
139 _ => Err(ServiceError::InvalidInput(format!(
140 "unsupported relation resolution {value:?}"
141 ))),
142 }
143}
144
145#[derive(Clone, Debug)]
147pub struct DetailedRelationQuery {
148 pub anchor: RelationAnchor,
150 pub direction: RelationDirection,
152 pub relation: Option<GraphRelationKind>,
154 pub minimum_confidence: ConfidenceClass,
156 pub resolution: RelationResolutionFilter,
158 pub include_occurrences: bool,
160 pub budget: DetailedRelationBudget,
162 pub cursor: Option<String>,
164}
165
166#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
168pub struct DetailedRelationBudget {
169 page_rows: u32,
171 depth: u32,
173 edges: u32,
175 nodes: u32,
177 visited: u32,
179 occurrences_per_relation: u32,
181 occurrences_total: u32,
183 intermediate_bytes: u64,
185 deadline_ms: u64,
187 output_bytes: u32,
189}
190
191impl DetailedRelationBudget {
192 pub const MAX_EDGES: u32 = 100_000;
194 pub const MAX_NODES: u32 = GraphLimits::MAX_ROWS;
196 pub const MAX_OCCURRENCES_TOTAL: u32 = 100_000;
198 pub const MAX_INTERMEDIATE_BYTES: u64 = 32 * 1_024 * 1_024;
200 pub const MAX_DEADLINE_MS: u64 = 60_000;
202
203 #[must_use]
205 pub fn from_graph_limits(limits: GraphLimits) -> Self {
206 let nodes = Self::MAX_NODES;
207 let occurrences_total = limits
208 .rows()
209 .saturating_mul(limits.occurrences())
210 .min(Self::MAX_OCCURRENCES_TOTAL);
211 let intermediate_bytes = u64::from(limits.output_bytes())
212 .saturating_mul(4)
213 .clamp(64 * 1_024, Self::MAX_INTERMEDIATE_BYTES);
214 Self {
215 page_rows: limits.rows(),
216 depth: limits.depth(),
217 edges: limits.rows(),
218 nodes,
219 visited: nodes,
220 occurrences_per_relation: limits.occurrences(),
221 occurrences_total,
222 intermediate_bytes,
223 deadline_ms: 10_000,
224 output_bytes: limits.output_bytes(),
225 }
226 }
227
228 pub fn with_aggregate_limits(
235 mut self,
236 edges: Option<u32>,
237 nodes: Option<u32>,
238 visited: Option<u32>,
239 occurrences_total: Option<u32>,
240 intermediate_bytes: Option<u64>,
241 deadline_ms: Option<u64>,
242 ) -> ServiceResult<Self> {
243 self.edges = edges.unwrap_or(self.edges);
244 self.nodes = nodes.unwrap_or(self.nodes);
245 self.visited = visited.unwrap_or(self.visited);
246 self.occurrences_total = occurrences_total.unwrap_or(self.occurrences_total);
247 self.intermediate_bytes = intermediate_bytes.unwrap_or(self.intermediate_bytes);
248 self.deadline_ms = deadline_ms.unwrap_or(self.deadline_ms);
249 self.validate()
250 }
251
252 fn validate(self) -> ServiceResult<Self> {
254 let valid = self.page_rows > 0
255 && self.page_rows <= GraphLimits::MAX_ROWS
256 && self.depth > 0
257 && self.depth <= GraphLimits::MAX_DEPTH
258 && self.edges > 0
259 && self.edges <= Self::MAX_EDGES
260 && self.nodes > 0
261 && self.nodes <= Self::MAX_NODES
262 && self.visited > 0
263 && self.visited <= Self::MAX_NODES
264 && self.occurrences_per_relation > 0
265 && self.occurrences_per_relation <= GraphLimits::MAX_OCCURRENCES
266 && self.occurrences_total > 0
267 && self.occurrences_total <= Self::MAX_OCCURRENCES_TOTAL
268 && self.intermediate_bytes >= 64 * 1_024
269 && self.intermediate_bytes <= Self::MAX_INTERMEDIATE_BYTES
270 && self.deadline_ms > 0
271 && self.deadline_ms <= Self::MAX_DEADLINE_MS
272 && self.output_bytes > 0
273 && self.output_bytes <= GraphLimits::MAX_OUTPUT_BYTES;
274 if !valid {
275 return Err(ServiceError::InvalidInput(
276 "detailed relation budget is zero, internally inconsistent, or above a product ceiling"
277 .to_string(),
278 ));
279 }
280 Ok(self)
281 }
282
283 #[must_use]
285 pub const fn page_rows(self) -> u32 {
286 self.page_rows
287 }
288
289 #[must_use]
291 pub const fn depth(self) -> u32 {
292 self.depth
293 }
294
295 #[must_use]
297 pub const fn edges(self) -> u32 {
298 self.edges
299 }
300
301 #[must_use]
303 pub const fn nodes(self) -> u32 {
304 self.nodes
305 }
306
307 #[must_use]
309 pub const fn visited(self) -> u32 {
310 self.visited
311 }
312
313 #[must_use]
315 pub const fn occurrences_per_relation(self) -> u32 {
316 self.occurrences_per_relation
317 }
318
319 #[must_use]
321 pub const fn occurrences_total(self) -> u32 {
322 self.occurrences_total
323 }
324
325 #[must_use]
327 pub const fn intermediate_bytes(self) -> u64 {
328 self.intermediate_bytes
329 }
330
331 #[must_use]
333 pub const fn deadline_ms(self) -> u64 {
334 self.deadline_ms
335 }
336
337 #[must_use]
339 pub const fn output_bytes(self) -> u32 {
340 self.output_bytes
341 }
342}
343
344#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
346#[serde(tag = "state", rename_all = "snake_case")]
347pub enum RelationPurpose {
348 Approved {
350 path: String,
352 purpose: String,
354 source: PurposeSource,
356 status: PurposeStatus,
358 },
359 Unavailable {
361 path: Option<String>,
363 },
364 NotApplicable,
366}
367
368#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
370#[serde(tag = "capability", rename_all = "snake_case")]
371pub enum RelationNextCall {
372 Files {
374 folder: projectatlas_core::graph::RepositoryNodePath,
376 },
377 Summary {
379 file: RepositoryFilePath,
381 },
382 SymbolSlice {
384 symbol: SymbolSelector,
386 },
387}
388
389#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
391pub struct DetailedRelationNode {
392 pub entity: GraphEntity,
394 pub purpose: RelationPurpose,
396 pub coverage: Vec<CoverageRecord>,
398}
399
400#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
402pub struct DetailedRelationRow {
403 pub depth: u32,
405 pub direction: RelationDirection,
407 pub relation: LogicalRelation,
409 pub source: DetailedRelationNode,
411 pub target: Option<DetailedRelationNode>,
413 pub target_purpose: RelationPurpose,
415 pub path: Vec<DetailedRelationNode>,
417 pub occurrences: Vec<RelationOccurrence>,
419 pub occurrences_truncated: bool,
421 pub next_call: Option<RelationNextCall>,
423}
424
425#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
427#[serde(tag = "state", content = "value", rename_all = "snake_case")]
428pub enum RelationTotalState {
429 Exact(u64),
431 AtLeast(u64),
433 Unknown,
435}
436
437#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
439pub struct DetailedRelationWork {
440 pub returned_rows: u32,
442 pub inspected_edges: u32,
444 pub active_nodes: u32,
446 pub visited_nodes: u32,
448 pub retained_occurrences: u32,
450 pub database_requested_rows: u32,
452 pub database_returned_rows: u32,
454 pub database_decoded_bytes: u64,
456 pub hydrated_entities: u32,
458 pub hydrated_purpose_paths: u32,
460 pub retained_composition_bytes: u64,
462 pub intermediate_bytes: u64,
464 pub rendered_output_bytes: u64,
466}
467
468#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
470struct RelationDatabaseWork {
471 requested_rows: u32,
473 returned_rows: u32,
475 decoded_bytes: u64,
477 hydrated_entities: u32,
479 hydrated_paths: u32,
481}
482
483impl RelationDatabaseWork {
484 fn record(&mut self, work: RepositoryGraphReadWork) -> ServiceResult<()> {
486 self.requested_rows = self
487 .requested_rows
488 .checked_add(work.requested_rows)
489 .ok_or_else(relation_work_overflow)?;
490 self.returned_rows = self
491 .returned_rows
492 .checked_add(work.returned_rows)
493 .ok_or_else(relation_work_overflow)?;
494 self.decoded_bytes = self
495 .decoded_bytes
496 .checked_add(work.decoded_bytes)
497 .ok_or_else(relation_work_overflow)?;
498 self.hydrated_entities = self
499 .hydrated_entities
500 .checked_add(work.hydrated_entities)
501 .ok_or_else(relation_work_overflow)?;
502 self.hydrated_paths = self
503 .hydrated_paths
504 .checked_add(work.hydrated_paths)
505 .ok_or_else(relation_work_overflow)?;
506 Ok(())
507 }
508}
509
510#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
512pub struct DetailedRelationReport {
513 pub anchor: DetailedRelationNode,
515 pub generation: IndexGeneration,
517 pub authored_purpose_revision: u64,
519 pub direction: RelationDirection,
521 pub returned: u32,
523 pub pruned_paths: u64,
525 pub truncated: bool,
527 pub continuation: Option<String>,
529 pub total: RelationTotalState,
531 pub reached_limits: Vec<GraphLimitKind>,
533 pub work: DetailedRelationWork,
535 pub rows: Vec<DetailedRelationRow>,
537}
538
539pub(super) type ExternalRelationIdentity = (String, String, String);
541
542#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
544#[serde(rename_all = "snake_case")]
545enum DetailedRelationAlgorithm {
546 BoundedFrontierV1,
548}
549
550#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
552#[serde(rename_all = "snake_case")]
553enum DetailedRelationOrdering {
554 BreadthFirstRankedBatchV1,
556}
557
558#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
560#[serde(deny_unknown_fields)]
561struct DetailedRelationCursorQuery {
562 anchor: RelationAnchor,
564 direction: RelationDirection,
566 relation: Option<GraphRelationKind>,
568 minimum_confidence: ConfidenceClass,
570 resolution: RelationResolutionFilter,
572 include_occurrences: bool,
574}
575
576impl From<&DetailedRelationQuery> for DetailedRelationCursorQuery {
577 fn from(query: &DetailedRelationQuery) -> Self {
578 Self {
579 anchor: query.anchor.clone(),
580 direction: query.direction,
581 relation: query.relation,
582 minimum_confidence: query.minimum_confidence,
583 resolution: query.resolution,
584 include_occurrences: query.include_occurrences,
585 }
586 }
587}
588
589#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
591#[serde(deny_unknown_fields)]
592struct DetailedRelationCursorBinding {
593 project: projectatlas_core::graph::ProjectInstanceId,
595 root_digest: [u8; 32],
597 generation: IndexGeneration,
599 authored_purpose_revision: u64,
601 capability: DetailedRelationAlgorithm,
603 query: DetailedRelationCursorQuery,
605 ordering: DetailedRelationOrdering,
607 budget: DetailedRelationBudget,
609}
610
611#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
613#[serde(deny_unknown_fields)]
614struct TraversalNodeState {
615 digest: [u8; 32],
617 parent: Option<u32>,
619}
620
621#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
623#[serde(deny_unknown_fields)]
624struct PendingRelationState {
625 relation_digest: [u8; 32],
627 depth: u32,
629 path_terminal: u32,
631}
632
633#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
635#[serde(deny_unknown_fields)]
636struct RelationTraversalState {
637 depth: u32,
639 nodes: Vec<TraversalNodeState>,
641 frontier: Vec<u32>,
643 frontier_index: u32,
645 next_frontier: Vec<u32>,
647 adjacency: Option<RepositoryGraphAdjacencyContinuation>,
649 pending: Vec<PendingRelationState>,
651 pending_index: u32,
653 emitted_rows: u64,
655 pruned_paths: u64,
657}
658
659#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
661#[serde(deny_unknown_fields)]
662struct DetailedRelationCursor {
663 version: u16,
665 binding: DetailedRelationCursorBinding,
667 state: RelationTraversalState,
669}
670
671pub struct DetailedRelationPageDraft {
673 report: DetailedRelationReport,
675 old_emitted: u64,
677 binding: DetailedRelationCursorBinding,
679 prefix_state: Option<RelationTraversalState>,
681 budget: DetailedRelationBudget,
683 deadline: Instant,
685}
686
687impl DetailedRelationPageDraft {
688 #[must_use]
690 pub fn candidate_rows(&self) -> usize {
691 self.report.rows.len()
692 }
693
694 #[must_use]
696 pub const fn maximum_output_bytes(&self) -> usize {
697 self.budget.output_bytes() as usize
698 }
699
700 pub fn report_for_prefix(&self, selected_rows: usize) -> ServiceResult<DetailedRelationReport> {
707 if selected_rows > self.report.rows.len() {
708 return Err(ServiceError::InvalidInput(
709 "detailed relation output prefix exceeds the candidate page".to_string(),
710 ));
711 }
712 let full_rows = self.report.rows.len();
713 let mut report = self.report.clone();
714 if selected_rows < full_rows {
715 let mut state =
716 self.prefix_state
717 .clone()
718 .ok_or(ServiceError::RelationCursorInvalid {
719 reason: "output prefix has no matching traversal checkpoint",
720 })?;
721 state.pending_index = state
722 .pending_index
723 .checked_add(u32::try_from(selected_rows).map_err(|_overflow| {
724 ServiceError::InvalidInput(
725 "detailed relation output prefix index overflowed".to_string(),
726 )
727 })?)
728 .ok_or_else(|| {
729 ServiceError::InvalidInput(
730 "detailed relation output prefix index overflowed".to_string(),
731 )
732 })?;
733 state.emitted_rows = self.old_emitted.saturating_add(selected_rows as u64);
734 report.continuation = Some(encode_relation_cursor(&self.binding, &state, self.budget)?);
735 report.pruned_paths = state.pruned_paths;
736 push_limit(&mut report.reached_limits, GraphLimitKind::OutputBytes);
737 let pending = state
738 .pending
739 .len()
740 .saturating_sub(state.pending_index as usize) as u64;
741 report.total = RelationTotalState::AtLeast(state.emitted_rows.saturating_add(pending));
742 report.work.active_nodes = u32::try_from(state.nodes.len()).unwrap_or(u32::MAX);
743 report.work.visited_nodes = report.work.active_nodes;
744 }
745 report.rows.truncate(selected_rows);
746 report.returned = u32::try_from(selected_rows).map_err(|_overflow| {
747 ServiceError::InvalidInput("output row count overflowed".to_string())
748 })?;
749 report.work.returned_rows = report.returned;
750 report.work.retained_occurrences = report
751 .rows
752 .iter()
753 .map(|row| row.occurrences.len() as u32)
754 .sum();
755 report.work.retained_composition_bytes =
756 relation_composition_bytes(&report.anchor, &report.rows)?;
757 let prefix_cursor_bytes = report
758 .continuation
759 .as_ref()
760 .map_or(0, |cursor| cursor.len() as u64);
761 let prefix_intermediate_bytes = relation_intermediate_bytes(
762 report.work.database_decoded_bytes,
763 prefix_cursor_bytes,
764 0,
765 report.work.retained_composition_bytes,
766 )?;
767 report.work.intermediate_bytes = report
768 .work
769 .intermediate_bytes
770 .max(prefix_intermediate_bytes);
771 if report.work.intermediate_bytes > self.budget.intermediate_bytes() {
772 return Err(ServiceError::InvalidInput(
773 "detailed relation output prefix exceeds the aggregate intermediate-byte budget"
774 .to_string(),
775 ));
776 }
777 report.work.rendered_output_bytes = 0;
778 report.truncated = report.continuation.is_some() || !report.reached_limits.is_empty();
779 Ok(report)
780 }
781
782 pub fn fit_output<F, E>(
793 &self,
794 control: Option<&IndexWorkControl>,
795 encode: F,
796 ) -> Result<(DetailedRelationReport, String), E>
797 where
798 F: FnMut(&DetailedRelationReport) -> Result<String, E>,
799 E: From<ServiceError>,
800 {
801 check_relation_deadline(self.deadline).map_err(E::from)?;
802 fit_detailed_relation_output(self, control, encode)
803 }
804
805 fn fit_compact(
807 &self,
808 control: Option<&IndexWorkControl>,
809 ) -> ServiceResult<DetailedRelationReport> {
810 self.fit_output(control, |report| {
811 serde_json::to_string(report).map_err(ServiceError::from)
812 })
813 .map(|(report, _encoded)| report)
814 }
815}
816
817struct TraversalRow {
819 depth: u32,
821 detail: RepositoryGraphRelationRow,
823 path: Vec<GraphEntity>,
825}
826
827#[derive(Default)]
829struct SerializedByteCounter {
830 bytes: u64,
832}
833
834impl Write for SerializedByteCounter {
835 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
836 let bytes = u64::try_from(buffer.len())
837 .map_err(|_overflow| io::Error::other("serialized byte count overflowed"))?;
838 self.bytes = self
839 .bytes
840 .checked_add(bytes)
841 .ok_or_else(|| io::Error::other("serialized byte count overflowed"))?;
842 Ok(buffer.len())
843 }
844
845 fn flush(&mut self) -> io::Result<()> {
846 Ok(())
847 }
848}
849
850pub fn load_detailed_relation_page(
857 store: &AtlasStore,
858 query: &DetailedRelationQuery,
859 control: Option<&IndexWorkControl>,
860) -> ServiceResult<DetailedRelationPageDraft> {
861 check_relation_control(control)?;
862 let budget = query.budget.validate()?;
863 let started = Instant::now();
864 let deadline = started
865 .checked_add(Duration::from_millis(budget.deadline_ms()))
866 .unwrap_or(started);
867 let request_control = relation_request_control(control, deadline);
868 let control = Some(&request_control);
869 let binding = selected_project_binding(store)?;
870 check_relation_control(control)?;
871 let generation = store.repository_graph_generation()?.ok_or_else(|| {
872 ServiceError::InvalidInput(
873 "repository graph has no complete generation for relation navigation".to_string(),
874 )
875 })?;
876 let mut database_work = RelationDatabaseWork::default();
877 let anchor = resolve_anchor(
878 store,
879 binding.project_instance_id,
880 generation,
881 &query.anchor,
882 budget,
883 &mut database_work,
884 control,
885 )?;
886 check_relation_control(control)?;
887 let anchor_digest = anchor.key().digest_bytes().map_err(invalid_graph_input)?;
888 let authored_purpose_revision = store.authored_purpose_revision()?;
889 check_relation_control(control)?;
890 let cursor_binding = DetailedRelationCursorBinding {
891 project: binding.project_instance_id,
892 root_digest: detailed_relation_root_digest(&binding.project_root),
893 generation,
894 authored_purpose_revision,
895 capability: DetailedRelationAlgorithm::BoundedFrontierV1,
896 query: DetailedRelationCursorQuery::from(query),
897 ordering: DetailedRelationOrdering::BreadthFirstRankedBatchV1,
898 budget,
899 };
900 let mut state = if let Some(encoded) = &query.cursor {
901 decode_relation_cursor(encoded, &cursor_binding)?
902 } else {
903 RelationTraversalState {
904 depth: 1,
905 nodes: vec![TraversalNodeState {
906 digest: anchor_digest,
907 parent: None,
908 }],
909 frontier: vec![0],
910 frontier_index: 0,
911 next_frontier: Vec::new(),
912 adjacency: None,
913 pending: Vec::new(),
914 pending_index: 0,
915 emitted_rows: 0,
916 pruned_paths: 0,
917 }
918 };
919 validate_traversal_state(&state, budget, anchor_digest)?;
920 let mut entities = hydrate_traversal_entities(
921 store,
922 binding.project_instance_id,
923 generation,
924 &state,
925 budget,
926 encoded_relation_state_bytes(&cursor_binding, &state, budget)?,
927 &mut database_work,
928 control,
929 )?;
930 let mut visited = state
931 .nodes
932 .iter()
933 .enumerate()
934 .map(|(index, node)| {
935 u32::try_from(index)
936 .map(|index| (node.digest, index))
937 .map_err(|_overflow| ServiceError::RelationCursorInvalid {
938 reason: "node index exceeds the cursor representation",
939 })
940 })
941 .collect::<ServiceResult<HashMap<_, _>>>()?;
942 let mut selected = Vec::new();
943 let mut prefix_state = None;
944 let mut inspected_edges = 0_u32;
945 let mut reached_limits = Vec::new();
946 let mut terminal_limit = false;
947 let mut exhausted = false;
948
949 while selected.len() < budget.page_rows() as usize {
950 if relation_deadline_elapsed(deadline) {
951 push_limit(&mut reached_limits, GraphLimitKind::Deadline);
952 break;
953 }
954 check_relation_control(control)?;
955
956 if (state.pending_index as usize) < state.pending.len() {
957 prefix_state.get_or_insert_with(|| state.clone());
958 selected.push(state.pending[state.pending_index as usize].clone());
959 state.pending_index = state.pending_index.saturating_add(1);
960 continue;
961 }
962 if !selected.is_empty() {
963 break;
964 }
965 state.pending.clear();
966 state.pending_index = 0;
967
968 if state.frontier_index as usize >= state.frontier.len() {
969 if state.next_frontier.is_empty() {
970 exhausted = true;
971 break;
972 }
973 if state.depth >= budget.depth() {
974 push_limit(&mut reached_limits, GraphLimitKind::Depth);
975 terminal_limit = true;
976 break;
977 }
978 state.frontier = std::mem::take(&mut state.next_frontier);
979 state.frontier_index = 0;
980 state.adjacency = None;
981 state.depth = state.depth.saturating_add(1);
982 }
983
984 if inspected_edges >= budget.edges() {
985 push_limit(&mut reached_limits, GraphLimitKind::Edges);
986 break;
987 }
988 let chunk_start = state.frontier_index as usize;
989 let chunk_end = chunk_start
990 .saturating_add(MAX_REPOSITORY_GRAPH_FRONTIER)
991 .min(state.frontier.len());
992 let chunk_nodes = state.frontier[chunk_start..chunk_end].to_vec();
993 let frontier = chunk_nodes
994 .iter()
995 .map(|index| {
996 entities
997 .get(*index as usize)
998 .map(|entity| entity.key().clone())
999 .ok_or(ServiceError::RelationCursorInvalid {
1000 reason: "frontier node index is absent",
1001 })
1002 })
1003 .collect::<ServiceResult<Vec<_>>>()?;
1004 let mut depth_rows = Vec::new();
1005 loop {
1006 if relation_deadline_elapsed(deadline) {
1007 push_limit(&mut reached_limits, GraphLimitKind::Deadline);
1008 break;
1009 }
1010 check_relation_control(control)?;
1011 let remaining_edges = budget.edges().saturating_sub(inspected_edges);
1012 if remaining_edges == 0 {
1013 break;
1014 }
1015 let per_page = (ADJACENCY_WORK_ROWS / frontier.len())
1016 .saturating_sub(1)
1017 .min(remaining_edges as usize)
1018 .max(1);
1019 let page_limit = u32::try_from(per_page).map_err(|_overflow| {
1020 ServiceError::InvalidInput("graph adjacency page limit overflowed".to_string())
1021 })?;
1022 let state_bytes = encoded_relation_state_bytes(&cursor_binding, &state, budget)?;
1023 let endpoint_limit = page_limit.saturating_add(1).saturating_mul(2);
1024 let database_budget = relation_database_budget(
1025 budget,
1026 database_work,
1027 state_bytes,
1028 frontier.len(),
1029 page_limit,
1030 endpoint_limit,
1031 endpoint_limit,
1032 )?;
1033 let bounded_page = store.repository_graph_adjacency_page_filtered_bounded(
1034 &frontier,
1035 query.direction.into(),
1036 query.relation,
1037 state.adjacency.as_ref(),
1038 page_limit,
1039 database_budget,
1040 control,
1041 )?;
1042 database_work.record(bounded_page.work)?;
1043 let page = bounded_page.page;
1044 inspected_edges = inspected_edges
1045 .checked_add(u32::try_from(page.rows.len()).map_err(|_overflow| {
1046 ServiceError::InvalidInput("inspected edge count overflowed".to_string())
1047 })?)
1048 .ok_or_else(|| {
1049 ServiceError::InvalidInput("inspected edge count overflowed".to_string())
1050 })?;
1051 depth_rows.extend(page.rows.into_iter().map(|row| FrontierRow {
1052 frontier_index: row.frontier_index,
1053 detail: row.detail,
1054 }));
1055 if page.truncated {
1056 state.adjacency = Some(page.continuation.ok_or_else(|| {
1057 ServiceError::InvalidInput(
1058 "truncated graph adjacency page omitted its continuation".to_string(),
1059 )
1060 })?);
1061 if inspected_edges >= budget.edges() {
1062 break;
1063 }
1064 } else {
1065 state.adjacency = None;
1066 state.frontier_index = u32::try_from(chunk_end).map_err(|_overflow| {
1067 ServiceError::InvalidInput("frontier index overflowed".to_string())
1068 })?;
1069 break;
1070 }
1071 }
1072 depth_rows.retain(|row| relation_matches(&row.detail.relation, query));
1073 depth_rows.sort_by(|left, right| relation_rank_order(&left.detail, &right.detail));
1074 let prior_state = state.clone();
1075 let prior_entity_count = entities.len();
1076 for row in depth_rows {
1077 check_relation_control(control)?;
1078 let local_frontier = row.frontier_index as usize;
1079 let Some(&parent_index) = chunk_nodes.get(local_frontier) else {
1080 return Err(ServiceError::InvalidInput(
1081 "graph adjacency row selected an invalid frontier index".to_string(),
1082 ));
1083 };
1084 let mut path_terminal = parent_index;
1085 if let Some(next) = traversable_entity(&row.detail, query.direction) {
1086 let digest = next.key().digest_bytes().map_err(invalid_graph_input)?;
1087 if visited.contains_key(&digest) {
1088 state.pruned_paths = state.pruned_paths.saturating_add(1);
1089 continue;
1090 }
1091 if state.nodes.len() >= budget.nodes() as usize {
1092 push_limit(&mut reached_limits, GraphLimitKind::Nodes);
1093 terminal_limit = true;
1094 break;
1095 }
1096 if visited.len() >= budget.visited() as usize {
1097 push_limit(&mut reached_limits, GraphLimitKind::Visited);
1098 terminal_limit = true;
1099 break;
1100 }
1101 path_terminal = u32::try_from(state.nodes.len()).map_err(|_overflow| {
1102 ServiceError::InvalidInput("traversal node index overflowed".to_string())
1103 })?;
1104 state.nodes.push(TraversalNodeState {
1105 digest,
1106 parent: Some(parent_index),
1107 });
1108 state.next_frontier.push(path_terminal);
1109 visited.insert(digest, path_terminal);
1110 entities.push(next.clone());
1111 }
1112 state.pending.push(PendingRelationState {
1113 relation_digest: row
1114 .detail
1115 .relation
1116 .key()
1117 .digest_bytes()
1118 .map_err(invalid_graph_input)?,
1119 depth: state.depth,
1120 path_terminal,
1121 });
1122 }
1123 if terminal_limit {
1124 break;
1125 }
1126 let state_bytes = encoded_relation_state_bytes(&cursor_binding, &state, budget)?;
1127 if state_bytes.saturating_add(database_work.decoded_bytes) > budget.intermediate_bytes() {
1128 state = prior_state;
1129 entities.truncate(prior_entity_count);
1130 visited = state
1131 .nodes
1132 .iter()
1133 .enumerate()
1134 .map(|(index, node)| (node.digest, index as u32))
1135 .collect();
1136 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
1137 terminal_limit = true;
1138 break;
1139 }
1140 }
1141
1142 if inspected_edges >= budget.edges()
1143 && traversal_has_work(&state, budget.depth())
1144 && !terminal_limit
1145 && !exhausted
1146 {
1147 push_limit(&mut reached_limits, GraphLimitKind::Edges);
1148 }
1149 if selected.len() >= budget.page_rows() as usize && traversal_has_work(&state, budget.depth()) {
1150 push_limit(&mut reached_limits, GraphLimitKind::Rows);
1151 }
1152 let relation_digests = selected
1153 .iter()
1154 .map(|pending| pending.relation_digest)
1155 .collect::<Vec<_>>();
1156 let mut projected_state = state.clone();
1157 projected_state.emitted_rows = projected_state
1158 .emitted_rows
1159 .saturating_add(selected.len() as u64);
1160 let retained_cursor_bytes =
1161 encoded_relation_state_bytes(&cursor_binding, &projected_state, budget)?;
1162 let mut relation_details = Vec::with_capacity(relation_digests.len());
1163 for chunk in relation_digests.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
1164 let chunk_rows = u32::try_from(chunk.len()).map_err(|_overflow| {
1165 ServiceError::InvalidInput("relation hydration batch size overflowed".to_string())
1166 })?;
1167 let endpoint_limit = chunk_rows.saturating_mul(2).max(1);
1168 let database_budget = relation_database_budget(
1169 budget,
1170 database_work,
1171 retained_cursor_bytes,
1172 chunk.len(),
1173 chunk_rows,
1174 endpoint_limit,
1175 endpoint_limit,
1176 )?;
1177 let batch = store.repository_graph_relation_rows_by_digest(
1178 binding.project_instance_id,
1179 generation,
1180 chunk,
1181 database_budget,
1182 control,
1183 )?;
1184 database_work.record(batch.work)?;
1185 relation_details.extend(batch.rows);
1186 }
1187 let retained = selected
1188 .iter()
1189 .cloned()
1190 .zip(relation_details)
1191 .map(|(pending, detail)| {
1192 let path = traversal_path(&state.nodes, &entities, pending.path_terminal)?;
1193 Ok(TraversalRow {
1194 depth: pending.depth,
1195 detail,
1196 path,
1197 })
1198 })
1199 .collect::<ServiceResult<Vec<_>>>()?;
1200
1201 let purposes = load_purposes(
1202 store,
1203 binding.project_instance_id,
1204 generation,
1205 &anchor,
1206 &retained,
1207 budget,
1208 &mut database_work,
1209 retained_cursor_bytes,
1210 control,
1211 )?;
1212 let coverage = load_coverage(
1213 store,
1214 binding.project_instance_id,
1215 generation,
1216 &anchor,
1217 &retained,
1218 budget,
1219 &mut database_work,
1220 retained_cursor_bytes,
1221 control,
1222 )?;
1223 let anchor_node = detailed_node(anchor, &purposes, &coverage);
1224 let (occurrence_pages, retained_occurrences) = load_occurrence_pages(
1225 store,
1226 &retained,
1227 query,
1228 budget,
1229 &mut database_work,
1230 retained_cursor_bytes,
1231 control,
1232 &mut reached_limits,
1233 )?;
1234 let working_composition_bytes = relation_working_composition_bytes(
1235 &entities,
1236 &retained,
1237 &purposes,
1238 &coverage,
1239 &occurrence_pages,
1240 )?;
1241 let precomposition_bytes = relation_intermediate_bytes(
1242 database_work.decoded_bytes,
1243 retained_cursor_bytes,
1244 working_composition_bytes,
1245 0,
1246 )?;
1247 if precomposition_bytes > budget.intermediate_bytes() {
1248 return Err(ServiceError::InvalidInput(
1249 "detailed relation aggregate intermediate-byte budget was exhausted before composition"
1250 .to_string(),
1251 ));
1252 }
1253 let mut rows = Vec::with_capacity(retained.len());
1254 for (row, occurrences) in retained.into_iter().zip(occurrence_pages) {
1255 check_relation_control(control)?;
1256 rows.push(detailed_row(row, query, &purposes, &coverage, occurrences));
1257 }
1258 let retained_composition_bytes = relation_composition_bytes(&anchor_node, &rows)?;
1259
1260 let old_emitted = state.emitted_rows;
1261 state.emitted_rows = state.emitted_rows.saturating_add(rows.len() as u64);
1262 let traversal_remaining = traversal_has_work(&state, budget.depth());
1263 let has_more = traversal_remaining && !terminal_limit;
1264 let continuation = has_more
1265 .then(|| encode_relation_cursor(&cursor_binding, &state, budget))
1266 .transpose()?;
1267 let cursor_bytes = continuation
1268 .as_ref()
1269 .map_or(serialized_relation_state_bytes(&state)?, |cursor| {
1270 cursor.len() as u64
1271 });
1272 let intermediate_bytes = relation_intermediate_bytes(
1273 database_work.decoded_bytes,
1274 cursor_bytes,
1275 working_composition_bytes,
1276 retained_composition_bytes,
1277 )?;
1278 if intermediate_bytes > budget.intermediate_bytes() {
1279 return Err(ServiceError::InvalidInput(
1280 "detailed relation aggregate intermediate-byte budget was exhausted before composition"
1281 .to_string(),
1282 ));
1283 }
1284 let total = if !traversal_remaining && !terminal_limit {
1285 RelationTotalState::Exact(state.emitted_rows)
1286 } else {
1287 let pending = state
1288 .pending
1289 .len()
1290 .saturating_sub(state.pending_index as usize) as u64;
1291 let proved = state.emitted_rows.saturating_add(pending);
1292 if proved > 0 {
1293 RelationTotalState::AtLeast(proved)
1294 } else {
1295 RelationTotalState::Unknown
1296 }
1297 };
1298 let returned = u32::try_from(rows.len()).map_err(|_overflow| {
1299 ServiceError::InvalidInput("returned relation row count overflowed".to_string())
1300 })?;
1301 let report = DetailedRelationReport {
1302 anchor: anchor_node,
1303 generation,
1304 authored_purpose_revision,
1305 direction: query.direction,
1306 returned,
1307 pruned_paths: state.pruned_paths,
1308 truncated: continuation.is_some() || terminal_limit || !reached_limits.is_empty(),
1309 continuation,
1310 total,
1311 reached_limits,
1312 work: DetailedRelationWork {
1313 returned_rows: returned,
1314 inspected_edges,
1315 active_nodes: u32::try_from(state.nodes.len()).unwrap_or(u32::MAX),
1316 visited_nodes: u32::try_from(visited.len()).unwrap_or(u32::MAX),
1317 retained_occurrences,
1318 database_requested_rows: database_work.requested_rows,
1319 database_returned_rows: database_work.returned_rows,
1320 database_decoded_bytes: database_work.decoded_bytes,
1321 hydrated_entities: database_work.hydrated_entities,
1322 hydrated_purpose_paths: database_work.hydrated_paths,
1323 retained_composition_bytes,
1324 intermediate_bytes,
1325 rendered_output_bytes: 0,
1326 },
1327 rows,
1328 };
1329 Ok(DetailedRelationPageDraft {
1330 report,
1331 old_emitted,
1332 binding: cursor_binding,
1333 prefix_state,
1334 budget,
1335 deadline,
1336 })
1337}
1338
1339pub fn load_detailed_relations(
1349 store: &AtlasStore,
1350 query: &DetailedRelationQuery,
1351 control: Option<&IndexWorkControl>,
1352) -> ServiceResult<DetailedRelationReport> {
1353 load_detailed_relation_page(store, query, control)?.fit_compact(control)
1354}
1355
1356struct FrontierRow {
1358 frontier_index: u32,
1360 detail: RepositoryGraphRelationRow,
1362}
1363
1364fn detailed_relation_root_digest(root: &str) -> [u8; 32] {
1366 let mut hasher = blake3::Hasher::new();
1367 hasher.update(DETAILED_RELATION_ROOT_DOMAIN.as_bytes());
1368 hasher.update(&[0]);
1369 hasher.update(root.as_bytes());
1370 *hasher.finalize().as_bytes()
1371}
1372
1373fn decode_relation_cursor(
1375 encoded: &str,
1376 expected: &DetailedRelationCursorBinding,
1377) -> ServiceResult<RelationTraversalState> {
1378 if encoded.is_empty() || encoded.len() > DETAILED_RELATION_CURSOR_MAX_BYTES {
1379 return Err(ServiceError::RelationCursorInvalid {
1380 reason: "cursor length is empty or above the product ceiling",
1381 });
1382 }
1383 let cursor: DetailedRelationCursor =
1384 serde_json::from_str(encoded).map_err(|_source| ServiceError::RelationCursorInvalid {
1385 reason: "cursor JSON is malformed or contains unknown fields",
1386 })?;
1387 if cursor.version != DETAILED_RELATION_CURSOR_VERSION
1388 || cursor.binding.capability != expected.capability
1389 {
1390 return Err(ServiceError::RelationCursorStale {
1391 field: "algorithm version",
1392 });
1393 }
1394 for (changed, field) in [
1395 (
1396 cursor.binding.project != expected.project,
1397 "project identity",
1398 ),
1399 (
1400 cursor.binding.root_digest != expected.root_digest,
1401 "project root",
1402 ),
1403 (
1404 cursor.binding.generation != expected.generation,
1405 "graph generation",
1406 ),
1407 (
1408 cursor.binding.authored_purpose_revision != expected.authored_purpose_revision,
1409 "authored-purpose revision",
1410 ),
1411 ] {
1412 if changed {
1413 return Err(ServiceError::RelationCursorStale { field });
1414 }
1415 }
1416 if cursor.binding.query != expected.query {
1417 return Err(ServiceError::RelationCursorMismatched { field: "query" });
1418 }
1419 if cursor.binding.ordering != expected.ordering {
1420 return Err(ServiceError::RelationCursorMismatched { field: "ordering" });
1421 }
1422 if cursor.binding.budget != expected.budget {
1423 return Err(ServiceError::RelationCursorMismatched { field: "budget" });
1424 }
1425 Ok(cursor.state)
1426}
1427
1428fn encode_relation_cursor(
1430 binding: &DetailedRelationCursorBinding,
1431 state: &RelationTraversalState,
1432 budget: DetailedRelationBudget,
1433) -> ServiceResult<String> {
1434 let encoded = serde_json::to_string(&DetailedRelationCursor {
1435 version: DETAILED_RELATION_CURSOR_VERSION,
1436 binding: binding.clone(),
1437 state: state.clone(),
1438 })?;
1439 if encoded.len() > DETAILED_RELATION_CURSOR_MAX_BYTES
1440 || encoded.len() > budget.intermediate_bytes() as usize
1441 {
1442 return Err(ServiceError::RelationCursorInvalid {
1443 reason: "encoded cursor exceeds the intermediate-state ceiling",
1444 });
1445 }
1446 Ok(encoded)
1447}
1448
1449fn validate_traversal_state(
1451 state: &RelationTraversalState,
1452 budget: DetailedRelationBudget,
1453 anchor: [u8; 32],
1454) -> ServiceResult<()> {
1455 let serialized = serde_json::to_vec(state)?;
1456 let invalid_shape = state.depth == 0
1457 || state.depth > budget.depth()
1458 || state.nodes.is_empty()
1459 || state.nodes.len() > budget.nodes() as usize
1460 || state.nodes.len() > budget.visited() as usize
1461 || state.nodes[0].digest != anchor
1462 || state.nodes[0].parent.is_some()
1463 || state.frontier.is_empty()
1464 || state.frontier_index as usize > state.frontier.len()
1465 || state.pending_index as usize > state.pending.len()
1466 || serialized.len() > budget.intermediate_bytes() as usize;
1467 if invalid_shape {
1468 return Err(ServiceError::RelationCursorInvalid {
1469 reason: "cursor state exceeds its budget or has an invalid root/index shape",
1470 });
1471 }
1472 let mut unique = HashSet::with_capacity(state.nodes.len());
1473 for (index, node) in state.nodes.iter().enumerate() {
1474 if !unique.insert(node.digest)
1475 || (index > 0 && node.parent.is_none())
1476 || node.parent.is_some_and(|parent| parent as usize >= index)
1477 {
1478 return Err(ServiceError::RelationCursorInvalid {
1479 reason: "cursor nodes are duplicate, cyclic, or not parent ordered",
1480 });
1481 }
1482 }
1483 if state
1484 .frontier
1485 .iter()
1486 .chain(&state.next_frontier)
1487 .any(|index| *index as usize >= state.nodes.len())
1488 || state.pending.iter().any(|pending| {
1489 pending.path_terminal as usize >= state.nodes.len()
1490 || pending.depth == 0
1491 || pending.depth > budget.depth()
1492 })
1493 || (state.adjacency.is_some() && state.frontier_index as usize >= state.frontier.len())
1494 {
1495 return Err(ServiceError::RelationCursorInvalid {
1496 reason: "cursor frontier or pending row references an absent node",
1497 });
1498 }
1499 if state
1500 .pending
1501 .iter()
1502 .map(|pending| pending.relation_digest)
1503 .collect::<HashSet<_>>()
1504 .len()
1505 != state.pending.len()
1506 {
1507 return Err(ServiceError::RelationCursorInvalid {
1508 reason: "cursor pending relations are not unique",
1509 });
1510 }
1511 Ok(())
1512}
1513
1514fn hydrate_traversal_entities(
1516 store: &AtlasStore,
1517 project: projectatlas_core::graph::ProjectInstanceId,
1518 generation: IndexGeneration,
1519 state: &RelationTraversalState,
1520 budget: DetailedRelationBudget,
1521 retained_state_bytes: u64,
1522 database_work: &mut RelationDatabaseWork,
1523 control: Option<&IndexWorkControl>,
1524) -> ServiceResult<Vec<GraphEntity>> {
1525 let digests = state
1526 .nodes
1527 .iter()
1528 .map(|node| node.digest)
1529 .collect::<Vec<_>>();
1530 let mut entities = Vec::with_capacity(digests.len());
1531 for chunk in digests.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
1532 let chunk_rows = u32::try_from(chunk.len()).map_err(|_overflow| {
1533 ServiceError::InvalidInput("entity hydration batch size overflowed".to_string())
1534 })?;
1535 let database_budget = relation_database_budget(
1536 budget,
1537 *database_work,
1538 retained_state_bytes,
1539 chunk.len(),
1540 chunk_rows,
1541 chunk_rows,
1542 chunk_rows,
1543 )?;
1544 let batch = store.repository_graph_entities_by_digest(
1545 project,
1546 generation,
1547 chunk,
1548 database_budget,
1549 control,
1550 )?;
1551 database_work.record(batch.work)?;
1552 entities.extend(batch.rows);
1553 }
1554 if entities.len() != state.nodes.len()
1555 || entities
1556 .iter()
1557 .any(|entity| entity.key().project() != project || entity.generation() != generation)
1558 {
1559 return Err(ServiceError::RelationCursorStale {
1560 field: "traversal entities",
1561 });
1562 }
1563 Ok(entities)
1564}
1565
1566fn traversal_path(
1568 nodes: &[TraversalNodeState],
1569 entities: &[GraphEntity],
1570 terminal: u32,
1571) -> ServiceResult<Vec<GraphEntity>> {
1572 let mut indices = Vec::new();
1573 let mut cursor = Some(terminal);
1574 while let Some(index) = cursor {
1575 let node = nodes
1576 .get(index as usize)
1577 .ok_or(ServiceError::RelationCursorInvalid {
1578 reason: "path terminal references an absent node",
1579 })?;
1580 indices.push(index);
1581 cursor = node.parent;
1582 }
1583 indices.reverse();
1584 indices
1585 .into_iter()
1586 .map(|index| {
1587 entities
1588 .get(index as usize)
1589 .cloned()
1590 .ok_or(ServiceError::RelationCursorInvalid {
1591 reason: "hydrated path entity is absent",
1592 })
1593 })
1594 .collect()
1595}
1596
1597fn traversal_has_work(state: &RelationTraversalState, maximum_depth: u32) -> bool {
1599 (state.pending_index as usize) < state.pending.len()
1600 || (state.frontier_index as usize) < state.frontier.len()
1601 || (!state.next_frontier.is_empty() && state.depth < maximum_depth)
1602}
1603
1604fn resolve_anchor(
1606 store: &AtlasStore,
1607 project: projectatlas_core::graph::ProjectInstanceId,
1608 generation: IndexGeneration,
1609 anchor: &RelationAnchor,
1610 budget: DetailedRelationBudget,
1611 database_work: &mut RelationDatabaseWork,
1612 control: Option<&IndexWorkControl>,
1613) -> ServiceResult<GraphEntity> {
1614 match anchor {
1615 RelationAnchor::File { file } => {
1616 let selector = EntitySelector::File { path: file.clone() };
1617 let key = GraphEntityKey::new(project, &selector);
1618 let database_budget = relation_database_budget(budget, *database_work, 0, 1, 1, 1, 1)?;
1619 let batch = store.repository_graph_entity_bounded(
1620 &key,
1621 generation,
1622 database_budget,
1623 control,
1624 )?;
1625 database_work.record(batch.work)?;
1626 batch.rows.into_iter().next().ok_or_else(|| {
1627 ServiceError::InvalidInput(format!(
1628 "graph file anchor is not available: {}",
1629 file.as_str()
1630 ))
1631 })
1632 }
1633 RelationAnchor::Symbol {
1634 file,
1635 name,
1636 symbol_kind,
1637 parent,
1638 signature,
1639 } => {
1640 let path = projectatlas_core::graph::RepositoryNodePath::new(std::path::Path::new(
1641 file.as_str(),
1642 ))
1643 .map_err(invalid_graph_input)?;
1644 let entity_limit = GraphLimits::MAX_ROWS;
1645 let database_budget = relation_database_budget(
1646 budget,
1647 *database_work,
1648 0,
1649 1,
1650 entity_limit,
1651 entity_limit.saturating_add(1),
1652 1,
1653 )?;
1654 let batch = store.repository_graph_entities_by_path_bounded(
1655 project,
1656 generation,
1657 &path,
1658 entity_limit,
1659 database_budget,
1660 control,
1661 )?;
1662 database_work.record(batch.work)?;
1663 let page = batch.page;
1664 if page.truncated {
1665 return Err(ServiceError::InvalidInput(format!(
1666 "graph symbol anchor search exceeded the row ceiling for {}",
1667 file.as_str()
1668 )));
1669 }
1670 let matches = page
1671 .rows
1672 .into_iter()
1673 .filter(|entity| {
1674 let EntitySelector::Symbol { symbol } = entity.selector() else {
1675 return false;
1676 };
1677 symbol.file == *file
1678 && symbol.name.as_str() == name
1679 && symbol_kind.is_none_or(|kind| symbol.kind == kind)
1680 && parent.as_deref().is_none_or(|value| {
1681 symbol
1682 .parent
1683 .as_ref()
1684 .is_some_and(|item| item.as_str() == value)
1685 })
1686 && signature
1687 .as_deref()
1688 .is_none_or(|value| symbol.signature.as_str() == value)
1689 })
1690 .collect::<Vec<_>>();
1691 match matches.as_slice() {
1692 [entity] => Ok(entity.clone()),
1693 [] => Err(ServiceError::InvalidInput(format!(
1694 "graph symbol anchor is not available: {}::{name}",
1695 file.as_str()
1696 ))),
1697 _ => Err(ServiceError::InvalidInput(format!(
1698 "graph symbol anchor is ambiguous: {}::{name}; add kind, parent, or signature",
1699 file.as_str()
1700 ))),
1701 }
1702 }
1703 }
1704}
1705
1706pub(super) fn relation_matches(relation: &LogicalRelation, query: &DetailedRelationQuery) -> bool {
1708 query.relation.is_none_or(|kind| relation.kind() == kind)
1709 && confidence_rank(relation.confidence()) >= confidence_rank(query.minimum_confidence)
1710 && match query.resolution {
1711 RelationResolutionFilter::Any => true,
1712 RelationResolutionFilter::Resolved => {
1713 matches!(relation.resolution(), RelationResolution::Resolved { .. })
1714 }
1715 RelationResolutionFilter::Ambiguous => {
1716 matches!(relation.resolution(), RelationResolution::Ambiguous { .. })
1717 }
1718 RelationResolutionFilter::Unresolved => {
1719 matches!(relation.resolution(), RelationResolution::Unresolved { .. })
1720 }
1721 RelationResolutionFilter::External => {
1722 matches!(relation.resolution(), RelationResolution::External { .. })
1723 }
1724 }
1725}
1726
1727pub(super) fn external_relation_identities(
1729 report: &DetailedRelationReport,
1730) -> BTreeSet<ExternalRelationIdentity> {
1731 report
1732 .rows
1733 .iter()
1734 .filter_map(|row| {
1735 let RelationResolution::External { external, .. } = row.relation.resolution() else {
1736 return None;
1737 };
1738 Some((
1739 row.relation.kind().as_str().to_string(),
1740 external.system.as_str().to_string(),
1741 external.identity.as_str().to_string(),
1742 ))
1743 })
1744 .collect()
1745}
1746
1747const fn confidence_rank(value: ConfidenceClass) -> u8 {
1749 match value {
1750 ConfidenceClass::Exact => 4,
1751 ConfidenceClass::High => 3,
1752 ConfidenceClass::Medium => 2,
1753 ConfidenceClass::Low => 1,
1754 }
1755}
1756
1757fn resolution_rank(value: &RelationResolution) -> u8 {
1759 match value {
1760 RelationResolution::Resolved { .. } => 4,
1761 RelationResolution::External { .. } => 3,
1762 RelationResolution::Ambiguous { .. } => 2,
1763 RelationResolution::Unresolved { .. } => 1,
1764 }
1765}
1766
1767fn relation_rank_order(
1769 left: &RepositoryGraphRelationRow,
1770 right: &RepositoryGraphRelationRow,
1771) -> std::cmp::Ordering {
1772 confidence_rank(right.relation.confidence())
1773 .cmp(&confidence_rank(left.relation.confidence()))
1774 .then_with(|| {
1775 resolution_rank(right.relation.resolution())
1776 .cmp(&resolution_rank(left.relation.resolution()))
1777 })
1778 .then_with(|| {
1779 left.relation
1780 .key()
1781 .canonical_identity()
1782 .cmp(right.relation.key().canonical_identity())
1783 })
1784 .then_with(|| {
1785 left.relation
1786 .key()
1787 .digest()
1788 .cmp(right.relation.key().digest())
1789 })
1790}
1791
1792fn traversable_entity(
1794 detail: &RepositoryGraphRelationRow,
1795 direction: RelationDirection,
1796) -> Option<&GraphEntity> {
1797 match direction {
1798 RelationDirection::Outbound
1799 if matches!(
1800 detail.relation.resolution(),
1801 RelationResolution::Resolved { .. }
1802 ) =>
1803 {
1804 detail.target.as_ref()
1805 }
1806 RelationDirection::Inbound => Some(&detail.source),
1807 RelationDirection::Outbound => None,
1808 }
1809}
1810
1811fn load_purposes(
1813 store: &AtlasStore,
1814 project: projectatlas_core::graph::ProjectInstanceId,
1815 generation: IndexGeneration,
1816 anchor: &GraphEntity,
1817 rows: &[TraversalRow],
1818 budget: DetailedRelationBudget,
1819 database_work: &mut RelationDatabaseWork,
1820 retained_state_bytes: u64,
1821 control: Option<&IndexWorkControl>,
1822) -> ServiceResult<BTreeMap<String, Purpose>> {
1823 if !store.has_agent_approved_purpose()? {
1824 return Ok(BTreeMap::new());
1825 }
1826 let mut paths = BTreeSet::new();
1827 paths.extend(purpose_candidates(anchor));
1828 for row in rows {
1829 check_relation_control(control)?;
1830 paths.extend(purpose_candidates(&row.detail.source));
1831 if let Some(target) = &row.detail.target {
1832 paths.extend(purpose_candidates(target));
1833 }
1834 for entity in &row.path {
1835 paths.extend(purpose_candidates(entity));
1836 }
1837 }
1838 let selected = paths.into_iter().collect::<Vec<_>>();
1839 let mut purposes = BTreeMap::new();
1840 for chunk in selected.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
1841 let chunk_rows = u32::try_from(chunk.len()).map_err(|_overflow| {
1842 ServiceError::InvalidInput("purpose hydration batch size overflowed".to_string())
1843 })?;
1844 let database_budget = relation_database_budget(
1845 budget,
1846 *database_work,
1847 retained_state_bytes,
1848 chunk.len(),
1849 chunk_rows,
1850 1,
1851 chunk_rows,
1852 )?;
1853 let batch = store.load_purpose_owner_nodes_by_paths_controlled(
1854 project,
1855 generation,
1856 chunk,
1857 database_budget,
1858 control,
1859 )?;
1860 database_work.record(batch.work)?;
1861 purposes.extend(
1862 batch
1863 .rows
1864 .into_iter()
1865 .map(|node| (node.node.path.clone(), node.purpose)),
1866 );
1867 }
1868 Ok(purposes)
1869}
1870
1871fn purpose_candidates(entity: &GraphEntity) -> Vec<String> {
1873 let Some(exact) = purpose_owner(entity) else {
1874 return Vec::new();
1875 };
1876 let mut candidates = vec![exact.clone()];
1877 let mut cursor = exact.as_str();
1878 while let Some((parent, _name)) = cursor.rsplit_once('/') {
1879 candidates.push(parent.to_string());
1880 cursor = parent;
1881 }
1882 if exact != "." {
1883 candidates.push(".".to_string());
1884 }
1885 candidates
1886}
1887
1888fn purpose_owner(entity: &GraphEntity) -> Option<String> {
1890 match entity.selector() {
1891 EntitySelector::Project => Some(".".to_string()),
1892 EntitySelector::Folder { path } => Some(path.as_str().to_string()),
1893 EntitySelector::File { path } => Some(path.as_str().to_string()),
1894 EntitySelector::Package { package } => Some(package.manifest.as_str().to_string()),
1895 EntitySelector::Symbol { symbol } => Some(symbol.file.as_str().to_string()),
1896 EntitySelector::External { .. } => None,
1897 }
1898}
1899
1900fn purpose_projection(
1902 entity: &GraphEntity,
1903 purposes: &BTreeMap<String, Purpose>,
1904) -> RelationPurpose {
1905 let Some(exact_path) = purpose_owner(entity) else {
1906 return RelationPurpose::NotApplicable;
1907 };
1908 for path in purpose_candidates(entity) {
1909 if let Some(purpose) = purposes.get(&path)
1910 && purpose.status == PurposeStatus::Approved
1911 && purpose.source == PurposeSource::Agent
1912 && let Some(text) = &purpose.purpose
1913 {
1914 return RelationPurpose::Approved {
1915 path,
1916 purpose: text.clone(),
1917 source: purpose.source,
1918 status: purpose.status,
1919 };
1920 }
1921 }
1922 RelationPurpose::Unavailable {
1923 path: Some(exact_path),
1924 }
1925}
1926
1927fn detailed_node(
1929 entity: GraphEntity,
1930 purposes: &BTreeMap<String, Purpose>,
1931 coverage: &BTreeMap<String, Vec<CoverageRecord>>,
1932) -> DetailedRelationNode {
1933 let purpose = purpose_projection(&entity, purposes);
1934 let coverage = purpose_owner(&entity)
1935 .and_then(|path| coverage.get(&path).cloned())
1936 .unwrap_or_default();
1937 DetailedRelationNode {
1938 entity,
1939 purpose,
1940 coverage,
1941 }
1942}
1943
1944fn detailed_row(
1946 row: TraversalRow,
1947 query: &DetailedRelationQuery,
1948 purposes: &BTreeMap<String, Purpose>,
1949 coverage: &BTreeMap<String, Vec<CoverageRecord>>,
1950 occurrence_page: (Vec<RelationOccurrence>, bool),
1951) -> DetailedRelationRow {
1952 let (occurrences, occurrences_truncated) = occurrence_page;
1953 let next_call = traversable_entity(&row.detail, query.direction).and_then(next_call_for_entity);
1954 let target_purpose = row
1955 .detail
1956 .target
1957 .as_ref()
1958 .map_or(RelationPurpose::Unavailable { path: None }, |target| {
1959 purpose_projection(target, purposes)
1960 });
1961 let path = row
1962 .path
1963 .into_iter()
1964 .map(|entity| detailed_node(entity, purposes, coverage))
1965 .collect();
1966 DetailedRelationRow {
1967 depth: row.depth,
1968 direction: query.direction,
1969 relation: row.detail.relation,
1970 source: detailed_node(row.detail.source, purposes, coverage),
1971 target: row
1972 .detail
1973 .target
1974 .map(|target| detailed_node(target, purposes, coverage)),
1975 target_purpose,
1976 path,
1977 occurrences,
1978 occurrences_truncated,
1979 next_call,
1980 }
1981}
1982
1983fn load_coverage(
1985 store: &AtlasStore,
1986 project: projectatlas_core::graph::ProjectInstanceId,
1987 generation: IndexGeneration,
1988 anchor: &GraphEntity,
1989 rows: &[TraversalRow],
1990 budget: DetailedRelationBudget,
1991 database_work: &mut RelationDatabaseWork,
1992 retained_state_bytes: u64,
1993 control: Option<&IndexWorkControl>,
1994) -> ServiceResult<BTreeMap<String, Vec<CoverageRecord>>> {
1995 let mut paths = BTreeSet::new();
1996 for entity in std::iter::once(anchor).chain(rows.iter().flat_map(|row| {
1997 std::iter::once(&row.detail.source)
1998 .chain(row.detail.target.iter())
1999 .chain(row.path.iter())
2000 })) {
2001 if let Some(path) = purpose_owner(entity)
2002 && path != "."
2003 {
2004 paths.insert(path);
2005 }
2006 }
2007
2008 let mut coverage = BTreeMap::<String, Vec<CoverageRecord>>::new();
2009 let selected = paths
2010 .into_iter()
2011 .map(|path| {
2012 RepositoryNodePath::new(std::path::Path::new(&path))
2013 .map(|normalized| (path, normalized))
2014 .map_err(invalid_graph_input)
2015 })
2016 .collect::<ServiceResult<Vec<_>>>()?;
2017 for chunk in selected.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
2018 let normalized = chunk
2019 .iter()
2020 .map(|(_, path)| path.clone())
2021 .collect::<Vec<_>>();
2022 let database_budget = relation_database_budget(
2023 budget,
2024 *database_work,
2025 retained_state_bytes,
2026 normalized.len(),
2027 GraphLimits::MAX_ROWS,
2028 1,
2029 u32::try_from(normalized.len()).map_err(|_overflow| {
2030 ServiceError::InvalidInput("coverage path batch size overflowed".to_string())
2031 })?,
2032 )?;
2033 let batch = store.repository_graph_path_coverage_bounded(
2034 project,
2035 generation,
2036 &normalized,
2037 database_budget,
2038 control,
2039 )?;
2040 database_work.record(batch.work)?;
2041 let page = batch.page;
2042 if page.truncated {
2043 return Err(ServiceError::InvalidInput(
2044 "graph coverage hydration exceeded the bounded work ceiling".to_string(),
2045 ));
2046 }
2047 for record in page.rows {
2048 let CoverageScope::Path { path } = record.scope() else {
2049 return Err(ServiceError::InvalidInput(
2050 "path coverage hydration returned a project-scoped row".to_string(),
2051 ));
2052 };
2053 coverage
2054 .entry(path.as_str().to_string())
2055 .or_default()
2056 .push(record);
2057 }
2058 }
2059 Ok(coverage)
2060}
2061
2062fn load_occurrence_pages(
2064 store: &AtlasStore,
2065 rows: &[TraversalRow],
2066 query: &DetailedRelationQuery,
2067 budget: DetailedRelationBudget,
2068 database_work: &mut RelationDatabaseWork,
2069 retained_state_bytes: u64,
2070 control: Option<&IndexWorkControl>,
2071 reached_limits: &mut Vec<GraphLimitKind>,
2072) -> ServiceResult<(Vec<(Vec<RelationOccurrence>, bool)>, u32)> {
2073 if !query.include_occurrences {
2074 return Ok(((0..rows.len()).map(|_| (Vec::new(), false)).collect(), 0));
2075 }
2076 let per_relation = budget.occurrences_per_relation();
2077 let mut remaining = budget.occurrences_total();
2078 let mut retained = 0_u32;
2079 let mut pages = Vec::with_capacity(rows.len());
2080 let mut start = 0_usize;
2081 while start < rows.len() {
2082 check_relation_control(control)?;
2083 if remaining == 0 {
2084 push_limit(reached_limits, GraphLimitKind::Occurrences);
2085 pages.extend((start..rows.len()).map(|_| (Vec::new(), true)));
2086 break;
2087 }
2088 let limit = per_relation.min(remaining);
2089 let per_relation_work = limit as usize + 1;
2090 let aggregate_batch = (remaining / limit).max(1) as usize;
2091 let batch_size = (ADJACENCY_WORK_ROWS / per_relation_work)
2092 .clamp(1, MAX_REPOSITORY_GRAPH_FRONTIER)
2093 .min(aggregate_batch)
2094 .min(rows.len() - start);
2095 let chunk = &rows[start..start + batch_size];
2096 let relations = chunk
2097 .iter()
2098 .map(|row| row.detail.relation.clone())
2099 .collect::<Vec<_>>();
2100 let batch_rows = u32::try_from(batch_size).map_err(|_overflow| {
2101 ServiceError::InvalidInput("occurrence batch size overflowed".to_string())
2102 })?;
2103 let returned_rows = batch_rows.saturating_mul(limit).max(1);
2104 let hydrated_paths = batch_rows.saturating_mul(limit.saturating_add(1)).max(1);
2105 let database_budget = relation_database_budget(
2106 budget,
2107 *database_work,
2108 retained_state_bytes,
2109 relations.len(),
2110 returned_rows,
2111 1,
2112 hydrated_paths,
2113 )?;
2114 let batch = store.repository_graph_occurrence_pages_bounded(
2115 &relations,
2116 limit,
2117 database_budget,
2118 control,
2119 )?;
2120 database_work.record(batch.work)?;
2121 for page in batch.pages {
2122 let count = u32::try_from(page.rows.len()).map_err(|_overflow| {
2123 ServiceError::InvalidInput("occurrence count overflowed".to_string())
2124 })?;
2125 remaining = remaining.saturating_sub(count);
2126 retained = retained.saturating_add(count);
2127 if page.truncated {
2128 push_limit(reached_limits, GraphLimitKind::Occurrences);
2129 }
2130 pages.push((page.rows, page.truncated));
2131 }
2132 start += batch_size;
2133 }
2134 Ok((pages, retained))
2135}
2136
2137fn next_call_for_entity(entity: &GraphEntity) -> Option<RelationNextCall> {
2139 match entity.selector() {
2140 EntitySelector::Project | EntitySelector::External { .. } => None,
2141 EntitySelector::Folder { path } => Some(RelationNextCall::Files {
2142 folder: path.clone(),
2143 }),
2144 EntitySelector::File { path } => Some(RelationNextCall::Summary { file: path.clone() }),
2145 EntitySelector::Package { package } => Some(RelationNextCall::Summary {
2146 file: package.manifest.clone(),
2147 }),
2148 EntitySelector::Symbol { symbol } => Some(RelationNextCall::SymbolSlice {
2149 symbol: symbol.clone(),
2150 }),
2151 }
2152}
2153
2154fn render_relation_prefix<F, E>(
2156 draft: &DetailedRelationPageDraft,
2157 selected_rows: usize,
2158 encode: &mut F,
2159) -> Result<(DetailedRelationReport, String), E>
2160where
2161 F: FnMut(&DetailedRelationReport) -> Result<String, E>,
2162 E: From<ServiceError>,
2163{
2164 check_relation_deadline(draft.deadline).map_err(E::from)?;
2165 let mut report = draft.report_for_prefix(selected_rows).map_err(E::from)?;
2166 for _attempt in 0..8 {
2167 check_relation_deadline(draft.deadline).map_err(E::from)?;
2168 let encoded = encode(&report)?;
2169 check_relation_deadline(draft.deadline).map_err(E::from)?;
2170 let rendered = encoded.len() as u64;
2171 if report.work.rendered_output_bytes == rendered {
2172 return Ok((report, encoded));
2173 }
2174 report.work.rendered_output_bytes = rendered;
2175 }
2176 Err(E::from(ServiceError::InvalidInput(
2177 "detailed relation output byte metadata did not converge".to_string(),
2178 )))
2179}
2180
2181fn fit_detailed_relation_output<F, E>(
2183 draft: &DetailedRelationPageDraft,
2184 control: Option<&IndexWorkControl>,
2185 mut encode: F,
2186) -> Result<(DetailedRelationReport, String), E>
2187where
2188 F: FnMut(&DetailedRelationReport) -> Result<String, E>,
2189 E: From<ServiceError>,
2190{
2191 let maximum = draft.maximum_output_bytes();
2192 let full_rows = draft.candidate_rows();
2193 check_relation_control(control).map_err(E::from)?;
2194 check_relation_deadline(draft.deadline).map_err(E::from)?;
2195 let full = render_relation_prefix(draft, full_rows, &mut encode)?;
2196 if full.1.len() <= maximum {
2197 return Ok(full);
2198 }
2199 drop(full);
2200 let mut low = 0_usize;
2201 let mut high = full_rows.saturating_sub(1);
2202 let mut best_rows = None;
2203 while low <= high {
2204 check_relation_control(control).map_err(E::from)?;
2205 check_relation_deadline(draft.deadline).map_err(E::from)?;
2206 let middle = low + (high - low) / 2;
2207 let candidate = render_relation_prefix(draft, middle, &mut encode)?;
2208 if candidate.1.len() <= maximum {
2209 best_rows = Some(middle);
2210 low = middle.saturating_add(1);
2211 } else if middle == 0 {
2212 break;
2213 } else {
2214 high = middle - 1;
2215 }
2216 }
2217 let selected_rows = best_rows.ok_or_else(|| {
2218 E::from(ServiceError::InvalidInput(
2219 "graph output byte limit is too small for the empty response envelope".to_string(),
2220 ))
2221 })?;
2222 render_relation_prefix(draft, selected_rows, &mut encode)
2223}
2224
2225fn relation_database_budget(
2227 budget: DetailedRelationBudget,
2228 work: RelationDatabaseWork,
2229 retained_state_bytes: u64,
2230 requested_rows: usize,
2231 returned_rows: u32,
2232 hydrated_entities: u32,
2233 hydrated_paths: u32,
2234) -> ServiceResult<RepositoryGraphReadBudget> {
2235 let requested_rows = u32::try_from(requested_rows).map_err(|_overflow| {
2236 ServiceError::InvalidInput("database request row count overflowed".to_string())
2237 })?;
2238 let charged = work
2239 .decoded_bytes
2240 .checked_add(retained_state_bytes)
2241 .ok_or_else(relation_work_overflow)?;
2242 let decoded_bytes = budget
2243 .intermediate_bytes()
2244 .checked_sub(charged)
2245 .filter(|remaining| *remaining > 0)
2246 .ok_or_else(|| {
2247 ServiceError::InvalidInput(
2248 "detailed relation intermediate-byte budget is exhausted".to_string(),
2249 )
2250 })?
2251 .min(RepositoryGraphReadBudget::MAX_DECODED_BYTES);
2252 RepositoryGraphReadBudget::new(
2253 requested_rows,
2254 returned_rows,
2255 decoded_bytes,
2256 hydrated_entities,
2257 hydrated_paths,
2258 )
2259 .map_err(invalid_graph_input)
2260}
2261
2262pub(super) fn serialized_equivalent_bytes<T>(value: &T) -> ServiceResult<u64>
2264where
2265 T: Serialize + ?Sized,
2266{
2267 let mut counter = SerializedByteCounter::default();
2268 serde_json::to_writer(&mut counter, value)?;
2269 Ok(counter.bytes)
2270}
2271
2272fn relation_working_composition_bytes(
2274 entities: &[GraphEntity],
2275 rows: &[TraversalRow],
2276 purposes: &BTreeMap<String, Purpose>,
2277 coverage: &BTreeMap<String, Vec<CoverageRecord>>,
2278 occurrence_pages: &[(Vec<RelationOccurrence>, bool)],
2279) -> ServiceResult<u64> {
2280 let parts = [
2281 serialized_equivalent_bytes(entities)?,
2282 serialized_equivalent_bytes(purposes)?,
2283 serialized_equivalent_bytes(coverage)?,
2284 serialized_equivalent_bytes(occurrence_pages)?,
2285 ];
2286 let mut bytes = 0_u64;
2287 for part in parts {
2288 bytes = bytes.checked_add(part).ok_or_else(relation_work_overflow)?;
2289 }
2290 for row in rows {
2291 let row_bytes = serialized_equivalent_bytes(&(
2292 row.depth,
2293 &row.detail.relation,
2294 &row.detail.source,
2295 &row.detail.target,
2296 &row.path,
2297 ))?;
2298 bytes = bytes
2299 .checked_add(row_bytes)
2300 .ok_or_else(relation_work_overflow)?;
2301 }
2302 Ok(bytes)
2303}
2304
2305fn relation_composition_bytes(
2307 anchor: &DetailedRelationNode,
2308 rows: &[DetailedRelationRow],
2309) -> ServiceResult<u64> {
2310 serialized_equivalent_bytes(&(anchor, rows))
2311}
2312
2313fn relation_intermediate_bytes(
2315 database_decoded_bytes: u64,
2316 cursor_bytes: u64,
2317 working_composition_bytes: u64,
2318 retained_composition_bytes: u64,
2319) -> ServiceResult<u64> {
2320 let construction_peak = working_composition_bytes
2321 .checked_add(retained_composition_bytes)
2322 .ok_or_else(relation_work_overflow)?;
2323 let fitting_peak = retained_composition_bytes
2324 .checked_mul(2)
2325 .ok_or_else(relation_work_overflow)?;
2326 database_decoded_bytes
2327 .checked_add(cursor_bytes)
2328 .and_then(|value| value.checked_add(construction_peak.max(fitting_peak)))
2329 .ok_or_else(relation_work_overflow)
2330}
2331
2332fn serialized_relation_state_bytes(state: &RelationTraversalState) -> ServiceResult<u64> {
2334 u64::try_from(serde_json::to_vec(state)?.len()).map_err(|_overflow| relation_work_overflow())
2335}
2336
2337fn encoded_relation_state_bytes(
2339 binding: &DetailedRelationCursorBinding,
2340 state: &RelationTraversalState,
2341 budget: DetailedRelationBudget,
2342) -> ServiceResult<u64> {
2343 u64::try_from(encode_relation_cursor(binding, state, budget)?.len())
2344 .map_err(|_overflow| relation_work_overflow())
2345}
2346
2347fn relation_work_overflow() -> ServiceError {
2349 ServiceError::InvalidInput("detailed relation work accounting overflowed".to_string())
2350}
2351
2352pub(super) fn relation_request_control(
2354 caller: Option<&IndexWorkControl>,
2355 service_deadline: Instant,
2356) -> IndexWorkControl {
2357 let cancellation =
2358 caller.map_or_else(IndexCancellation::new, |value| value.cancellation().clone());
2359 let deadline = caller
2360 .and_then(IndexWorkControl::deadline)
2361 .map_or(service_deadline, |value| value.min(service_deadline));
2362 IndexWorkControl::with_deadline(cancellation, deadline)
2363}
2364
2365fn relation_deadline_elapsed(deadline: Instant) -> bool {
2367 Instant::now() >= deadline
2368}
2369
2370fn check_relation_deadline(deadline: Instant) -> ServiceResult<()> {
2372 if relation_deadline_elapsed(deadline) {
2373 return Err(DbError::from(IndexWorkFailure::DeadlineExceeded {
2374 stage: IndexWorkStage::RepositoryTraversal,
2375 })
2376 .into());
2377 }
2378 Ok(())
2379}
2380
2381fn check_relation_control(control: Option<&IndexWorkControl>) -> ServiceResult<()> {
2383 if let Some(control) = control {
2384 control
2385 .check(IndexWorkStage::RepositoryTraversal)
2386 .map_err(DbError::from)?;
2387 }
2388 Ok(())
2389}
2390
2391fn invalid_graph_input(error: impl std::fmt::Display) -> ServiceError {
2393 ServiceError::InvalidInput(error.to_string())
2394}
2395
2396fn push_limit(reached_limits: &mut Vec<GraphLimitKind>, limit: GraphLimitKind) {
2398 if !reached_limits.contains(&limit) {
2399 reached_limits.push(limit);
2400 }
2401}
2402
2403#[cfg(test)]
2404mod tests {
2405 use super::*;
2406 use projectatlas_core::graph::{
2407 Completeness, CoverageState, ExtendedRelationKind, GraphIdentityText, RelationResolution,
2408 SourceSpan,
2409 };
2410 use projectatlas_core::symbols::RelationKind;
2411 use projectatlas_core::{IndexGeneration, Node, NodeKind};
2412 use std::error::Error;
2413 use std::fs;
2414 use std::io;
2415 use std::path::Path;
2416 use std::thread;
2417
2418 #[test]
2419 fn maximum_depth_does_not_advertise_an_unusable_continuation() {
2420 let state = RelationTraversalState {
2421 depth: 1,
2422 nodes: vec![
2423 TraversalNodeState {
2424 digest: [1; 32],
2425 parent: None,
2426 },
2427 TraversalNodeState {
2428 digest: [2; 32],
2429 parent: Some(0),
2430 },
2431 ],
2432 frontier: vec![0],
2433 frontier_index: 1,
2434 next_frontier: vec![1],
2435 adjacency: None,
2436 pending: Vec::new(),
2437 pending_index: 0,
2438 emitted_rows: 1,
2439 pruned_paths: 0,
2440 };
2441
2442 assert!(!traversal_has_work(&state, 1));
2443 assert!(traversal_has_work(&state, 2));
2444 }
2445
2446 #[test]
2447 fn detailed_relations_are_node_simple_ranked_and_purpose_aware() -> Result<(), Box<dyn Error>> {
2448 let temp = tempfile::tempdir()?;
2449 let root = temp.path().join("relation-service");
2450 fs::create_dir_all(root.join("src"))?;
2451 fs::write(root.join("src/a.rs"), "pub fn a() {}\n")?;
2452 fs::write(root.join("src/b.rs"), "pub fn b() {}\n")?;
2453 let database = root.join("projectatlas.db");
2454 let mut store = AtlasStore::open_for_project(&database, &root)?;
2455 let project = store
2456 .project_instance_id()?
2457 .ok_or("relation service fixture project identity is missing")?;
2458 let generation = IndexGeneration::new(1);
2459 let source = GraphEntity::new(
2460 project,
2461 EntitySelector::File {
2462 path: RepositoryFilePath::new(Path::new("src/a.rs"))?,
2463 },
2464 generation,
2465 )?;
2466 let target = GraphEntity::new(
2467 project,
2468 EntitySelector::File {
2469 path: RepositoryFilePath::new(Path::new("src/b.rs"))?,
2470 },
2471 generation,
2472 )?;
2473 let forward = LogicalRelation::new(
2474 &source,
2475 GraphRelationKind::Legacy(RelationKind::Calls),
2476 RelationResolution::resolved(&target)?,
2477 ConfidenceClass::Exact,
2478 Completeness::Complete,
2479 generation,
2480 )?;
2481 let backward = LogicalRelation::new(
2482 &target,
2483 GraphRelationKind::Legacy(RelationKind::Calls),
2484 RelationResolution::resolved(&source)?,
2485 ConfidenceClass::High,
2486 Completeness::Complete,
2487 generation,
2488 )?;
2489 let unresolved = LogicalRelation::new(
2490 &source,
2491 GraphRelationKind::Legacy(RelationKind::Calls),
2492 RelationResolution::Unresolved {
2493 reference: GraphIdentityText::new("missing::target")?,
2494 },
2495 ConfidenceClass::Medium,
2496 Completeness::Complete,
2497 generation,
2498 )?;
2499 let occurrence = RelationOccurrence::new(
2500 &forward,
2501 RepositoryFilePath::new(Path::new("src/a.rs"))?,
2502 SourceSpan::new(1, 0, 1, 10)?,
2503 generation,
2504 )?;
2505 let second_occurrence = RelationOccurrence::new(
2506 &forward,
2507 RepositoryFilePath::new(Path::new("src/a.rs"))?,
2508 SourceSpan::new(1, 11, 1, 20)?,
2509 generation,
2510 )?;
2511 let coverage = ["src/a.rs", "src/b.rs"]
2512 .into_iter()
2513 .map(|path| {
2514 CoverageRecord::new(
2515 CoverageScope::Path {
2516 path: RepositoryNodePath::new(Path::new(path))?,
2517 },
2518 None,
2519 CoverageState::Complete,
2520 1,
2521 0,
2522 generation,
2523 None,
2524 None,
2525 )
2526 .map_err(Into::into)
2527 })
2528 .collect::<Result<Vec<_>, Box<dyn Error>>>()?;
2529 let mut publication = store.begin_index_publication("relation-service")?;
2530 publication.begin_scan_replacement()?;
2531 publication.upsert_scan_node_batch(&[
2532 test_folder_node("src"),
2533 test_node("src/a.rs", "hash-a"),
2534 test_node("src/b.rs", "hash-b"),
2535 ])?;
2536 publication.finish_scan_replacement()?;
2537 publication.replace_repository_graph(
2538 project,
2539 &[source, target],
2540 &[forward, backward, unresolved],
2541 &[occurrence, second_occurrence],
2542 &coverage,
2543 )?;
2544 publication.complete()?;
2545 store.set_purpose("src/a.rs", "Own source calls", PurposeSource::Agent)?;
2546 store.set_purpose("src", "Own source folder", PurposeSource::Agent)?;
2547 drop(store);
2548
2549 let store = AtlasStore::open_read_only_for_project(&database, &root)?;
2550 let report = load_detailed_relations(
2551 &store,
2552 &DetailedRelationQuery {
2553 anchor: RelationAnchor::File {
2554 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
2555 },
2556 direction: RelationDirection::Outbound,
2557 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2558 minimum_confidence: ConfidenceClass::Low,
2559 resolution: RelationResolutionFilter::Resolved,
2560 include_occurrences: true,
2561 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
2562 10,
2563 10,
2564 3,
2565 64 * 1024,
2566 )?),
2567 cursor: None,
2568 },
2569 None,
2570 )?;
2571
2572 require(report.returned == 1, "resolved row count changed")?;
2573 if report.work.inspected_edges != 2 {
2574 return Err(io::Error::other(format!(
2575 "inspected edge count changed: expected 2, got {}",
2576 report.work.inspected_edges
2577 ))
2578 .into());
2579 }
2580 require(
2581 report.work.database_requested_rows > 0
2582 && report.work.database_returned_rows > 0
2583 && report.work.database_decoded_bytes > 0
2584 && report.work.hydrated_entities >= 2
2585 && report.work.hydrated_purpose_paths >= 2
2586 && report.work.retained_composition_bytes > 0
2587 && report.work.intermediate_bytes <= 64 * 1024,
2588 "bounded database work was not aggregated into the service envelope",
2589 )?;
2590 require(
2591 report.work.intermediate_bytes
2592 >= report
2593 .work
2594 .database_decoded_bytes
2595 .saturating_add(
2596 report
2597 .continuation
2598 .as_ref()
2599 .map_or(0, |value| value.len() as u64),
2600 )
2601 .saturating_add(report.work.retained_composition_bytes.saturating_mul(2)),
2602 "aggregate intermediate work omitted database, cursor, or composition bytes",
2603 )?;
2604 require(report.pruned_paths == 0, "first-page pruning count changed")?;
2605 require(
2606 report.truncated,
2607 "resumable traversal lost truncation state",
2608 )?;
2609 let first_cursor = report
2610 .continuation
2611 .clone()
2612 .ok_or("resumable traversal omitted its continuation")?;
2613 require(report.rows[0].depth == 1, "resolved row depth changed")?;
2614 require(report.rows[0].path.len() == 2, "node-simple path changed")?;
2615 require(
2616 report.rows[0].occurrences.len() == 2,
2617 "exact occurrence was not retained",
2618 )?;
2619 require(report.anchor.coverage.len() == 1, "anchor coverage missing")?;
2620 require(
2621 report.rows[0].source.coverage.len() == 1,
2622 "source coverage missing",
2623 )?;
2624 require(
2625 report.rows[0]
2626 .target
2627 .as_ref()
2628 .map(|node| node.coverage.len())
2629 == Some(1),
2630 "target coverage missing",
2631 )?;
2632 require(
2633 matches!(
2634 report.anchor.purpose,
2635 RelationPurpose::Approved { ref purpose, .. } if purpose == "Own source calls"
2636 ),
2637 "anchor purpose projection changed",
2638 )?;
2639 require(
2640 matches!(
2641 report.rows[0].target,
2642 Some(DetailedRelationNode {
2643 purpose: RelationPurpose::Approved {
2644 ref path,
2645 ref purpose,
2646 ..
2647 },
2648 ..
2649 }) if path == "src" && purpose == "Own source folder"
2650 ),
2651 "target did not inherit the nearest accepted folder purpose",
2652 )?;
2653 require(
2654 matches!(
2655 report.rows[0].path.as_slice(),
2656 [
2657 DetailedRelationNode {
2658 purpose: RelationPurpose::Approved { path: source, .. },
2659 ..
2660 },
2661 DetailedRelationNode {
2662 purpose: RelationPurpose::Approved { path: target, .. },
2663 ..
2664 }
2665 ] if source == "src/a.rs" && target == "src"
2666 ),
2667 "node-simple path omitted authoritative purpose projection",
2668 )?;
2669 require(
2670 matches!(
2671 report.rows[0].next_call,
2672 Some(RelationNextCall::Summary { ref file }) if file.as_str() == "src/b.rs"
2673 ),
2674 "resolved target next call changed",
2675 )?;
2676 let terminal_report = load_detailed_relations(
2677 &store,
2678 &DetailedRelationQuery {
2679 anchor: RelationAnchor::File {
2680 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
2681 },
2682 direction: RelationDirection::Outbound,
2683 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2684 minimum_confidence: ConfidenceClass::Low,
2685 resolution: RelationResolutionFilter::Resolved,
2686 include_occurrences: true,
2687 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
2688 10,
2689 10,
2690 3,
2691 64 * 1024,
2692 )?),
2693 cursor: Some(first_cursor.clone()),
2694 },
2695 None,
2696 )?;
2697 require(
2698 terminal_report.returned == 0
2699 && terminal_report.pruned_paths == 1
2700 && terminal_report.continuation.is_none()
2701 && terminal_report.total == RelationTotalState::Exact(1),
2702 "cursor continuation did not finish the cycle-safe traversal exactly",
2703 )?;
2704
2705 let repeated_report = load_detailed_relations(
2706 &store,
2707 &DetailedRelationQuery {
2708 anchor: RelationAnchor::File {
2709 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
2710 },
2711 direction: RelationDirection::Outbound,
2712 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2713 minimum_confidence: ConfidenceClass::Low,
2714 resolution: RelationResolutionFilter::Resolved,
2715 include_occurrences: true,
2716 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
2717 10,
2718 10,
2719 3,
2720 64 * 1024,
2721 )?),
2722 cursor: None,
2723 },
2724 None,
2725 )?;
2726 require(
2727 repeated_report.rows == report.rows
2728 && repeated_report.continuation == report.continuation,
2729 "repeated detailed relation page changed rows or cursor bytes",
2730 )?;
2731
2732 let mut mismatched_query = DetailedRelationQuery {
2733 anchor: RelationAnchor::File {
2734 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
2735 },
2736 direction: RelationDirection::Inbound,
2737 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2738 minimum_confidence: ConfidenceClass::Low,
2739 resolution: RelationResolutionFilter::Resolved,
2740 include_occurrences: true,
2741 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
2742 10,
2743 10,
2744 3,
2745 64 * 1024,
2746 )?),
2747 cursor: Some(first_cursor.clone()),
2748 };
2749 require(
2750 matches!(
2751 load_detailed_relations(&store, &mismatched_query, None),
2752 Err(ServiceError::RelationCursorMismatched { field: "query" })
2753 ),
2754 "query-bound cursor accepted a different direction",
2755 )?;
2756 let mut invalid_cursor: serde_json::Value = serde_json::from_str(&first_cursor)?;
2757 invalid_cursor["version"] = serde_json::json!(DETAILED_RELATION_CURSOR_VERSION + 1);
2758 mismatched_query.direction = RelationDirection::Outbound;
2759 mismatched_query.cursor = Some(serde_json::to_string(&invalid_cursor)?);
2760 require(
2761 matches!(
2762 load_detailed_relations(&store, &mismatched_query, None),
2763 Err(ServiceError::RelationCursorStale {
2764 field: "algorithm version"
2765 })
2766 ),
2767 "unknown cursor version did not fail closed",
2768 )?;
2769 mismatched_query.cursor = Some("{".to_string());
2770 require(
2771 matches!(
2772 load_detailed_relations(&store, &mismatched_query, None),
2773 Err(ServiceError::RelationCursorInvalid { .. })
2774 ),
2775 "malformed cursor did not fail closed",
2776 )?;
2777 mismatched_query.cursor = Some("x".repeat(DETAILED_RELATION_CURSOR_MAX_BYTES + 1));
2778 require(
2779 matches!(
2780 load_detailed_relations(&store, &mismatched_query, None),
2781 Err(ServiceError::RelationCursorInvalid { .. })
2782 ),
2783 "oversized cursor did not fail closed before decoding",
2784 )?;
2785 mismatched_query.cursor = Some(first_cursor.clone());
2786 mismatched_query.budget =
2787 mismatched_query
2788 .budget
2789 .with_aggregate_limits(Some(9), None, None, None, None, None)?;
2790 require(
2791 matches!(
2792 load_detailed_relations(&store, &mismatched_query, None),
2793 Err(ServiceError::RelationCursorMismatched { field: "budget" })
2794 ),
2795 "cursor accepted a different result-defining budget",
2796 )?;
2797
2798 let cancellation = projectatlas_core::IndexCancellation::new();
2799 cancellation.cancel();
2800 let control = IndexWorkControl::new(cancellation, None);
2801 mismatched_query.cursor = None;
2802 let cancelled = load_detailed_relations(&store, &mismatched_query, Some(&control));
2803 require(
2804 cancelled
2805 .err()
2806 .is_some_and(|error| error.to_string().contains("cancel")),
2807 "relation traversal did not propagate cancellation",
2808 )?;
2809 require(
2810 relation_deadline_elapsed(
2811 Instant::now()
2812 .checked_sub(Duration::from_millis(2))
2813 .ok_or("deadline test clock underflowed")?,
2814 ),
2815 "service-owned relation deadline was not classified deterministically",
2816 )?;
2817 let mut expired_draft = load_detailed_relation_page(
2818 &store,
2819 &DetailedRelationQuery {
2820 anchor: RelationAnchor::File {
2821 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
2822 },
2823 direction: RelationDirection::Outbound,
2824 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2825 minimum_confidence: ConfidenceClass::Low,
2826 resolution: RelationResolutionFilter::Resolved,
2827 include_occurrences: false,
2828 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
2829 10,
2830 10,
2831 1,
2832 64 * 1024,
2833 )?),
2834 cursor: None,
2835 },
2836 None,
2837 )?;
2838 expired_draft.deadline = Instant::now()
2839 .checked_sub(Duration::from_millis(1))
2840 .ok_or("render deadline test clock underflowed")?;
2841 require(
2842 matches!(
2843 expired_draft.fit_compact(None),
2844 Err(ServiceError::Db(DbError::IndexWork(
2845 IndexWorkFailure::DeadlineExceeded {
2846 stage: IndexWorkStage::RepositoryTraversal
2847 }
2848 )))
2849 ),
2850 "adapter rendering ignored the service-owned relation deadline",
2851 )?;
2852 let exact_output_limit = 4 * 1024;
2853 let limited_report = load_detailed_relations(
2854 &store,
2855 &DetailedRelationQuery {
2856 anchor: RelationAnchor::File {
2857 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
2858 },
2859 direction: RelationDirection::Outbound,
2860 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2861 minimum_confidence: ConfidenceClass::Low,
2862 resolution: RelationResolutionFilter::Resolved,
2863 include_occurrences: true,
2864 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
2865 10,
2866 10,
2867 3,
2868 exact_output_limit,
2869 )?),
2870 cursor: None,
2871 },
2872 None,
2873 )?;
2874 require(
2875 limited_report.truncated,
2876 "output truncation was not reported",
2877 )?;
2878 require(
2879 limited_report
2880 .reached_limits
2881 .contains(&GraphLimitKind::OutputBytes),
2882 "output byte limit was not reported",
2883 )?;
2884 require(
2885 serde_json::to_vec(&limited_report)?.len() <= exact_output_limit as usize,
2886 "serialized report exceeded its hard output limit",
2887 )?;
2888
2889 let unresolved_report = load_detailed_relations(
2890 &store,
2891 &DetailedRelationQuery {
2892 anchor: RelationAnchor::File {
2893 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
2894 },
2895 direction: RelationDirection::Outbound,
2896 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2897 minimum_confidence: ConfidenceClass::Low,
2898 resolution: RelationResolutionFilter::Unresolved,
2899 include_occurrences: false,
2900 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
2901 10,
2902 10,
2903 1,
2904 64 * 1024,
2905 )?),
2906 cursor: None,
2907 },
2908 None,
2909 )?;
2910 require(
2911 unresolved_report.returned == 1,
2912 "unresolved relation was not retained",
2913 )?;
2914 require(
2915 unresolved_report.rows[0].target.is_none(),
2916 "unresolved relation fabricated a target",
2917 )?;
2918 require(
2919 unresolved_report.rows[0].target_purpose == RelationPurpose::Unavailable { path: None },
2920 "unresolved purpose state changed",
2921 )?;
2922
2923 let row_limited = load_detailed_relations(
2924 &store,
2925 &DetailedRelationQuery {
2926 anchor: RelationAnchor::File {
2927 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
2928 },
2929 direction: RelationDirection::Outbound,
2930 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2931 minimum_confidence: ConfidenceClass::Low,
2932 resolution: RelationResolutionFilter::Any,
2933 include_occurrences: false,
2934 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
2935 1,
2936 10,
2937 1,
2938 64 * 1024,
2939 )?)
2940 .with_aggregate_limits(Some(10), None, None, None, None, None)?,
2941 cursor: None,
2942 },
2943 None,
2944 )?;
2945 require(
2946 row_limited.returned == 1
2947 && row_limited.continuation.is_some()
2948 && row_limited.reached_limits.contains(&GraphLimitKind::Rows)
2949 && !row_limited.reached_limits.contains(&GraphLimitKind::Edges),
2950 "row budget did not remain independent from edge work",
2951 )?;
2952
2953 let edge_limited = load_detailed_relations(
2954 &store,
2955 &DetailedRelationQuery {
2956 anchor: RelationAnchor::File {
2957 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
2958 },
2959 direction: RelationDirection::Outbound,
2960 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2961 minimum_confidence: ConfidenceClass::Low,
2962 resolution: RelationResolutionFilter::Any,
2963 include_occurrences: false,
2964 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
2965 10,
2966 10,
2967 1,
2968 64 * 1024,
2969 )?)
2970 .with_aggregate_limits(Some(1), None, None, None, None, None)?,
2971 cursor: None,
2972 },
2973 None,
2974 )?;
2975 require(
2976 edge_limited.work.inspected_edges == 1
2977 && edge_limited.continuation.is_some()
2978 && edge_limited.reached_limits.contains(&GraphLimitKind::Edges)
2979 && !edge_limited.reached_limits.contains(&GraphLimitKind::Rows),
2980 "edge budget exhaustion was not reported independently",
2981 )?;
2982
2983 let node_limited = load_detailed_relations(
2984 &store,
2985 &DetailedRelationQuery {
2986 anchor: RelationAnchor::File {
2987 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
2988 },
2989 direction: RelationDirection::Outbound,
2990 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2991 minimum_confidence: ConfidenceClass::Low,
2992 resolution: RelationResolutionFilter::Resolved,
2993 include_occurrences: false,
2994 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
2995 10,
2996 10,
2997 1,
2998 64 * 1024,
2999 )?)
3000 .with_aggregate_limits(
3001 None,
3002 Some(1),
3003 Some(11),
3004 None,
3005 None,
3006 None,
3007 )?,
3008 cursor: None,
3009 },
3010 None,
3011 )?;
3012 require(
3013 node_limited.returned == 0
3014 && node_limited.continuation.is_none()
3015 && node_limited.total == RelationTotalState::Unknown
3016 && node_limited.reached_limits.contains(&GraphLimitKind::Nodes),
3017 "terminal node-state budget did not fail bounded",
3018 )?;
3019
3020 let visited_limited = load_detailed_relations(
3021 &store,
3022 &DetailedRelationQuery {
3023 anchor: RelationAnchor::File {
3024 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3025 },
3026 direction: RelationDirection::Outbound,
3027 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3028 minimum_confidence: ConfidenceClass::Low,
3029 resolution: RelationResolutionFilter::Resolved,
3030 include_occurrences: false,
3031 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3032 10,
3033 10,
3034 1,
3035 64 * 1024,
3036 )?)
3037 .with_aggregate_limits(
3038 None,
3039 Some(11),
3040 Some(1),
3041 None,
3042 None,
3043 None,
3044 )?,
3045 cursor: None,
3046 },
3047 None,
3048 )?;
3049 require(
3050 visited_limited.returned == 0
3051 && visited_limited.continuation.is_none()
3052 && visited_limited.total == RelationTotalState::Unknown
3053 && visited_limited
3054 .reached_limits
3055 .contains(&GraphLimitKind::Visited),
3056 "terminal visited-state budget did not fail bounded",
3057 )?;
3058
3059 let occurrence_limited = load_detailed_relations(
3060 &store,
3061 &DetailedRelationQuery {
3062 anchor: RelationAnchor::File {
3063 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3064 },
3065 direction: RelationDirection::Outbound,
3066 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3067 minimum_confidence: ConfidenceClass::Low,
3068 resolution: RelationResolutionFilter::Resolved,
3069 include_occurrences: true,
3070 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3071 10,
3072 10,
3073 3,
3074 64 * 1024,
3075 )?)
3076 .with_aggregate_limits(None, None, None, Some(1), None, None)?,
3077 cursor: None,
3078 },
3079 None,
3080 )?;
3081 require(
3082 occurrence_limited.work.retained_occurrences == 1
3083 && occurrence_limited.rows[0].occurrences.len() == 1
3084 && occurrence_limited
3085 .reached_limits
3086 .contains(&GraphLimitKind::Occurrences),
3087 "aggregate occurrence budget did not truncate exact evidence",
3088 )?;
3089
3090 drop(store);
3091 let writable = AtlasStore::open_for_project(&database, &root)?;
3092 writable.set_purpose("src/a.rs", "Own source calls", PurposeSource::Agent)?;
3093 drop(writable);
3094 let store = AtlasStore::open_read_only_for_project(&database, &root)?;
3095 let unchanged_query = DetailedRelationQuery {
3096 anchor: RelationAnchor::File {
3097 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3098 },
3099 direction: RelationDirection::Outbound,
3100 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3101 minimum_confidence: ConfidenceClass::Low,
3102 resolution: RelationResolutionFilter::Resolved,
3103 include_occurrences: true,
3104 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3105 10,
3106 10,
3107 3,
3108 64 * 1024,
3109 )?),
3110 cursor: Some(first_cursor.clone()),
3111 };
3112 require(
3113 load_detailed_relations(&store, &unchanged_query, None).is_ok(),
3114 "an accepted-purpose no-op made the relation cursor stale",
3115 )?;
3116 drop(store);
3117 let writable = AtlasStore::open_for_project(&database, &root)?;
3118 writable.set_purpose("src/a.rs", "Own updated source calls", PurposeSource::Agent)?;
3119 drop(writable);
3120 let store = AtlasStore::open_read_only_for_project(&database, &root)?;
3121 let stale_query = DetailedRelationQuery {
3122 anchor: RelationAnchor::File {
3123 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3124 },
3125 direction: RelationDirection::Outbound,
3126 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3127 minimum_confidence: ConfidenceClass::Low,
3128 resolution: RelationResolutionFilter::Resolved,
3129 include_occurrences: true,
3130 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3131 10,
3132 10,
3133 3,
3134 64 * 1024,
3135 )?),
3136 cursor: Some(first_cursor),
3137 };
3138 require(
3139 matches!(
3140 load_detailed_relations(&store, &stale_query, None),
3141 Err(ServiceError::RelationCursorStale {
3142 field: "authored-purpose revision"
3143 })
3144 ),
3145 "purpose-bound cursor survived an authored-purpose revision",
3146 )?;
3147
3148 let mut current_query = stale_query;
3149 current_query.cursor = None;
3150 let current_cursor = load_detailed_relations(&store, ¤t_query, None)?
3151 .continuation
3152 .ok_or("current-generation traversal omitted its continuation")?;
3153 drop(store);
3154
3155 let mut writable = AtlasStore::open_for_project(&database, &root)?;
3156 let generation = IndexGeneration::new(2);
3157 let source = GraphEntity::new(
3158 project,
3159 EntitySelector::File {
3160 path: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3161 },
3162 generation,
3163 )?;
3164 let target = GraphEntity::new(
3165 project,
3166 EntitySelector::File {
3167 path: RepositoryFilePath::new(Path::new("src/b.rs"))?,
3168 },
3169 generation,
3170 )?;
3171 let forward = LogicalRelation::new(
3172 &source,
3173 GraphRelationKind::Legacy(RelationKind::Calls),
3174 RelationResolution::resolved(&target)?,
3175 ConfidenceClass::Exact,
3176 Completeness::Complete,
3177 generation,
3178 )?;
3179 let backward = LogicalRelation::new(
3180 &target,
3181 GraphRelationKind::Legacy(RelationKind::Calls),
3182 RelationResolution::resolved(&source)?,
3183 ConfidenceClass::High,
3184 Completeness::Complete,
3185 generation,
3186 )?;
3187 let unresolved = LogicalRelation::new(
3188 &source,
3189 GraphRelationKind::Legacy(RelationKind::Calls),
3190 RelationResolution::Unresolved {
3191 reference: GraphIdentityText::new("missing::target")?,
3192 },
3193 ConfidenceClass::Medium,
3194 Completeness::Complete,
3195 generation,
3196 )?;
3197 let mut publication = writable.begin_index_publication("relation-service-generation")?;
3198 publication.replace_repository_graph(
3199 project,
3200 &[source, target],
3201 &[forward, backward, unresolved],
3202 &[],
3203 &[],
3204 )?;
3205 publication.complete()?;
3206 drop(writable);
3207
3208 let store = AtlasStore::open_read_only_for_project(&database, &root)?;
3209 current_query.cursor = Some(current_cursor);
3210 require(
3211 matches!(
3212 load_detailed_relations(&store, ¤t_query, None),
3213 Err(ServiceError::RelationCursorStale {
3214 field: "graph generation"
3215 })
3216 ),
3217 "generation-bound cursor survived graph publication",
3218 )?;
3219 Ok(())
3220 }
3221
3222 #[test]
3223 fn detailed_relation_pages_preserve_extended_inbound_symbol_and_parallel_behavior()
3224 -> Result<(), Box<dyn Error>> {
3225 let temp = tempfile::tempdir()?;
3226 let root = temp.path().join("relation-pagination");
3227 fs::create_dir_all(root.join("src"))?;
3228 for name in ["a.rs", "b.rs", "c.rs", "d.rs"] {
3229 fs::write(root.join("src").join(name), format!("// {name}\n"))?;
3230 }
3231 let database = root.join("projectatlas.db");
3232 let mut store = AtlasStore::open_for_project(&database, &root)?;
3233 let project = store
3234 .project_instance_id()?
3235 .ok_or("relation pagination fixture identity is missing")?;
3236 let generation = IndexGeneration::new(1);
3237 let a = GraphEntity::new(
3238 project,
3239 EntitySelector::File {
3240 path: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3241 },
3242 generation,
3243 )?;
3244 let b = GraphEntity::new(
3245 project,
3246 EntitySelector::File {
3247 path: RepositoryFilePath::new(Path::new("src/b.rs"))?,
3248 },
3249 generation,
3250 )?;
3251 let c = GraphEntity::new(
3252 project,
3253 EntitySelector::File {
3254 path: RepositoryFilePath::new(Path::new("src/c.rs"))?,
3255 },
3256 generation,
3257 )?;
3258 let d = GraphEntity::new(
3259 project,
3260 EntitySelector::File {
3261 path: RepositoryFilePath::new(Path::new("src/d.rs"))?,
3262 },
3263 generation,
3264 )?;
3265 let entry = GraphEntity::new(
3266 project,
3267 EntitySelector::Symbol {
3268 symbol: SymbolSelector {
3269 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3270 name: GraphIdentityText::new("entry")?,
3271 kind: SymbolKind::Function,
3272 parent: Some(GraphIdentityText::new("Root")?),
3273 signature: GraphIdentityText::new("entry()")?,
3274 },
3275 },
3276 generation,
3277 )?;
3278 let entry_overload = GraphEntity::new(
3279 project,
3280 EntitySelector::Symbol {
3281 symbol: SymbolSelector {
3282 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3283 name: GraphIdentityText::new("entry")?,
3284 kind: SymbolKind::Function,
3285 parent: Some(GraphIdentityText::new("Root")?),
3286 signature: GraphIdentityText::new("entry(u8)")?,
3287 },
3288 },
3289 generation,
3290 )?;
3291 let references = GraphRelationKind::Extended(ExtendedRelationKind::References);
3292 let a_b = LogicalRelation::new(
3293 &a,
3294 references,
3295 RelationResolution::resolved(&b)?,
3296 ConfidenceClass::Exact,
3297 Completeness::Complete,
3298 generation,
3299 )?;
3300 let a_c = LogicalRelation::new(
3301 &a,
3302 references,
3303 RelationResolution::resolved(&c)?,
3304 ConfidenceClass::High,
3305 Completeness::Complete,
3306 generation,
3307 )?;
3308 let b_d = LogicalRelation::new(
3309 &b,
3310 references,
3311 RelationResolution::resolved(&d)?,
3312 ConfidenceClass::Exact,
3313 Completeness::Complete,
3314 generation,
3315 )?;
3316 let c_d = LogicalRelation::new(
3317 &c,
3318 references,
3319 RelationResolution::resolved(&d)?,
3320 ConfidenceClass::Medium,
3321 Completeness::Complete,
3322 generation,
3323 )?;
3324 let d_a = LogicalRelation::new(
3325 &d,
3326 references,
3327 RelationResolution::resolved(&a)?,
3328 ConfidenceClass::Low,
3329 Completeness::Complete,
3330 generation,
3331 )?;
3332 let entry_b = LogicalRelation::new(
3333 &entry,
3334 references,
3335 RelationResolution::resolved(&b)?,
3336 ConfidenceClass::Exact,
3337 Completeness::Complete,
3338 generation,
3339 )?;
3340 let mut publication = store.begin_index_publication("relation-pagination")?;
3341 publication.begin_scan_replacement()?;
3342 publication.upsert_scan_node_batch(&[
3343 test_folder_node("src"),
3344 test_node("src/a.rs", "hash-a"),
3345 test_node("src/b.rs", "hash-b"),
3346 test_node("src/c.rs", "hash-c"),
3347 test_node("src/d.rs", "hash-d"),
3348 ])?;
3349 publication.finish_scan_replacement()?;
3350 publication.replace_repository_graph(
3351 project,
3352 &[a, b, c, d, entry, entry_overload],
3353 &[a_b, a_c, b_d, c_d, d_a, entry_b],
3354 &[],
3355 &[],
3356 )?;
3357 publication.complete()?;
3358 store.set_purpose("src", "Own graph components", PurposeSource::Agent)?;
3359 drop(store);
3360
3361 let store = AtlasStore::open_read_only_for_project(&database, &root)?;
3362 let file_anchor = RelationAnchor::File {
3363 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3364 };
3365 let complete_budget =
3366 DetailedRelationBudget::from_graph_limits(GraphLimits::new(10, 5, 3, 256 * 1024)?)
3367 .with_aggregate_limits(Some(100), Some(100), Some(100), None, None, None)?;
3368 let complete_query = DetailedRelationQuery {
3369 anchor: file_anchor.clone(),
3370 direction: RelationDirection::Outbound,
3371 relation: Some(references),
3372 minimum_confidence: ConfidenceClass::Low,
3373 resolution: RelationResolutionFilter::Resolved,
3374 include_occurrences: false,
3375 budget: complete_budget,
3376 cursor: None,
3377 };
3378 let (complete_rows, complete_total, complete_pruned_paths) =
3379 collect_relation_pages(&store, complete_query.clone(), 10)?;
3380 require(
3381 complete_rows.len() == 3
3382 && complete_pruned_paths == 2
3383 && complete_total == RelationTotalState::Exact(3),
3384 "extended diamond/cycle traversal did not finish node-simple and exact",
3385 )?;
3386
3387 let page_budget =
3388 DetailedRelationBudget::from_graph_limits(GraphLimits::new(1, 5, 3, 256 * 1024)?)
3389 .with_aggregate_limits(Some(100), Some(100), Some(100), None, None, None)?;
3390 let (paged_rows, terminal_total, _paged_pruned_paths) = collect_relation_pages(
3391 &store,
3392 DetailedRelationQuery {
3393 anchor: file_anchor,
3394 direction: RelationDirection::Outbound,
3395 relation: Some(references),
3396 minimum_confidence: ConfidenceClass::Low,
3397 resolution: RelationResolutionFilter::Resolved,
3398 include_occurrences: false,
3399 budget: page_budget,
3400 cursor: None,
3401 },
3402 10,
3403 )?;
3404 require(
3405 paged_rows == complete_rows && terminal_total == RelationTotalState::Exact(3),
3406 "multi-page traversal changed extended relation ranking, paths, or total",
3407 )?;
3408
3409 let inbound = load_detailed_relations(
3410 &store,
3411 &DetailedRelationQuery {
3412 anchor: RelationAnchor::File {
3413 file: RepositoryFilePath::new(Path::new("src/d.rs"))?,
3414 },
3415 direction: RelationDirection::Inbound,
3416 relation: Some(references),
3417 minimum_confidence: ConfidenceClass::Low,
3418 resolution: RelationResolutionFilter::Resolved,
3419 include_occurrences: false,
3420 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3421 10,
3422 5,
3423 1,
3424 256 * 1024,
3425 )?)
3426 .with_aggregate_limits(
3427 Some(100),
3428 None,
3429 None,
3430 None,
3431 None,
3432 None,
3433 )?,
3434 cursor: None,
3435 },
3436 None,
3437 )?;
3438 require(
3439 inbound.returned == 2
3440 && inbound.rows[0].relation.confidence() == ConfidenceClass::Exact
3441 && inbound.rows[1].relation.confidence() == ConfidenceClass::Medium,
3442 "inbound extended relations were not ranked across the bounded batch",
3443 )?;
3444
3445 let ambiguous_symbol = DetailedRelationQuery {
3446 anchor: RelationAnchor::Symbol {
3447 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3448 name: "entry".to_string(),
3449 symbol_kind: None,
3450 parent: None,
3451 signature: None,
3452 },
3453 direction: RelationDirection::Outbound,
3454 relation: Some(references),
3455 minimum_confidence: ConfidenceClass::Low,
3456 resolution: RelationResolutionFilter::Resolved,
3457 include_occurrences: false,
3458 budget: complete_budget,
3459 cursor: None,
3460 };
3461 require(
3462 load_detailed_relations(&store, &ambiguous_symbol, None)
3463 .err()
3464 .is_some_and(|error| error.to_string().contains("ambiguous")),
3465 "ambiguous symbol anchor did not require an exact selector",
3466 )?;
3467 let exact_symbol = load_detailed_relations(
3468 &store,
3469 &DetailedRelationQuery {
3470 anchor: RelationAnchor::Symbol {
3471 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3472 name: "entry".to_string(),
3473 symbol_kind: Some(SymbolKind::Function),
3474 parent: Some("Root".to_string()),
3475 signature: Some("entry()".to_string()),
3476 },
3477 ..ambiguous_symbol
3478 },
3479 None,
3480 )?;
3481 require(
3482 exact_symbol.returned == 1
3483 && exact_symbol.rows[0]
3484 .next_call
3485 .as_ref()
3486 .is_some_and(|next| matches!(next, RelationNextCall::Summary { file } if file.as_str() == "src/b.rs")),
3487 "exact symbol selector did not retain its reusable target call",
3488 )?;
3489 drop(store);
3490
3491 let parallel_query = complete_query;
3492 let mut readers = Vec::new();
3493 for _reader in 0..2 {
3494 let database = database.clone();
3495 let root = root.clone();
3496 let query = parallel_query.clone();
3497 readers.push(thread::spawn(
3498 move || -> Result<Vec<DetailedRelationRow>, String> {
3499 let store = AtlasStore::open_read_only_for_project(&database, &root)
3500 .map_err(|error| error.to_string())?;
3501 collect_relation_pages(&store, query, 10)
3502 .map(|(rows, _total, _pruned_paths)| rows)
3503 .map_err(|error| error.to_string())
3504 },
3505 ));
3506 }
3507 for reader in readers {
3508 let rows = reader
3509 .join()
3510 .map_err(|_panic| io::Error::other("parallel relation reader panicked"))?
3511 .map_err(io::Error::other)?;
3512 require(
3513 rows == complete_rows,
3514 "parallel relation snapshot changed deterministic rows",
3515 )?;
3516 }
3517 Ok(())
3518 }
3519
3520 #[test]
3521 fn confidence_and_output_limits_fail_bounded() -> Result<(), Box<dyn Error>> {
3522 let limits = GraphLimits::new(1, 1, 1, 1)?;
3523 require(limits.rows() == 1, "graph row limit changed")?;
3524 let relation = RelationResolution::Unresolved {
3525 reference: GraphIdentityText::new("missing::target")?,
3526 };
3527 require(resolution_rank(&relation) == 1, "unresolved rank changed")?;
3528 require(
3529 confidence_rank(ConfidenceClass::Exact) > confidence_rank(ConfidenceClass::Low),
3530 "confidence rank changed",
3531 )?;
3532 let base = DetailedRelationBudget::from_graph_limits(GraphLimits::new(5, 2, 3, 64 * 1024)?);
3533 let aggregate = base.with_aggregate_limits(
3534 Some(17),
3535 Some(13),
3536 Some(11),
3537 Some(7),
3538 Some(128 * 1024),
3539 Some(2_000),
3540 )?;
3541 require(
3542 aggregate.edges() == 17
3543 && aggregate.nodes() == 13
3544 && aggregate.visited() == 11
3545 && aggregate.occurrences_total() == 7
3546 && aggregate.intermediate_bytes() == 128 * 1024
3547 && aggregate.deadline_ms() == 2_000,
3548 "aggregate relation budget overrides changed",
3549 )?;
3550 require(
3551 base.with_aggregate_limits(None, None, Some(0), None, None, None)
3552 .is_err(),
3553 "zero visited-state budget was accepted",
3554 )?;
3555 require(
3556 base.with_aggregate_limits(
3557 Some(DetailedRelationBudget::MAX_EDGES + 1),
3558 None,
3559 None,
3560 None,
3561 None,
3562 None,
3563 )
3564 .is_err(),
3565 "oversized edge budget was accepted",
3566 )?;
3567 Ok(())
3568 }
3569
3570 fn collect_relation_pages(
3573 store: &AtlasStore,
3574 mut query: DetailedRelationQuery,
3575 maximum_pages: usize,
3576 ) -> ServiceResult<(Vec<DetailedRelationRow>, RelationTotalState, u64)> {
3577 let mut rows = Vec::new();
3578 for _page in 0..maximum_pages {
3579 let report = load_detailed_relations(store, &query, None)?;
3580 rows.extend(report.rows);
3581 let Some(cursor) = report.continuation else {
3582 return Ok((rows, report.total, report.pruned_paths));
3583 };
3584 query.cursor = Some(cursor);
3585 }
3586 Err(ServiceError::InvalidInput(
3587 "relation traversal did not terminate within the test page ceiling".to_string(),
3588 ))
3589 }
3590
3591 fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
3592 if condition {
3593 return Ok(());
3594 }
3595 Err(io::Error::other(message).into())
3596 }
3597
3598 fn test_node(path: &str, hash: &str) -> Node {
3599 Node {
3600 path: path.to_string(),
3601 kind: NodeKind::File,
3602 parent_path: Some("src".to_string()),
3603 extension: Some(".rs".to_string()),
3604 language: Some("rust".to_string()),
3605 size_bytes: Some(16),
3606 mtime_ns: Some(1),
3607 content_hash: Some(hash.to_string()),
3608 }
3609 }
3610
3611 fn test_folder_node(path: &str) -> Node {
3612 Node {
3613 path: path.to_string(),
3614 kind: NodeKind::Folder,
3615 parent_path: Some(".".to_string()),
3616 extension: None,
3617 language: None,
3618 size_bytes: None,
3619 mtime_ns: Some(1),
3620 content_hash: None,
3621 }
3622 }
3623}