1use super::{ServiceError, ServiceResult, canonical_root_digest, selected_project_binding};
4use projectatlas_core::graph::{
5 Completeness, ConfidenceClass, CoverageRecord, CoverageScope, DocumentTargetUnresolvedReason,
6 EntitySelector, ExtendedRelationKind, GraphEntity, GraphEntityKey, GraphLimitKind, GraphLimits,
7 GraphRelationKind, LogicalRelation, RelationOccurrence, RelationResolution, RepositoryFilePath,
8 RepositoryNodePath, SymbolSelector,
9};
10use projectatlas_core::language::{ContentClassification, ContentSelection};
11use projectatlas_core::symbols::SymbolKind;
12use projectatlas_core::{
13 CanonicalProjectRoot, IndexCancellation, IndexGeneration, IndexWorkControl, IndexWorkFailure,
14 IndexWorkStage, Purpose, PurposeSource, PurposeStatus,
15};
16use projectatlas_db::{
17 AtlasStore, DbError, MAX_FILE_CONTENT_CLASSIFICATION_PATHS, MAX_REPOSITORY_GRAPH_FRONTIER,
18 RepositoryGraphAdjacencyContinuation, RepositoryGraphDirection, RepositoryGraphReadBudget,
19 RepositoryGraphReadWork, RepositoryGraphRelationRow,
20};
21use serde::{Deserialize, Serialize};
22use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
23use std::io::{self, Write};
24use std::time::{Duration, Instant};
25
26const ADJACENCY_WORK_ROWS: usize = GraphLimits::MAX_ROWS as usize + 1;
28
29const DETAILED_RELATION_CURSOR_MAX_BYTES: usize = 4 * 1_024 * 1_024;
31
32const DETAILED_RELATION_CURSOR_VERSION: u16 = 1;
34
35const DETAILED_RELATION_ROOT_DOMAIN: &str = "projectatlas:detailed-relation-root:v1";
37
38const DOCUMENTED_BY_INBOUND_VIEW: &str = "documented_by";
40
41#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
43#[serde(rename_all = "snake_case")]
44pub enum RelationDirection {
45 Outbound,
47 Inbound,
49}
50
51impl From<RelationDirection> for RepositoryGraphDirection {
52 fn from(value: RelationDirection) -> Self {
53 match value {
54 RelationDirection::Outbound => Self::Outbound,
55 RelationDirection::Inbound => Self::Inbound,
56 }
57 }
58}
59
60#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
62#[serde(tag = "kind", rename_all = "snake_case")]
63pub enum RelationAnchor {
64 File {
66 file: RepositoryFilePath,
68 },
69 Symbol {
71 file: RepositoryFilePath,
73 name: String,
75 symbol_kind: Option<SymbolKind>,
77 parent: Option<String>,
79 signature: Option<String>,
81 },
82}
83
84#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
86#[serde(rename_all = "snake_case")]
87pub enum RelationResolutionFilter {
88 Any,
90 Resolved,
92 Ambiguous,
94 Unresolved,
96 External,
98}
99
100pub fn parse_relation_direction(value: &str) -> ServiceResult<RelationDirection> {
106 match value.trim().to_ascii_lowercase().as_str() {
107 "outbound" => Ok(RelationDirection::Outbound),
108 "inbound" => Ok(RelationDirection::Inbound),
109 _ => Err(ServiceError::InvalidInput(format!(
110 "unsupported relation direction {value:?}"
111 ))),
112 }
113}
114
115pub fn parse_relation_confidence(value: &str) -> ServiceResult<ConfidenceClass> {
121 match value.trim().to_ascii_lowercase().as_str() {
122 "exact" => Ok(ConfidenceClass::Exact),
123 "high" => Ok(ConfidenceClass::High),
124 "medium" => Ok(ConfidenceClass::Medium),
125 "low" => Ok(ConfidenceClass::Low),
126 _ => Err(ServiceError::InvalidInput(format!(
127 "unsupported relation confidence {value:?}"
128 ))),
129 }
130}
131
132pub fn parse_relation_resolution(value: &str) -> ServiceResult<RelationResolutionFilter> {
138 match value.trim().to_ascii_lowercase().as_str() {
139 "any" => Ok(RelationResolutionFilter::Any),
140 "resolved" => Ok(RelationResolutionFilter::Resolved),
141 "ambiguous" => Ok(RelationResolutionFilter::Ambiguous),
142 "unresolved" => Ok(RelationResolutionFilter::Unresolved),
143 "external" => Ok(RelationResolutionFilter::External),
144 _ => Err(ServiceError::InvalidInput(format!(
145 "unsupported relation resolution {value:?}"
146 ))),
147 }
148}
149
150#[derive(Clone, Debug)]
152pub struct DetailedRelationQuery {
153 pub anchor: RelationAnchor,
155 pub direction: RelationDirection,
157 pub relation: Option<GraphRelationKind>,
159 pub minimum_confidence: ConfidenceClass,
161 pub resolution: RelationResolutionFilter,
163 pub include_occurrences: bool,
165 pub budget: DetailedRelationBudget,
167 pub cursor: Option<String>,
169 pub content_selection: ContentSelection,
171}
172
173#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
175pub struct DetailedRelationBudget {
176 page_rows: u32,
178 depth: u32,
180 edges: u32,
182 nodes: u32,
184 visited: u32,
186 occurrences_per_relation: u32,
188 occurrences_total: u32,
190 intermediate_bytes: u64,
192 deadline_ms: u64,
194 output_bytes: u32,
196}
197
198impl DetailedRelationBudget {
199 pub const MAX_EDGES: u32 = 100_000;
201 pub const MAX_NODES: u32 = GraphLimits::MAX_ROWS;
203 pub const MAX_OCCURRENCES_TOTAL: u32 = 100_000;
205 pub const MAX_INTERMEDIATE_BYTES: u64 = 32 * 1_024 * 1_024;
207 pub const MAX_DEADLINE_MS: u64 = 60_000;
209
210 #[must_use]
212 pub fn from_graph_limits(limits: GraphLimits) -> Self {
213 let nodes = Self::MAX_NODES;
214 let occurrences_total = limits
215 .rows()
216 .saturating_mul(limits.occurrences())
217 .min(Self::MAX_OCCURRENCES_TOTAL);
218 let intermediate_bytes = u64::from(limits.output_bytes())
219 .saturating_mul(4)
220 .clamp(64 * 1_024, Self::MAX_INTERMEDIATE_BYTES);
221 Self {
222 page_rows: limits.rows(),
223 depth: limits.depth(),
224 edges: limits.rows(),
225 nodes,
226 visited: nodes,
227 occurrences_per_relation: limits.occurrences(),
228 occurrences_total,
229 intermediate_bytes,
230 deadline_ms: 10_000,
231 output_bytes: limits.output_bytes(),
232 }
233 }
234
235 pub fn with_aggregate_limits(
242 mut self,
243 edges: Option<u32>,
244 nodes: Option<u32>,
245 visited: Option<u32>,
246 occurrences_total: Option<u32>,
247 intermediate_bytes: Option<u64>,
248 deadline_ms: Option<u64>,
249 ) -> ServiceResult<Self> {
250 self.edges = edges.unwrap_or(self.edges);
251 self.nodes = nodes.unwrap_or(self.nodes);
252 self.visited = visited.unwrap_or(self.visited);
253 self.occurrences_total = occurrences_total.unwrap_or(self.occurrences_total);
254 self.intermediate_bytes = intermediate_bytes.unwrap_or(self.intermediate_bytes);
255 self.deadline_ms = deadline_ms.unwrap_or(self.deadline_ms);
256 self.validate()
257 }
258
259 fn validate(self) -> ServiceResult<Self> {
261 let valid = self.page_rows > 0
262 && self.page_rows <= GraphLimits::MAX_ROWS
263 && self.depth > 0
264 && self.depth <= GraphLimits::MAX_DEPTH
265 && self.edges > 0
266 && self.edges <= Self::MAX_EDGES
267 && self.nodes > 0
268 && self.nodes <= Self::MAX_NODES
269 && self.visited > 0
270 && self.visited <= Self::MAX_NODES
271 && self.occurrences_per_relation > 0
272 && self.occurrences_per_relation <= GraphLimits::MAX_OCCURRENCES
273 && self.occurrences_total > 0
274 && self.occurrences_total <= Self::MAX_OCCURRENCES_TOTAL
275 && self.intermediate_bytes >= 64 * 1_024
276 && self.intermediate_bytes <= Self::MAX_INTERMEDIATE_BYTES
277 && self.deadline_ms > 0
278 && self.deadline_ms <= Self::MAX_DEADLINE_MS
279 && self.output_bytes > 0
280 && self.output_bytes <= GraphLimits::MAX_OUTPUT_BYTES;
281 if !valid {
282 return Err(ServiceError::InvalidInput(
283 "detailed relation budget is zero, internally inconsistent, or above a product ceiling"
284 .to_string(),
285 ));
286 }
287 Ok(self)
288 }
289
290 #[must_use]
292 pub const fn page_rows(self) -> u32 {
293 self.page_rows
294 }
295
296 #[must_use]
298 pub const fn depth(self) -> u32 {
299 self.depth
300 }
301
302 #[must_use]
304 pub const fn edges(self) -> u32 {
305 self.edges
306 }
307
308 #[must_use]
310 pub const fn nodes(self) -> u32 {
311 self.nodes
312 }
313
314 #[must_use]
316 pub const fn visited(self) -> u32 {
317 self.visited
318 }
319
320 #[must_use]
322 pub const fn occurrences_per_relation(self) -> u32 {
323 self.occurrences_per_relation
324 }
325
326 #[must_use]
328 pub const fn occurrences_total(self) -> u32 {
329 self.occurrences_total
330 }
331
332 #[must_use]
334 pub const fn intermediate_bytes(self) -> u64 {
335 self.intermediate_bytes
336 }
337
338 #[must_use]
340 pub const fn deadline_ms(self) -> u64 {
341 self.deadline_ms
342 }
343
344 #[must_use]
346 pub const fn output_bytes(self) -> u32 {
347 self.output_bytes
348 }
349}
350
351#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
353#[serde(tag = "state", rename_all = "snake_case")]
354pub enum RelationPurpose {
355 Approved {
357 path: String,
359 purpose: String,
361 source: PurposeSource,
363 status: PurposeStatus,
365 },
366 Unavailable {
368 path: Option<String>,
370 },
371 NotApplicable,
373}
374
375#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
377#[serde(tag = "capability", rename_all = "snake_case")]
378pub enum RelationNextCall {
379 Files {
381 folder: projectatlas_core::graph::RepositoryNodePath,
383 #[serde(skip_serializing_if = "Option::is_none")]
385 content_selection: Option<ContentSelection>,
386 },
387 Summary {
389 file: RepositoryFilePath,
391 #[serde(skip_serializing_if = "Option::is_none")]
393 content_selection: Option<ContentSelection>,
394 },
395 SymbolSlice {
397 symbol: SymbolSelector,
399 #[serde(skip_serializing_if = "Option::is_none")]
401 content_selection: Option<ContentSelection>,
402 },
403}
404
405#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
407pub struct DetailedRelationNode {
408 pub entity: GraphEntity,
410 pub classification: Option<ContentClassification>,
412 #[serde(skip_serializing_if = "Option::is_none")]
414 pub content_selection: Option<ContentSelection>,
415 pub purpose: RelationPurpose,
417 pub coverage: Vec<CoverageRecord>,
419}
420
421#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
423pub struct DetailedRelationRow {
424 pub depth: u32,
426 pub direction: RelationDirection,
428 pub relation: LogicalRelation,
430 #[serde(skip_serializing_if = "Option::is_none")]
432 pub document_unresolved_reason: Option<DocumentTargetUnresolvedReason>,
433 #[serde(skip_serializing_if = "Option::is_none")]
435 pub inbound_view: Option<&'static str>,
436 pub source: DetailedRelationNode,
438 pub target: Option<DetailedRelationNode>,
440 pub target_purpose: RelationPurpose,
442 pub path: Vec<DetailedRelationNode>,
444 pub occurrences: Vec<RelationOccurrence>,
446 pub occurrences_truncated: bool,
448 pub next_call: Option<RelationNextCall>,
450}
451
452#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
454#[serde(tag = "state", content = "value", rename_all = "snake_case")]
455pub enum RelationTotalState {
456 Exact(u64),
458 AtLeast(u64),
460 Unknown,
462}
463
464#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
466pub struct DetailedRelationWork {
467 pub returned_rows: u32,
469 pub inspected_edges: u32,
471 pub active_nodes: u32,
473 pub visited_nodes: u32,
475 pub retained_occurrences: u32,
477 pub database_requested_rows: u32,
479 pub database_returned_rows: u32,
481 pub database_decoded_bytes: u64,
483 pub hydrated_entities: u32,
485 pub hydrated_purpose_paths: u32,
487 pub hydrated_classification_paths: u32,
489 pub retained_composition_bytes: u64,
491 pub intermediate_bytes: u64,
493 pub rendered_output_bytes: u64,
495}
496
497#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
499struct RelationDatabaseWork {
500 requested_rows: u32,
502 returned_rows: u32,
504 decoded_bytes: u64,
506 hydrated_entities: u32,
508 hydrated_paths: u32,
510}
511
512impl RelationDatabaseWork {
513 fn record(&mut self, work: RepositoryGraphReadWork) -> ServiceResult<()> {
515 self.requested_rows = self
516 .requested_rows
517 .checked_add(work.requested_rows)
518 .ok_or_else(relation_work_overflow)?;
519 self.returned_rows = self
520 .returned_rows
521 .checked_add(work.returned_rows)
522 .ok_or_else(relation_work_overflow)?;
523 self.decoded_bytes = self
524 .decoded_bytes
525 .checked_add(work.decoded_bytes)
526 .ok_or_else(relation_work_overflow)?;
527 self.hydrated_entities = self
528 .hydrated_entities
529 .checked_add(work.hydrated_entities)
530 .ok_or_else(relation_work_overflow)?;
531 self.hydrated_paths = self
532 .hydrated_paths
533 .checked_add(work.hydrated_paths)
534 .ok_or_else(relation_work_overflow)?;
535 Ok(())
536 }
537}
538
539#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
541pub struct DetailedRelationReport {
542 pub anchor: DetailedRelationNode,
544 pub generation: IndexGeneration,
546 pub authored_purpose_revision: u64,
548 pub direction: RelationDirection,
550 #[serde(skip_serializing_if = "Option::is_none")]
552 pub content_selection: Option<ContentSelection>,
553 pub returned: u32,
555 pub pruned_paths: u64,
557 #[serde(skip)]
559 pub(crate) pruned_incomplete_paths: u64,
560 #[serde(skip)]
562 pub(crate) pruned_relations: Vec<projectatlas_core::graph::LogicalRelation>,
563 #[serde(skip)]
565 pub(crate) pruned_evidence_truncated: bool,
566 pub truncated: bool,
568 pub continuation: Option<String>,
570 #[serde(skip)]
572 pub(crate) adjacency_continuation: Option<RepositoryGraphAdjacencyContinuation>,
573 pub total: RelationTotalState,
575 pub reached_limits: Vec<GraphLimitKind>,
577 pub work: DetailedRelationWork,
579 pub rows: Vec<DetailedRelationRow>,
581}
582
583pub(super) type ExternalRelationIdentity = (String, String, String);
585
586#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
588#[serde(rename_all = "snake_case")]
589enum DetailedRelationAlgorithm {
590 BoundedFrontierV1,
592}
593
594#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
596#[serde(rename_all = "snake_case")]
597enum DetailedRelationOrdering {
598 BreadthFirstRankedBatchV1,
600}
601
602#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
604#[serde(deny_unknown_fields)]
605struct DetailedRelationCursorQuery {
606 anchor: RelationAnchor,
608 direction: RelationDirection,
610 relation: Option<GraphRelationKind>,
612 minimum_confidence: ConfidenceClass,
614 resolution: RelationResolutionFilter,
616 include_occurrences: bool,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
620 content_selection: Option<ContentSelection>,
621}
622
623impl From<&DetailedRelationQuery> for DetailedRelationCursorQuery {
624 fn from(query: &DetailedRelationQuery) -> Self {
625 Self {
626 anchor: query.anchor.clone(),
627 direction: query.direction,
628 relation: query.relation,
629 minimum_confidence: query.minimum_confidence,
630 resolution: query.resolution,
631 include_occurrences: query.include_occurrences,
632 content_selection: query
633 .content_selection
634 .explicit_value()
635 .map(|_| query.content_selection),
636 }
637 }
638}
639
640#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
642#[serde(deny_unknown_fields)]
643struct DetailedRelationCursorBinding {
644 project: projectatlas_core::graph::ProjectInstanceId,
646 root_digest: [u8; 32],
648 generation: IndexGeneration,
650 authored_purpose_revision: u64,
652 capability: DetailedRelationAlgorithm,
654 query: DetailedRelationCursorQuery,
656 ordering: DetailedRelationOrdering,
658 budget: DetailedRelationBudget,
660}
661
662#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
664#[serde(deny_unknown_fields)]
665struct TraversalNodeState {
666 digest: [u8; 32],
668 parent: Option<u32>,
670}
671
672#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
674#[serde(deny_unknown_fields)]
675struct PendingRelationState {
676 relation_digest: [u8; 32],
678 depth: u32,
680 path_terminal: u32,
682}
683
684#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
686#[serde(deny_unknown_fields)]
687struct RelationTraversalState {
688 depth: u32,
690 nodes: Vec<TraversalNodeState>,
692 frontier: Vec<u32>,
694 frontier_index: u32,
696 next_frontier: Vec<u32>,
698 adjacency: Option<RepositoryGraphAdjacencyContinuation>,
700 pending: Vec<PendingRelationState>,
702 pending_index: u32,
704 emitted_rows: u64,
706 pruned_paths: u64,
708}
709
710#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
712#[serde(deny_unknown_fields)]
713struct DetailedRelationCursor {
714 version: u16,
716 binding: DetailedRelationCursorBinding,
718 state: RelationTraversalState,
720}
721
722pub struct DetailedRelationPageDraft {
724 report: DetailedRelationReport,
726 old_emitted: u64,
728 binding: DetailedRelationCursorBinding,
730 prefix_state: Option<RelationTraversalState>,
732 budget: DetailedRelationBudget,
734 deadline: Instant,
736}
737
738impl DetailedRelationPageDraft {
739 #[must_use]
741 pub fn candidate_rows(&self) -> usize {
742 self.report.rows.len()
743 }
744
745 #[must_use]
747 pub const fn maximum_output_bytes(&self) -> usize {
748 self.budget.output_bytes() as usize
749 }
750
751 pub fn report_for_prefix(&self, selected_rows: usize) -> ServiceResult<DetailedRelationReport> {
758 if selected_rows > self.report.rows.len() {
759 return Err(ServiceError::InvalidInput(
760 "detailed relation output prefix exceeds the candidate page".to_string(),
761 ));
762 }
763 let full_rows = self.report.rows.len();
764 let mut report = self.report.clone();
765 if selected_rows < full_rows {
766 let mut state =
767 self.prefix_state
768 .clone()
769 .ok_or(ServiceError::RelationCursorInvalid {
770 reason: "output prefix has no matching traversal checkpoint",
771 })?;
772 state.pending_index = state
773 .pending_index
774 .checked_add(u32::try_from(selected_rows).map_err(|_overflow| {
775 ServiceError::InvalidInput(
776 "detailed relation output prefix index overflowed".to_string(),
777 )
778 })?)
779 .ok_or_else(|| {
780 ServiceError::InvalidInput(
781 "detailed relation output prefix index overflowed".to_string(),
782 )
783 })?;
784 state.emitted_rows = self.old_emitted.saturating_add(selected_rows as u64);
785 report.continuation = Some(encode_relation_cursor(&self.binding, &state, self.budget)?);
786 report.pruned_paths = state.pruned_paths;
787 push_limit(&mut report.reached_limits, GraphLimitKind::OutputBytes);
788 let pending = state
789 .pending
790 .len()
791 .saturating_sub(state.pending_index as usize) as u64;
792 report.total = RelationTotalState::AtLeast(state.emitted_rows.saturating_add(pending));
793 report.work.active_nodes = u32::try_from(state.nodes.len()).unwrap_or(u32::MAX);
794 report.work.visited_nodes = report.work.active_nodes;
795 }
796 report.rows.truncate(selected_rows);
797 report.returned = u32::try_from(selected_rows).map_err(|_overflow| {
798 ServiceError::InvalidInput("output row count overflowed".to_string())
799 })?;
800 report.work.returned_rows = report.returned;
801 report.work.retained_occurrences = report
802 .rows
803 .iter()
804 .map(|row| row.occurrences.len() as u32)
805 .sum();
806 report.work.retained_composition_bytes =
807 relation_composition_bytes(&report.anchor, &report.rows)?;
808 let prefix_cursor_bytes = report
809 .continuation
810 .as_ref()
811 .map_or(0, |cursor| cursor.len() as u64);
812 let prefix_intermediate_bytes = relation_intermediate_bytes(
813 report.work.database_decoded_bytes,
814 prefix_cursor_bytes,
815 0,
816 report.work.retained_composition_bytes,
817 )?;
818 report.work.intermediate_bytes = report
819 .work
820 .intermediate_bytes
821 .max(prefix_intermediate_bytes);
822 if report.work.intermediate_bytes > self.budget.intermediate_bytes() {
823 return Err(ServiceError::InvalidInput(
824 "detailed relation output prefix exceeds the aggregate intermediate-byte budget"
825 .to_string(),
826 ));
827 }
828 report.work.rendered_output_bytes = 0;
829 report.truncated = report.continuation.is_some() || !report.reached_limits.is_empty();
830 Ok(report)
831 }
832
833 pub fn fit_output<F, E>(
844 &self,
845 control: Option<&IndexWorkControl>,
846 encode: F,
847 ) -> Result<(DetailedRelationReport, String), E>
848 where
849 F: FnMut(&DetailedRelationReport) -> Result<String, E>,
850 E: From<ServiceError>,
851 {
852 check_relation_deadline(self.deadline).map_err(E::from)?;
853 fit_detailed_relation_output(self, control, encode)
854 }
855
856 fn fit_compact(
858 &self,
859 control: Option<&IndexWorkControl>,
860 ) -> ServiceResult<DetailedRelationReport> {
861 self.fit_output(control, |report| {
862 serde_json::to_string(report).map_err(ServiceError::from)
863 })
864 .map(|(report, _encoded)| report)
865 }
866}
867
868struct TraversalRow {
870 depth: u32,
872 detail: RepositoryGraphRelationRow,
874 path: Vec<GraphEntity>,
876}
877
878#[derive(Default)]
880struct SerializedByteCounter {
881 bytes: u64,
883}
884
885pub(super) fn classification_path(entity: &GraphEntity) -> Option<String> {
887 match entity.selector() {
888 EntitySelector::File { path } => Some(path.as_str().to_string()),
889 EntitySelector::Package { package } => Some(package.manifest.as_str().to_string()),
890 EntitySelector::Symbol { symbol } => Some(symbol.file.as_str().to_string()),
891 EntitySelector::Project
892 | EntitySelector::Folder { .. }
893 | EntitySelector::External { .. } => None,
894 }
895}
896
897fn load_entity_classifications<'entity>(
899 store: &AtlasStore,
900 entities: impl IntoIterator<Item = &'entity GraphEntity>,
901) -> ServiceResult<BTreeMap<String, ContentClassification>> {
902 let paths = entities
903 .into_iter()
904 .filter_map(classification_path)
905 .collect::<BTreeSet<_>>()
906 .into_iter()
907 .collect::<Vec<_>>();
908 let mut classifications = BTreeMap::new();
909 for chunk in paths.chunks(MAX_FILE_CONTENT_CLASSIFICATION_PATHS) {
910 classifications.extend(
911 store
912 .file_content_classifications_for_paths(chunk)?
913 .into_iter()
914 .map(|row| (row.path, row.classification)),
915 );
916 }
917 Ok(classifications)
918}
919
920pub(super) fn entity_matches_selection(
922 entity: &GraphEntity,
923 classifications: &BTreeMap<String, ContentClassification>,
924 selection: ContentSelection,
925) -> bool {
926 selection == ContentSelection::UnspecifiedLegacy
927 || classification_path(entity)
928 .and_then(|path| classifications.get(&path).copied())
929 .is_some_and(|classification| selection.includes(classification))
930}
931
932fn explicit_document_endpoint(query: &DetailedRelationQuery, relation: &LogicalRelation) -> bool {
934 query.relation == Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents))
935 && relation.kind() == GraphRelationKind::Extended(ExtendedRelationKind::Documents)
936}
937
938fn inbound_relation_view(
940 direction: RelationDirection,
941 relation: &LogicalRelation,
942) -> Option<&'static str> {
943 (direction == RelationDirection::Inbound
944 && relation.kind() == GraphRelationKind::Extended(ExtendedRelationKind::Documents))
945 .then_some(DOCUMENTED_BY_INBOUND_VIEW)
946}
947
948impl Write for SerializedByteCounter {
949 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
950 let bytes = u64::try_from(buffer.len())
951 .map_err(|_overflow| io::Error::other("serialized byte count overflowed"))?;
952 self.bytes = self
953 .bytes
954 .checked_add(bytes)
955 .ok_or_else(|| io::Error::other("serialized byte count overflowed"))?;
956 Ok(buffer.len())
957 }
958
959 fn flush(&mut self) -> io::Result<()> {
960 Ok(())
961 }
962}
963
964pub fn load_detailed_relation_page(
971 store: &AtlasStore,
972 query: &DetailedRelationQuery,
973 control: Option<&IndexWorkControl>,
974) -> ServiceResult<DetailedRelationPageDraft> {
975 check_relation_control(control)?;
976 let budget = query.budget.validate()?;
977 let started = Instant::now();
978 let deadline = started
979 .checked_add(Duration::from_millis(budget.deadline_ms()))
980 .unwrap_or(started);
981 let request_control = relation_request_control(control, deadline);
982 let control = Some(&request_control);
983 let binding = selected_project_binding(store)?;
984 check_relation_control(control)?;
985 let generation = store.repository_graph_generation()?.ok_or_else(|| {
986 ServiceError::InvalidInput(
987 "repository graph has no complete generation for relation navigation".to_string(),
988 )
989 })?;
990 let anchor_path = match &query.anchor {
991 RelationAnchor::File { file } | RelationAnchor::Symbol { file, .. } => file.as_str(),
992 };
993 let anchor_classification =
994 super::selected_file_classification(store, anchor_path, query.content_selection)?;
995 let mut database_work = RelationDatabaseWork::default();
996 let anchor = resolve_anchor(
997 store,
998 binding.project_instance_id,
999 generation,
1000 &query.anchor,
1001 budget,
1002 &mut database_work,
1003 control,
1004 )?;
1005 let anchor_classifications = BTreeMap::from([(anchor_path.to_string(), anchor_classification)]);
1006 let mut hydrated_classification_paths = anchor_classifications
1007 .keys()
1008 .cloned()
1009 .collect::<BTreeSet<_>>();
1010 check_relation_control(control)?;
1011 let anchor_digest = anchor.key().digest_bytes().map_err(invalid_graph_input)?;
1012 let authored_purpose_revision = store.authored_purpose_revision()?;
1013 check_relation_control(control)?;
1014 let cursor_binding = DetailedRelationCursorBinding {
1015 project: binding.project_instance_id,
1016 root_digest: detailed_relation_root_digest(&binding.project_root_identity)?,
1017 generation,
1018 authored_purpose_revision,
1019 capability: DetailedRelationAlgorithm::BoundedFrontierV1,
1020 query: DetailedRelationCursorQuery::from(query),
1021 ordering: DetailedRelationOrdering::BreadthFirstRankedBatchV1,
1022 budget,
1023 };
1024 let mut state = if let Some(encoded) = &query.cursor {
1025 decode_relation_cursor(encoded, &cursor_binding)?
1026 } else {
1027 RelationTraversalState {
1028 depth: 1,
1029 nodes: vec![TraversalNodeState {
1030 digest: anchor_digest,
1031 parent: None,
1032 }],
1033 frontier: vec![0],
1034 frontier_index: 0,
1035 next_frontier: Vec::new(),
1036 adjacency: None,
1037 pending: Vec::new(),
1038 pending_index: 0,
1039 emitted_rows: 0,
1040 pruned_paths: 0,
1041 }
1042 };
1043 validate_traversal_state(&state, budget, anchor_digest)?;
1044 let mut entities = hydrate_traversal_entities(
1045 store,
1046 binding.project_instance_id,
1047 generation,
1048 &state,
1049 budget,
1050 encoded_relation_state_bytes(&cursor_binding, &state, budget)?,
1051 &mut database_work,
1052 control,
1053 )?;
1054 let mut visited = state
1055 .nodes
1056 .iter()
1057 .enumerate()
1058 .map(|(index, node)| {
1059 u32::try_from(index)
1060 .map(|index| (node.digest, index))
1061 .map_err(|_overflow| ServiceError::RelationCursorInvalid {
1062 reason: "node index exceeds the cursor representation",
1063 })
1064 })
1065 .collect::<ServiceResult<HashMap<_, _>>>()?;
1066 let mut selected = Vec::new();
1067 let mut prefix_state = None;
1068 let mut inspected_edges = 0_u32;
1069 let mut reached_limits = Vec::new();
1070 let mut terminal_limit = false;
1071 let mut exhausted = false;
1072 let mut pruned_incomplete_paths = 0_u64;
1073 let mut pruned_relations = Vec::new();
1074 let mut pruned_evidence_truncated = false;
1075 let mut pruned_relation_bytes = 0_u64;
1076
1077 while selected.len() < budget.page_rows() as usize {
1078 if relation_deadline_elapsed(deadline) {
1079 push_limit(&mut reached_limits, GraphLimitKind::Deadline);
1080 break;
1081 }
1082 check_relation_control(control)?;
1083
1084 if (state.pending_index as usize) < state.pending.len() {
1085 prefix_state.get_or_insert_with(|| state.clone());
1086 selected.push(state.pending[state.pending_index as usize].clone());
1087 state.pending_index = state.pending_index.saturating_add(1);
1088 continue;
1089 }
1090 if !selected.is_empty() {
1091 break;
1092 }
1093 state.pending.clear();
1094 state.pending_index = 0;
1095
1096 if state.frontier_index as usize >= state.frontier.len() {
1097 if state.next_frontier.is_empty() {
1098 exhausted = true;
1099 break;
1100 }
1101 if state.depth >= budget.depth() {
1102 push_limit(&mut reached_limits, GraphLimitKind::Depth);
1103 terminal_limit = true;
1104 break;
1105 }
1106 state.frontier = std::mem::take(&mut state.next_frontier);
1107 state.frontier_index = 0;
1108 state.adjacency = None;
1109 state.depth = state.depth.saturating_add(1);
1110 }
1111
1112 if inspected_edges >= budget.edges() {
1113 push_limit(&mut reached_limits, GraphLimitKind::Edges);
1114 break;
1115 }
1116 let chunk_start = state.frontier_index as usize;
1117 let chunk_end = chunk_start
1118 .saturating_add(MAX_REPOSITORY_GRAPH_FRONTIER)
1119 .min(state.frontier.len());
1120 let chunk_nodes = state.frontier[chunk_start..chunk_end].to_vec();
1121 let frontier = chunk_nodes
1122 .iter()
1123 .map(|index| {
1124 entities
1125 .get(*index as usize)
1126 .map(|entity| entity.key().clone())
1127 .ok_or(ServiceError::RelationCursorInvalid {
1128 reason: "frontier node index is absent",
1129 })
1130 })
1131 .collect::<ServiceResult<Vec<_>>>()?;
1132 let mut depth_rows = Vec::new();
1133 loop {
1134 if relation_deadline_elapsed(deadline) {
1135 push_limit(&mut reached_limits, GraphLimitKind::Deadline);
1136 break;
1137 }
1138 check_relation_control(control)?;
1139 let remaining_edges = budget.edges().saturating_sub(inspected_edges);
1140 if remaining_edges == 0 {
1141 break;
1142 }
1143 let per_page = (ADJACENCY_WORK_ROWS / frontier.len())
1144 .saturating_sub(1)
1145 .min(remaining_edges as usize)
1146 .max(1);
1147 let page_limit = u32::try_from(per_page).map_err(|_overflow| {
1148 ServiceError::InvalidInput("graph adjacency page limit overflowed".to_string())
1149 })?;
1150 let state_bytes = encoded_relation_state_bytes(&cursor_binding, &state, budget)?;
1151 let endpoint_limit = page_limit.saturating_add(1).saturating_mul(2);
1152 let database_budget = relation_database_budget(
1153 budget,
1154 database_work,
1155 state_bytes,
1156 frontier.len(),
1157 page_limit,
1158 endpoint_limit,
1159 endpoint_limit,
1160 )?;
1161 let bounded_page = store
1162 .repository_graph_adjacency_page_filtered_bounded_with_documents(
1163 &frontier,
1164 query.direction.into(),
1165 query.relation,
1166 include_document_relations(query),
1167 state.adjacency.as_ref(),
1168 page_limit,
1169 database_budget,
1170 control,
1171 )?;
1172 database_work.record(bounded_page.work)?;
1173 let page = bounded_page.page;
1174 inspected_edges = inspected_edges
1175 .checked_add(u32::try_from(page.rows.len()).map_err(|_overflow| {
1176 ServiceError::InvalidInput("inspected edge count overflowed".to_string())
1177 })?)
1178 .ok_or_else(|| {
1179 ServiceError::InvalidInput("inspected edge count overflowed".to_string())
1180 })?;
1181 depth_rows.extend(page.rows.into_iter().map(|row| FrontierRow {
1182 frontier_index: row.frontier_index,
1183 detail: row.detail,
1184 }));
1185 if page.truncated {
1186 state.adjacency = Some(page.continuation.ok_or_else(|| {
1187 ServiceError::InvalidInput(
1188 "truncated graph adjacency page omitted its continuation".to_string(),
1189 )
1190 })?);
1191 if inspected_edges >= budget.edges() {
1192 break;
1193 }
1194 } else {
1195 state.adjacency = None;
1196 state.frontier_index = u32::try_from(chunk_end).map_err(|_overflow| {
1197 ServiceError::InvalidInput("frontier index overflowed".to_string())
1198 })?;
1199 break;
1200 }
1201 }
1202 depth_rows.retain(|row| relation_matches(&row.detail.relation, query));
1203 depth_rows.sort_by(|left, right| relation_rank_order(&left.detail, &right.detail));
1204 let endpoint_classifications = load_entity_classifications(
1205 store,
1206 depth_rows
1207 .iter()
1208 .filter_map(|row| traversable_entity(&row.detail, query.direction)),
1209 )?;
1210 hydrated_classification_paths.extend(endpoint_classifications.keys().cloned());
1211 let prior_state = state.clone();
1212 let prior_entity_count = entities.len();
1213 for row in depth_rows {
1214 check_relation_control(control)?;
1215 let local_frontier = row.frontier_index as usize;
1216 let Some(&parent_index) = chunk_nodes.get(local_frontier) else {
1217 return Err(ServiceError::InvalidInput(
1218 "graph adjacency row selected an invalid frontier index".to_string(),
1219 ));
1220 };
1221 let mut path_terminal = parent_index;
1222 if let Some(next) = traversable_entity(&row.detail, query.direction) {
1223 let endpoint_selected = entity_matches_selection(
1224 next,
1225 &endpoint_classifications,
1226 query.content_selection,
1227 );
1228 let cross_class_document = explicit_document_endpoint(query, &row.detail.relation);
1229 if !endpoint_selected && !cross_class_document {
1230 continue;
1231 }
1232 let digest = next.key().digest_bytes().map_err(invalid_graph_input)?;
1233 if visited.contains_key(&digest) {
1234 state.pruned_paths = state.pruned_paths.saturating_add(1);
1235 if row.detail.relation.completeness() != Completeness::Complete {
1236 pruned_incomplete_paths = pruned_incomplete_paths.saturating_add(1);
1237 }
1238 if query.include_occurrences {
1239 let relation_bytes = serialized_equivalent_bytes(&row.detail.relation)?;
1240 let state_bytes =
1241 encoded_relation_state_bytes(&cursor_binding, &state, budget)?;
1242 let reserved = database_work.decoded_bytes.saturating_add(state_bytes);
1243 let prospective = pruned_relation_bytes.saturating_add(relation_bytes);
1244 if prospective > budget.intermediate_bytes().saturating_sub(reserved) {
1245 pruned_evidence_truncated = true;
1246 } else {
1247 pruned_relation_bytes = prospective;
1248 pruned_relations.push(row.detail.relation.clone());
1249 }
1250 }
1251 continue;
1252 }
1253 if state.nodes.len() >= budget.nodes() as usize {
1254 push_limit(&mut reached_limits, GraphLimitKind::Nodes);
1255 terminal_limit = true;
1256 break;
1257 }
1258 if visited.len() >= budget.visited() as usize {
1259 push_limit(&mut reached_limits, GraphLimitKind::Visited);
1260 terminal_limit = true;
1261 break;
1262 }
1263 path_terminal = u32::try_from(state.nodes.len()).map_err(|_overflow| {
1264 ServiceError::InvalidInput("traversal node index overflowed".to_string())
1265 })?;
1266 state.nodes.push(TraversalNodeState {
1267 digest,
1268 parent: Some(parent_index),
1269 });
1270 if endpoint_selected {
1271 state.next_frontier.push(path_terminal);
1272 }
1273 visited.insert(digest, path_terminal);
1274 entities.push(next.clone());
1275 }
1276 state.pending.push(PendingRelationState {
1277 relation_digest: row
1278 .detail
1279 .relation
1280 .key()
1281 .digest_bytes()
1282 .map_err(invalid_graph_input)?,
1283 depth: state.depth,
1284 path_terminal,
1285 });
1286 }
1287 if terminal_limit {
1288 break;
1289 }
1290 let state_bytes = encoded_relation_state_bytes(&cursor_binding, &state, budget)?;
1291 if state_bytes.saturating_add(database_work.decoded_bytes) > budget.intermediate_bytes() {
1292 state = prior_state;
1293 entities.truncate(prior_entity_count);
1294 visited = state
1295 .nodes
1296 .iter()
1297 .enumerate()
1298 .map(|(index, node)| (node.digest, index as u32))
1299 .collect();
1300 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
1301 terminal_limit = true;
1302 break;
1303 }
1304 }
1305
1306 if inspected_edges >= budget.edges()
1307 && traversal_has_work(&state, budget.depth())
1308 && !terminal_limit
1309 && !exhausted
1310 {
1311 push_limit(&mut reached_limits, GraphLimitKind::Edges);
1312 }
1313 if selected.len() >= budget.page_rows() as usize && traversal_has_work(&state, budget.depth()) {
1314 push_limit(&mut reached_limits, GraphLimitKind::Rows);
1315 }
1316 let relation_digests = selected
1317 .iter()
1318 .map(|pending| pending.relation_digest)
1319 .collect::<Vec<_>>();
1320 let mut projected_state = state.clone();
1321 projected_state.emitted_rows = projected_state
1322 .emitted_rows
1323 .saturating_add(selected.len() as u64);
1324 let retained_cursor_bytes =
1325 encoded_relation_state_bytes(&cursor_binding, &projected_state, budget)?;
1326 let mut relation_details = Vec::with_capacity(relation_digests.len());
1327 for chunk in relation_digests.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
1328 let chunk_rows = u32::try_from(chunk.len()).map_err(|_overflow| {
1329 ServiceError::InvalidInput("relation hydration batch size overflowed".to_string())
1330 })?;
1331 let endpoint_limit = chunk_rows.saturating_mul(2).max(1);
1332 let database_budget = relation_database_budget(
1333 budget,
1334 database_work,
1335 retained_cursor_bytes,
1336 chunk.len(),
1337 chunk_rows,
1338 endpoint_limit,
1339 endpoint_limit,
1340 )?;
1341 let batch = store.repository_graph_relation_rows_by_digest(
1342 binding.project_instance_id,
1343 generation,
1344 chunk,
1345 database_budget,
1346 control,
1347 )?;
1348 database_work.record(batch.work)?;
1349 relation_details.extend(batch.rows);
1350 }
1351 let retained = selected
1352 .iter()
1353 .cloned()
1354 .zip(relation_details)
1355 .map(|(pending, detail)| {
1356 let path = traversal_path(&state.nodes, &entities, pending.path_terminal)?;
1357 Ok(TraversalRow {
1358 depth: pending.depth,
1359 detail,
1360 path,
1361 })
1362 })
1363 .collect::<ServiceResult<Vec<_>>>()?;
1364
1365 let mut classification_entities = vec![&anchor];
1366 for row in &retained {
1367 classification_entities.push(&row.detail.source);
1368 classification_entities.extend(row.detail.target.iter());
1369 classification_entities.extend(&row.path);
1370 }
1371 let classifications = load_entity_classifications(store, classification_entities)?;
1372 hydrated_classification_paths.extend(classifications.keys().cloned());
1373
1374 let purposes = load_purposes(
1375 store,
1376 binding.project_instance_id,
1377 generation,
1378 &anchor,
1379 &retained,
1380 budget,
1381 &mut database_work,
1382 retained_cursor_bytes,
1383 control,
1384 )?;
1385 let coverage = load_coverage(
1386 store,
1387 binding.project_instance_id,
1388 generation,
1389 &anchor,
1390 &retained,
1391 budget,
1392 &mut database_work,
1393 retained_cursor_bytes,
1394 control,
1395 )?;
1396 let anchor_node = detailed_node(
1397 anchor,
1398 query.content_selection,
1399 &classifications,
1400 &purposes,
1401 &coverage,
1402 );
1403 let (occurrence_pages, retained_occurrences) = load_occurrence_pages(
1404 store,
1405 &retained,
1406 query,
1407 budget,
1408 &mut database_work,
1409 retained_cursor_bytes,
1410 control,
1411 &mut reached_limits,
1412 )?;
1413 let pruned_evidence_bytes = serialized_equivalent_bytes(&pruned_relations)?;
1414 let mut working_composition_bytes = relation_working_composition_bytes(
1415 &entities,
1416 &retained,
1417 query.direction,
1418 &purposes,
1419 &coverage,
1420 &classifications,
1421 &occurrence_pages,
1422 &pruned_relations,
1423 )?;
1424 let mut precomposition_bytes = relation_intermediate_bytes(
1425 database_work.decoded_bytes,
1426 retained_cursor_bytes,
1427 working_composition_bytes,
1428 0,
1429 )?;
1430 if precomposition_bytes > budget.intermediate_bytes() && !pruned_relations.is_empty() {
1431 pruned_relations.clear();
1432 working_composition_bytes = working_composition_bytes.saturating_sub(pruned_evidence_bytes);
1433 pruned_evidence_truncated = true;
1434 precomposition_bytes = relation_intermediate_bytes(
1435 database_work.decoded_bytes,
1436 retained_cursor_bytes,
1437 working_composition_bytes,
1438 0,
1439 )?;
1440 }
1441 if precomposition_bytes > budget.intermediate_bytes() {
1442 return Err(ServiceError::InvalidInput(
1443 "detailed relation aggregate intermediate-byte budget was exhausted before composition"
1444 .to_string(),
1445 ));
1446 }
1447 let mut rows = Vec::with_capacity(retained.len());
1448 for (row, occurrences) in retained.into_iter().zip(occurrence_pages) {
1449 check_relation_control(control)?;
1450 rows.push(detailed_row(
1451 row,
1452 query,
1453 &classifications,
1454 &purposes,
1455 &coverage,
1456 occurrences,
1457 ));
1458 }
1459 let retained_composition_bytes = relation_composition_bytes(&anchor_node, &rows)?;
1460
1461 let old_emitted = state.emitted_rows;
1462 state.emitted_rows = state.emitted_rows.saturating_add(rows.len() as u64);
1463 let traversal_remaining = traversal_has_work(&state, budget.depth());
1464 let has_more = traversal_remaining && !terminal_limit;
1465 let continuation = has_more
1466 .then(|| encode_relation_cursor(&cursor_binding, &state, budget))
1467 .transpose()?;
1468 let cursor_bytes = continuation
1469 .as_ref()
1470 .map_or(serialized_relation_state_bytes(&state)?, |cursor| {
1471 cursor.len() as u64
1472 });
1473 let mut intermediate_bytes = relation_intermediate_bytes(
1474 database_work.decoded_bytes,
1475 cursor_bytes,
1476 working_composition_bytes,
1477 retained_composition_bytes,
1478 )?;
1479 if intermediate_bytes > budget.intermediate_bytes() && !pruned_relations.is_empty() {
1480 pruned_relations.clear();
1481 working_composition_bytes = working_composition_bytes.saturating_sub(pruned_evidence_bytes);
1482 pruned_evidence_truncated = true;
1483 intermediate_bytes = relation_intermediate_bytes(
1484 database_work.decoded_bytes,
1485 cursor_bytes,
1486 working_composition_bytes,
1487 retained_composition_bytes,
1488 )?;
1489 }
1490 if intermediate_bytes > budget.intermediate_bytes() {
1491 return Err(ServiceError::InvalidInput(
1492 "detailed relation aggregate intermediate-byte budget was exhausted before composition"
1493 .to_string(),
1494 ));
1495 }
1496 let total = if !traversal_remaining && !terminal_limit {
1497 RelationTotalState::Exact(state.emitted_rows)
1498 } else {
1499 let pending = state
1500 .pending
1501 .len()
1502 .saturating_sub(state.pending_index as usize) as u64;
1503 let proved = state.emitted_rows.saturating_add(pending);
1504 if proved > 0 {
1505 RelationTotalState::AtLeast(proved)
1506 } else {
1507 RelationTotalState::Unknown
1508 }
1509 };
1510 let returned = u32::try_from(rows.len()).map_err(|_overflow| {
1511 ServiceError::InvalidInput("returned relation row count overflowed".to_string())
1512 })?;
1513 let report = DetailedRelationReport {
1514 anchor: anchor_node,
1515 generation,
1516 authored_purpose_revision,
1517 direction: query.direction,
1518 content_selection: query
1519 .content_selection
1520 .explicit_value()
1521 .map(|_| query.content_selection),
1522 returned,
1523 pruned_paths: state.pruned_paths,
1524 pruned_incomplete_paths,
1525 pruned_relations,
1526 pruned_evidence_truncated,
1527 truncated: continuation.is_some() || terminal_limit || !reached_limits.is_empty(),
1528 continuation,
1529 adjacency_continuation: state.adjacency.clone(),
1530 total,
1531 reached_limits,
1532 work: DetailedRelationWork {
1533 returned_rows: returned,
1534 inspected_edges,
1535 active_nodes: u32::try_from(state.nodes.len()).unwrap_or(u32::MAX),
1536 visited_nodes: u32::try_from(visited.len()).unwrap_or(u32::MAX),
1537 retained_occurrences,
1538 database_requested_rows: database_work.requested_rows,
1539 database_returned_rows: database_work.returned_rows,
1540 database_decoded_bytes: database_work.decoded_bytes,
1541 hydrated_entities: database_work.hydrated_entities,
1542 hydrated_purpose_paths: database_work.hydrated_paths,
1543 hydrated_classification_paths: u32::try_from(hydrated_classification_paths.len())
1544 .unwrap_or(u32::MAX),
1545 retained_composition_bytes,
1546 intermediate_bytes,
1547 rendered_output_bytes: 0,
1548 },
1549 rows,
1550 };
1551 Ok(DetailedRelationPageDraft {
1552 report,
1553 old_emitted,
1554 binding: cursor_binding,
1555 prefix_state,
1556 budget,
1557 deadline,
1558 })
1559}
1560
1561pub fn load_detailed_relations(
1571 store: &AtlasStore,
1572 query: &DetailedRelationQuery,
1573 control: Option<&IndexWorkControl>,
1574) -> ServiceResult<DetailedRelationReport> {
1575 load_detailed_relation_page(store, query, control)?.fit_compact(control)
1576}
1577
1578struct FrontierRow {
1580 frontier_index: u32,
1582 detail: RepositoryGraphRelationRow,
1584}
1585
1586fn detailed_relation_root_digest(root: &CanonicalProjectRoot) -> ServiceResult<[u8; 32]> {
1588 canonical_root_digest(DETAILED_RELATION_ROOT_DOMAIN, root)
1589}
1590
1591fn decode_relation_cursor(
1593 encoded: &str,
1594 expected: &DetailedRelationCursorBinding,
1595) -> ServiceResult<RelationTraversalState> {
1596 if encoded.is_empty() || encoded.len() > DETAILED_RELATION_CURSOR_MAX_BYTES {
1597 return Err(ServiceError::RelationCursorInvalid {
1598 reason: "cursor length is empty or above the product ceiling",
1599 });
1600 }
1601 let cursor: DetailedRelationCursor =
1602 serde_json::from_str(encoded).map_err(|_source| ServiceError::RelationCursorInvalid {
1603 reason: "cursor JSON is malformed or contains unknown fields",
1604 })?;
1605 if cursor.version != DETAILED_RELATION_CURSOR_VERSION
1606 || cursor.binding.capability != expected.capability
1607 {
1608 return Err(ServiceError::RelationCursorStale {
1609 field: "algorithm version",
1610 });
1611 }
1612 for (changed, field) in [
1613 (
1614 cursor.binding.project != expected.project,
1615 "project identity",
1616 ),
1617 (
1618 cursor.binding.root_digest != expected.root_digest,
1619 "project root",
1620 ),
1621 (
1622 cursor.binding.generation != expected.generation,
1623 "graph generation",
1624 ),
1625 (
1626 cursor.binding.authored_purpose_revision != expected.authored_purpose_revision,
1627 "authored-purpose revision",
1628 ),
1629 ] {
1630 if changed {
1631 return Err(ServiceError::RelationCursorStale { field });
1632 }
1633 }
1634 if cursor.binding.query != expected.query {
1635 return Err(ServiceError::RelationCursorMismatched { field: "query" });
1636 }
1637 if cursor.binding.ordering != expected.ordering {
1638 return Err(ServiceError::RelationCursorMismatched { field: "ordering" });
1639 }
1640 if cursor.binding.budget != expected.budget {
1641 return Err(ServiceError::RelationCursorMismatched { field: "budget" });
1642 }
1643 Ok(cursor.state)
1644}
1645
1646fn encode_relation_cursor(
1648 binding: &DetailedRelationCursorBinding,
1649 state: &RelationTraversalState,
1650 budget: DetailedRelationBudget,
1651) -> ServiceResult<String> {
1652 let encoded = serde_json::to_string(&DetailedRelationCursor {
1653 version: DETAILED_RELATION_CURSOR_VERSION,
1654 binding: binding.clone(),
1655 state: state.clone(),
1656 })?;
1657 if encoded.len() > DETAILED_RELATION_CURSOR_MAX_BYTES
1658 || encoded.len() > budget.intermediate_bytes() as usize
1659 {
1660 return Err(ServiceError::RelationCursorInvalid {
1661 reason: "encoded cursor exceeds the intermediate-state ceiling",
1662 });
1663 }
1664 Ok(encoded)
1665}
1666
1667fn validate_traversal_state(
1669 state: &RelationTraversalState,
1670 budget: DetailedRelationBudget,
1671 anchor: [u8; 32],
1672) -> ServiceResult<()> {
1673 let serialized = serde_json::to_vec(state)?;
1674 let invalid_shape = state.depth == 0
1675 || state.depth > budget.depth()
1676 || state.nodes.is_empty()
1677 || state.nodes.len() > budget.nodes() as usize
1678 || state.nodes.len() > budget.visited() as usize
1679 || state.nodes[0].digest != anchor
1680 || state.nodes[0].parent.is_some()
1681 || state.frontier.is_empty()
1682 || state.frontier_index as usize > state.frontier.len()
1683 || state.pending_index as usize > state.pending.len()
1684 || serialized.len() > budget.intermediate_bytes() as usize;
1685 if invalid_shape {
1686 return Err(ServiceError::RelationCursorInvalid {
1687 reason: "cursor state exceeds its budget or has an invalid root/index shape",
1688 });
1689 }
1690 let mut unique = HashSet::with_capacity(state.nodes.len());
1691 for (index, node) in state.nodes.iter().enumerate() {
1692 if !unique.insert(node.digest)
1693 || (index > 0 && node.parent.is_none())
1694 || node.parent.is_some_and(|parent| parent as usize >= index)
1695 {
1696 return Err(ServiceError::RelationCursorInvalid {
1697 reason: "cursor nodes are duplicate, cyclic, or not parent ordered",
1698 });
1699 }
1700 }
1701 if state
1702 .frontier
1703 .iter()
1704 .chain(&state.next_frontier)
1705 .any(|index| *index as usize >= state.nodes.len())
1706 || state.pending.iter().any(|pending| {
1707 pending.path_terminal as usize >= state.nodes.len()
1708 || pending.depth == 0
1709 || pending.depth > budget.depth()
1710 })
1711 || (state.adjacency.is_some() && state.frontier_index as usize >= state.frontier.len())
1712 {
1713 return Err(ServiceError::RelationCursorInvalid {
1714 reason: "cursor frontier or pending row references an absent node",
1715 });
1716 }
1717 if state
1718 .pending
1719 .iter()
1720 .map(|pending| pending.relation_digest)
1721 .collect::<HashSet<_>>()
1722 .len()
1723 != state.pending.len()
1724 {
1725 return Err(ServiceError::RelationCursorInvalid {
1726 reason: "cursor pending relations are not unique",
1727 });
1728 }
1729 Ok(())
1730}
1731
1732fn hydrate_traversal_entities(
1734 store: &AtlasStore,
1735 project: projectatlas_core::graph::ProjectInstanceId,
1736 generation: IndexGeneration,
1737 state: &RelationTraversalState,
1738 budget: DetailedRelationBudget,
1739 retained_state_bytes: u64,
1740 database_work: &mut RelationDatabaseWork,
1741 control: Option<&IndexWorkControl>,
1742) -> ServiceResult<Vec<GraphEntity>> {
1743 let digests = state
1744 .nodes
1745 .iter()
1746 .map(|node| node.digest)
1747 .collect::<Vec<_>>();
1748 let mut entities = Vec::with_capacity(digests.len());
1749 for chunk in digests.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
1750 let chunk_rows = u32::try_from(chunk.len()).map_err(|_overflow| {
1751 ServiceError::InvalidInput("entity hydration batch size overflowed".to_string())
1752 })?;
1753 let database_budget = relation_database_budget(
1754 budget,
1755 *database_work,
1756 retained_state_bytes,
1757 chunk.len(),
1758 chunk_rows,
1759 chunk_rows,
1760 chunk_rows,
1761 )?;
1762 let batch = store.repository_graph_entities_by_digest(
1763 project,
1764 generation,
1765 chunk,
1766 database_budget,
1767 control,
1768 )?;
1769 database_work.record(batch.work)?;
1770 entities.extend(batch.rows);
1771 }
1772 if entities.len() != state.nodes.len()
1773 || entities
1774 .iter()
1775 .any(|entity| entity.key().project() != project || entity.generation() != generation)
1776 {
1777 return Err(ServiceError::RelationCursorStale {
1778 field: "traversal entities",
1779 });
1780 }
1781 Ok(entities)
1782}
1783
1784fn traversal_path(
1786 nodes: &[TraversalNodeState],
1787 entities: &[GraphEntity],
1788 terminal: u32,
1789) -> ServiceResult<Vec<GraphEntity>> {
1790 let mut indices = Vec::new();
1791 let mut cursor = Some(terminal);
1792 while let Some(index) = cursor {
1793 let node = nodes
1794 .get(index as usize)
1795 .ok_or(ServiceError::RelationCursorInvalid {
1796 reason: "path terminal references an absent node",
1797 })?;
1798 indices.push(index);
1799 cursor = node.parent;
1800 }
1801 indices.reverse();
1802 indices
1803 .into_iter()
1804 .map(|index| {
1805 entities
1806 .get(index as usize)
1807 .cloned()
1808 .ok_or(ServiceError::RelationCursorInvalid {
1809 reason: "hydrated path entity is absent",
1810 })
1811 })
1812 .collect()
1813}
1814
1815fn traversal_has_work(state: &RelationTraversalState, maximum_depth: u32) -> bool {
1817 (state.pending_index as usize) < state.pending.len()
1818 || (state.frontier_index as usize) < state.frontier.len()
1819 || (!state.next_frontier.is_empty() && state.depth < maximum_depth)
1820}
1821
1822fn resolve_anchor(
1824 store: &AtlasStore,
1825 project: projectatlas_core::graph::ProjectInstanceId,
1826 generation: IndexGeneration,
1827 anchor: &RelationAnchor,
1828 budget: DetailedRelationBudget,
1829 database_work: &mut RelationDatabaseWork,
1830 control: Option<&IndexWorkControl>,
1831) -> ServiceResult<GraphEntity> {
1832 match anchor {
1833 RelationAnchor::File { file } => {
1834 let selector = EntitySelector::File { path: file.clone() };
1835 let key = GraphEntityKey::new(project, &selector);
1836 let database_budget = relation_database_budget(budget, *database_work, 0, 1, 1, 1, 1)?;
1837 let batch = store.repository_graph_entity_bounded(
1838 &key,
1839 generation,
1840 database_budget,
1841 control,
1842 )?;
1843 database_work.record(batch.work)?;
1844 batch.rows.into_iter().next().ok_or_else(|| {
1845 ServiceError::InvalidInput(format!(
1846 "graph file anchor is not available: {}",
1847 file.as_str()
1848 ))
1849 })
1850 }
1851 RelationAnchor::Symbol {
1852 file,
1853 name,
1854 symbol_kind,
1855 parent,
1856 signature,
1857 } => {
1858 let path = projectatlas_core::graph::RepositoryNodePath::new(std::path::Path::new(
1859 file.as_str(),
1860 ))
1861 .map_err(invalid_graph_input)?;
1862 let entity_limit = GraphLimits::MAX_ROWS;
1863 let database_budget = relation_database_budget(
1864 budget,
1865 *database_work,
1866 0,
1867 1,
1868 entity_limit,
1869 entity_limit.saturating_add(1),
1870 1,
1871 )?;
1872 let batch = store.repository_graph_entities_by_path_bounded(
1873 project,
1874 generation,
1875 &path,
1876 entity_limit,
1877 database_budget,
1878 control,
1879 )?;
1880 database_work.record(batch.work)?;
1881 let page = batch.page;
1882 if page.truncated {
1883 return Err(ServiceError::InvalidInput(format!(
1884 "graph symbol anchor search exceeded the row ceiling for {}",
1885 file.as_str()
1886 )));
1887 }
1888 let matches = page
1889 .rows
1890 .into_iter()
1891 .filter(|entity| {
1892 let EntitySelector::Symbol { symbol } = entity.selector() else {
1893 return false;
1894 };
1895 symbol.file == *file
1896 && symbol.name.as_str() == name
1897 && symbol_kind.is_none_or(|kind| symbol.kind == kind)
1898 && parent.as_deref().is_none_or(|value| {
1899 symbol
1900 .parent
1901 .as_ref()
1902 .is_some_and(|item| item.as_str() == value)
1903 })
1904 && signature
1905 .as_deref()
1906 .is_none_or(|value| symbol.signature.as_str() == value)
1907 })
1908 .collect::<Vec<_>>();
1909 match matches.as_slice() {
1910 [entity] => Ok(entity.clone()),
1911 [] => Err(ServiceError::InvalidInput(format!(
1912 "graph symbol anchor is not available: {}::{name}",
1913 file.as_str()
1914 ))),
1915 _ => Err(ServiceError::InvalidInput(format!(
1916 "graph symbol anchor is ambiguous: {}::{name}; add kind, parent, or signature",
1917 file.as_str()
1918 ))),
1919 }
1920 }
1921 }
1922}
1923
1924pub(super) fn resolve_relation_anchor_for_analysis(
1926 store: &AtlasStore,
1927 project: projectatlas_core::graph::ProjectInstanceId,
1928 generation: IndexGeneration,
1929 anchor: &RelationAnchor,
1930 budget: DetailedRelationBudget,
1931 control: Option<&IndexWorkControl>,
1932) -> ServiceResult<(GraphEntity, DetailedRelationWork)> {
1933 let mut database_work = RelationDatabaseWork::default();
1934 let entity = resolve_anchor(
1935 store,
1936 project,
1937 generation,
1938 anchor,
1939 budget,
1940 &mut database_work,
1941 control,
1942 )?;
1943 Ok((
1944 entity,
1945 DetailedRelationWork {
1946 database_requested_rows: database_work.requested_rows,
1947 database_returned_rows: database_work.returned_rows,
1948 database_decoded_bytes: database_work.decoded_bytes,
1949 hydrated_entities: database_work.hydrated_entities,
1950 hydrated_purpose_paths: database_work.hydrated_paths,
1951 intermediate_bytes: database_work.decoded_bytes,
1952 ..DetailedRelationWork::default()
1953 },
1954 ))
1955}
1956
1957pub(super) fn relation_matches(relation: &LogicalRelation, query: &DetailedRelationQuery) -> bool {
1959 query.relation.is_none_or(|kind| relation.kind() == kind)
1960 && !(query.relation.is_none()
1961 && query.content_selection == ContentSelection::UnspecifiedLegacy
1962 && relation.kind() == GraphRelationKind::Extended(ExtendedRelationKind::Documents))
1963 && confidence_rank(relation.confidence()) >= confidence_rank(query.minimum_confidence)
1964 && match query.resolution {
1965 RelationResolutionFilter::Any => true,
1966 RelationResolutionFilter::Resolved => {
1967 matches!(relation.resolution(), RelationResolution::Resolved { .. })
1968 }
1969 RelationResolutionFilter::Ambiguous => {
1970 matches!(relation.resolution(), RelationResolution::Ambiguous { .. })
1971 }
1972 RelationResolutionFilter::Unresolved => {
1973 matches!(relation.resolution(), RelationResolution::Unresolved { .. })
1974 }
1975 RelationResolutionFilter::External => {
1976 matches!(relation.resolution(), RelationResolution::External { .. })
1977 }
1978 }
1979}
1980
1981pub(super) fn include_document_relations(query: &DetailedRelationQuery) -> bool {
1983 query.content_selection != ContentSelection::UnspecifiedLegacy
1984}
1985
1986pub(super) fn external_relation_identities(
1988 report: &DetailedRelationReport,
1989) -> BTreeSet<ExternalRelationIdentity> {
1990 report
1991 .rows
1992 .iter()
1993 .filter_map(|row| {
1994 let RelationResolution::External { external, .. } = row.relation.resolution() else {
1995 return None;
1996 };
1997 Some((
1998 row.relation.kind().as_str().to_string(),
1999 external.system.as_str().to_string(),
2000 external.identity.as_str().to_string(),
2001 ))
2002 })
2003 .collect()
2004}
2005
2006const fn confidence_rank(value: ConfidenceClass) -> u8 {
2008 match value {
2009 ConfidenceClass::Exact => 4,
2010 ConfidenceClass::High => 3,
2011 ConfidenceClass::Medium => 2,
2012 ConfidenceClass::Low => 1,
2013 }
2014}
2015
2016fn resolution_rank(value: &RelationResolution) -> u8 {
2018 match value {
2019 RelationResolution::Resolved { .. } => 4,
2020 RelationResolution::External { .. } => 3,
2021 RelationResolution::Ambiguous { .. } => 2,
2022 RelationResolution::Unresolved { .. } => 1,
2023 }
2024}
2025
2026fn relation_rank_order(
2028 left: &RepositoryGraphRelationRow,
2029 right: &RepositoryGraphRelationRow,
2030) -> std::cmp::Ordering {
2031 confidence_rank(right.relation.confidence())
2032 .cmp(&confidence_rank(left.relation.confidence()))
2033 .then_with(|| {
2034 resolution_rank(right.relation.resolution())
2035 .cmp(&resolution_rank(left.relation.resolution()))
2036 })
2037 .then_with(|| {
2038 left.relation
2039 .key()
2040 .canonical_identity()
2041 .cmp(right.relation.key().canonical_identity())
2042 })
2043 .then_with(|| {
2044 left.relation
2045 .key()
2046 .digest()
2047 .cmp(right.relation.key().digest())
2048 })
2049}
2050
2051fn traversable_entity(
2053 detail: &RepositoryGraphRelationRow,
2054 direction: RelationDirection,
2055) -> Option<&GraphEntity> {
2056 match direction {
2057 RelationDirection::Outbound
2058 if matches!(
2059 detail.relation.resolution(),
2060 RelationResolution::Resolved { .. }
2061 ) =>
2062 {
2063 detail.target.as_ref()
2064 }
2065 RelationDirection::Inbound => Some(&detail.source),
2066 RelationDirection::Outbound => None,
2067 }
2068}
2069
2070fn load_purposes(
2072 store: &AtlasStore,
2073 project: projectatlas_core::graph::ProjectInstanceId,
2074 generation: IndexGeneration,
2075 anchor: &GraphEntity,
2076 rows: &[TraversalRow],
2077 budget: DetailedRelationBudget,
2078 database_work: &mut RelationDatabaseWork,
2079 retained_state_bytes: u64,
2080 control: Option<&IndexWorkControl>,
2081) -> ServiceResult<BTreeMap<String, Purpose>> {
2082 if !store.has_agent_approved_purpose()? {
2083 return Ok(BTreeMap::new());
2084 }
2085 let mut paths = BTreeSet::new();
2086 paths.extend(purpose_candidates(anchor));
2087 for row in rows {
2088 check_relation_control(control)?;
2089 paths.extend(purpose_candidates(&row.detail.source));
2090 if let Some(target) = &row.detail.target {
2091 paths.extend(purpose_candidates(target));
2092 }
2093 for entity in &row.path {
2094 paths.extend(purpose_candidates(entity));
2095 }
2096 }
2097 let selected = paths.into_iter().collect::<Vec<_>>();
2098 let mut purposes = BTreeMap::new();
2099 for chunk in selected.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
2100 let chunk_rows = u32::try_from(chunk.len()).map_err(|_overflow| {
2101 ServiceError::InvalidInput("purpose hydration batch size overflowed".to_string())
2102 })?;
2103 let database_budget = relation_database_budget(
2104 budget,
2105 *database_work,
2106 retained_state_bytes,
2107 chunk.len(),
2108 chunk_rows,
2109 1,
2110 chunk_rows,
2111 )?;
2112 let batch = store.load_purpose_owner_nodes_by_paths_controlled(
2113 project,
2114 generation,
2115 chunk,
2116 database_budget,
2117 control,
2118 )?;
2119 database_work.record(batch.work)?;
2120 purposes.extend(
2121 batch
2122 .rows
2123 .into_iter()
2124 .map(|node| (node.node.path.clone(), node.purpose)),
2125 );
2126 }
2127 Ok(purposes)
2128}
2129
2130fn purpose_candidates(entity: &GraphEntity) -> Vec<String> {
2132 let Some(exact) = purpose_owner(entity) else {
2133 return Vec::new();
2134 };
2135 let mut candidates = vec![exact.clone()];
2136 let mut cursor = exact.as_str();
2137 while let Some((parent, _name)) = cursor.rsplit_once('/') {
2138 candidates.push(parent.to_string());
2139 cursor = parent;
2140 }
2141 if exact != "." {
2142 candidates.push(".".to_string());
2143 }
2144 candidates
2145}
2146
2147fn purpose_owner(entity: &GraphEntity) -> Option<String> {
2149 match entity.selector() {
2150 EntitySelector::Project => Some(".".to_string()),
2151 EntitySelector::Folder { path } => Some(path.as_str().to_string()),
2152 EntitySelector::File { path } => Some(path.as_str().to_string()),
2153 EntitySelector::Package { package } => Some(package.manifest.as_str().to_string()),
2154 EntitySelector::Symbol { symbol } => Some(symbol.file.as_str().to_string()),
2155 EntitySelector::External { .. } => None,
2156 }
2157}
2158
2159fn purpose_projection(
2161 entity: &GraphEntity,
2162 purposes: &BTreeMap<String, Purpose>,
2163) -> RelationPurpose {
2164 let Some(exact_path) = purpose_owner(entity) else {
2165 return RelationPurpose::NotApplicable;
2166 };
2167 for path in purpose_candidates(entity) {
2168 if let Some(purpose) = purposes.get(&path)
2169 && purpose.status == PurposeStatus::Approved
2170 && purpose.source == PurposeSource::Agent
2171 && let Some(text) = &purpose.purpose
2172 {
2173 return RelationPurpose::Approved {
2174 path,
2175 purpose: text.clone(),
2176 source: purpose.source,
2177 status: purpose.status,
2178 };
2179 }
2180 }
2181 RelationPurpose::Unavailable {
2182 path: Some(exact_path),
2183 }
2184}
2185
2186fn detailed_node(
2188 entity: GraphEntity,
2189 content_selection: ContentSelection,
2190 classifications: &BTreeMap<String, ContentClassification>,
2191 purposes: &BTreeMap<String, Purpose>,
2192 coverage: &BTreeMap<String, Vec<CoverageRecord>>,
2193) -> DetailedRelationNode {
2194 let classification =
2195 classification_path(&entity).and_then(|path| classifications.get(&path).copied());
2196 let purpose = purpose_projection(&entity, purposes);
2197 let coverage = purpose_owner(&entity)
2198 .and_then(|path| coverage.get(&path).cloned())
2199 .unwrap_or_default();
2200 DetailedRelationNode {
2201 entity,
2202 classification,
2203 content_selection: next_call_content_selection(content_selection, classification),
2204 purpose,
2205 coverage,
2206 }
2207}
2208
2209pub(super) fn hydrate_single_detailed_node(
2212 store: &AtlasStore,
2213 entity: &GraphEntity,
2214 generation: IndexGeneration,
2215 content_selection: ContentSelection,
2216 budget: DetailedRelationBudget,
2217 control: Option<&IndexWorkControl>,
2218) -> ServiceResult<(DetailedRelationNode, DetailedRelationWork)> {
2219 check_relation_control(control)?;
2220 let classifications = load_entity_classifications(store, std::iter::once(entity))?;
2221 let classification_bytes = serialized_equivalent_bytes(&classifications)?;
2222 let remaining_metadata_bytes = budget
2223 .intermediate_bytes()
2224 .checked_sub(classification_bytes)
2225 .ok_or_else(|| {
2226 ServiceError::InvalidInput(
2227 "terminal node metadata exceeded the intermediate-byte budget".to_string(),
2228 )
2229 })?;
2230 let metadata_budget = budget.with_aggregate_limits(
2231 None,
2232 None,
2233 None,
2234 None,
2235 Some(remaining_metadata_bytes),
2236 None,
2237 )?;
2238 let mut database_work = RelationDatabaseWork::default();
2239 let purposes = load_purposes(
2240 store,
2241 entity.key().project(),
2242 generation,
2243 entity,
2244 &[],
2245 metadata_budget,
2246 &mut database_work,
2247 0,
2248 control,
2249 )?;
2250 let coverage = load_coverage(
2251 store,
2252 entity.key().project(),
2253 generation,
2254 entity,
2255 &[],
2256 metadata_budget,
2257 &mut database_work,
2258 0,
2259 control,
2260 )?;
2261 let intermediate_bytes = database_work
2262 .decoded_bytes
2263 .checked_add(classification_bytes)
2264 .ok_or_else(relation_work_overflow)?;
2265 if intermediate_bytes > budget.intermediate_bytes() {
2266 return Err(ServiceError::InvalidInput(
2267 "terminal node metadata exceeded the intermediate-byte budget".to_string(),
2268 ));
2269 }
2270 check_relation_control(control)?;
2271 Ok((
2272 detailed_node(
2273 entity.clone(),
2274 content_selection,
2275 &classifications,
2276 &purposes,
2277 &coverage,
2278 ),
2279 DetailedRelationWork {
2280 database_requested_rows: database_work.requested_rows,
2281 database_returned_rows: database_work.returned_rows,
2282 database_decoded_bytes: database_work.decoded_bytes,
2283 hydrated_entities: database_work.hydrated_entities,
2284 hydrated_purpose_paths: database_work.hydrated_paths,
2285 hydrated_classification_paths: u32::try_from(classifications.len()).unwrap_or(u32::MAX),
2286 intermediate_bytes,
2287 ..DetailedRelationWork::default()
2288 },
2289 ))
2290}
2291
2292fn detailed_row(
2294 row: TraversalRow,
2295 query: &DetailedRelationQuery,
2296 classifications: &BTreeMap<String, ContentClassification>,
2297 purposes: &BTreeMap<String, Purpose>,
2298 coverage: &BTreeMap<String, Vec<CoverageRecord>>,
2299 occurrence_page: (Vec<RelationOccurrence>, bool),
2300) -> DetailedRelationRow {
2301 let (occurrences, occurrences_truncated) = occurrence_page;
2302 let document_unresolved_reason = row.detail.document_unresolved_reason;
2303 let inbound_view = inbound_relation_view(query.direction, &row.detail.relation);
2304 let next_call = traversable_entity(&row.detail, query.direction).and_then(|entity| {
2305 let classification =
2306 classification_path(entity).and_then(|path| classifications.get(&path).copied());
2307 next_call_for_entity(entity, query.content_selection, classification)
2308 });
2309 let target_purpose = row
2310 .detail
2311 .target
2312 .as_ref()
2313 .map_or(RelationPurpose::Unavailable { path: None }, |target| {
2314 purpose_projection(target, purposes)
2315 });
2316 let path = row
2317 .path
2318 .into_iter()
2319 .map(|entity| {
2320 detailed_node(
2321 entity,
2322 query.content_selection,
2323 classifications,
2324 purposes,
2325 coverage,
2326 )
2327 })
2328 .collect();
2329 DetailedRelationRow {
2330 depth: row.depth,
2331 direction: query.direction,
2332 relation: row.detail.relation,
2333 document_unresolved_reason,
2334 inbound_view,
2335 source: detailed_node(
2336 row.detail.source,
2337 query.content_selection,
2338 classifications,
2339 purposes,
2340 coverage,
2341 ),
2342 target: row.detail.target.map(|target| {
2343 detailed_node(
2344 target,
2345 query.content_selection,
2346 classifications,
2347 purposes,
2348 coverage,
2349 )
2350 }),
2351 target_purpose,
2352 path,
2353 occurrences,
2354 occurrences_truncated,
2355 next_call,
2356 }
2357}
2358
2359fn load_coverage(
2361 store: &AtlasStore,
2362 project: projectatlas_core::graph::ProjectInstanceId,
2363 generation: IndexGeneration,
2364 anchor: &GraphEntity,
2365 rows: &[TraversalRow],
2366 budget: DetailedRelationBudget,
2367 database_work: &mut RelationDatabaseWork,
2368 retained_state_bytes: u64,
2369 control: Option<&IndexWorkControl>,
2370) -> ServiceResult<BTreeMap<String, Vec<CoverageRecord>>> {
2371 let mut paths = BTreeSet::new();
2372 for entity in std::iter::once(anchor).chain(rows.iter().flat_map(|row| {
2373 std::iter::once(&row.detail.source)
2374 .chain(row.detail.target.iter())
2375 .chain(row.path.iter())
2376 })) {
2377 if let Some(path) = purpose_owner(entity)
2378 && path != "."
2379 {
2380 paths.insert(path);
2381 }
2382 }
2383
2384 let mut coverage = BTreeMap::<String, Vec<CoverageRecord>>::new();
2385 let selected = paths
2386 .into_iter()
2387 .map(|path| {
2388 RepositoryNodePath::new(std::path::Path::new(&path))
2389 .map(|normalized| (path, normalized))
2390 .map_err(invalid_graph_input)
2391 })
2392 .collect::<ServiceResult<Vec<_>>>()?;
2393 for chunk in selected.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
2394 let normalized = chunk
2395 .iter()
2396 .map(|(_, path)| path.clone())
2397 .collect::<Vec<_>>();
2398 let database_budget = relation_database_budget(
2399 budget,
2400 *database_work,
2401 retained_state_bytes,
2402 normalized.len(),
2403 GraphLimits::MAX_ROWS,
2404 1,
2405 u32::try_from(normalized.len()).map_err(|_overflow| {
2406 ServiceError::InvalidInput("coverage path batch size overflowed".to_string())
2407 })?,
2408 )?;
2409 let batch = store.repository_graph_path_coverage_bounded(
2410 project,
2411 generation,
2412 &normalized,
2413 database_budget,
2414 control,
2415 )?;
2416 database_work.record(batch.work)?;
2417 let page = batch.page;
2418 if page.truncated {
2419 return Err(ServiceError::InvalidInput(
2420 "graph coverage hydration exceeded the bounded work ceiling".to_string(),
2421 ));
2422 }
2423 for record in page.rows {
2424 let CoverageScope::Path { path } = record.scope() else {
2425 return Err(ServiceError::InvalidInput(
2426 "path coverage hydration returned a project-scoped row".to_string(),
2427 ));
2428 };
2429 coverage
2430 .entry(path.as_str().to_string())
2431 .or_default()
2432 .push(record);
2433 }
2434 }
2435 Ok(coverage)
2436}
2437
2438fn load_occurrence_pages(
2440 store: &AtlasStore,
2441 rows: &[TraversalRow],
2442 query: &DetailedRelationQuery,
2443 budget: DetailedRelationBudget,
2444 database_work: &mut RelationDatabaseWork,
2445 retained_state_bytes: u64,
2446 control: Option<&IndexWorkControl>,
2447 reached_limits: &mut Vec<GraphLimitKind>,
2448) -> ServiceResult<(Vec<(Vec<RelationOccurrence>, bool)>, u32)> {
2449 if !query.include_occurrences {
2450 return Ok(((0..rows.len()).map(|_| (Vec::new(), false)).collect(), 0));
2451 }
2452 let per_relation = budget.occurrences_per_relation();
2453 let mut remaining = budget.occurrences_total();
2454 let mut retained = 0_u32;
2455 let mut pages = Vec::with_capacity(rows.len());
2456 let mut start = 0_usize;
2457 while start < rows.len() {
2458 check_relation_control(control)?;
2459 if remaining == 0 {
2460 let mut occurrence_evidence_omitted = false;
2461 while start < rows.len() {
2462 check_relation_control(control)?;
2463 let batch_size = (rows.len() - start).min(MAX_REPOSITORY_GRAPH_FRONTIER);
2464 let chunk = &rows[start..start + batch_size];
2465 let relations = chunk
2466 .iter()
2467 .map(|row| row.detail.relation.clone())
2468 .collect::<Vec<_>>();
2469 let batch_rows = u32::try_from(batch_size).map_err(|_overflow| {
2470 ServiceError::InvalidInput("occurrence batch size overflowed".to_string())
2471 })?;
2472 let hydrated_paths = batch_rows.saturating_mul(2).max(1);
2473 let Ok(database_budget) = relation_database_budget(
2474 budget,
2475 *database_work,
2476 retained_state_bytes,
2477 relations.len(),
2478 batch_rows,
2479 1,
2480 hydrated_paths,
2481 ) else {
2482 occurrence_evidence_omitted = true;
2483 pages.extend((start..rows.len()).map(|_| (Vec::new(), true)));
2484 break;
2485 };
2486 let batch = match store.repository_graph_occurrence_pages_bounded(
2487 &relations,
2488 1,
2489 database_budget,
2490 control,
2491 ) {
2492 Ok(batch) => batch,
2493 Err(DbError::GraphContract(
2494 projectatlas_core::graph::GraphContractError::InvalidLimits {
2495 reason: "graph read decoded bytes exceed the batch budget",
2496 },
2497 )) => {
2498 occurrence_evidence_omitted = true;
2499 pages.extend((start..rows.len()).map(|_| (Vec::new(), true)));
2500 break;
2501 }
2502 Err(error) => return Err(error.into()),
2503 };
2504 database_work.record(batch.work)?;
2505 occurrence_evidence_omitted |= batch
2506 .pages
2507 .iter()
2508 .any(|page| page.truncated || !page.rows.is_empty());
2509 pages.extend(
2510 batch
2511 .pages
2512 .into_iter()
2513 .map(|page| (Vec::new(), page.truncated || !page.rows.is_empty())),
2514 );
2515 start += batch_size;
2516 }
2517 if occurrence_evidence_omitted {
2518 push_limit(reached_limits, GraphLimitKind::Occurrences);
2519 }
2520 break;
2521 }
2522 let limit = per_relation.min(remaining);
2523 let per_relation_work = limit as usize + 1;
2524 let aggregate_batch = (remaining / limit).max(1) as usize;
2525 let batch_size = (ADJACENCY_WORK_ROWS / per_relation_work)
2526 .clamp(1, MAX_REPOSITORY_GRAPH_FRONTIER)
2527 .min(aggregate_batch)
2528 .min(rows.len() - start);
2529 let chunk = &rows[start..start + batch_size];
2530 let relations = chunk
2531 .iter()
2532 .map(|row| row.detail.relation.clone())
2533 .collect::<Vec<_>>();
2534 let batch_rows = u32::try_from(batch_size).map_err(|_overflow| {
2535 ServiceError::InvalidInput("occurrence batch size overflowed".to_string())
2536 })?;
2537 let returned_rows = batch_rows.saturating_mul(limit).max(1);
2538 let hydrated_paths = batch_rows.saturating_mul(limit.saturating_add(1)).max(1);
2539 let database_budget = relation_database_budget(
2540 budget,
2541 *database_work,
2542 retained_state_bytes,
2543 relations.len(),
2544 returned_rows,
2545 1,
2546 hydrated_paths,
2547 )?;
2548 let batch = store.repository_graph_occurrence_pages_bounded(
2549 &relations,
2550 limit,
2551 database_budget,
2552 control,
2553 )?;
2554 database_work.record(batch.work)?;
2555 for page in batch.pages {
2556 let count = u32::try_from(page.rows.len()).map_err(|_overflow| {
2557 ServiceError::InvalidInput("occurrence count overflowed".to_string())
2558 })?;
2559 remaining = remaining.saturating_sub(count);
2560 retained = retained.saturating_add(count);
2561 if page.truncated {
2562 push_limit(reached_limits, GraphLimitKind::Occurrences);
2563 }
2564 pages.push((page.rows, page.truncated));
2565 }
2566 start += batch_size;
2567 }
2568 Ok((pages, retained))
2569}
2570
2571pub(super) fn next_call_for_entity(
2573 entity: &GraphEntity,
2574 content_selection: ContentSelection,
2575 classification: Option<ContentClassification>,
2576) -> Option<RelationNextCall> {
2577 let content_selection = next_call_content_selection(content_selection, classification);
2578 match entity.selector() {
2579 EntitySelector::Project | EntitySelector::External { .. } => None,
2580 EntitySelector::Folder { path } => Some(RelationNextCall::Files {
2581 folder: path.clone(),
2582 content_selection,
2583 }),
2584 EntitySelector::File { path } => Some(RelationNextCall::Summary {
2585 file: path.clone(),
2586 content_selection,
2587 }),
2588 EntitySelector::Package { package } => Some(RelationNextCall::Summary {
2589 file: package.manifest.clone(),
2590 content_selection,
2591 }),
2592 EntitySelector::Symbol { symbol } => Some(RelationNextCall::SymbolSlice {
2593 symbol: symbol.clone(),
2594 content_selection,
2595 }),
2596 }
2597}
2598
2599fn next_call_content_selection(
2601 requested: ContentSelection,
2602 classification: Option<ContentClassification>,
2603) -> Option<ContentSelection> {
2604 if requested == ContentSelection::UnspecifiedLegacy {
2605 return None;
2606 }
2607 let Some(classification) = classification else {
2608 return Some(requested);
2609 };
2610 if requested.includes(classification) {
2611 return Some(requested);
2612 }
2613 match classification {
2614 ContentClassification::Source => Some(ContentSelection::Source),
2615 ContentClassification::Documentation => Some(ContentSelection::Documentation),
2616 ContentClassification::ConfigurationData
2617 | ContentClassification::OtherText
2618 | ContentClassification::Opaque => None,
2619 }
2620}
2621
2622fn render_relation_prefix<F, E>(
2624 draft: &DetailedRelationPageDraft,
2625 selected_rows: usize,
2626 encode: &mut F,
2627) -> Result<(DetailedRelationReport, String), E>
2628where
2629 F: FnMut(&DetailedRelationReport) -> Result<String, E>,
2630 E: From<ServiceError>,
2631{
2632 check_relation_deadline(draft.deadline).map_err(E::from)?;
2633 let mut report = draft.report_for_prefix(selected_rows).map_err(E::from)?;
2634 for _attempt in 0..8 {
2635 check_relation_deadline(draft.deadline).map_err(E::from)?;
2636 let encoded = encode(&report)?;
2637 check_relation_deadline(draft.deadline).map_err(E::from)?;
2638 let rendered = encoded.len() as u64;
2639 if report.work.rendered_output_bytes == rendered {
2640 return Ok((report, encoded));
2641 }
2642 report.work.rendered_output_bytes = rendered;
2643 }
2644 Err(E::from(ServiceError::InvalidInput(
2645 "detailed relation output byte metadata did not converge".to_string(),
2646 )))
2647}
2648
2649fn fit_detailed_relation_output<F, E>(
2651 draft: &DetailedRelationPageDraft,
2652 control: Option<&IndexWorkControl>,
2653 mut encode: F,
2654) -> Result<(DetailedRelationReport, String), E>
2655where
2656 F: FnMut(&DetailedRelationReport) -> Result<String, E>,
2657 E: From<ServiceError>,
2658{
2659 let maximum = draft.maximum_output_bytes();
2660 let full_rows = draft.candidate_rows();
2661 check_relation_control(control).map_err(E::from)?;
2662 check_relation_deadline(draft.deadline).map_err(E::from)?;
2663 let full = render_relation_prefix(draft, full_rows, &mut encode)?;
2664 if full.1.len() <= maximum {
2665 return Ok(full);
2666 }
2667 drop(full);
2668 let mut low = 0_usize;
2669 let mut high = full_rows.saturating_sub(1);
2670 let mut best_rows = None;
2671 while low <= high {
2672 check_relation_control(control).map_err(E::from)?;
2673 check_relation_deadline(draft.deadline).map_err(E::from)?;
2674 let middle = low + (high - low) / 2;
2675 let candidate = render_relation_prefix(draft, middle, &mut encode)?;
2676 if candidate.1.len() <= maximum {
2677 best_rows = Some(middle);
2678 low = middle.saturating_add(1);
2679 } else if middle == 0 {
2680 break;
2681 } else {
2682 high = middle - 1;
2683 }
2684 }
2685 let selected_rows = best_rows.ok_or_else(|| {
2686 E::from(ServiceError::InvalidInput(
2687 "graph output byte limit is too small for the empty response envelope".to_string(),
2688 ))
2689 })?;
2690 render_relation_prefix(draft, selected_rows, &mut encode)
2691}
2692
2693fn relation_database_budget(
2695 budget: DetailedRelationBudget,
2696 work: RelationDatabaseWork,
2697 retained_state_bytes: u64,
2698 requested_rows: usize,
2699 returned_rows: u32,
2700 hydrated_entities: u32,
2701 hydrated_paths: u32,
2702) -> ServiceResult<RepositoryGraphReadBudget> {
2703 let requested_rows = u32::try_from(requested_rows).map_err(|_overflow| {
2704 ServiceError::InvalidInput("database request row count overflowed".to_string())
2705 })?;
2706 let charged = work
2707 .decoded_bytes
2708 .checked_add(retained_state_bytes)
2709 .ok_or_else(relation_work_overflow)?;
2710 let decoded_bytes = budget
2711 .intermediate_bytes()
2712 .checked_sub(charged)
2713 .filter(|remaining| *remaining > 0)
2714 .ok_or_else(|| {
2715 ServiceError::InvalidInput(
2716 "detailed relation intermediate-byte budget is exhausted".to_string(),
2717 )
2718 })?
2719 .min(RepositoryGraphReadBudget::MAX_DECODED_BYTES);
2720 RepositoryGraphReadBudget::new(
2721 requested_rows,
2722 returned_rows,
2723 decoded_bytes,
2724 hydrated_entities,
2725 hydrated_paths,
2726 )
2727 .map_err(invalid_graph_input)
2728}
2729
2730pub(super) fn serialized_equivalent_bytes<T>(value: &T) -> ServiceResult<u64>
2732where
2733 T: Serialize + ?Sized,
2734{
2735 let mut counter = SerializedByteCounter::default();
2736 serde_json::to_writer(&mut counter, value)?;
2737 Ok(counter.bytes)
2738}
2739
2740fn relation_working_composition_bytes(
2742 entities: &[GraphEntity],
2743 rows: &[TraversalRow],
2744 direction: RelationDirection,
2745 purposes: &BTreeMap<String, Purpose>,
2746 coverage: &BTreeMap<String, Vec<CoverageRecord>>,
2747 classifications: &BTreeMap<String, ContentClassification>,
2748 occurrence_pages: &[(Vec<RelationOccurrence>, bool)],
2749 pruned_relations: &[LogicalRelation],
2750) -> ServiceResult<u64> {
2751 let parts = [
2752 serialized_equivalent_bytes(entities)?,
2753 serialized_equivalent_bytes(purposes)?,
2754 serialized_equivalent_bytes(coverage)?,
2755 serialized_equivalent_bytes(classifications)?,
2756 serialized_equivalent_bytes(occurrence_pages)?,
2757 serialized_equivalent_bytes(pruned_relations)?,
2758 ];
2759 let mut bytes = 0_u64;
2760 for part in parts {
2761 bytes = bytes.checked_add(part).ok_or_else(relation_work_overflow)?;
2762 }
2763 for row in rows {
2764 let row_bytes = serialized_equivalent_bytes(&(
2765 row.depth,
2766 &row.detail.relation,
2767 inbound_relation_view(direction, &row.detail.relation),
2768 &row.detail.source,
2769 &row.detail.target,
2770 &row.path,
2771 ))?;
2772 bytes = bytes
2773 .checked_add(row_bytes)
2774 .ok_or_else(relation_work_overflow)?;
2775 }
2776 Ok(bytes)
2777}
2778
2779fn relation_composition_bytes(
2781 anchor: &DetailedRelationNode,
2782 rows: &[DetailedRelationRow],
2783) -> ServiceResult<u64> {
2784 serialized_equivalent_bytes(&(anchor, rows))
2785}
2786
2787fn relation_intermediate_bytes(
2789 database_decoded_bytes: u64,
2790 cursor_bytes: u64,
2791 working_composition_bytes: u64,
2792 retained_composition_bytes: u64,
2793) -> ServiceResult<u64> {
2794 let construction_peak = working_composition_bytes
2795 .checked_add(retained_composition_bytes)
2796 .ok_or_else(relation_work_overflow)?;
2797 let fitting_peak = retained_composition_bytes
2798 .checked_mul(2)
2799 .ok_or_else(relation_work_overflow)?;
2800 database_decoded_bytes
2801 .checked_add(cursor_bytes)
2802 .and_then(|value| value.checked_add(construction_peak.max(fitting_peak)))
2803 .ok_or_else(relation_work_overflow)
2804}
2805
2806fn serialized_relation_state_bytes(state: &RelationTraversalState) -> ServiceResult<u64> {
2808 u64::try_from(serde_json::to_vec(state)?.len()).map_err(|_overflow| relation_work_overflow())
2809}
2810
2811fn encoded_relation_state_bytes(
2813 binding: &DetailedRelationCursorBinding,
2814 state: &RelationTraversalState,
2815 budget: DetailedRelationBudget,
2816) -> ServiceResult<u64> {
2817 u64::try_from(encode_relation_cursor(binding, state, budget)?.len())
2818 .map_err(|_overflow| relation_work_overflow())
2819}
2820
2821fn relation_work_overflow() -> ServiceError {
2823 ServiceError::InvalidInput("detailed relation work accounting overflowed".to_string())
2824}
2825
2826pub(super) fn relation_request_control(
2828 caller: Option<&IndexWorkControl>,
2829 service_deadline: Instant,
2830) -> IndexWorkControl {
2831 let cancellation =
2832 caller.map_or_else(IndexCancellation::new, |value| value.cancellation().clone());
2833 let deadline = caller
2834 .and_then(IndexWorkControl::deadline)
2835 .map_or(service_deadline, |value| value.min(service_deadline));
2836 IndexWorkControl::with_deadline(cancellation, deadline)
2837}
2838
2839fn relation_deadline_elapsed(deadline: Instant) -> bool {
2841 Instant::now() >= deadline
2842}
2843
2844fn check_relation_deadline(deadline: Instant) -> ServiceResult<()> {
2846 if relation_deadline_elapsed(deadline) {
2847 return Err(DbError::from(IndexWorkFailure::DeadlineExceeded {
2848 stage: IndexWorkStage::RepositoryTraversal,
2849 })
2850 .into());
2851 }
2852 Ok(())
2853}
2854
2855fn check_relation_control(control: Option<&IndexWorkControl>) -> ServiceResult<()> {
2857 if let Some(control) = control {
2858 control
2859 .check(IndexWorkStage::RepositoryTraversal)
2860 .map_err(DbError::from)?;
2861 }
2862 Ok(())
2863}
2864
2865fn invalid_graph_input(error: impl std::fmt::Display) -> ServiceError {
2867 ServiceError::InvalidInput(error.to_string())
2868}
2869
2870fn push_limit(reached_limits: &mut Vec<GraphLimitKind>, limit: GraphLimitKind) {
2872 if !reached_limits.contains(&limit) {
2873 reached_limits.push(limit);
2874 }
2875}
2876
2877#[cfg(test)]
2878mod tests {
2879 use super::*;
2880 use projectatlas_core::graph::{
2881 Completeness, CoverageState, ExtendedRelationKind, GraphIdentityText, RelationResolution,
2882 SourceSpan,
2883 };
2884 use projectatlas_core::symbols::RelationKind;
2885 use projectatlas_core::{IndexGeneration, Node, NodeKind};
2886 use std::error::Error;
2887 use std::fs;
2888 use std::io;
2889 use std::path::Path;
2890 use std::thread;
2891
2892 #[test]
2893 fn maximum_depth_does_not_advertise_an_unusable_continuation() {
2894 let state = RelationTraversalState {
2895 depth: 1,
2896 nodes: vec![
2897 TraversalNodeState {
2898 digest: [1; 32],
2899 parent: None,
2900 },
2901 TraversalNodeState {
2902 digest: [2; 32],
2903 parent: Some(0),
2904 },
2905 ],
2906 frontier: vec![0],
2907 frontier_index: 1,
2908 next_frontier: vec![1],
2909 adjacency: None,
2910 pending: Vec::new(),
2911 pending_index: 0,
2912 emitted_rows: 1,
2913 pruned_paths: 0,
2914 };
2915
2916 assert!(!traversal_has_work(&state, 1));
2917 assert!(traversal_has_work(&state, 2));
2918 }
2919
2920 #[test]
2921 fn detailed_relations_are_node_simple_ranked_and_purpose_aware() -> Result<(), Box<dyn Error>> {
2922 let temp = tempfile::tempdir()?;
2923 let root = temp.path().join("relation-service");
2924 fs::create_dir_all(root.join("src"))?;
2925 fs::write(root.join("src/a.rs"), "pub fn a() {}\n")?;
2926 fs::write(root.join("src/b.rs"), "pub fn b() {}\n")?;
2927 let database = root.join("projectatlas.db");
2928 let mut store = AtlasStore::open_for_project(&database, &root)?;
2929 let project = store
2930 .project_instance_id()?
2931 .ok_or("relation service fixture project identity is missing")?;
2932 let generation = IndexGeneration::new(1);
2933 let source = GraphEntity::new(
2934 project,
2935 EntitySelector::File {
2936 path: RepositoryFilePath::new(Path::new("src/a.rs"))?,
2937 },
2938 generation,
2939 )?;
2940 let target = GraphEntity::new(
2941 project,
2942 EntitySelector::File {
2943 path: RepositoryFilePath::new(Path::new("src/b.rs"))?,
2944 },
2945 generation,
2946 )?;
2947 let forward = LogicalRelation::new(
2948 &source,
2949 GraphRelationKind::Legacy(RelationKind::Calls),
2950 RelationResolution::resolved(&target)?,
2951 ConfidenceClass::Exact,
2952 Completeness::Complete,
2953 generation,
2954 )?;
2955 let backward = LogicalRelation::new(
2956 &target,
2957 GraphRelationKind::Legacy(RelationKind::Calls),
2958 RelationResolution::resolved(&source)?,
2959 ConfidenceClass::High,
2960 Completeness::Complete,
2961 generation,
2962 )?;
2963 let unresolved = LogicalRelation::new(
2964 &source,
2965 GraphRelationKind::Legacy(RelationKind::Calls),
2966 RelationResolution::Unresolved {
2967 reference: GraphIdentityText::new("missing::target")?,
2968 },
2969 ConfidenceClass::Medium,
2970 Completeness::Complete,
2971 generation,
2972 )?;
2973 let occurrence = RelationOccurrence::new(
2974 &forward,
2975 RepositoryFilePath::new(Path::new("src/a.rs"))?,
2976 SourceSpan::new(1, 0, 1, 10)?,
2977 generation,
2978 )?;
2979 let second_occurrence = RelationOccurrence::new(
2980 &forward,
2981 RepositoryFilePath::new(Path::new("src/a.rs"))?,
2982 SourceSpan::new(1, 11, 1, 20)?,
2983 generation,
2984 )?;
2985 let coverage = ["src/a.rs", "src/b.rs"]
2986 .into_iter()
2987 .map(|path| {
2988 CoverageRecord::new(
2989 CoverageScope::Path {
2990 path: RepositoryNodePath::new(Path::new(path))?,
2991 },
2992 None,
2993 CoverageState::Complete,
2994 1,
2995 0,
2996 generation,
2997 None,
2998 None,
2999 )
3000 .map_err(Into::into)
3001 })
3002 .collect::<Result<Vec<_>, Box<dyn Error>>>()?;
3003 let mut publication = store.begin_index_publication("relation-service")?;
3004 publication.begin_scan_replacement()?;
3005 publication.upsert_scan_node_batch(&[
3006 test_folder_node("src"),
3007 test_node("src/a.rs", "hash-a"),
3008 test_node("src/b.rs", "hash-b"),
3009 ])?;
3010 publication.finish_scan_replacement()?;
3011 publication.replace_repository_graph(
3012 project,
3013 &[source, target],
3014 &[forward, backward, unresolved],
3015 &[occurrence, second_occurrence],
3016 &coverage,
3017 )?;
3018 publication.complete()?;
3019 store.set_purpose("src/a.rs", "Own source calls", PurposeSource::Agent)?;
3020 store.set_purpose("src", "Own source folder", PurposeSource::Agent)?;
3021 drop(store);
3022
3023 let store = AtlasStore::open_read_only_for_project(&database, &root)?;
3024 let report = load_detailed_relations(
3025 &store,
3026 &DetailedRelationQuery {
3027 anchor: RelationAnchor::File {
3028 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3029 },
3030 direction: RelationDirection::Outbound,
3031 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3032 minimum_confidence: ConfidenceClass::Low,
3033 resolution: RelationResolutionFilter::Resolved,
3034 include_occurrences: true,
3035 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3036 10,
3037 10,
3038 3,
3039 64 * 1024,
3040 )?),
3041 cursor: None,
3042 content_selection: ContentSelection::UnspecifiedLegacy,
3043 },
3044 None,
3045 )?;
3046
3047 require(report.returned == 1, "resolved row count changed")?;
3048 if report.work.inspected_edges != 2 {
3049 return Err(io::Error::other(format!(
3050 "inspected edge count changed: expected 2, got {}",
3051 report.work.inspected_edges
3052 ))
3053 .into());
3054 }
3055 require(
3056 report.work.database_requested_rows > 0
3057 && report.work.database_returned_rows > 0
3058 && report.work.database_decoded_bytes > 0
3059 && report.work.hydrated_entities >= 2
3060 && report.work.hydrated_purpose_paths >= 2
3061 && report.work.retained_composition_bytes > 0
3062 && report.work.intermediate_bytes <= 64 * 1024,
3063 "bounded database work was not aggregated into the service envelope",
3064 )?;
3065 require(
3066 report.work.intermediate_bytes
3067 >= report
3068 .work
3069 .database_decoded_bytes
3070 .saturating_add(
3071 report
3072 .continuation
3073 .as_ref()
3074 .map_or(0, |value| value.len() as u64),
3075 )
3076 .saturating_add(report.work.retained_composition_bytes.saturating_mul(2)),
3077 "aggregate intermediate work omitted database, cursor, or composition bytes",
3078 )?;
3079 require(report.pruned_paths == 0, "first-page pruning count changed")?;
3080 require(
3081 report.truncated,
3082 "resumable traversal lost truncation state",
3083 )?;
3084 let first_cursor = report
3085 .continuation
3086 .clone()
3087 .ok_or("resumable traversal omitted its continuation")?;
3088 require(report.rows[0].depth == 1, "resolved row depth changed")?;
3089 require(report.rows[0].path.len() == 2, "node-simple path changed")?;
3090 require(
3091 report.rows[0].occurrences.len() == 2,
3092 "exact occurrence was not retained",
3093 )?;
3094 require(report.anchor.coverage.len() == 1, "anchor coverage missing")?;
3095 require(
3096 report.rows[0].source.coverage.len() == 1,
3097 "source coverage missing",
3098 )?;
3099 require(
3100 report.rows[0]
3101 .target
3102 .as_ref()
3103 .map(|node| node.coverage.len())
3104 == Some(1),
3105 "target coverage missing",
3106 )?;
3107 require(
3108 matches!(
3109 report.anchor.purpose,
3110 RelationPurpose::Approved { ref purpose, .. } if purpose == "Own source calls"
3111 ),
3112 "anchor purpose projection changed",
3113 )?;
3114 require(
3115 matches!(
3116 report.rows[0].target,
3117 Some(DetailedRelationNode {
3118 purpose: RelationPurpose::Approved {
3119 ref path,
3120 ref purpose,
3121 ..
3122 },
3123 ..
3124 }) if path == "src" && purpose == "Own source folder"
3125 ),
3126 "target did not inherit the nearest accepted folder purpose",
3127 )?;
3128 require(
3129 matches!(
3130 report.rows[0].path.as_slice(),
3131 [
3132 DetailedRelationNode {
3133 purpose: RelationPurpose::Approved { path: source, .. },
3134 ..
3135 },
3136 DetailedRelationNode {
3137 purpose: RelationPurpose::Approved { path: target, .. },
3138 ..
3139 }
3140 ] if source == "src/a.rs" && target == "src"
3141 ),
3142 "node-simple path omitted authoritative purpose projection",
3143 )?;
3144 require(
3145 matches!(
3146 report.rows[0].next_call,
3147 Some(RelationNextCall::Summary { ref file, .. }) if file.as_str() == "src/b.rs"
3148 ),
3149 "resolved target next call changed",
3150 )?;
3151 let terminal_report = load_detailed_relations(
3152 &store,
3153 &DetailedRelationQuery {
3154 anchor: RelationAnchor::File {
3155 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3156 },
3157 direction: RelationDirection::Outbound,
3158 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3159 minimum_confidence: ConfidenceClass::Low,
3160 resolution: RelationResolutionFilter::Resolved,
3161 include_occurrences: true,
3162 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3163 10,
3164 10,
3165 3,
3166 64 * 1024,
3167 )?),
3168 cursor: Some(first_cursor.clone()),
3169 content_selection: ContentSelection::UnspecifiedLegacy,
3170 },
3171 None,
3172 )?;
3173 require(
3174 terminal_report.returned == 0
3175 && terminal_report.pruned_paths == 1
3176 && terminal_report.continuation.is_none()
3177 && terminal_report.total == RelationTotalState::Exact(1),
3178 "cursor continuation did not finish the cycle-safe traversal exactly",
3179 )?;
3180
3181 let repeated_report = load_detailed_relations(
3182 &store,
3183 &DetailedRelationQuery {
3184 anchor: RelationAnchor::File {
3185 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3186 },
3187 direction: RelationDirection::Outbound,
3188 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3189 minimum_confidence: ConfidenceClass::Low,
3190 resolution: RelationResolutionFilter::Resolved,
3191 include_occurrences: true,
3192 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3193 10,
3194 10,
3195 3,
3196 64 * 1024,
3197 )?),
3198 cursor: None,
3199 content_selection: ContentSelection::UnspecifiedLegacy,
3200 },
3201 None,
3202 )?;
3203 require(
3204 repeated_report.rows == report.rows
3205 && repeated_report.continuation == report.continuation,
3206 "repeated detailed relation page changed rows or cursor bytes",
3207 )?;
3208
3209 let mut mismatched_query = DetailedRelationQuery {
3210 anchor: RelationAnchor::File {
3211 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3212 },
3213 direction: RelationDirection::Inbound,
3214 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3215 minimum_confidence: ConfidenceClass::Low,
3216 resolution: RelationResolutionFilter::Resolved,
3217 include_occurrences: true,
3218 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3219 10,
3220 10,
3221 3,
3222 64 * 1024,
3223 )?),
3224 cursor: Some(first_cursor.clone()),
3225 content_selection: ContentSelection::UnspecifiedLegacy,
3226 };
3227 require(
3228 matches!(
3229 load_detailed_relations(&store, &mismatched_query, None),
3230 Err(ServiceError::RelationCursorMismatched { field: "query" })
3231 ),
3232 "query-bound cursor accepted a different direction",
3233 )?;
3234 let mut invalid_cursor: serde_json::Value = serde_json::from_str(&first_cursor)?;
3235 invalid_cursor["version"] = serde_json::json!(DETAILED_RELATION_CURSOR_VERSION + 1);
3236 mismatched_query.direction = RelationDirection::Outbound;
3237 mismatched_query.cursor = Some(serde_json::to_string(&invalid_cursor)?);
3238 require(
3239 matches!(
3240 load_detailed_relations(&store, &mismatched_query, None),
3241 Err(ServiceError::RelationCursorStale {
3242 field: "algorithm version"
3243 })
3244 ),
3245 "unknown cursor version did not fail closed",
3246 )?;
3247 mismatched_query.cursor = Some("{".to_string());
3248 require(
3249 matches!(
3250 load_detailed_relations(&store, &mismatched_query, None),
3251 Err(ServiceError::RelationCursorInvalid { .. })
3252 ),
3253 "malformed cursor did not fail closed",
3254 )?;
3255 mismatched_query.cursor = Some("x".repeat(DETAILED_RELATION_CURSOR_MAX_BYTES + 1));
3256 require(
3257 matches!(
3258 load_detailed_relations(&store, &mismatched_query, None),
3259 Err(ServiceError::RelationCursorInvalid { .. })
3260 ),
3261 "oversized cursor did not fail closed before decoding",
3262 )?;
3263 mismatched_query.cursor = Some(first_cursor.clone());
3264 mismatched_query.budget =
3265 mismatched_query
3266 .budget
3267 .with_aggregate_limits(Some(9), None, None, None, None, None)?;
3268 require(
3269 matches!(
3270 load_detailed_relations(&store, &mismatched_query, None),
3271 Err(ServiceError::RelationCursorMismatched { field: "budget" })
3272 ),
3273 "cursor accepted a different result-defining budget",
3274 )?;
3275
3276 let cancellation = projectatlas_core::IndexCancellation::new();
3277 cancellation.cancel();
3278 let control = IndexWorkControl::new(cancellation, None);
3279 mismatched_query.cursor = None;
3280 let cancelled = load_detailed_relations(&store, &mismatched_query, Some(&control));
3281 require(
3282 cancelled
3283 .err()
3284 .is_some_and(|error| error.to_string().contains("cancel")),
3285 "relation traversal did not propagate cancellation",
3286 )?;
3287 require(
3288 relation_deadline_elapsed(
3289 Instant::now()
3290 .checked_sub(Duration::from_millis(2))
3291 .ok_or("deadline test clock underflowed")?,
3292 ),
3293 "service-owned relation deadline was not classified deterministically",
3294 )?;
3295 let mut expired_draft = load_detailed_relation_page(
3296 &store,
3297 &DetailedRelationQuery {
3298 anchor: RelationAnchor::File {
3299 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3300 },
3301 direction: RelationDirection::Outbound,
3302 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3303 minimum_confidence: ConfidenceClass::Low,
3304 resolution: RelationResolutionFilter::Resolved,
3305 include_occurrences: false,
3306 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3307 10,
3308 10,
3309 1,
3310 64 * 1024,
3311 )?),
3312 cursor: None,
3313 content_selection: ContentSelection::UnspecifiedLegacy,
3314 },
3315 None,
3316 )?;
3317 expired_draft.deadline = Instant::now()
3318 .checked_sub(Duration::from_millis(1))
3319 .ok_or("render deadline test clock underflowed")?;
3320 require(
3321 matches!(
3322 expired_draft.fit_compact(None),
3323 Err(ServiceError::Db(DbError::IndexWork(
3324 IndexWorkFailure::DeadlineExceeded {
3325 stage: IndexWorkStage::RepositoryTraversal
3326 }
3327 )))
3328 ),
3329 "adapter rendering ignored the service-owned relation deadline",
3330 )?;
3331 let exact_output_limit = 4 * 1024;
3332 let limited_report = load_detailed_relations(
3333 &store,
3334 &DetailedRelationQuery {
3335 anchor: RelationAnchor::File {
3336 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3337 },
3338 direction: RelationDirection::Outbound,
3339 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3340 minimum_confidence: ConfidenceClass::Low,
3341 resolution: RelationResolutionFilter::Resolved,
3342 include_occurrences: true,
3343 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3344 10,
3345 10,
3346 3,
3347 exact_output_limit,
3348 )?),
3349 cursor: None,
3350 content_selection: ContentSelection::UnspecifiedLegacy,
3351 },
3352 None,
3353 )?;
3354 require(
3355 limited_report.truncated,
3356 "output truncation was not reported",
3357 )?;
3358 require(
3359 limited_report
3360 .reached_limits
3361 .contains(&GraphLimitKind::OutputBytes),
3362 "output byte limit was not reported",
3363 )?;
3364 require(
3365 serde_json::to_vec(&limited_report)?.len() <= exact_output_limit as usize,
3366 "serialized report exceeded its hard output limit",
3367 )?;
3368
3369 let unresolved_report = load_detailed_relations(
3370 &store,
3371 &DetailedRelationQuery {
3372 anchor: RelationAnchor::File {
3373 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3374 },
3375 direction: RelationDirection::Outbound,
3376 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3377 minimum_confidence: ConfidenceClass::Low,
3378 resolution: RelationResolutionFilter::Unresolved,
3379 include_occurrences: false,
3380 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3381 10,
3382 10,
3383 1,
3384 64 * 1024,
3385 )?),
3386 cursor: None,
3387 content_selection: ContentSelection::UnspecifiedLegacy,
3388 },
3389 None,
3390 )?;
3391 require(
3392 unresolved_report.returned == 1,
3393 "unresolved relation was not retained",
3394 )?;
3395 require(
3396 unresolved_report.rows[0].target.is_none(),
3397 "unresolved relation fabricated a target",
3398 )?;
3399 require(
3400 unresolved_report.rows[0].target_purpose == RelationPurpose::Unavailable { path: None },
3401 "unresolved purpose state changed",
3402 )?;
3403
3404 let row_limited = load_detailed_relations(
3405 &store,
3406 &DetailedRelationQuery {
3407 anchor: RelationAnchor::File {
3408 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3409 },
3410 direction: RelationDirection::Outbound,
3411 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3412 minimum_confidence: ConfidenceClass::Low,
3413 resolution: RelationResolutionFilter::Any,
3414 include_occurrences: false,
3415 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3416 1,
3417 10,
3418 1,
3419 64 * 1024,
3420 )?)
3421 .with_aggregate_limits(Some(10), None, None, None, None, None)?,
3422 cursor: None,
3423 content_selection: ContentSelection::UnspecifiedLegacy,
3424 },
3425 None,
3426 )?;
3427 require(
3428 row_limited.returned == 1
3429 && row_limited.continuation.is_some()
3430 && row_limited.reached_limits.contains(&GraphLimitKind::Rows)
3431 && !row_limited.reached_limits.contains(&GraphLimitKind::Edges),
3432 "row budget did not remain independent from edge work",
3433 )?;
3434
3435 let edge_limited = load_detailed_relations(
3436 &store,
3437 &DetailedRelationQuery {
3438 anchor: RelationAnchor::File {
3439 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3440 },
3441 direction: RelationDirection::Outbound,
3442 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3443 minimum_confidence: ConfidenceClass::Low,
3444 resolution: RelationResolutionFilter::Any,
3445 include_occurrences: false,
3446 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3447 10,
3448 10,
3449 1,
3450 64 * 1024,
3451 )?)
3452 .with_aggregate_limits(Some(1), None, None, None, None, None)?,
3453 cursor: None,
3454 content_selection: ContentSelection::UnspecifiedLegacy,
3455 },
3456 None,
3457 )?;
3458 require(
3459 edge_limited.work.inspected_edges == 1
3460 && edge_limited.continuation.is_some()
3461 && edge_limited.reached_limits.contains(&GraphLimitKind::Edges)
3462 && !edge_limited.reached_limits.contains(&GraphLimitKind::Rows),
3463 "edge budget exhaustion was not reported independently",
3464 )?;
3465
3466 let node_limited = load_detailed_relations(
3467 &store,
3468 &DetailedRelationQuery {
3469 anchor: RelationAnchor::File {
3470 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3471 },
3472 direction: RelationDirection::Outbound,
3473 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3474 minimum_confidence: ConfidenceClass::Low,
3475 resolution: RelationResolutionFilter::Resolved,
3476 include_occurrences: false,
3477 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3478 10,
3479 10,
3480 1,
3481 64 * 1024,
3482 )?)
3483 .with_aggregate_limits(
3484 None,
3485 Some(1),
3486 Some(11),
3487 None,
3488 None,
3489 None,
3490 )?,
3491 cursor: None,
3492 content_selection: ContentSelection::UnspecifiedLegacy,
3493 },
3494 None,
3495 )?;
3496 require(
3497 node_limited.returned == 0
3498 && node_limited.continuation.is_none()
3499 && node_limited.total == RelationTotalState::Unknown
3500 && node_limited.reached_limits.contains(&GraphLimitKind::Nodes),
3501 "terminal node-state budget did not fail bounded",
3502 )?;
3503
3504 let visited_limited = load_detailed_relations(
3505 &store,
3506 &DetailedRelationQuery {
3507 anchor: RelationAnchor::File {
3508 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3509 },
3510 direction: RelationDirection::Outbound,
3511 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3512 minimum_confidence: ConfidenceClass::Low,
3513 resolution: RelationResolutionFilter::Resolved,
3514 include_occurrences: false,
3515 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3516 10,
3517 10,
3518 1,
3519 64 * 1024,
3520 )?)
3521 .with_aggregate_limits(
3522 None,
3523 Some(11),
3524 Some(1),
3525 None,
3526 None,
3527 None,
3528 )?,
3529 cursor: None,
3530 content_selection: ContentSelection::UnspecifiedLegacy,
3531 },
3532 None,
3533 )?;
3534 require(
3535 visited_limited.returned == 0
3536 && visited_limited.continuation.is_none()
3537 && visited_limited.total == RelationTotalState::Unknown
3538 && visited_limited
3539 .reached_limits
3540 .contains(&GraphLimitKind::Visited),
3541 "terminal visited-state budget did not fail bounded",
3542 )?;
3543
3544 let occurrence_limited = load_detailed_relations(
3545 &store,
3546 &DetailedRelationQuery {
3547 anchor: RelationAnchor::File {
3548 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3549 },
3550 direction: RelationDirection::Outbound,
3551 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3552 minimum_confidence: ConfidenceClass::Low,
3553 resolution: RelationResolutionFilter::Resolved,
3554 include_occurrences: true,
3555 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3556 10,
3557 10,
3558 3,
3559 64 * 1024,
3560 )?)
3561 .with_aggregate_limits(None, None, None, Some(1), None, None)?,
3562 cursor: None,
3563 content_selection: ContentSelection::UnspecifiedLegacy,
3564 },
3565 None,
3566 )?;
3567 require(
3568 occurrence_limited.work.retained_occurrences == 1
3569 && occurrence_limited.rows[0].occurrences.len() == 1
3570 && occurrence_limited
3571 .reached_limits
3572 .contains(&GraphLimitKind::Occurrences),
3573 "aggregate occurrence budget did not truncate exact evidence",
3574 )?;
3575
3576 drop(store);
3577 let writable = AtlasStore::open_for_project(&database, &root)?;
3578 writable.set_purpose("src/a.rs", "Own source calls", PurposeSource::Agent)?;
3579 drop(writable);
3580 let store = AtlasStore::open_read_only_for_project(&database, &root)?;
3581 let unchanged_query = DetailedRelationQuery {
3582 anchor: RelationAnchor::File {
3583 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3584 },
3585 direction: RelationDirection::Outbound,
3586 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3587 minimum_confidence: ConfidenceClass::Low,
3588 resolution: RelationResolutionFilter::Resolved,
3589 include_occurrences: true,
3590 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3591 10,
3592 10,
3593 3,
3594 64 * 1024,
3595 )?),
3596 cursor: Some(first_cursor.clone()),
3597 content_selection: ContentSelection::UnspecifiedLegacy,
3598 };
3599 require(
3600 load_detailed_relations(&store, &unchanged_query, None).is_ok(),
3601 "an accepted-purpose no-op made the relation cursor stale",
3602 )?;
3603 drop(store);
3604 let writable = AtlasStore::open_for_project(&database, &root)?;
3605 writable.set_purpose("src/a.rs", "Own updated source calls", PurposeSource::Agent)?;
3606 drop(writable);
3607 let store = AtlasStore::open_read_only_for_project(&database, &root)?;
3608 let stale_query = DetailedRelationQuery {
3609 anchor: RelationAnchor::File {
3610 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3611 },
3612 direction: RelationDirection::Outbound,
3613 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3614 minimum_confidence: ConfidenceClass::Low,
3615 resolution: RelationResolutionFilter::Resolved,
3616 include_occurrences: true,
3617 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3618 10,
3619 10,
3620 3,
3621 64 * 1024,
3622 )?),
3623 cursor: Some(first_cursor),
3624 content_selection: ContentSelection::UnspecifiedLegacy,
3625 };
3626 require(
3627 matches!(
3628 load_detailed_relations(&store, &stale_query, None),
3629 Err(ServiceError::RelationCursorStale {
3630 field: "authored-purpose revision"
3631 })
3632 ),
3633 "purpose-bound cursor survived an authored-purpose revision",
3634 )?;
3635
3636 let mut current_query = stale_query;
3637 current_query.cursor = None;
3638 let current_cursor = load_detailed_relations(&store, ¤t_query, None)?
3639 .continuation
3640 .ok_or("current-generation traversal omitted its continuation")?;
3641 drop(store);
3642
3643 let mut writable = AtlasStore::open_for_project(&database, &root)?;
3644 let generation = IndexGeneration::new(2);
3645 let source = GraphEntity::new(
3646 project,
3647 EntitySelector::File {
3648 path: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3649 },
3650 generation,
3651 )?;
3652 let target = GraphEntity::new(
3653 project,
3654 EntitySelector::File {
3655 path: RepositoryFilePath::new(Path::new("src/b.rs"))?,
3656 },
3657 generation,
3658 )?;
3659 let forward = LogicalRelation::new(
3660 &source,
3661 GraphRelationKind::Legacy(RelationKind::Calls),
3662 RelationResolution::resolved(&target)?,
3663 ConfidenceClass::Exact,
3664 Completeness::Complete,
3665 generation,
3666 )?;
3667 let backward = LogicalRelation::new(
3668 &target,
3669 GraphRelationKind::Legacy(RelationKind::Calls),
3670 RelationResolution::resolved(&source)?,
3671 ConfidenceClass::High,
3672 Completeness::Complete,
3673 generation,
3674 )?;
3675 let unresolved = LogicalRelation::new(
3676 &source,
3677 GraphRelationKind::Legacy(RelationKind::Calls),
3678 RelationResolution::Unresolved {
3679 reference: GraphIdentityText::new("missing::target")?,
3680 },
3681 ConfidenceClass::Medium,
3682 Completeness::Complete,
3683 generation,
3684 )?;
3685 let mut publication = writable.begin_index_publication("relation-service-generation")?;
3686 publication.replace_repository_graph(
3687 project,
3688 &[source, target],
3689 &[forward, backward, unresolved],
3690 &[],
3691 &[],
3692 )?;
3693 publication.complete()?;
3694 drop(writable);
3695
3696 let store = AtlasStore::open_read_only_for_project(&database, &root)?;
3697 current_query.cursor = Some(current_cursor);
3698 require(
3699 matches!(
3700 load_detailed_relations(&store, ¤t_query, None),
3701 Err(ServiceError::RelationCursorStale {
3702 field: "graph generation"
3703 })
3704 ),
3705 "generation-bound cursor survived graph publication",
3706 )?;
3707 Ok(())
3708 }
3709
3710 #[test]
3711 fn detailed_relation_pages_preserve_extended_inbound_symbol_and_parallel_behavior()
3712 -> Result<(), Box<dyn Error>> {
3713 let temp = tempfile::tempdir()?;
3714 let root = temp.path().join("relation-pagination");
3715 fs::create_dir_all(root.join("src"))?;
3716 for name in ["a.rs", "b.rs", "c.rs", "d.rs"] {
3717 fs::write(root.join("src").join(name), format!("// {name}\n"))?;
3718 }
3719 let database = root.join("projectatlas.db");
3720 let mut store = AtlasStore::open_for_project(&database, &root)?;
3721 let project = store
3722 .project_instance_id()?
3723 .ok_or("relation pagination fixture identity is missing")?;
3724 let generation = IndexGeneration::new(1);
3725 let a = GraphEntity::new(
3726 project,
3727 EntitySelector::File {
3728 path: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3729 },
3730 generation,
3731 )?;
3732 let b = GraphEntity::new(
3733 project,
3734 EntitySelector::File {
3735 path: RepositoryFilePath::new(Path::new("src/b.rs"))?,
3736 },
3737 generation,
3738 )?;
3739 let c = GraphEntity::new(
3740 project,
3741 EntitySelector::File {
3742 path: RepositoryFilePath::new(Path::new("src/c.rs"))?,
3743 },
3744 generation,
3745 )?;
3746 let d = GraphEntity::new(
3747 project,
3748 EntitySelector::File {
3749 path: RepositoryFilePath::new(Path::new("src/d.rs"))?,
3750 },
3751 generation,
3752 )?;
3753 let entry = GraphEntity::new(
3754 project,
3755 EntitySelector::Symbol {
3756 symbol: SymbolSelector {
3757 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3758 name: GraphIdentityText::new("entry")?,
3759 kind: SymbolKind::Function,
3760 parent: Some(GraphIdentityText::new("Root")?),
3761 signature: GraphIdentityText::new("entry()")?,
3762 },
3763 },
3764 generation,
3765 )?;
3766 let entry_overload = GraphEntity::new(
3767 project,
3768 EntitySelector::Symbol {
3769 symbol: SymbolSelector {
3770 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3771 name: GraphIdentityText::new("entry")?,
3772 kind: SymbolKind::Function,
3773 parent: Some(GraphIdentityText::new("Root")?),
3774 signature: GraphIdentityText::new("entry(u8)")?,
3775 },
3776 },
3777 generation,
3778 )?;
3779 let references = GraphRelationKind::Extended(ExtendedRelationKind::References);
3780 let a_b = LogicalRelation::new(
3781 &a,
3782 references,
3783 RelationResolution::resolved(&b)?,
3784 ConfidenceClass::Exact,
3785 Completeness::Complete,
3786 generation,
3787 )?;
3788 let a_c = LogicalRelation::new(
3789 &a,
3790 references,
3791 RelationResolution::resolved(&c)?,
3792 ConfidenceClass::High,
3793 Completeness::Complete,
3794 generation,
3795 )?;
3796 let b_d = LogicalRelation::new(
3797 &b,
3798 references,
3799 RelationResolution::resolved(&d)?,
3800 ConfidenceClass::Exact,
3801 Completeness::Complete,
3802 generation,
3803 )?;
3804 let c_d = LogicalRelation::new(
3805 &c,
3806 references,
3807 RelationResolution::resolved(&d)?,
3808 ConfidenceClass::Medium,
3809 Completeness::Complete,
3810 generation,
3811 )?;
3812 let d_a = LogicalRelation::new(
3813 &d,
3814 references,
3815 RelationResolution::resolved(&a)?,
3816 ConfidenceClass::Low,
3817 Completeness::Complete,
3818 generation,
3819 )?;
3820 let entry_b = LogicalRelation::new(
3821 &entry,
3822 references,
3823 RelationResolution::resolved(&b)?,
3824 ConfidenceClass::Exact,
3825 Completeness::Complete,
3826 generation,
3827 )?;
3828 let mut publication = store.begin_index_publication("relation-pagination")?;
3829 publication.begin_scan_replacement()?;
3830 publication.upsert_scan_node_batch(&[
3831 test_folder_node("src"),
3832 test_node("src/a.rs", "hash-a"),
3833 test_node("src/b.rs", "hash-b"),
3834 test_node("src/c.rs", "hash-c"),
3835 test_node("src/d.rs", "hash-d"),
3836 ])?;
3837 publication.finish_scan_replacement()?;
3838 publication.replace_repository_graph(
3839 project,
3840 &[a, b, c, d, entry, entry_overload],
3841 &[a_b, a_c, b_d, c_d, d_a, entry_b],
3842 &[],
3843 &[],
3844 )?;
3845 publication.complete()?;
3846 store.set_purpose("src", "Own graph components", PurposeSource::Agent)?;
3847 drop(store);
3848
3849 let store = AtlasStore::open_read_only_for_project(&database, &root)?;
3850 let file_anchor = RelationAnchor::File {
3851 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3852 };
3853 let complete_budget =
3854 DetailedRelationBudget::from_graph_limits(GraphLimits::new(10, 5, 3, 256 * 1024)?)
3855 .with_aggregate_limits(Some(100), Some(100), Some(100), None, None, None)?;
3856 let complete_query = DetailedRelationQuery {
3857 anchor: file_anchor.clone(),
3858 direction: RelationDirection::Outbound,
3859 relation: Some(references),
3860 minimum_confidence: ConfidenceClass::Low,
3861 resolution: RelationResolutionFilter::Resolved,
3862 include_occurrences: false,
3863 budget: complete_budget,
3864 cursor: None,
3865 content_selection: ContentSelection::UnspecifiedLegacy,
3866 };
3867 let (complete_rows, complete_total, complete_pruned_paths) =
3868 collect_relation_pages(&store, complete_query.clone(), 10)?;
3869 require(
3870 complete_rows.len() == 3
3871 && complete_pruned_paths == 2
3872 && complete_total == RelationTotalState::Exact(3),
3873 "extended diamond/cycle traversal did not finish node-simple and exact",
3874 )?;
3875
3876 let page_budget =
3877 DetailedRelationBudget::from_graph_limits(GraphLimits::new(1, 5, 3, 256 * 1024)?)
3878 .with_aggregate_limits(Some(100), Some(100), Some(100), None, None, None)?;
3879 let (paged_rows, terminal_total, _paged_pruned_paths) = collect_relation_pages(
3880 &store,
3881 DetailedRelationQuery {
3882 anchor: file_anchor,
3883 direction: RelationDirection::Outbound,
3884 relation: Some(references),
3885 minimum_confidence: ConfidenceClass::Low,
3886 resolution: RelationResolutionFilter::Resolved,
3887 include_occurrences: false,
3888 budget: page_budget,
3889 cursor: None,
3890 content_selection: ContentSelection::UnspecifiedLegacy,
3891 },
3892 10,
3893 )?;
3894 require(
3895 paged_rows == complete_rows && terminal_total == RelationTotalState::Exact(3),
3896 "multi-page traversal changed extended relation ranking, paths, or total",
3897 )?;
3898
3899 let inbound = load_detailed_relations(
3900 &store,
3901 &DetailedRelationQuery {
3902 anchor: RelationAnchor::File {
3903 file: RepositoryFilePath::new(Path::new("src/d.rs"))?,
3904 },
3905 direction: RelationDirection::Inbound,
3906 relation: Some(references),
3907 minimum_confidence: ConfidenceClass::Low,
3908 resolution: RelationResolutionFilter::Resolved,
3909 include_occurrences: false,
3910 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
3911 10,
3912 5,
3913 1,
3914 256 * 1024,
3915 )?)
3916 .with_aggregate_limits(
3917 Some(100),
3918 None,
3919 None,
3920 None,
3921 None,
3922 None,
3923 )?,
3924 cursor: None,
3925 content_selection: ContentSelection::UnspecifiedLegacy,
3926 },
3927 None,
3928 )?;
3929 require(
3930 inbound.returned == 2
3931 && inbound.rows.iter().all(|row| row.inbound_view.is_none())
3932 && inbound.rows[0].relation.confidence() == ConfidenceClass::Exact
3933 && inbound.rows[1].relation.confidence() == ConfidenceClass::Medium,
3934 "inbound extended relations were not ranked across the bounded batch",
3935 )?;
3936
3937 let ambiguous_symbol = DetailedRelationQuery {
3938 anchor: RelationAnchor::Symbol {
3939 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3940 name: "entry".to_string(),
3941 symbol_kind: None,
3942 parent: None,
3943 signature: None,
3944 },
3945 direction: RelationDirection::Outbound,
3946 relation: Some(references),
3947 minimum_confidence: ConfidenceClass::Low,
3948 resolution: RelationResolutionFilter::Resolved,
3949 include_occurrences: false,
3950 budget: complete_budget,
3951 cursor: None,
3952 content_selection: ContentSelection::UnspecifiedLegacy,
3953 };
3954 require(
3955 load_detailed_relations(&store, &ambiguous_symbol, None)
3956 .err()
3957 .is_some_and(|error| error.to_string().contains("ambiguous")),
3958 "ambiguous symbol anchor did not require an exact selector",
3959 )?;
3960 let exact_symbol = load_detailed_relations(
3961 &store,
3962 &DetailedRelationQuery {
3963 anchor: RelationAnchor::Symbol {
3964 file: RepositoryFilePath::new(Path::new("src/a.rs"))?,
3965 name: "entry".to_string(),
3966 symbol_kind: Some(SymbolKind::Function),
3967 parent: Some("Root".to_string()),
3968 signature: Some("entry()".to_string()),
3969 },
3970 ..ambiguous_symbol
3971 },
3972 None,
3973 )?;
3974 require(
3975 exact_symbol.returned == 1
3976 && exact_symbol.rows[0]
3977 .next_call
3978 .as_ref()
3979 .is_some_and(|next| matches!(next, RelationNextCall::Summary { file, .. } if file.as_str() == "src/b.rs")),
3980 "exact symbol selector did not retain its reusable target call",
3981 )?;
3982 drop(store);
3983
3984 let parallel_query = complete_query;
3985 let mut readers = Vec::new();
3986 for _reader in 0..2 {
3987 let database = database.clone();
3988 let root = root.clone();
3989 let query = parallel_query.clone();
3990 readers.push(thread::spawn(
3991 move || -> Result<Vec<DetailedRelationRow>, String> {
3992 let store = AtlasStore::open_read_only_for_project(&database, &root)
3993 .map_err(|error| error.to_string())?;
3994 collect_relation_pages(&store, query, 10)
3995 .map(|(rows, _total, _pruned_paths)| rows)
3996 .map_err(|error| error.to_string())
3997 },
3998 ));
3999 }
4000 for reader in readers {
4001 let rows = reader
4002 .join()
4003 .map_err(|_panic| io::Error::other("parallel relation reader panicked"))?
4004 .map_err(io::Error::other)?;
4005 require(
4006 rows == complete_rows,
4007 "parallel relation snapshot changed deterministic rows",
4008 )?;
4009 }
4010 Ok(())
4011 }
4012
4013 #[test]
4014 fn classified_relations_preserve_legacy_defaults_and_stop_cross_class_frontiers()
4015 -> Result<(), Box<dyn Error>> {
4016 let temp = tempfile::tempdir()?;
4017 let root = temp.path().join("classified-relations");
4018 fs::create_dir_all(root.join("docs"))?;
4019 fs::create_dir_all(root.join("src"))?;
4020 fs::write(root.join("docs/guide.md"), "# Guide\n")?;
4021 fs::write(root.join("docs/other.md"), "# Other\n")?;
4022 fs::write(root.join("src/lib.rs"), "pub fn library() {}\n")?;
4023 let database = root.join("projectatlas.db");
4024 let mut store = AtlasStore::open_for_project(&database, &root)?;
4025 let project = store
4026 .project_instance_id()?
4027 .ok_or("classified relation fixture project identity is missing")?;
4028 let generation = IndexGeneration::new(1);
4029 let guide = GraphEntity::new(
4030 project,
4031 EntitySelector::File {
4032 path: RepositoryFilePath::new(Path::new("docs/guide.md"))?,
4033 },
4034 generation,
4035 )?;
4036 let other_document = GraphEntity::new(
4037 project,
4038 EntitySelector::File {
4039 path: RepositoryFilePath::new(Path::new("docs/other.md"))?,
4040 },
4041 generation,
4042 )?;
4043 let source = GraphEntity::new(
4044 project,
4045 EntitySelector::File {
4046 path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
4047 },
4048 generation,
4049 )?;
4050 let documents = GraphRelationKind::Extended(ExtendedRelationKind::Documents);
4051 let references = GraphRelationKind::Extended(ExtendedRelationKind::References);
4052 let guide_documents_source = LogicalRelation::new(
4053 &guide,
4054 documents,
4055 RelationResolution::resolved(&source)?,
4056 ConfidenceClass::Exact,
4057 Completeness::Complete,
4058 generation,
4059 )?;
4060 let source_documents_other = LogicalRelation::new(
4061 &source,
4062 documents,
4063 RelationResolution::resolved(&other_document)?,
4064 ConfidenceClass::Exact,
4065 Completeness::Complete,
4066 generation,
4067 )?;
4068 let guide_references_other = LogicalRelation::new(
4069 &guide,
4070 references,
4071 RelationResolution::resolved(&other_document)?,
4072 ConfidenceClass::Low,
4073 Completeness::Complete,
4074 generation,
4075 )?;
4076 let first_document_occurrence = RelationOccurrence::new(
4077 &guide_documents_source,
4078 RepositoryFilePath::new(Path::new("docs/guide.md"))?,
4079 SourceSpan::new(2, 0, 2, 12)?,
4080 generation,
4081 )?;
4082 let second_document_occurrence = RelationOccurrence::new(
4083 &guide_documents_source,
4084 RepositoryFilePath::new(Path::new("docs/guide.md"))?,
4085 SourceSpan::new(4, 0, 4, 12)?,
4086 generation,
4087 )?;
4088 let document_coverage = [
4089 CoverageRecord::new(
4090 CoverageScope::Path {
4091 path: RepositoryNodePath::new(Path::new("docs/guide.md"))?,
4092 },
4093 Some(documents),
4094 CoverageState::Complete,
4095 1,
4096 0,
4097 generation,
4098 None,
4099 None,
4100 )?,
4101 CoverageRecord::new(
4102 CoverageScope::Path {
4103 path: RepositoryNodePath::new(Path::new("docs/other.md"))?,
4104 },
4105 Some(documents),
4106 CoverageState::NoCandidates,
4107 0,
4108 0,
4109 generation,
4110 None,
4111 None,
4112 )?,
4113 CoverageRecord::new(
4114 CoverageScope::Path {
4115 path: RepositoryNodePath::new(Path::new("src/lib.rs"))?,
4116 },
4117 Some(documents),
4118 CoverageState::Complete,
4119 1,
4120 0,
4121 generation,
4122 None,
4123 None,
4124 )?,
4125 ];
4126 let mut publication = store.begin_index_publication("classified-relations")?;
4127 publication.begin_scan_replacement()?;
4128 publication.upsert_scan_node_batch(&[
4129 test_folder_node("docs"),
4130 test_folder_node("src"),
4131 classified_test_node("docs/guide.md", "hash-guide", ".md", "markdown"),
4132 classified_test_node("docs/other.md", "hash-other", ".md", "markdown"),
4133 classified_test_node("src/lib.rs", "hash-source", ".rs", "rust"),
4134 ])?;
4135 publication.upsert_file_content_classification_batch(&[
4136 projectatlas_db::FileContentClassification {
4137 path: "docs/guide.md".to_string(),
4138 classification: ContentClassification::Documentation,
4139 },
4140 projectatlas_db::FileContentClassification {
4141 path: "docs/other.md".to_string(),
4142 classification: ContentClassification::Documentation,
4143 },
4144 projectatlas_db::FileContentClassification {
4145 path: "src/lib.rs".to_string(),
4146 classification: ContentClassification::Source,
4147 },
4148 ])?;
4149 publication.finish_scan_replacement()?;
4150 publication.replace_repository_graph(
4151 project,
4152 &[guide, other_document, source],
4153 &[
4154 guide_documents_source,
4155 source_documents_other,
4156 guide_references_other,
4157 ],
4158 &[first_document_occurrence, second_document_occurrence],
4159 &document_coverage,
4160 )?;
4161 publication.complete()?;
4162 drop(store);
4163
4164 let store = AtlasStore::open_read_only_for_project(&database, &root)?;
4165 let anchor = RelationAnchor::File {
4166 file: RepositoryFilePath::new(Path::new("docs/guide.md"))?,
4167 };
4168 let legacy = load_detailed_relations(
4169 &store,
4170 &DetailedRelationQuery {
4171 anchor: anchor.clone(),
4172 direction: RelationDirection::Outbound,
4173 relation: None,
4174 minimum_confidence: ConfidenceClass::Low,
4175 resolution: RelationResolutionFilter::Resolved,
4176 include_occurrences: false,
4177 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
4178 1,
4179 5,
4180 3,
4181 256 * 1024,
4182 )?)
4183 .with_aggregate_limits(
4184 Some(1),
4185 Some(10),
4186 Some(10),
4187 None,
4188 None,
4189 None,
4190 )?,
4191 cursor: None,
4192 content_selection: ContentSelection::UnspecifiedLegacy,
4193 },
4194 None,
4195 )?;
4196 require(
4197 legacy.returned == 1 && legacy.rows[0].relation.kind() == references,
4198 "legacy all-family query let a document edge consume its pre-limit candidate page",
4199 )?;
4200 let legacy_json = serde_json::to_value(&legacy)?;
4201 require(
4202 legacy_json.get("content_selection").is_none()
4203 && legacy_json["anchor"].get("content_selection").is_none()
4204 && legacy_json["rows"][0].get("inbound_view").is_none()
4205 && legacy_json["rows"][0]["source"]
4206 .get("content_selection")
4207 .is_none()
4208 && legacy_json["rows"][0]["next_call"]
4209 .get("content_selection")
4210 .is_none(),
4211 "legacy relation output serialized a new selection field",
4212 )?;
4213
4214 let explicit_documents = load_detailed_relations(
4215 &store,
4216 &DetailedRelationQuery {
4217 anchor: anchor.clone(),
4218 direction: RelationDirection::Outbound,
4219 relation: Some(documents),
4220 minimum_confidence: ConfidenceClass::Low,
4221 resolution: RelationResolutionFilter::Resolved,
4222 include_occurrences: true,
4223 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
4224 10,
4225 5,
4226 3,
4227 256 * 1024,
4228 )?)
4229 .with_aggregate_limits(
4230 Some(10),
4231 Some(10),
4232 Some(10),
4233 None,
4234 None,
4235 None,
4236 )?,
4237 cursor: None,
4238 content_selection: ContentSelection::Documentation,
4239 },
4240 None,
4241 )?;
4242 require(
4243 explicit_documents.returned == 1
4244 && explicit_documents.anchor.coverage.iter().any(|coverage| {
4245 coverage.relation() == Some(documents)
4246 && coverage.state() == CoverageState::Complete
4247 })
4248 && explicit_documents.rows[0].inbound_view.is_none()
4249 && explicit_documents.anchor.classification
4250 == Some(ContentClassification::Documentation)
4251 && explicit_documents.rows[0]
4252 .target
4253 .as_ref()
4254 .is_some_and(|target| {
4255 target.classification == Some(ContentClassification::Source)
4256 && target.content_selection == Some(ContentSelection::Source)
4257 })
4258 && matches!(
4259 explicit_documents.rows[0].next_call,
4260 Some(RelationNextCall::Summary {
4261 content_selection: Some(ContentSelection::Source),
4262 ..
4263 })
4264 ),
4265 "explicit document relation did not retain its classified cross-class endpoint",
4266 )?;
4267
4268 let empty_documents = load_detailed_relations(
4269 &store,
4270 &DetailedRelationQuery {
4271 anchor: RelationAnchor::File {
4272 file: RepositoryFilePath::new(Path::new("docs/other.md"))?,
4273 },
4274 direction: RelationDirection::Outbound,
4275 relation: Some(documents),
4276 minimum_confidence: ConfidenceClass::Low,
4277 resolution: RelationResolutionFilter::Any,
4278 include_occurrences: true,
4279 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
4280 10,
4281 5,
4282 3,
4283 256 * 1024,
4284 )?),
4285 cursor: None,
4286 content_selection: ContentSelection::Documentation,
4287 },
4288 None,
4289 )?;
4290 require(
4291 empty_documents.returned == 0
4292 && empty_documents.total == RelationTotalState::Exact(0)
4293 && empty_documents.anchor.coverage.iter().any(|coverage| {
4294 coverage.relation() == Some(documents)
4295 && coverage.state() == CoverageState::NoCandidates
4296 && coverage.total() == 0
4297 }),
4298 "empty document traversal omitted explicit no-candidate coverage",
4299 )?;
4300 require(
4301 explicit_documents.rows.iter().all(|row| row.depth == 1),
4302 "cross-class document endpoint expanded as an unrelated traversal frontier",
4303 )?;
4304 require(
4305 explicit_documents.total == RelationTotalState::Exact(1)
4306 && explicit_documents.continuation.is_none()
4307 && !explicit_documents.truncated,
4308 "classified cross-class traversal did not report exact terminal completeness",
4309 )?;
4310 require(
4311 explicit_documents.work.hydrated_classification_paths == 2,
4312 "relation projection did not batch the two unique classified endpoint paths",
4313 )?;
4314 let explicit_documents_json = serde_json::to_value(&explicit_documents)?;
4315 require(
4316 explicit_documents_json["rows"][0]
4317 .get("inbound_view")
4318 .is_none(),
4319 "outbound document relation serialized an inverse view",
4320 )?;
4321
4322 let inbound_documents = load_detailed_relations(
4323 &store,
4324 &DetailedRelationQuery {
4325 anchor: RelationAnchor::File {
4326 file: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
4327 },
4328 direction: RelationDirection::Inbound,
4329 relation: Some(documents),
4330 minimum_confidence: ConfidenceClass::Low,
4331 resolution: RelationResolutionFilter::Resolved,
4332 include_occurrences: true,
4333 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
4334 10,
4335 5,
4336 3,
4337 256 * 1024,
4338 )?)
4339 .with_aggregate_limits(
4340 Some(10),
4341 Some(10),
4342 Some(10),
4343 None,
4344 None,
4345 None,
4346 )?,
4347 cursor: None,
4348 content_selection: ContentSelection::Source,
4349 },
4350 None,
4351 )?;
4352 require(
4353 inbound_documents.returned == 1
4354 && inbound_documents.rows[0].relation.kind() == documents
4355 && inbound_documents.rows[0].inbound_view == Some("documented_by")
4356 && inbound_documents.rows[0].source.classification
4357 == Some(ContentClassification::Documentation)
4358 && matches!(
4359 inbound_documents.rows[0].next_call,
4360 Some(RelationNextCall::Summary {
4361 content_selection: Some(ContentSelection::Documentation),
4362 ..
4363 })
4364 ),
4365 "inbound document relation did not expose its read-only documented_by view",
4366 )?;
4367 let outbound_row = &explicit_documents.rows[0];
4368 let inbound_row = &inbound_documents.rows[0];
4369 require(
4370 outbound_row.relation.key() == inbound_row.relation.key()
4371 && outbound_row.relation.resolution() == inbound_row.relation.resolution()
4372 && outbound_row.relation.confidence() == inbound_row.relation.confidence()
4373 && outbound_row.relation.completeness() == inbound_row.relation.completeness()
4374 && outbound_row.relation.generation() == inbound_row.relation.generation()
4375 && outbound_row.occurrences == inbound_row.occurrences
4376 && outbound_row.occurrences.len() == 2
4377 && !outbound_row.occurrences_truncated
4378 && !inbound_row.occurrences_truncated,
4379 "outbound documents and inbound documented_by views disagreed on canonical evidence",
4380 )?;
4381 let inbound_documents_json = serde_json::to_value(&inbound_documents)?;
4382 let inbound_documents_encoded = serde_json::to_string(&inbound_documents)?;
4383 require(
4384 inbound_documents_json["rows"][0]["inbound_view"] == "documented_by"
4385 && inbound_documents_encoded.len() as u64
4386 == inbound_documents.work.rendered_output_bytes,
4387 "inbound document relation serialized the wrong inverse view",
4388 )?;
4389
4390 let cursor_query = DetailedRelationQuery {
4391 anchor,
4392 direction: RelationDirection::Outbound,
4393 relation: Some(references),
4394 minimum_confidence: ConfidenceClass::Low,
4395 resolution: RelationResolutionFilter::Resolved,
4396 include_occurrences: false,
4397 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
4398 1,
4399 5,
4400 3,
4401 256 * 1024,
4402 )?)
4403 .with_aggregate_limits(Some(10), Some(10), Some(10), None, None, None)?,
4404 cursor: None,
4405 content_selection: ContentSelection::Documentation,
4406 };
4407 let cursor = load_detailed_relations(&store, &cursor_query, None)?
4408 .continuation
4409 .ok_or("classified relation page omitted a continuation")?;
4410 let mismatched = DetailedRelationQuery {
4411 cursor: Some(cursor),
4412 content_selection: ContentSelection::Both,
4413 ..cursor_query
4414 };
4415 require(
4416 matches!(
4417 load_detailed_relations(&store, &mismatched, None),
4418 Err(ServiceError::RelationCursorMismatched { field: "query" })
4419 ),
4420 "relation cursor accepted a different content selection",
4421 )?;
4422 Ok(())
4423 }
4424
4425 #[test]
4426 fn confidence_and_output_limits_fail_bounded() -> Result<(), Box<dyn Error>> {
4427 let limits = GraphLimits::new(1, 1, 1, 1)?;
4428 require(limits.rows() == 1, "graph row limit changed")?;
4429 let relation = RelationResolution::Unresolved {
4430 reference: GraphIdentityText::new("missing::target")?,
4431 };
4432 require(resolution_rank(&relation) == 1, "unresolved rank changed")?;
4433 require(
4434 confidence_rank(ConfidenceClass::Exact) > confidence_rank(ConfidenceClass::Low),
4435 "confidence rank changed",
4436 )?;
4437 let base = DetailedRelationBudget::from_graph_limits(GraphLimits::new(5, 2, 3, 64 * 1024)?);
4438 let aggregate = base.with_aggregate_limits(
4439 Some(17),
4440 Some(13),
4441 Some(11),
4442 Some(7),
4443 Some(128 * 1024),
4444 Some(2_000),
4445 )?;
4446 require(
4447 aggregate.edges() == 17
4448 && aggregate.nodes() == 13
4449 && aggregate.visited() == 11
4450 && aggregate.occurrences_total() == 7
4451 && aggregate.intermediate_bytes() == 128 * 1024
4452 && aggregate.deadline_ms() == 2_000,
4453 "aggregate relation budget overrides changed",
4454 )?;
4455 require(
4456 base.with_aggregate_limits(None, None, Some(0), None, None, None)
4457 .is_err(),
4458 "zero visited-state budget was accepted",
4459 )?;
4460 require(
4461 base.with_aggregate_limits(
4462 Some(DetailedRelationBudget::MAX_EDGES + 1),
4463 None,
4464 None,
4465 None,
4466 None,
4467 None,
4468 )
4469 .is_err(),
4470 "oversized edge budget was accepted",
4471 )?;
4472 Ok(())
4473 }
4474
4475 fn collect_relation_pages(
4478 store: &AtlasStore,
4479 mut query: DetailedRelationQuery,
4480 maximum_pages: usize,
4481 ) -> ServiceResult<(Vec<DetailedRelationRow>, RelationTotalState, u64)> {
4482 let mut rows = Vec::new();
4483 for _page in 0..maximum_pages {
4484 let report = load_detailed_relations(store, &query, None)?;
4485 rows.extend(report.rows);
4486 let Some(cursor) = report.continuation else {
4487 return Ok((rows, report.total, report.pruned_paths));
4488 };
4489 query.cursor = Some(cursor);
4490 }
4491 Err(ServiceError::InvalidInput(
4492 "relation traversal did not terminate within the test page ceiling".to_string(),
4493 ))
4494 }
4495
4496 fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
4497 if condition {
4498 return Ok(());
4499 }
4500 Err(io::Error::other(message).into())
4501 }
4502
4503 fn test_node(path: &str, hash: &str) -> Node {
4504 classified_test_node(path, hash, ".rs", "rust")
4505 }
4506
4507 fn classified_test_node(path: &str, hash: &str, extension: &str, language: &str) -> Node {
4508 Node {
4509 path: path.to_string(),
4510 kind: NodeKind::File,
4511 parent_path: Some(
4512 path.rsplit_once('/')
4513 .map_or(".", |(parent, _file)| parent)
4514 .to_string(),
4515 ),
4516 extension: Some(extension.to_string()),
4517 language: Some(language.to_string()),
4518 size_bytes: Some(16),
4519 mtime_ns: Some(1),
4520 content_hash: Some(hash.to_string()),
4521 }
4522 }
4523
4524 fn test_folder_node(path: &str) -> Node {
4525 Node {
4526 path: path.to_string(),
4527 kind: NodeKind::Folder,
4528 parent_path: Some(".".to_string()),
4529 extension: None,
4530 language: None,
4531 size_bytes: None,
4532 mtime_ns: Some(1),
4533 content_hash: None,
4534 }
4535 }
4536}