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