1mod impact;
4
5#[cfg(test)]
6mod analysis_test_observer {
7 use std::cell::RefCell;
8
9 #[derive(Clone, Debug, Eq, PartialEq)]
11 pub(super) enum AnalysisPhaseEvent {
12 CompositionBudget {
14 symbol_byte_budget: u64,
16 existing_finding_append_bytes: u64,
18 community_budget: u64,
20 },
21 Traversal,
23 SymbolHydration,
25 Composition,
27 DeadCodeDiscovery,
29 ClassificationHydration,
31 CandidateTraversal,
33 CandidateReport {
35 has_continuation: bool,
37 has_edges_limit: bool,
39 },
40 TerminalProbe,
42 OccurrenceProbe,
44 OccurrenceProbeBeforeRead,
46 TerminalCandidateCoverageProbe {
48 intermediate_bytes: u64,
50 },
51 CandidateEntityHydration {
53 remaining_intermediate_bytes: u64,
55 },
56 CandidateEnumeration,
58 OutputRendering,
60 }
61
62 type Observer = Box<dyn FnMut(AnalysisPhaseEvent)>;
64
65 thread_local! {
66 static OBSERVER: RefCell<Option<Observer>> = RefCell::new(None);
68 }
69
70 struct ObserverGuard {
72 previous: Option<Observer>,
74 }
75
76 impl Drop for ObserverGuard {
77 fn drop(&mut self) {
78 OBSERVER.with(|slot| {
79 drop(slot.replace(self.previous.take()));
80 });
81 }
82 }
83
84 pub(super) fn observe_analysis_phase<T>(
86 observer: impl FnMut(AnalysisPhaseEvent) + 'static,
87 operation: impl FnOnce() -> T,
88 ) -> T {
89 let previous = OBSERVER.with(|slot| slot.replace(Some(Box::new(observer))));
90 let _guard = ObserverGuard { previous };
91 operation()
92 }
93
94 pub(super) fn notify(event: AnalysisPhaseEvent) {
96 OBSERVER.with(|slot| {
97 if let Some(observer) = slot.borrow_mut().as_mut() {
98 observer(event);
99 }
100 });
101 }
102}
103
104#[cfg(test)]
105use super::relations::classification_path;
106use super::relations::{
107 ExternalRelationIdentity, external_relation_identities, hydrate_single_detailed_node,
108 load_detailed_relations, resolve_relation_anchor_for_analysis,
109};
110use super::{
111 CoverageTrustState, DetailedRelationBudget, DetailedRelationNode, DetailedRelationQuery,
112 DetailedRelationReport, DetailedRelationRow, DetailedRelationWork, RelationAnchor,
113 RelationDirection, RelationNextCall, RelationPurpose, RelationResolutionFilter,
114 RelationTotalState, ServiceError, ServiceResult, coverage_trust, selected_project_binding,
115};
116use impact::{LoadedVcs, digest_vcs_paths, impact_findings, load_vcs_paths};
117use projectatlas_core::graph::{
118 Completeness, ConfidenceClass, CoverageRecord, EntitySelector, ExtendedRelationKind,
119 GraphEntity, GraphEntityKey, GraphIdentityText, GraphLimitKind, GraphLimits, GraphRelationKind,
120 ProjectInstanceId, RelationResolution, RepositoryNodePath,
121};
122#[cfg(test)]
123use projectatlas_core::language::ContentClassification;
124use projectatlas_core::language::ContentSelection;
125use projectatlas_core::symbols::{CodeSymbol, RelationKind};
126use projectatlas_core::{
127 CanonicalProjectRoot, IndexCancellation, IndexGeneration, IndexWorkControl, IndexWorkStage,
128};
129#[cfg(test)]
130use projectatlas_db::MAX_FILE_CONTENT_CLASSIFICATION_PATHS;
131use projectatlas_db::{
132 AtlasStore, DbError, MAX_REPOSITORY_GRAPH_FRONTIER, MAX_SYMBOL_BATCH_DECODED_BYTES,
133 MAX_SYMBOL_BATCH_PATHS, MAX_SYMBOL_BATCH_ROWS, RepositoryGraphAdjacencyContinuation,
134 RepositoryGraphDirection, RepositoryGraphReadBudget, RepositoryGraphReadWork,
135 SymbolBatchReadBudget, SymbolBatchReadLimit,
136};
137use serde::{Deserialize, Serialize};
138use std::collections::{BTreeMap, BTreeSet, VecDeque};
139use std::io::{self, Write};
140use std::time::{Duration, Instant};
141
142const ANALYSIS_CURSOR_VERSION: u16 = 1;
144const ANALYSIS_ROOT_DOMAIN: &str = "projectatlas:analysis-root:v1";
146const ANALYSIS_CURSOR_MAX_BYTES: usize = 256 * 1024;
148const MAX_ANALYSIS_NODES: u32 = 512;
150const MAX_ANALYSIS_EDGES: u32 = 2_048;
152const COMMUNITY_ALGORITHM_VERSION: u16 = 1;
154const COMMUNITY_ORDERING_VERSION: u16 = 1;
156const COMMUNITY_MAX_ITERATIONS: u32 = 24;
158const COMMUNITY_SELF_WEIGHT: u32 = 1;
160const COMMUNITY_WORKING_SET_MULTIPLIER: u64 = 8;
162const COMMUNITY_WORKING_SET_ENTRY_BYTES: u64 = 256;
164const COMMUNITY_WORKING_SET_FIXED_BYTES: u64 = 32 * 1024;
166const JSON_ARRAY_FRAMING_BYTES: u64 = 2;
168const JSON_ARRAY_SEPARATOR_BYTES: u64 = 1;
170
171#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
173#[serde(rename_all = "snake_case")]
174pub enum RelationAnalysisMode {
175 Architecture,
177 Impact,
179 Trace,
181 Entrypoint,
183}
184
185#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
190pub struct EntrypointProfile {
191 pub name: String,
193 pub anchors: Vec<RelationAnchor>,
195 pub relations: Vec<GraphRelationKind>,
197}
198
199#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
201#[serde(rename_all = "snake_case")]
202pub enum EntrypointProfileCoverage {
203 Complete,
205 Partial,
207}
208
209#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
211pub struct EntrypointProfileResult {
212 pub name: String,
214 pub anchors: Vec<RelationAnchor>,
216 pub relations: Vec<GraphRelationKind>,
218 pub coverage: EntrypointProfileCoverage,
220 pub reachable: u32,
222 pub unreachable_candidates: u32,
224}
225
226#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
228#[serde(tag = "kind", rename_all = "snake_case")]
229pub enum GitImpactSelection {
230 WorkingTree,
232 Index,
234 RevisionRange {
236 base: String,
238 head: String,
240 },
241}
242
243#[derive(Clone, Debug)]
245pub struct RelationAnalysisQuery {
246 pub relations: DetailedRelationQuery,
248 pub mode: RelationAnalysisMode,
250 pub trace_target: Option<RelationAnchor>,
252 pub vcs: Option<GitImpactSelection>,
254 pub include_communities: bool,
256 pub include_cycles: bool,
258 pub include_dead_code: bool,
260 pub entrypoint_profile: Option<EntrypointProfile>,
262}
263
264#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
266#[serde(rename_all = "snake_case")]
267pub enum AnalysisStatus {
268 Confirmed,
270 Candidate,
272 Absent,
274 Inconclusive,
276}
277
278#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
280#[serde(rename_all = "snake_case")]
281pub enum AnalysisFindingKind {
282 Component,
284 Community,
286 DependencyCycle,
288 PurposeAlignment,
290 PurposeDrift,
292 StructuralComplexity,
294 Bottleneck,
296 Impact,
298 DeadCode,
300 StaticTrace,
302 ResolutionGap,
304 EntrypointReachability,
306}
307
308#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
310pub struct AnalysisFinding {
311 pub kind: AnalysisFindingKind,
313 pub status: AnalysisStatus,
315 pub summary: String,
317 pub nodes: Vec<AnalysisNode>,
319 pub metric: Option<u64>,
321 pub evidence: Option<AnalysisRelationEvidence>,
323 #[serde(skip_serializing_if = "Option::is_none")]
325 pub community: Option<CommunityAnalysis>,
326}
327
328#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
330#[serde(rename_all = "snake_case")]
331pub enum CommunityConvergence {
332 Converged,
334 IterationLimit,
336 Inconclusive,
338}
339
340#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
342#[serde(rename_all = "snake_case")]
343pub enum CommunityCoverage {
344 Complete,
346 Partial,
348}
349
350#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
352pub struct CommunityRelationWeight {
353 pub relation: GraphRelationKind,
355 pub weight: u32,
357}
358
359#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
361pub struct CommunityParameters {
362 pub algorithm_version: u16,
364 pub ordering_version: u16,
366 pub max_iterations: u32,
368 pub node_limit: u32,
370 pub edge_limit: u32,
372 pub output_bytes: u32,
374 pub relation: Option<GraphRelationKind>,
376}
377
378#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
380pub struct CommunityEdgeEvidence {
381 pub source: String,
383 pub target: String,
385 pub relation: GraphRelationKind,
387 pub weight: u32,
389}
390
391#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
393pub struct CommunityAnalysis {
394 pub id: String,
396 pub members: Vec<AnalysisNode>,
398 pub evidence: Vec<CommunityEdgeEvidence>,
400 pub weights: Vec<CommunityRelationWeight>,
402 pub parameters: CommunityParameters,
404 pub iteration: u32,
406 pub convergence: CommunityConvergence,
408 pub coverage: CommunityCoverage,
410 pub truncated: bool,
412}
413
414#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
416pub struct AnalysisNode {
417 pub node: DetailedRelationNode,
419 pub next_call: Option<RelationNextCall>,
421}
422
423#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
425pub struct AnalysisRelationEvidence {
426 pub relation: projectatlas_core::graph::LogicalRelation,
428 pub next_call: Option<RelationAnalysisNextCall>,
430}
431
432#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
434pub struct RelationAnalysisNextCall {
435 pub anchor: RelationAnchor,
437 pub direction: RelationDirection,
439 pub relation: GraphRelationKind,
441 pub resolution: RelationResolutionFilter,
443 pub minimum_confidence: ConfidenceClass,
445 #[serde(skip_serializing_if = "Option::is_none")]
447 pub content_selection: Option<ContentSelection>,
448}
449
450#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
452#[serde(tag = "state", rename_all = "snake_case")]
453pub enum VcsImpact {
454 NotRequested,
456 Available {
458 selection: GitImpactSelection,
460 changed_path_count: u64,
462 },
463 Unavailable {
465 selection: GitImpactSelection,
467 reason: String,
469 },
470}
471
472#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
474pub struct RelationAnalysisWork {
475 pub relations: DetailedRelationWork,
477 pub closure_inspected_edges: u32,
479 pub closure_decoded_bytes: u64,
481 pub vcs_retained_bytes: u64,
483 pub analyzed_nodes: u32,
485 pub analyzed_edges: u32,
487 pub hydrated_symbols: u32,
489 pub hydrated_symbol_bytes: u64,
491 pub symbol_hydration_truncated: bool,
493 pub retained_composition_bytes: u64,
495 pub peak_intermediate_bytes: u64,
498 pub composition_truncated: bool,
500 pub rendered_output_bytes: u64,
502}
503
504#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
506pub struct RelationAnalysisReport {
507 pub mode: RelationAnalysisMode,
509 pub anchor: DetailedRelationNode,
511 pub generation: projectatlas_core::IndexGeneration,
513 pub authored_purpose_revision: u64,
515 pub continuation: Option<String>,
517 pub returned: u32,
519 pub total: RelationTotalState,
521 pub truncated: bool,
523 pub reached_limits: Vec<GraphLimitKind>,
525 pub vcs: VcsImpact,
527 #[serde(skip_serializing_if = "Option::is_none")]
529 pub entrypoint_profile: Option<EntrypointProfileResult>,
530 pub work: RelationAnalysisWork,
532 pub findings: Vec<AnalysisFinding>,
534}
535
536pub struct RelationAnalysisDraft {
538 report: RelationAnalysisReport,
540 output_bytes: u32,
542 budget: DetailedRelationBudget,
544 cursor_binding: AnalysisCursorBinding,
546 cursor_snapshot: AnalysisCursorSnapshot,
548 replay_relation_cursor: Option<String>,
550 finding_offset: u32,
552 vcs_digest: Option<[u8; 32]>,
554 external_relation_identities: BTreeSet<ExternalRelationIdentity>,
556 control: IndexWorkControl,
558}
559
560impl RelationAnalysisDraft {
561 #[must_use]
563 pub const fn candidate_report(&self) -> &RelationAnalysisReport {
564 &self.report
565 }
566
567 pub(super) const fn control(&self) -> &IndexWorkControl {
569 &self.control
570 }
571
572 pub(super) fn take_external_relation_identities(
574 &mut self,
575 ) -> BTreeSet<ExternalRelationIdentity> {
576 std::mem::take(&mut self.external_relation_identities)
577 }
578
579 pub fn fit_output<F, E, O>(self, mut encode: F) -> Result<(RelationAnalysisReport, O), E>
586 where
587 F: FnMut(&RelationAnalysisReport, &IndexWorkControl) -> Result<O, E>,
588 E: From<ServiceError>,
589 O: AsRef<[u8]>,
590 {
591 if self.report.mode == RelationAnalysisMode::Entrypoint {
592 return self.fit_entrypoint_output(encode);
593 }
594 check_control(Some(&self.control)).map_err(E::from)?;
595 #[cfg(test)]
596 analysis_test_observer::notify(analysis_test_observer::AnalysisPhaseEvent::OutputRendering);
597 let original_report_bytes =
598 serialized_bytes_controlled(&self.report, Some(&self.control)).map_err(E::from)?;
599 let construction_peak = self.report.work.peak_intermediate_bytes;
600 let mut low = 0;
601 let mut high = self.report.findings.len();
602 let mut best = None;
603 let mut output_limited = false;
604 let mut intermediate_limited = false;
605 let mut empty_output_oversized = false;
606 let mut empty_intermediate_oversized = false;
607 while low <= high {
608 check_control(Some(&self.control)).map_err(E::from)?;
609 let middle = low + (high - low) / 2;
610 let fit_limits = [
611 output_limited.then_some(GraphLimitKind::OutputBytes),
612 intermediate_limited.then_some(GraphLimitKind::IntermediateBytes),
613 ];
614 let mut candidate =
615 analysis_prefix(&self.report, middle, fit_limits.into_iter().flatten());
616 if middle < self.report.findings.len() {
617 let middle = u32::try_from(middle).map_err(|_overflow| {
618 E::from(ServiceError::InvalidInput(
619 "analysis finding offset overflowed".to_string(),
620 ))
621 })?;
622 let finding_offset = self.finding_offset.checked_add(middle).ok_or_else(|| {
623 E::from(ServiceError::InvalidInput(
624 "analysis finding offset overflowed".to_string(),
625 ))
626 })?;
627 candidate.continuation = Some(
628 encode_analysis_cursor(
629 self.replay_relation_cursor.as_deref(),
630 finding_offset,
631 &self.cursor_binding,
632 self.cursor_snapshot,
633 self.vcs_digest,
634 self.budget,
635 )
636 .map_err(E::from)?,
637 );
638 }
639 check_control(Some(&self.control)).map_err(E::from)?;
640 let mut encoded = encode(&candidate, &self.control)?;
641 check_control(Some(&self.control)).map_err(E::from)?;
642 let mut stable = false;
643 for _ in 0..8 {
644 check_control(Some(&self.control)).map_err(E::from)?;
645 let rendered = u64::try_from(encoded.as_ref().len()).map_err(|source| {
646 E::from(ServiceError::InvalidInput(format!(
647 "analysis rendered byte count overflowed: {source}"
648 )))
649 })?;
650 let candidate_report_bytes =
651 serialized_bytes_controlled(&candidate, Some(&self.control))
652 .map_err(E::from)?;
653 let fitting_peak = original_report_bytes
654 .checked_add(candidate_report_bytes)
655 .and_then(|bytes| bytes.checked_add(rendered))
656 .ok_or_else(|| {
657 E::from(ServiceError::InvalidInput(
658 "analysis output fitting byte count overflowed".to_string(),
659 ))
660 })?;
661 let peak = construction_peak.max(fitting_peak);
662 if candidate.work.rendered_output_bytes == rendered
663 && candidate.work.peak_intermediate_bytes == peak
664 {
665 stable = true;
666 break;
667 }
668 candidate.work.rendered_output_bytes = rendered;
669 candidate.work.peak_intermediate_bytes = peak;
670 drop(encoded);
671 encoded = encode(&candidate, &self.control)?;
672 check_control(Some(&self.control)).map_err(E::from)?;
673 }
674 if !stable {
675 return Err(E::from(ServiceError::InvalidInput(
676 "analysis output accounting did not stabilize".to_string(),
677 )));
678 }
679 let output_fits = encoded.as_ref().len() <= self.output_bytes as usize;
680 let intermediate_fits =
681 candidate.work.peak_intermediate_bytes <= self.budget.intermediate_bytes();
682 if output_fits && intermediate_fits {
683 best = Some((candidate, encoded));
684 low = middle.saturating_add(1);
685 } else {
686 if middle == 0 {
687 empty_output_oversized = !output_fits;
688 empty_intermediate_oversized = !intermediate_fits;
689 break;
690 }
691 let newly_output_limited = !output_fits && !output_limited;
692 let newly_intermediate_limited = !intermediate_fits && !intermediate_limited;
693 output_limited |= !output_fits;
694 intermediate_limited |= !intermediate_fits;
695 if newly_output_limited || newly_intermediate_limited {
696 best = None;
697 low = 0;
698 }
699 high = middle - 1;
700 }
701 }
702 check_control(Some(&self.control)).map_err(E::from)?;
703 best.ok_or_else(|| {
704 let message = if empty_intermediate_oversized {
705 "empty analysis envelope exceeds the aggregate intermediate-byte budget"
706 } else if empty_output_oversized {
707 "graph output byte limit is too small for the empty analysis envelope"
708 } else if intermediate_limited {
709 "analysis output fitting exceeds the aggregate intermediate-byte budget"
710 } else {
711 "graph output byte limit is too small for the empty analysis envelope"
712 };
713 E::from(ServiceError::InvalidInput(message.to_string()))
714 })
715 }
716
717 fn fit_entrypoint_output<F, E, O>(self, mut encode: F) -> Result<(RelationAnalysisReport, O), E>
719 where
720 F: FnMut(&RelationAnalysisReport, &IndexWorkControl) -> Result<O, E>,
721 E: From<ServiceError>,
722 O: AsRef<[u8]>,
723 {
724 check_control(Some(&self.control)).map_err(E::from)?;
725 #[cfg(test)]
726 analysis_test_observer::notify(analysis_test_observer::AnalysisPhaseEvent::OutputRendering);
727 let mut candidate = self.report;
728 let mut encoded = encode(&candidate, &self.control)?;
729 for _ in 0..8 {
730 check_control(Some(&self.control)).map_err(E::from)?;
731 let rendered = u64::try_from(encoded.as_ref().len()).map_err(|source| {
732 E::from(ServiceError::InvalidInput(format!(
733 "entrypoint rendered byte count overflowed: {source}"
734 )))
735 })?;
736 let candidate_report_bytes =
737 serialized_bytes_controlled(&candidate, Some(&self.control)).map_err(E::from)?;
738 let fitting_peak = candidate_report_bytes
739 .checked_add(rendered)
740 .ok_or_else(|| {
741 E::from(ServiceError::InvalidInput(
742 "entrypoint output fitting byte count overflowed".to_string(),
743 ))
744 })?;
745 let peak = candidate.work.peak_intermediate_bytes.max(fitting_peak);
746 if candidate.work.rendered_output_bytes == rendered
747 && candidate.work.peak_intermediate_bytes == peak
748 {
749 if encoded.as_ref().len() > self.output_bytes as usize {
750 return Err(E::from(ServiceError::InvalidInput(
751 "entrypoint profile output exceeds its declared byte budget".to_string(),
752 )));
753 }
754 if peak > self.budget.intermediate_bytes() {
755 return Err(E::from(ServiceError::InvalidInput(
756 "entrypoint profile output exceeds its aggregate intermediate-byte budget"
757 .to_string(),
758 )));
759 }
760 return Ok((candidate, encoded));
761 }
762 candidate.work.rendered_output_bytes = rendered;
763 candidate.work.peak_intermediate_bytes = peak;
764 encoded = encode(&candidate, &self.control)?;
765 }
766 Err(E::from(ServiceError::InvalidInput(
767 "entrypoint output accounting did not stabilize".to_string(),
768 )))
769 }
770}
771
772#[derive(Clone, Serialize)]
773struct LocalEdge {
775 source: String,
777 target: String,
779 kind: GraphRelationKind,
781 complete: bool,
783}
784
785#[derive(Default)]
786struct SupplementalWork {
788 hydrated_symbols: u32,
790 hydrated_symbol_bytes: u64,
792 hydrated_symbol_peak_bytes: u64,
794 symbol_hydration_truncated: bool,
796 reached_limits: Vec<GraphLimitKind>,
798 retained_composition_bytes: u64,
800 composition_truncated: bool,
802 community_working_set_bytes: u64,
804}
805
806#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
807struct AnalysisCursorBinding {
809 root_digest: [u8; 32],
811 anchor: RelationAnchor,
813 direction: RelationDirection,
815 relation: Option<GraphRelationKind>,
817 minimum_confidence: ConfidenceClass,
819 resolution: RelationResolutionFilter,
821 #[serde(default, skip_serializing_if = "Option::is_none")]
823 content_selection: Option<ContentSelection>,
824 options: AnalysisCursorOptions,
826 budget: DetailedRelationBudget,
828 algorithm_version: u16,
830 ordering_version: u16,
832 mode: RelationAnalysisMode,
834 trace_target: Option<RelationAnchor>,
836 vcs: Option<GitImpactSelection>,
838 entrypoint_profile: Option<EntrypointProfile>,
840}
841
842#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
843#[serde(rename_all = "snake_case")]
844enum AnalysisFeatureSelection {
846 Excluded,
848 Included,
850}
851
852impl From<bool> for AnalysisFeatureSelection {
853 fn from(included: bool) -> Self {
854 if included {
855 Self::Included
856 } else {
857 Self::Excluded
858 }
859 }
860}
861
862#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
863struct AnalysisCursorOptions {
865 relation_occurrences: AnalysisFeatureSelection,
867 communities: AnalysisFeatureSelection,
869 cycles: AnalysisFeatureSelection,
871 dead_code: AnalysisFeatureSelection,
873}
874
875#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
876struct AnalysisCursorSnapshot {
878 project: ProjectInstanceId,
880 generation: projectatlas_core::IndexGeneration,
882 authored_purpose_revision: u64,
884}
885
886#[derive(Deserialize, Serialize)]
887#[serde(deny_unknown_fields)]
888struct AnalysisCursor {
890 version: u16,
892 binding: AnalysisCursorBinding,
894 snapshot: AnalysisCursorSnapshot,
896 relation_cursor: Option<String>,
898 finding_offset: u32,
900 vcs_digest: Option<[u8; 32]>,
902}
903
904pub fn load_relation_analysis(
911 store: &AtlasStore,
912 query: &RelationAnalysisQuery,
913 control: Option<&IndexWorkControl>,
914) -> ServiceResult<RelationAnalysisDraft> {
915 if query.mode == RelationAnalysisMode::Entrypoint {
916 return load_entrypoint_profile_draft(store, query, control);
917 }
918 load_relation_analysis_with_closure_deadline(store, query, control, None, false)
919}
920
921pub(super) fn load_relation_analysis_for_federation(
923 store: &AtlasStore,
924 query: &RelationAnalysisQuery,
925 control: Option<&IndexWorkControl>,
926) -> ServiceResult<RelationAnalysisDraft> {
927 if query.mode == RelationAnalysisMode::Entrypoint {
928 return Err(ServiceError::InvalidInput(
929 "entrypoint profiles require one project root".to_string(),
930 ));
931 }
932 load_relation_analysis_with_closure_deadline(store, query, control, None, true)
933}
934
935fn load_relation_analysis_with_closure_deadline(
937 store: &AtlasStore,
938 query: &RelationAnalysisQuery,
939 control: Option<&IndexWorkControl>,
940 closure_deadline_ceiling: Option<Instant>,
941 retain_external_relation_identities: bool,
942) -> ServiceResult<RelationAnalysisDraft> {
943 validate_analysis_query(query)?;
944 let started = Instant::now();
945 let deadline = started
946 .checked_add(Duration::from_millis(query.relations.budget.deadline_ms()))
947 .unwrap_or(started);
948 let deadline = control
949 .and_then(IndexWorkControl::deadline)
950 .map_or(deadline, |caller_deadline| caller_deadline.min(deadline));
951 let analysis_control = control.map_or_else(
952 || IndexWorkControl::with_deadline(IndexCancellation::new(), deadline),
953 |caller| {
954 caller.with_timeout_ceiling(deadline.saturating_duration_since(caller.started_at()))
955 },
956 );
957 let control = Some(&analysis_control);
958 check_control(control)?;
959 let selected_binding = selected_project_binding(store)?;
960 let cursor_binding = analysis_cursor_binding(query, &selected_binding.project_root_identity)?;
961 let decoded_cursor = query
962 .relations
963 .cursor
964 .as_deref()
965 .map(|cursor| decode_analysis_cursor(cursor, &cursor_binding))
966 .transpose()?;
967 let mut relation_query = query.relations.clone();
968 relation_query.budget = bounded_analysis_budget(query.relations.budget)?;
969 relation_query.cursor = decoded_cursor
970 .as_ref()
971 .and_then(|cursor| cursor.relation_cursor.clone());
972 let replay_relation_cursor = relation_query.cursor.clone();
973 let finding_offset = decoded_cursor
974 .as_ref()
975 .map_or(0, |cursor| cursor.finding_offset);
976 let relations = load_detailed_relations(store, &relation_query, control)?;
977 let external_relation_identities = if retain_external_relation_identities {
978 external_relation_identities(&relations)
979 } else {
980 BTreeSet::new()
981 };
982 let external_relation_identity_bytes =
983 serialized_bytes_controlled(&external_relation_identities, control)?;
984 let cursor_snapshot = AnalysisCursorSnapshot {
985 project: relations.anchor.entity.key().project(),
986 generation: relations.generation,
987 authored_purpose_revision: relations.authored_purpose_revision,
988 };
989 if decoded_cursor
990 .as_ref()
991 .is_some_and(|cursor| cursor.snapshot != cursor_snapshot)
992 {
993 return Err(ServiceError::RelationCursorStale {
994 field: "analysis snapshot",
995 });
996 }
997 check_control(control)?;
998 let mut nodes = collect_nodes(&relations, control)?;
999 let mut edges = collect_report_edges(&relations, control)?;
1000 let mut closure_query = query.clone();
1001 closure_query.relations = relation_query;
1002 let closure = close_induced_edges(
1003 store,
1004 &closure_query,
1005 &relations.work,
1006 closure_deadline_ceiling.map_or(deadline, |ceiling| ceiling.min(deadline)),
1007 &nodes,
1008 &mut edges,
1009 control,
1010 )?;
1011 check_control(control)?;
1012 let evidence_complete =
1013 relation_evidence_complete(&relations, &nodes, &edges, query, &closure, false);
1014 let community_evidence_complete =
1015 relation_evidence_complete(&relations, &nodes, &edges, query, &closure, true);
1016 let dead_code_scope_complete = dead_code_scope_complete(&relations, query);
1017 check_control(control)?;
1018 let vcs_load = if query.mode == RelationAnalysisMode::Impact {
1019 let selection = query.vcs.clone().unwrap_or(GitImpactSelection::WorkingTree);
1020 load_vcs_paths(
1021 selected_binding.project_root_identity.as_path(),
1022 selection,
1023 query.relations.budget.intermediate_bytes().saturating_sub(
1024 relations
1025 .work
1026 .intermediate_bytes
1027 .saturating_add(closure.decoded_bytes)
1028 .saturating_add(external_relation_identity_bytes),
1029 ),
1030 deadline,
1031 control,
1032 )
1033 } else {
1034 LoadedVcs {
1035 report: VcsImpact::NotRequested,
1036 changed_paths: Vec::new(),
1037 retained_bytes: 0,
1038 }
1039 };
1040 let vcs = vcs_load.report;
1041 let vcs_digest = (query.mode == RelationAnalysisMode::Impact)
1042 .then(|| digest_vcs_paths(&vcs_load.changed_paths, control))
1043 .transpose()?;
1044 if decoded_cursor
1045 .as_ref()
1046 .is_some_and(|cursor| cursor.vcs_digest != vcs_digest)
1047 {
1048 return Err(ServiceError::RelationCursorStale {
1049 field: "VCS evidence",
1050 });
1051 }
1052 let mut supplemental_work = SupplementalWork::default();
1053 let analysis_allowance = query.relations.budget.intermediate_bytes().saturating_sub(
1054 relations
1055 .work
1056 .intermediate_bytes
1057 .saturating_add(closure.decoded_bytes)
1058 .saturating_add(external_relation_identity_bytes),
1059 );
1060 let projection_allowance = analysis_allowance.saturating_sub(vcs_load.retained_bytes);
1061 #[cfg(test)]
1062 analysis_test_observer::notify(analysis_test_observer::AnalysisPhaseEvent::Composition);
1063 let mut topology_bytes =
1064 serialized_bytes_controlled(&(nodes.values().collect::<Vec<_>>(), &edges), control)?;
1065 let mut gaps = resolution_gap_findings(&relations, control)?;
1066 gaps.extend(closure.resolution_gaps.iter().cloned());
1067 check_control(control)?;
1068 gaps.sort_by(|left, right| resolution_gap_identity(left).cmp(resolution_gap_identity(right)));
1069 gaps.dedup_by(|left, right| resolution_gap_identity(left) == resolution_gap_identity(right));
1070 check_control(control)?;
1071 let gap_bytes = serialized_bytes_controlled(&gaps, control)?;
1072 let projection_safe = topology_bytes.saturating_add(gap_bytes) <= projection_allowance;
1073 let mut findings = if projection_safe {
1074 gaps
1075 } else {
1076 supplemental_work.composition_truncated = true;
1077 push_limit(
1078 &mut supplemental_work.reached_limits,
1079 GraphLimitKind::IntermediateBytes,
1080 );
1081 edges.clear();
1082 nodes.retain(|_, node| node.entity.key() == relations.anchor.entity.key());
1083 topology_bytes =
1084 serialized_bytes_controlled(&(nodes.values().collect::<Vec<_>>(), &edges), control)?;
1085 vec![AnalysisFinding {
1086 kind: AnalysisFindingKind::Component,
1087 status: AnalysisStatus::Inconclusive,
1088 summary: "analysis composition crossed the shared intermediate-byte budget".to_string(),
1089 nodes: vec![analysis_node(&relations.anchor)],
1090 metric: None,
1091 evidence: None,
1092 community: None,
1093 }]
1094 };
1095 let initial_finding_bytes = serialized_bytes_controlled(&findings, control)?;
1096 let initial_finding_count = findings.len();
1097 let symbol_byte_budget = projection_allowance
1098 .saturating_sub(topology_bytes)
1099 .saturating_sub(initial_finding_bytes);
1100 if projection_safe {
1101 findings.extend(match query.mode {
1102 RelationAnalysisMode::Architecture => architecture_findings(
1103 store,
1104 &nodes,
1105 &edges,
1106 evidence_complete,
1107 community_evidence_complete,
1108 query,
1109 symbol_byte_budget,
1110 initial_finding_count,
1111 &mut supplemental_work,
1112 control,
1113 )?,
1114 RelationAnalysisMode::Impact => impact_findings(
1115 store,
1116 &nodes,
1117 &edges,
1118 evidence_complete,
1119 dead_code_scope_complete,
1120 &vcs,
1121 &vcs_load.changed_paths,
1122 query,
1123 symbol_byte_budget,
1124 &mut supplemental_work,
1125 control,
1126 )?,
1127 RelationAnalysisMode::Trace => {
1128 trace_findings(&relations, query.trace_target.as_ref(), evidence_complete)?
1129 }
1130 RelationAnalysisMode::Entrypoint => Vec::new(),
1131 });
1132 }
1133 let generated_finding_count = u32::try_from(findings.len()).map_err(|_overflow| {
1134 ServiceError::InvalidInput("analysis finding count overflowed".to_string())
1135 })?;
1136 let mut finding_bytes = serialized_bytes_controlled(&findings, control)?;
1137 let generated_composition_bytes = vcs_load
1138 .retained_bytes
1139 .saturating_add(topology_bytes)
1140 .saturating_add(finding_bytes);
1141 supplemental_work.retained_composition_bytes = vcs_load
1142 .retained_bytes
1143 .saturating_add(topology_bytes)
1144 .saturating_add(finding_bytes);
1145 while supplemental_work.retained_composition_bytes > analysis_allowance && findings.len() > 1 {
1146 check_control(control)?;
1147 findings.pop();
1148 supplemental_work.composition_truncated = true;
1149 push_limit(
1150 &mut supplemental_work.reached_limits,
1151 GraphLimitKind::IntermediateBytes,
1152 );
1153 finding_bytes = serialized_bytes_controlled(&findings, control)?;
1154 supplemental_work.retained_composition_bytes = vcs_load
1155 .retained_bytes
1156 .saturating_add(topology_bytes)
1157 .saturating_add(finding_bytes);
1158 }
1159 if supplemental_work.retained_composition_bytes > analysis_allowance {
1160 findings.clear();
1161 supplemental_work.composition_truncated = true;
1162 push_limit(
1163 &mut supplemental_work.reached_limits,
1164 GraphLimitKind::IntermediateBytes,
1165 );
1166 finding_bytes = serialized_bytes_controlled(&findings, control)?;
1167 supplemental_work.retained_composition_bytes = vcs_load
1168 .retained_bytes
1169 .saturating_add(topology_bytes)
1170 .saturating_add(finding_bytes);
1171 }
1172 let hydration_peak = relations
1173 .work
1174 .intermediate_bytes
1175 .saturating_add(closure.decoded_bytes)
1176 .saturating_add(vcs_load.retained_bytes)
1177 .saturating_add(topology_bytes)
1178 .saturating_add(initial_finding_bytes)
1179 .saturating_add(external_relation_identity_bytes)
1180 .saturating_add(supplemental_work.hydrated_symbol_peak_bytes);
1181 let generation_peak = relations
1182 .work
1183 .intermediate_bytes
1184 .saturating_add(closure.decoded_bytes)
1185 .saturating_add(external_relation_identity_bytes)
1186 .saturating_add(generated_composition_bytes)
1187 .saturating_add(supplemental_work.community_working_set_bytes);
1188 let final_peak = relations
1189 .work
1190 .intermediate_bytes
1191 .saturating_add(closure.decoded_bytes)
1192 .saturating_add(supplemental_work.retained_composition_bytes)
1193 .saturating_add(external_relation_identity_bytes);
1194 let peak_intermediate_bytes = hydration_peak.max(generation_peak).max(final_peak);
1195 let community_projection_truncated = findings.iter().any(|finding| {
1196 finding
1197 .community
1198 .as_ref()
1199 .is_some_and(|community| community.truncated)
1200 });
1201 let full_finding_count = u32::try_from(findings.len()).map_err(|_overflow| {
1202 ServiceError::InvalidInput("analysis finding count overflowed".to_string())
1203 })?;
1204 if finding_offset > full_finding_count {
1205 return Err(ServiceError::RelationCursorInvalid {
1206 reason: "analysis finding offset exceeds the recomputed page",
1207 });
1208 }
1209 findings.drain(..finding_offset as usize);
1210 let mut reached_limits = relations.reached_limits.clone();
1211 if !closure.complete {
1212 push_limit(
1213 &mut reached_limits,
1214 if closure.deadline_reached {
1215 GraphLimitKind::Deadline
1216 } else {
1217 GraphLimitKind::Edges
1218 },
1219 );
1220 }
1221 for limit in &supplemental_work.reached_limits {
1222 push_limit(&mut reached_limits, *limit);
1223 }
1224 let work = RelationAnalysisWork {
1225 relations: relations.work.clone(),
1226 closure_inspected_edges: closure.inspected_edges,
1227 closure_decoded_bytes: closure.decoded_bytes,
1228 vcs_retained_bytes: vcs_load.retained_bytes,
1229 analyzed_nodes: u32::try_from(nodes.len()).unwrap_or(u32::MAX),
1230 analyzed_edges: u32::try_from(edges.len()).unwrap_or(u32::MAX),
1231 hydrated_symbols: supplemental_work.hydrated_symbols,
1232 hydrated_symbol_bytes: supplemental_work.hydrated_symbol_bytes,
1233 symbol_hydration_truncated: supplemental_work.symbol_hydration_truncated,
1234 retained_composition_bytes: supplemental_work.retained_composition_bytes,
1235 peak_intermediate_bytes,
1236 composition_truncated: supplemental_work.composition_truncated,
1237 rendered_output_bytes: 0,
1238 };
1239 let analysis_truncated = (!evidence_complete && relations.truncated)
1240 || !closure.complete
1241 || supplemental_work.symbol_hydration_truncated
1242 || supplemental_work.composition_truncated
1243 || community_projection_truncated;
1244 let returned = u32::try_from(findings.len()).unwrap_or(u32::MAX);
1245 let total = if analysis_truncated {
1246 RelationTotalState::AtLeast(u64::from(generated_finding_count))
1247 } else {
1248 RelationTotalState::Exact(u64::from(full_finding_count))
1249 };
1250 let continuation = if evidence_complete {
1251 None
1252 } else {
1253 relations
1254 .continuation
1255 .as_deref()
1256 .map(|cursor| {
1257 encode_analysis_cursor(
1258 Some(cursor),
1259 0,
1260 &cursor_binding,
1261 cursor_snapshot,
1262 vcs_digest,
1263 query.relations.budget,
1264 )
1265 })
1266 .transpose()?
1267 };
1268 let report = RelationAnalysisReport {
1269 mode: query.mode,
1270 anchor: relations.anchor,
1271 generation: relations.generation,
1272 authored_purpose_revision: relations.authored_purpose_revision,
1273 continuation,
1274 returned,
1275 total,
1276 truncated: analysis_truncated,
1277 reached_limits,
1278 vcs,
1279 entrypoint_profile: None,
1280 work,
1281 findings,
1282 };
1283 nodes.clear();
1284 check_control(control)?;
1285 Ok(RelationAnalysisDraft {
1286 report,
1287 output_bytes: query.relations.budget.output_bytes(),
1288 budget: query.relations.budget,
1289 cursor_binding,
1290 cursor_snapshot,
1291 replay_relation_cursor,
1292 finding_offset,
1293 vcs_digest,
1294 external_relation_identities,
1295 control: analysis_control,
1296 })
1297}
1298
1299fn validate_analysis_query(query: &RelationAnalysisQuery) -> ServiceResult<()> {
1301 if query.mode == RelationAnalysisMode::Entrypoint {
1302 let profile = query.entrypoint_profile.as_ref().ok_or_else(|| {
1303 ServiceError::InvalidInput(
1304 "entrypoint analysis requires an explicit profile".to_string(),
1305 )
1306 })?;
1307 validate_entrypoint_profile(profile)?;
1308 if query.relations.direction != RelationDirection::Outbound
1309 || query.relations.resolution != RelationResolutionFilter::Any
1310 {
1311 return Err(ServiceError::InvalidInput(
1312 "entrypoint profiles require outbound traversal with any resolution".to_string(),
1313 ));
1314 }
1315 if query.relations.cursor.is_some() {
1316 return Err(ServiceError::RelationCursorInvalid {
1317 reason: "entrypoint profiles do not support relation cursors; restart the profile",
1318 });
1319 }
1320 if query.relations.relation.is_some() {
1321 return Err(ServiceError::InvalidInput(
1322 "entrypoint profiles carry relation families in the profile itself".to_string(),
1323 ));
1324 }
1325 if query.trace_target.is_some()
1326 || query.vcs.is_some()
1327 || query.include_communities
1328 || query.include_cycles
1329 || query.include_dead_code
1330 {
1331 return Err(ServiceError::InvalidInput(
1332 "entrypoint profiles do not accept unrelated analysis controls".to_string(),
1333 ));
1334 }
1335 return Ok(());
1336 }
1337 if query.entrypoint_profile.is_some() {
1338 return Err(ServiceError::InvalidInput(
1339 "entrypoint profile is valid only for entrypoint analysis".to_string(),
1340 ));
1341 }
1342 if query.mode == RelationAnalysisMode::Trace {
1343 let Some(target) = query.trace_target.as_ref() else {
1344 return Err(ServiceError::InvalidInput(
1345 "analysis trace requires an exact file or symbol target".to_string(),
1346 ));
1347 };
1348 if matches!(
1349 target,
1350 RelationAnchor::Symbol {
1351 symbol_kind: None,
1352 ..
1353 }
1354 ) || matches!(
1355 target,
1356 RelationAnchor::Symbol {
1357 signature: None,
1358 ..
1359 }
1360 ) {
1361 return Err(ServiceError::InvalidInput(
1362 "analysis trace symbol targets require exact kind and signature".to_string(),
1363 ));
1364 }
1365 } else if query.trace_target.is_some() {
1366 return Err(ServiceError::InvalidInput(
1367 "trace_target is valid only for static trace analysis".to_string(),
1368 ));
1369 }
1370 if query.mode != RelationAnalysisMode::Impact && query.vcs.is_some() {
1371 return Err(ServiceError::InvalidInput(
1372 "VCS selection is valid only for impact analysis".to_string(),
1373 ));
1374 }
1375 if query.mode != RelationAnalysisMode::Architecture
1376 && (query.include_communities || query.include_cycles)
1377 {
1378 return Err(ServiceError::InvalidInput(
1379 "community and cycle controls are valid only for architecture analysis".to_string(),
1380 ));
1381 }
1382 if query.mode != RelationAnalysisMode::Impact && query.include_dead_code {
1383 return Err(ServiceError::InvalidInput(
1384 "dead-code controls are valid only for impact analysis".to_string(),
1385 ));
1386 }
1387 Ok(())
1388}
1389
1390fn validate_entrypoint_profile(profile: &EntrypointProfile) -> ServiceResult<()> {
1392 let name = profile.name.trim();
1393 if name.is_empty() || name.len() > 128 || name != profile.name {
1394 return Err(ServiceError::InvalidInput(
1395 "entrypoint profile name must be nonempty, trimmed, and at most 128 bytes".to_string(),
1396 ));
1397 }
1398 if profile.anchors.is_empty() || profile.anchors.len() > 32 {
1399 return Err(ServiceError::InvalidInput(
1400 "entrypoint profile requires 1 to 32 anchors".to_string(),
1401 ));
1402 }
1403 let mut anchors = BTreeSet::new();
1404 for anchor in &profile.anchors {
1405 let encoded = serde_json::to_string(anchor)?;
1406 if !anchors.insert(encoded) {
1407 return Err(ServiceError::InvalidInput(
1408 "entrypoint profile anchors must be unique".to_string(),
1409 ));
1410 }
1411 if matches!(
1412 anchor,
1413 RelationAnchor::Symbol {
1414 symbol_kind: None,
1415 ..
1416 }
1417 ) || matches!(
1418 anchor,
1419 RelationAnchor::Symbol {
1420 signature: None,
1421 ..
1422 }
1423 ) {
1424 return Err(ServiceError::InvalidInput(
1425 "entrypoint symbol anchors require exact kind and signature".to_string(),
1426 ));
1427 }
1428 if let RelationAnchor::Symbol {
1429 name,
1430 parent,
1431 signature,
1432 ..
1433 } = anchor
1434 && (name.trim().is_empty()
1435 || name != name.trim()
1436 || signature
1437 .as_deref()
1438 .is_some_and(|value| value.trim().is_empty())
1439 || parent
1440 .as_deref()
1441 .is_some_and(|value| value.trim().is_empty()))
1442 {
1443 return Err(ServiceError::InvalidInput(
1444 "entrypoint symbol anchors require nonempty exact identity fields".to_string(),
1445 ));
1446 }
1447 }
1448 if profile.relations.is_empty() || profile.relations.len() > GraphRelationKind::ALL.len() {
1449 return Err(ServiceError::InvalidInput(
1450 "entrypoint profile requires 1 to 12 relation families".to_string(),
1451 ));
1452 }
1453 let mut relations = BTreeSet::new();
1454 for relation in &profile.relations {
1455 if !GraphRelationKind::ALL.contains(relation) || !relations.insert(relation.as_str()) {
1456 return Err(ServiceError::InvalidInput(
1457 "entrypoint profile relation families must be unique supported values".to_string(),
1458 ));
1459 }
1460 }
1461 Ok(())
1462}
1463
1464fn load_entrypoint_profile_draft(
1466 store: &AtlasStore,
1467 query: &RelationAnalysisQuery,
1468 control: Option<&IndexWorkControl>,
1469) -> ServiceResult<RelationAnalysisDraft> {
1470 validate_analysis_query(query)?;
1471 let profile = query
1472 .entrypoint_profile
1473 .as_ref()
1474 .ok_or_else(|| ServiceError::InvalidInput("entrypoint profile is missing".to_string()))?;
1475 let started = Instant::now();
1476 let budget = bounded_analysis_budget(query.relations.budget)?;
1477 validate_entrypoint_profile_budget(profile, budget)?;
1478 let deadline = started
1479 .checked_add(Duration::from_millis(budget.deadline_ms()))
1480 .unwrap_or(started);
1481 let analysis_control = control.map_or_else(
1482 || IndexWorkControl::with_deadline(IndexCancellation::new(), deadline),
1483 |caller| {
1484 caller.with_timeout_ceiling(deadline.saturating_duration_since(caller.started_at()))
1485 },
1486 );
1487 let control = Some(&analysis_control);
1488 check_control(control)?;
1489 let selected_binding = selected_project_binding(store)?;
1490 let generation = store.repository_graph_generation()?.ok_or_else(|| {
1491 ServiceError::InvalidInput(
1492 "repository graph has no complete generation for entrypoint analysis".to_string(),
1493 )
1494 })?;
1495 let mut reachable = BTreeMap::<String, DetailedRelationNode>::new();
1496 let mut edges = Vec::new();
1497 let mut relation_work = DetailedRelationWork::default();
1498 let mut canonical_anchors = Vec::with_capacity(profile.anchors.len());
1499 let mut canonical_anchor_keys = BTreeSet::new();
1500 let mut resolved_anchor_keys = BTreeMap::<String, String>::new();
1501 let mut resolved_anchor_entities = BTreeMap::<String, GraphEntity>::new();
1502 let mut first_resolved_anchor = None;
1503 let mut complete = true;
1504 let mut reached_limits = Vec::new();
1505 'anchors: for anchor in &profile.anchors {
1506 let anchor_budget = match entrypoint_anchor_budget(budget, &relation_work)? {
1507 Ok(anchor_budget) => anchor_budget,
1508 Err(limit) => {
1509 complete = false;
1510 push_limit(&mut reached_limits, limit);
1511 break 'anchors;
1512 }
1513 };
1514 let (entity, anchor_work) = match resolve_relation_anchor_for_analysis(
1515 store,
1516 selected_binding.project_instance_id,
1517 generation,
1518 anchor,
1519 anchor_budget,
1520 control,
1521 ) {
1522 Ok(result) => result,
1523 Err(ServiceError::Db(DbError::GraphContract(
1524 projectatlas_core::graph::GraphContractError::InvalidLimits {
1525 reason: "graph read decoded bytes exceed the batch budget",
1526 },
1527 ))) => {
1528 complete = false;
1529 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
1530 break 'anchors;
1531 }
1532 Err(error) => return Err(error),
1533 };
1534 add_relation_work(&mut relation_work, &anchor_work)?;
1535 first_resolved_anchor.get_or_insert_with(|| entity.clone());
1536 let entity_key = entity.key().canonical_identity().to_string();
1537 if !canonical_anchor_keys.insert(entity_key.clone()) {
1538 return Err(ServiceError::InvalidInput(
1539 "entrypoint profile anchors must resolve to unique entities".to_string(),
1540 ));
1541 }
1542 let canonical_anchor = relation_anchor_for_entity(&entity).ok_or_else(|| {
1543 ServiceError::InvalidInput(
1544 "entrypoint profile anchor is not addressable by an exact anchor".to_string(),
1545 )
1546 })?;
1547 let anchor_identity = serde_json::to_string(&canonical_anchor)
1548 .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
1549 resolved_anchor_keys.insert(anchor_identity.clone(), entity_key);
1550 resolved_anchor_entities.insert(anchor_identity, entity);
1551 canonical_anchors.push(canonical_anchor);
1552 }
1553 let mut first_anchor = None;
1554 let mut authored_purpose_revision = 0;
1555 let mut purpose_revision_initialized = false;
1556 let entity_selected = |node: &DetailedRelationNode| {
1557 !matches!(node.entity.selector(), EntitySelector::External { .. })
1558 && (query.relations.content_selection == ContentSelection::UnspecifiedLegacy
1559 || node.classification.is_some_and(|classification| {
1560 query.relations.content_selection.includes(classification)
1561 }))
1562 };
1563
1564 let mut frontier = if complete {
1565 canonical_anchors
1566 } else {
1567 Vec::new()
1568 };
1569 let mut scheduled_anchors = frontier
1570 .iter()
1571 .map(serde_json::to_string)
1572 .collect::<Result<BTreeSet<_>, _>>()
1573 .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
1574 let mut visited = BTreeSet::new();
1575 let mut depth = 0_u32;
1576 'profile: while !frontier.is_empty() && depth < budget.depth() {
1577 let mut next_frontier = Vec::new();
1578 for anchor in frontier.drain(..) {
1579 for relation in &profile.relations {
1580 check_control(control)?;
1581 let anchor_identity = serde_json::to_string(&anchor)
1582 .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
1583 let anchor_is_retained = resolved_anchor_keys
1584 .get(&anchor_identity)
1585 .is_some_and(|key| reachable.contains_key(key));
1586 let retained_keys =
1587 anchor_is_retained.then(|| reachable.keys().cloned().collect::<BTreeSet<_>>());
1588 let collect_occurrences = query.relations.include_occurrences
1589 && relation_work.retained_occurrences < budget.occurrences_total();
1590 let step_budget = match entrypoint_step_budget(
1591 budget,
1592 &relation_work,
1593 reachable.len(),
1594 0,
1595 anchor_is_retained,
1596 false,
1597 collect_occurrences,
1598 None,
1599 )? {
1600 Ok(step_budget) => step_budget,
1601 Err(limit) => {
1602 if limit == GraphLimitKind::Edges
1603 && let Some(anchor_key) = resolved_anchor_keys.get(&anchor_identity)
1604 && let Some(entity) = resolved_anchor_entities
1605 .get(&anchor_identity)
1606 .or_else(|| reachable.get(anchor_key).map(|node| &node.entity))
1607 && entrypoint_terminal_adjacency_is_empty(
1608 store,
1609 generation,
1610 entity.key(),
1611 *relation,
1612 query.relations.minimum_confidence,
1613 query.relations.content_selection,
1614 control,
1615 )?
1616 {
1617 if reachable.contains_key(anchor_key) {
1618 continue;
1619 }
1620 if purpose_revision_initialized
1621 && store.authored_purpose_revision()? != authored_purpose_revision
1622 {
1623 return Err(ServiceError::RelationCursorStale {
1624 field: "entrypoint authored purpose revision",
1625 });
1626 }
1627 let anchor_node = match load_entrypoint_terminal_candidate_coverage(
1628 store,
1629 entity,
1630 generation,
1631 budget,
1632 &mut relation_work,
1633 query.relations.content_selection,
1634 control,
1635 )? {
1636 Ok(node) => node,
1637 Err(limit) => {
1638 complete = false;
1639 push_limit(&mut reached_limits, limit);
1640 break 'profile;
1641 }
1642 };
1643 if !entity_selected(&anchor_node) {
1644 return Err(ServiceError::InvalidInput(
1645 "entrypoint anchor is outside the selected content".to_string(),
1646 ));
1647 }
1648 if purpose_revision_initialized
1649 && store.authored_purpose_revision()? != authored_purpose_revision
1650 {
1651 return Err(ServiceError::RelationCursorStale {
1652 field: "entrypoint authored purpose revision",
1653 });
1654 }
1655 if !trusted_node_coverage(&anchor_node, &profile.relations) {
1656 complete = false;
1657 break 'profile;
1658 }
1659 if reachable.len() >= budget.nodes() as usize {
1660 complete = false;
1661 push_limit(&mut reached_limits, GraphLimitKind::Nodes);
1662 }
1663 if visited.len() >= budget.visited() as usize {
1664 complete = false;
1665 push_limit(&mut reached_limits, GraphLimitKind::Visited);
1666 }
1667 if !complete {
1668 break 'profile;
1669 }
1670 let entity_key = entity.key().canonical_identity().to_string();
1671 visited.insert(entity_key);
1672 insert_node(&mut reachable, &anchor_node);
1673 first_anchor.get_or_insert(anchor_node);
1674 continue;
1675 }
1676 complete = false;
1677 push_limit(&mut reached_limits, limit);
1678 break 'profile;
1679 }
1680 };
1681 let mut relation_query = query.relations.clone();
1682 relation_query.anchor = anchor.clone();
1683 relation_query.relation = Some(*relation);
1684 relation_query.direction = RelationDirection::Outbound;
1685 relation_query.resolution = RelationResolutionFilter::Any;
1686 relation_query.cursor = None;
1687 relation_query.budget = step_budget;
1688 relation_query.include_occurrences = collect_occurrences;
1689 #[cfg(test)]
1690 analysis_test_observer::notify(
1691 analysis_test_observer::AnalysisPhaseEvent::Traversal,
1692 );
1693 let report = match load_detailed_relations(store, &relation_query, control) {
1694 Ok(report) => report,
1695 Err(ServiceError::Db(DbError::GraphContract(
1696 projectatlas_core::graph::GraphContractError::InvalidLimits {
1697 reason: "graph read decoded bytes exceed the batch budget",
1698 },
1699 ))) => {
1700 complete = false;
1701 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
1702 break 'profile;
1703 }
1704 Err(error) => return Err(error),
1705 };
1706 if report.generation != generation {
1707 return Err(ServiceError::RelationCursorStale {
1708 field: "entrypoint graph generation",
1709 });
1710 }
1711 first_anchor.get_or_insert_with(|| report.anchor.clone());
1712 resolved_anchor_keys.insert(
1713 anchor_identity,
1714 report.anchor.entity.key().canonical_identity().to_string(),
1715 );
1716 if purpose_revision_initialized
1717 && report.authored_purpose_revision != authored_purpose_revision
1718 {
1719 return Err(ServiceError::RelationCursorStale {
1720 field: "entrypoint authored purpose revision",
1721 });
1722 }
1723 authored_purpose_revision = report.authored_purpose_revision;
1724 purpose_revision_initialized = true;
1725 add_relation_work(&mut relation_work, &report.work)?;
1726 if report.pruned_incomplete_paths > 0 {
1727 complete = false;
1728 }
1729 if report.pruned_evidence_truncated {
1730 complete = false;
1731 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
1732 }
1733 let filtered_edge_limit_is_terminal = entrypoint_filtered_edge_limit_is_terminal(
1734 store,
1735 generation,
1736 &report,
1737 *relation,
1738 query.relations.minimum_confidence,
1739 query.relations.content_selection,
1740 control,
1741 )?;
1742 if query.relations.include_occurrences
1743 && (!collect_occurrences || !report.pruned_relations.is_empty())
1744 {
1745 match entrypoint_occurrence_evidence_is_incomplete(
1746 store,
1747 &report,
1748 generation,
1749 budget,
1750 &mut relation_work,
1751 control,
1752 !collect_occurrences,
1753 )? {
1754 Ok(true) => {
1755 complete = false;
1756 push_limit(&mut reached_limits, GraphLimitKind::Occurrences);
1757 }
1758 Err(limit) => {
1759 complete = false;
1760 push_limit(&mut reached_limits, limit);
1761 }
1762 Ok(false) => {}
1763 }
1764 }
1765 for limit in &report.reached_limits {
1766 if !filtered_edge_limit_is_terminal
1767 || (*limit != GraphLimitKind::Edges && *limit != GraphLimitKind::Rows)
1768 {
1769 push_limit(&mut reached_limits, *limit);
1770 }
1771 }
1772 if relation_work.inspected_edges > budget.edges() {
1773 complete = false;
1774 push_limit(&mut reached_limits, GraphLimitKind::Edges);
1775 break 'profile;
1776 }
1777 if relation_work.intermediate_bytes > budget.intermediate_bytes() {
1778 complete = false;
1779 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
1780 break 'profile;
1781 }
1782 complete &= trusted_node_coverage(&report.anchor, &profile.relations);
1783 let anchor_key = report.anchor.entity.key().canonical_identity().to_string();
1784 visited.insert(anchor_key);
1785 insert_node(&mut reachable, &report.anchor);
1786 for row in &report.rows {
1787 check_control(control)?;
1788 let resolved = matches!(
1789 row.relation.resolution(),
1790 RelationResolution::Resolved { .. } | RelationResolution::External { .. }
1791 ) && row.relation.completeness() == Completeness::Complete;
1792 complete &= resolved && trusted_relation_row(row, &profile.relations);
1793 insert_node(&mut reachable, &row.source);
1794 if let Some(target) = &row.target
1795 && entity_selected(target)
1796 {
1797 insert_node(&mut reachable, target);
1798 if resolved {
1799 let target_key = target.entity.key().canonical_identity().to_string();
1800 if visited.insert(target_key.clone()) {
1801 match relation_anchor_for_entity(&target.entity) {
1802 Some(target_anchor) => {
1803 if let Ok(anchor_key) =
1804 serde_json::to_string(&target_anchor)
1805 {
1806 let is_new =
1807 scheduled_anchors.insert(anchor_key.clone());
1808 resolved_anchor_keys
1809 .entry(anchor_key)
1810 .or_insert_with(|| target_key.clone());
1811 if is_new {
1812 next_frontier.push(target_anchor);
1813 }
1814 }
1815 }
1816 None if !matches!(
1817 target.entity.selector(),
1818 EntitySelector::External { .. }
1819 ) =>
1820 {
1821 complete = false;
1822 }
1823 None => {}
1824 }
1825 }
1826 }
1827 }
1828 for node in &row.path {
1829 if entity_selected(node) {
1830 insert_node(&mut reachable, node);
1831 }
1832 }
1833 if let Some(edge) =
1834 local_edge(&row.relation, &row.source.entity, row.target.as_ref())
1835 {
1836 let edge_bytes = u64::try_from(edge.source.len())
1837 .unwrap_or(u64::MAX)
1838 .checked_add(u64::try_from(edge.target.len()).unwrap_or(u64::MAX))
1839 .and_then(|bytes| {
1840 bytes.checked_add(std::mem::size_of::<LocalEdge>() as u64)
1841 })
1842 .ok_or_else(entrypoint_work_overflow)?;
1843 let retained_edge_bytes = relation_work
1844 .intermediate_bytes
1845 .checked_add(edge_bytes)
1846 .ok_or_else(entrypoint_work_overflow)?;
1847 if retained_edge_bytes > budget.intermediate_bytes() {
1848 complete = false;
1849 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
1850 break 'profile;
1851 }
1852 relation_work.intermediate_bytes = retained_edge_bytes;
1853 edges.push(edge);
1854 }
1855 }
1856 if reachable.len() > budget.nodes() as usize {
1857 if let Some(retained_keys) = retained_keys {
1858 reachable.retain(|key, _| retained_keys.contains(key));
1859 }
1860 complete = false;
1861 push_limit(&mut reached_limits, GraphLimitKind::Nodes);
1862 break 'profile;
1863 }
1864 if report.continuation.is_some() {
1865 if !filtered_edge_limit_is_terminal {
1866 complete = false;
1867 push_limit(&mut reached_limits, GraphLimitKind::Rows);
1868 }
1869 } else {
1870 complete &= filtered_edge_limit_is_terminal
1871 || (!report.truncated
1872 && report.reached_limits.is_empty()
1873 && matches!(report.total, RelationTotalState::Exact(_)));
1874 }
1875 }
1876 }
1877 frontier = next_frontier;
1878 depth = depth.saturating_add(1);
1879 }
1880 if !frontier.is_empty() {
1881 complete = false;
1882 push_limit(&mut reached_limits, GraphLimitKind::Depth);
1883 }
1884 edges.sort_by(|left, right| {
1885 (&left.source, &left.target, left.kind.as_str()).cmp(&(
1886 &right.source,
1887 &right.target,
1888 right.kind.as_str(),
1889 ))
1890 });
1891 edges.dedup_by(|left, right| {
1892 left.source == right.source && left.target == right.target && left.kind == right.kind
1893 });
1894 if first_anchor.is_none()
1895 && first_resolved_anchor.is_none()
1896 && reached_limits.contains(&GraphLimitKind::IntermediateBytes)
1897 {
1898 return Err(ServiceError::ResourceLimit {
1899 limit: GraphLimitKind::IntermediateBytes,
1900 });
1901 }
1902 let anchor = first_anchor
1903 .or_else(|| {
1904 first_resolved_anchor.map(|entity| {
1905 entrypoint_unavailable_node(&entity, query.relations.content_selection)
1906 })
1907 })
1908 .ok_or_else(|| {
1909 ServiceError::InvalidInput("entrypoint profile resolved no anchors".to_string())
1910 })?;
1911 let entity_limit = budget.nodes();
1912 let reachable_keys = reachable.keys().cloned().collect::<BTreeSet<_>>();
1913 let mut protected_reachable_keys = reachable_keys.clone();
1914 for node in reachable.values() {
1915 if let EntitySelector::Symbol { symbol } = node.entity.selector() {
1916 let file_selector = EntitySelector::File {
1917 path: symbol.file.clone(),
1918 };
1919 protected_reachable_keys.insert(
1920 GraphEntityKey::new(node.entity.key().project(), &file_selector)
1921 .canonical_identity()
1922 .to_string(),
1923 );
1924 }
1925 }
1926 let mut protected_enclosure_entities = Vec::new();
1927 if complete {
1928 let owner_paths = reachable
1929 .values()
1930 .filter_map(|node| match node.entity.selector() {
1931 EntitySelector::Symbol { symbol } => Some(symbol.file.as_str().to_string()),
1932 _ => None,
1933 })
1934 .collect::<BTreeSet<_>>();
1935 for path in owner_paths {
1936 check_control(control)?;
1937 let path = RepositoryNodePath::new(std::path::Path::new(&path))
1938 .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
1939 let remaining_intermediate = budget
1940 .intermediate_bytes()
1941 .saturating_sub(relation_work.intermediate_bytes);
1942 if remaining_intermediate == 0 {
1943 complete = false;
1944 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
1945 break;
1946 }
1947 let read_budget = RepositoryGraphReadBudget::new(
1948 1,
1949 RepositoryGraphReadBudget::MAX_RETURNED_ROWS,
1950 remaining_intermediate.min(RepositoryGraphReadBudget::MAX_DECODED_BYTES),
1951 RepositoryGraphReadBudget::MAX_HYDRATED_ENTITIES,
1952 RepositoryGraphReadBudget::MAX_HYDRATED_PATHS,
1953 )
1954 .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
1955 let owner_page = match store.repository_graph_entities_by_path_bounded(
1956 generation_project(&anchor.entity),
1957 generation,
1958 &path,
1959 RepositoryGraphReadBudget::MAX_RETURNED_ROWS,
1960 read_budget,
1961 control,
1962 ) {
1963 Ok(owner_page) => owner_page,
1964 Err(DbError::GraphContract(
1965 projectatlas_core::graph::GraphContractError::InvalidLimits {
1966 reason: "graph read decoded bytes exceed the batch budget",
1967 },
1968 )) => {
1969 complete = false;
1970 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
1971 break;
1972 }
1973 Err(error) => return Err(error.into()),
1974 };
1975 add_repository_read_work(&mut relation_work, &owner_page.work)?;
1976 if owner_page.page.truncated {
1977 complete = false;
1978 push_limit(&mut reached_limits, GraphLimitKind::Nodes);
1979 break;
1980 }
1981 protected_enclosure_entities.extend(owner_page.page.rows);
1982 }
1983 }
1984 protect_reachable_symbol_enclosures(
1985 &reachable,
1986 &protected_enclosure_entities,
1987 &mut protected_reachable_keys,
1988 );
1989 let mut retained_candidate_keys = reachable_keys;
1990 let mut unreachable = BTreeMap::new();
1991 let remaining_intermediate = budget
1992 .intermediate_bytes()
1993 .saturating_sub(relation_work.intermediate_bytes);
1994 if remaining_intermediate == 0 {
1995 complete = false;
1996 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
1997 }
1998 if complete {
1999 let protected_entity_overhead = match u32::try_from(protected_reachable_keys.len()) {
2000 Ok(overhead) => overhead,
2001 Err(_overflow) => {
2002 complete = false;
2003 push_limit(&mut reached_limits, GraphLimitKind::Nodes);
2004 0
2005 }
2006 };
2007 let candidate_page_limit = match entity_limit.checked_add(protected_entity_overhead) {
2008 Some(limit) if limit <= GraphLimits::MAX_ROWS => limit,
2009 _ => {
2010 complete = false;
2011 push_limit(&mut reached_limits, GraphLimitKind::Nodes);
2012 0
2013 }
2014 };
2015 let all_entities = if candidate_page_limit == 0 {
2016 None
2017 } else {
2018 #[cfg(test)]
2019 analysis_test_observer::notify(
2020 analysis_test_observer::AnalysisPhaseEvent::CandidateEntityHydration {
2021 remaining_intermediate_bytes: remaining_intermediate,
2022 },
2023 );
2024 let read_budget = RepositoryGraphReadBudget::new(
2025 1,
2026 candidate_page_limit,
2027 remaining_intermediate.min(RepositoryGraphReadBudget::MAX_DECODED_BYTES),
2028 candidate_page_limit.saturating_add(1).saturating_mul(2),
2029 candidate_page_limit.saturating_add(1).saturating_mul(2),
2030 )
2031 .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
2032 match store.repository_graph_entrypoint_candidates_page_bounded(
2033 generation_project(&anchor.entity),
2034 generation,
2035 candidate_page_limit,
2036 query.relations.content_selection,
2037 read_budget,
2038 control,
2039 ) {
2040 Ok(all_entities) => {
2041 add_repository_read_work(&mut relation_work, &all_entities.work)?;
2042 Some(all_entities.page)
2043 }
2044 Err(DbError::GraphContract(
2045 projectatlas_core::graph::GraphContractError::InvalidLimits {
2046 reason: "graph read decoded bytes exceed the batch budget",
2047 },
2048 )) => {
2049 complete = false;
2050 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
2051 None
2052 }
2053 Err(error) => return Err(error.into()),
2054 }
2055 };
2056 if let Some(all_entities) = all_entities.as_ref() {
2057 protect_reachable_symbol_enclosures(
2058 &reachable,
2059 &all_entities.rows,
2060 &mut protected_reachable_keys,
2061 );
2062 }
2063 let candidate_entities = all_entities
2064 .as_ref()
2065 .map(|all_entities| {
2066 if all_entities.truncated {
2067 complete = false;
2068 push_limit(&mut reached_limits, GraphLimitKind::Nodes);
2069 }
2070 all_entities
2071 .rows
2072 .iter()
2073 .filter(|entity| {
2074 matches!(
2075 entity.selector(),
2076 EntitySelector::File { .. } | EntitySelector::Symbol { .. }
2077 ) && !protected_reachable_keys.contains(entity.key().canonical_identity())
2078 })
2079 .collect::<Vec<_>>()
2080 })
2081 .unwrap_or_default();
2082 #[cfg(test)]
2083 analysis_test_observer::notify(
2084 analysis_test_observer::AnalysisPhaseEvent::CandidateEnumeration,
2085 );
2086 if complete {
2087 let current_generation = store.repository_graph_generation()?.ok_or_else(|| {
2088 ServiceError::InvalidInput(
2089 "repository graph has no complete generation for entrypoint analysis"
2090 .to_string(),
2091 )
2092 })?;
2093 if current_generation != generation {
2094 return Err(ServiceError::RelationCursorStale {
2095 field: "entrypoint graph generation",
2096 });
2097 }
2098 for entity in candidate_entities {
2099 check_control(control)?;
2100 if !complete {
2101 break;
2102 }
2103 let candidate_key = entity.key().canonical_identity().to_string();
2104 let candidate_anchor = relation_anchor_for_entity(entity).ok_or_else(|| {
2105 ServiceError::InvalidInput(
2106 "entrypoint candidate is not addressable by an exact anchor".to_string(),
2107 )
2108 })?;
2109 let mut candidate_report_anchor = None;
2110 let mut candidate_unretained_keys = BTreeSet::new();
2111 for relation in &profile.relations {
2112 let accounted_candidates = retained_candidate_keys
2113 .len()
2114 .saturating_sub(reachable.len());
2115 let anchor_is_retained = retained_candidate_keys.contains(&candidate_key);
2116 let collect_occurrences = query.relations.include_occurrences
2117 && relation_work.retained_occurrences < budget.occurrences_total();
2118 let step_budget = match entrypoint_step_budget(
2119 budget,
2120 &relation_work,
2121 reachable.len(),
2122 accounted_candidates,
2123 anchor_is_retained,
2124 true,
2125 collect_occurrences,
2126 None,
2127 )? {
2128 Ok(step_budget) => step_budget,
2129 Err(GraphLimitKind::Edges)
2130 if entrypoint_terminal_adjacency_is_empty(
2131 store,
2132 generation,
2133 entity.key(),
2134 *relation,
2135 query.relations.minimum_confidence,
2136 query.relations.content_selection,
2137 control,
2138 )? =>
2139 {
2140 match entrypoint_step_budget(
2141 budget,
2142 &relation_work,
2143 reachable.len(),
2144 accounted_candidates,
2145 anchor_is_retained,
2146 true,
2147 collect_occurrences,
2148 Some(1),
2149 )? {
2150 Ok(_) => {}
2151 Err(limit) => {
2152 complete = false;
2153 push_limit(&mut reached_limits, limit);
2154 break;
2155 }
2156 }
2157 let candidate_node = if let Some(node) = candidate_report_anchor.clone()
2158 {
2159 node
2160 } else {
2161 let node = match load_entrypoint_terminal_candidate_coverage(
2162 store,
2163 entity,
2164 generation,
2165 budget,
2166 &mut relation_work,
2167 query.relations.content_selection,
2168 control,
2169 )? {
2170 Ok(node) => node,
2171 Err(limit) => {
2172 complete = false;
2173 push_limit(&mut reached_limits, limit);
2174 break;
2175 }
2176 };
2177 candidate_report_anchor = Some(node.clone());
2178 node
2179 };
2180 if !trusted_node_coverage(&candidate_node, &profile.relations) {
2181 complete = false;
2182 break;
2183 }
2184 if !anchor_is_retained {
2185 candidate_unretained_keys.insert(candidate_key.clone());
2186 }
2187 let remaining_candidate_capacity =
2188 u32::try_from(retained_candidate_keys.len()).unwrap_or(u32::MAX);
2189 let remaining_nodes = usize::try_from(
2190 budget.nodes().saturating_sub(remaining_candidate_capacity),
2191 )
2192 .unwrap_or(usize::MAX);
2193 let remaining_visited = usize::try_from(
2194 budget
2195 .visited()
2196 .saturating_sub(remaining_candidate_capacity),
2197 )
2198 .unwrap_or(usize::MAX);
2199 if candidate_unretained_keys.len() > remaining_nodes {
2200 complete = false;
2201 push_limit(&mut reached_limits, GraphLimitKind::Nodes);
2202 }
2203 if candidate_unretained_keys.len() > remaining_visited {
2204 complete = false;
2205 push_limit(&mut reached_limits, GraphLimitKind::Visited);
2206 }
2207 if !complete {
2208 break;
2209 }
2210 candidate_report_anchor.get_or_insert(candidate_node);
2211 continue;
2212 }
2213 Err(limit) => {
2214 complete = false;
2215 push_limit(&mut reached_limits, limit);
2216 break;
2217 }
2218 };
2219 let mut candidate_query = query.relations.clone();
2220 candidate_query.anchor = candidate_anchor.clone();
2221 candidate_query.relation = Some(*relation);
2222 candidate_query.direction = RelationDirection::Outbound;
2223 candidate_query.resolution = RelationResolutionFilter::Any;
2224 candidate_query.cursor = None;
2225 candidate_query.budget = step_budget;
2226 candidate_query.include_occurrences = collect_occurrences;
2227 #[cfg(test)]
2228 analysis_test_observer::notify(
2229 analysis_test_observer::AnalysisPhaseEvent::CandidateTraversal,
2230 );
2231 let candidate_report =
2232 match load_detailed_relations(store, &candidate_query, control) {
2233 Ok(report) => report,
2234 Err(ServiceError::Db(DbError::GraphContract(
2235 projectatlas_core::graph::GraphContractError::InvalidLimits {
2236 reason: "graph read decoded bytes exceed the batch budget",
2237 },
2238 ))) => {
2239 complete = false;
2240 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
2241 break;
2242 }
2243 Err(error) => return Err(error),
2244 };
2245 #[cfg(test)]
2246 analysis_test_observer::notify(
2247 analysis_test_observer::AnalysisPhaseEvent::CandidateReport {
2248 has_continuation: candidate_report.continuation.is_some(),
2249 has_edges_limit: candidate_report
2250 .reached_limits
2251 .contains(&GraphLimitKind::Edges),
2252 },
2253 );
2254 if candidate_report.generation != generation {
2255 return Err(ServiceError::RelationCursorStale {
2256 field: "entrypoint graph generation",
2257 });
2258 }
2259 if candidate_report.authored_purpose_revision != authored_purpose_revision {
2260 return Err(ServiceError::RelationCursorStale {
2261 field: "entrypoint authored purpose revision",
2262 });
2263 }
2264 add_relation_work(&mut relation_work, &candidate_report.work)?;
2265 if candidate_report.pruned_evidence_truncated {
2266 complete = false;
2267 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
2268 }
2269 let filtered_edge_limit_is_terminal =
2270 entrypoint_filtered_edge_limit_is_terminal(
2271 store,
2272 generation,
2273 &candidate_report,
2274 *relation,
2275 query.relations.minimum_confidence,
2276 query.relations.content_selection,
2277 control,
2278 )?;
2279 if query.relations.include_occurrences
2280 && (!collect_occurrences || !candidate_report.pruned_relations.is_empty())
2281 {
2282 match entrypoint_occurrence_evidence_is_incomplete(
2283 store,
2284 &candidate_report,
2285 generation,
2286 budget,
2287 &mut relation_work,
2288 control,
2289 !collect_occurrences,
2290 )? {
2291 Ok(true) => {
2292 complete = false;
2293 push_limit(&mut reached_limits, GraphLimitKind::Occurrences);
2294 }
2295 Err(limit) => {
2296 complete = false;
2297 push_limit(&mut reached_limits, limit);
2298 }
2299 Ok(false) => {}
2300 }
2301 }
2302 if relation_work.inspected_edges > budget.edges() {
2303 complete = false;
2304 push_limit(&mut reached_limits, GraphLimitKind::Edges);
2305 break;
2306 }
2307 if relation_work.intermediate_bytes > budget.intermediate_bytes() {
2308 complete = false;
2309 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
2310 break;
2311 }
2312 candidate_unretained_keys.extend(candidate_report_unretained_local_keys(
2313 &candidate_report,
2314 &retained_candidate_keys,
2315 query.relations.content_selection,
2316 ));
2317 let remaining_candidate_capacity =
2318 u32::try_from(retained_candidate_keys.len()).unwrap_or(u32::MAX);
2319 let remaining_nodes = usize::try_from(
2320 budget.nodes().saturating_sub(remaining_candidate_capacity),
2321 )
2322 .unwrap_or(usize::MAX);
2323 let remaining_visited = usize::try_from(
2324 budget
2325 .visited()
2326 .saturating_sub(remaining_candidate_capacity),
2327 )
2328 .unwrap_or(usize::MAX);
2329 if candidate_unretained_keys.len() > remaining_nodes {
2330 complete = false;
2331 push_limit(&mut reached_limits, GraphLimitKind::Nodes);
2332 }
2333 if candidate_unretained_keys.len() > remaining_visited {
2334 complete = false;
2335 push_limit(&mut reached_limits, GraphLimitKind::Visited);
2336 }
2337 if !complete {
2338 break;
2339 }
2340 for limit in &candidate_report.reached_limits {
2341 if !filtered_edge_limit_is_terminal
2342 || (*limit != GraphLimitKind::Edges && *limit != GraphLimitKind::Rows)
2343 {
2344 push_limit(&mut reached_limits, *limit);
2345 }
2346 }
2347 if candidate_report.continuation.is_some() && !filtered_edge_limit_is_terminal {
2348 push_limit(&mut reached_limits, GraphLimitKind::Rows);
2349 }
2350 if !entrypoint_report_complete(
2351 &candidate_report,
2352 &profile.relations,
2353 filtered_edge_limit_is_terminal,
2354 ) {
2355 complete = false;
2356 break;
2357 }
2358 candidate_report_anchor.get_or_insert(candidate_report.anchor);
2359 }
2360 if complete && let Some(candidate_report_anchor) = candidate_report_anchor {
2361 unreachable.insert(candidate_key.clone(), candidate_report_anchor);
2362 for key in candidate_unretained_keys {
2363 retained_candidate_keys.insert(key);
2364 }
2365 }
2366 }
2367 }
2368 }
2369 if complete {
2370 validate_entrypoint_generation(generation, store.repository_graph_generation())?;
2371 if purpose_revision_initialized
2372 && store.authored_purpose_revision()? != authored_purpose_revision
2373 {
2374 return Err(ServiceError::RelationCursorStale {
2375 field: "entrypoint authored purpose revision",
2376 });
2377 }
2378 }
2379 let coverage = if complete {
2380 EntrypointProfileCoverage::Complete
2381 } else {
2382 EntrypointProfileCoverage::Partial
2383 };
2384 let mut findings = Vec::new();
2385 if complete {
2386 findings.push(AnalysisFinding {
2387 kind: AnalysisFindingKind::EntrypointReachability,
2388 status: AnalysisStatus::Confirmed,
2389 summary: "explicit entrypoints reach these local entities through complete admitted relations"
2390 .to_string(),
2391 nodes: Vec::new(),
2392 metric: Some(reachable.len() as u64),
2393 evidence: None,
2394 community: None,
2395 });
2396 if !unreachable.is_empty() {
2397 findings.push(AnalysisFinding {
2398 kind: AnalysisFindingKind::EntrypointReachability,
2399 status: AnalysisStatus::Candidate,
2400 summary: "local entities are unreachable from the explicit entrypoints; review exact source evidence before any deletion decision"
2401 .to_string(),
2402 nodes: Vec::new(),
2403 metric: Some(unreachable.len() as u64),
2404 evidence: None,
2405 community: None,
2406 });
2407 }
2408 } else {
2409 findings.push(AnalysisFinding {
2410 kind: AnalysisFindingKind::EntrypointReachability,
2411 status: AnalysisStatus::Inconclusive,
2412 summary: "entrypoint reachability is inconclusive because relation coverage or a declared bound is partial"
2413 .to_string(),
2414 nodes: Vec::new(),
2415 metric: None,
2416 evidence: None,
2417 community: None,
2418 });
2419 }
2420 let mut profile_result = EntrypointProfileResult {
2421 name: profile.name.clone(),
2422 anchors: profile.anchors.clone(),
2423 relations: profile.relations.clone(),
2424 coverage,
2425 reachable: u32::try_from(reachable.len()).unwrap_or(u32::MAX),
2426 unreachable_candidates: if complete {
2427 u32::try_from(unreachable.len()).unwrap_or(u32::MAX)
2428 } else {
2429 0
2430 },
2431 };
2432 let mut work = RelationAnalysisWork {
2433 relations: relation_work,
2434 analyzed_nodes: u32::try_from(reachable.len().saturating_add(unreachable.len()))
2435 .unwrap_or(u32::MAX),
2436 analyzed_edges: u32::try_from(edges.len()).unwrap_or(u32::MAX),
2437 ..RelationAnalysisWork::default()
2438 };
2439 let metadata_composition_bytes =
2440 serialized_bytes_controlled(&(&findings, &profile_result), control)?;
2441 let mut node_composition_bytes = serialized_analysis_nodes_bytes(reachable.values(), control)?;
2442 if complete && !unreachable.is_empty() {
2443 node_composition_bytes = node_composition_bytes
2444 .checked_add(serialized_analysis_nodes_bytes(
2445 unreachable.values(),
2446 control,
2447 )?)
2448 .ok_or_else(entrypoint_work_overflow)?;
2449 }
2450 let composition_fits = work
2451 .relations
2452 .intermediate_bytes
2453 .checked_add(metadata_composition_bytes)
2454 .and_then(|bytes| bytes.checked_add(node_composition_bytes))
2455 .is_some_and(|bytes| bytes <= budget.intermediate_bytes());
2456 if composition_fits {
2457 if complete {
2458 findings[0].nodes = reachable.values().map(analysis_node).collect();
2459 if !unreachable.is_empty() {
2460 findings[1].nodes = unreachable.values().map(analysis_node).collect();
2461 }
2462 profile_result.unreachable_candidates =
2463 u32::try_from(unreachable.len()).unwrap_or(u32::MAX);
2464 } else {
2465 findings[0].nodes = reachable.values().map(analysis_node).collect();
2466 }
2467 } else {
2468 complete = false;
2469 work.composition_truncated = true;
2470 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
2471 profile_result.coverage = EntrypointProfileCoverage::Partial;
2472 profile_result.reachable = 0;
2473 profile_result.unreachable_candidates = 0;
2474 findings.truncate(1);
2475 findings[0].status = AnalysisStatus::Inconclusive;
2476 findings[0].summary =
2477 "entrypoint evidence was omitted because the declared composition bound was partial"
2478 .to_string();
2479 findings[0].nodes.clear();
2480 findings[0].metric = None;
2481 }
2482 work.retained_composition_bytes =
2483 serialized_bytes_controlled(&(&findings, &profile_result), control)?;
2484 work.peak_intermediate_bytes = work
2485 .relations
2486 .intermediate_bytes
2487 .checked_add(work.retained_composition_bytes)
2488 .ok_or_else(entrypoint_work_overflow)?;
2489 if work.peak_intermediate_bytes > budget.intermediate_bytes() {
2490 return Err(ServiceError::InvalidInput(
2491 "entrypoint profile construction exceeds its aggregate intermediate-byte budget"
2492 .to_string(),
2493 ));
2494 }
2495 if !complete {
2496 profile_result.coverage = EntrypointProfileCoverage::Partial;
2497 for finding in &mut findings {
2498 finding.status = AnalysisStatus::Inconclusive;
2499 }
2500 }
2501 let report = RelationAnalysisReport {
2502 mode: RelationAnalysisMode::Entrypoint,
2503 anchor,
2504 generation,
2505 authored_purpose_revision,
2506 continuation: None,
2507 returned: u32::try_from(findings.len()).unwrap_or(u32::MAX),
2508 total: if complete {
2509 RelationTotalState::Exact(findings.len() as u64)
2510 } else {
2511 RelationTotalState::AtLeast(findings.len() as u64)
2512 },
2513 truncated: !complete,
2514 reached_limits,
2515 vcs: VcsImpact::NotRequested,
2516 entrypoint_profile: Some(profile_result),
2517 work,
2518 findings,
2519 };
2520 let cursor_snapshot = AnalysisCursorSnapshot {
2521 project: generation_project(&report.anchor.entity),
2522 generation,
2523 authored_purpose_revision,
2524 };
2525 let cursor_binding = analysis_cursor_binding(query, &selected_binding.project_root_identity)?;
2526 Ok(RelationAnalysisDraft {
2527 report,
2528 output_bytes: budget.output_bytes(),
2529 budget,
2530 cursor_binding,
2531 cursor_snapshot,
2532 replay_relation_cursor: None,
2533 finding_offset: 0,
2534 vcs_digest: None,
2535 external_relation_identities: BTreeSet::new(),
2536 control: analysis_control,
2537 })
2538}
2539
2540fn validate_entrypoint_profile_budget(
2542 profile: &EntrypointProfile,
2543 budget: DetailedRelationBudget,
2544) -> ServiceResult<()> {
2545 let anchor_count = u32::try_from(profile.anchors.len()).unwrap_or(u32::MAX);
2546 if anchor_count > budget.nodes() {
2547 return Err(ServiceError::InvalidInput(
2548 "entrypoint profile anchor count exceeds the node budget".to_string(),
2549 ));
2550 }
2551 if anchor_count > budget.visited() {
2552 return Err(ServiceError::InvalidInput(
2553 "entrypoint profile anchor count exceeds the visited budget".to_string(),
2554 ));
2555 }
2556 Ok(())
2557}
2558
2559fn add_relation_work(
2561 total: &mut DetailedRelationWork,
2562 next: &DetailedRelationWork,
2563) -> ServiceResult<()> {
2564 total.returned_rows = total
2565 .returned_rows
2566 .checked_add(next.returned_rows)
2567 .ok_or_else(entrypoint_work_overflow)?;
2568 total.inspected_edges = total
2569 .inspected_edges
2570 .checked_add(next.inspected_edges)
2571 .ok_or_else(entrypoint_work_overflow)?;
2572 total.active_nodes = total.active_nodes.max(next.active_nodes);
2573 total.visited_nodes = total.visited_nodes.max(next.visited_nodes);
2574 total.retained_occurrences = total
2575 .retained_occurrences
2576 .checked_add(next.retained_occurrences)
2577 .ok_or_else(entrypoint_work_overflow)?;
2578 total.database_requested_rows = total
2579 .database_requested_rows
2580 .checked_add(next.database_requested_rows)
2581 .ok_or_else(entrypoint_work_overflow)?;
2582 total.database_returned_rows = total
2583 .database_returned_rows
2584 .checked_add(next.database_returned_rows)
2585 .ok_or_else(entrypoint_work_overflow)?;
2586 total.database_decoded_bytes = total
2587 .database_decoded_bytes
2588 .checked_add(next.database_decoded_bytes)
2589 .ok_or_else(entrypoint_work_overflow)?;
2590 total.hydrated_entities = total
2591 .hydrated_entities
2592 .checked_add(next.hydrated_entities)
2593 .ok_or_else(entrypoint_work_overflow)?;
2594 total.hydrated_purpose_paths = total
2595 .hydrated_purpose_paths
2596 .checked_add(next.hydrated_purpose_paths)
2597 .ok_or_else(entrypoint_work_overflow)?;
2598 total.hydrated_classification_paths = total
2599 .hydrated_classification_paths
2600 .checked_add(next.hydrated_classification_paths)
2601 .ok_or_else(entrypoint_work_overflow)?;
2602 total.retained_composition_bytes = total
2603 .retained_composition_bytes
2604 .saturating_add(next.retained_composition_bytes);
2605 total.intermediate_bytes = total
2606 .intermediate_bytes
2607 .saturating_add(next.intermediate_bytes);
2608 total.rendered_output_bytes = total
2609 .rendered_output_bytes
2610 .saturating_add(next.rendered_output_bytes);
2611 Ok(())
2612}
2613
2614fn add_repository_read_work(
2616 total: &mut DetailedRelationWork,
2617 next: &RepositoryGraphReadWork,
2618) -> ServiceResult<()> {
2619 total.database_requested_rows = total
2620 .database_requested_rows
2621 .checked_add(next.requested_rows)
2622 .ok_or_else(entrypoint_work_overflow)?;
2623 total.database_returned_rows = total
2624 .database_returned_rows
2625 .checked_add(next.returned_rows)
2626 .ok_or_else(entrypoint_work_overflow)?;
2627 total.database_decoded_bytes = total
2628 .database_decoded_bytes
2629 .checked_add(next.decoded_bytes)
2630 .ok_or_else(entrypoint_work_overflow)?;
2631 total.hydrated_entities = total
2632 .hydrated_entities
2633 .checked_add(next.hydrated_entities)
2634 .ok_or_else(entrypoint_work_overflow)?;
2635 total.hydrated_purpose_paths = total
2636 .hydrated_purpose_paths
2637 .checked_add(next.hydrated_paths)
2638 .ok_or_else(entrypoint_work_overflow)?;
2639 total.intermediate_bytes = total.intermediate_bytes.saturating_add(next.decoded_bytes);
2640 Ok(())
2641}
2642
2643#[cfg(test)]
2645fn load_entrypoint_candidate_classifications<'entity>(
2646 store: &AtlasStore,
2647 entities: impl IntoIterator<Item = &'entity GraphEntity>,
2648 budget: DetailedRelationBudget,
2649 relation_work: &mut DetailedRelationWork,
2650 control: Option<&IndexWorkControl>,
2651) -> ServiceResult<Option<BTreeMap<String, projectatlas_core::language::ContentClassification>>> {
2652 let mut path_set = BTreeSet::new();
2653 for entity in entities {
2654 check_control(control)?;
2655 let Some(path) = classification_path(entity) else {
2656 continue;
2657 };
2658 if path_set.contains(&path) {
2659 continue;
2660 }
2661 let path_bytes = classification_path_bytes(&path)?;
2662 if relation_work
2663 .intermediate_bytes
2664 .checked_add(path_bytes)
2665 .is_none_or(|bytes| bytes > budget.intermediate_bytes())
2666 {
2667 return Ok(None);
2668 }
2669 path_set.insert(path);
2670 relation_work.intermediate_bytes = relation_work
2671 .intermediate_bytes
2672 .checked_add(path_bytes)
2673 .ok_or_else(entrypoint_work_overflow)?;
2674 }
2675 let paths = path_set.into_iter().collect::<Vec<_>>();
2676 let mut classifications = BTreeMap::new();
2677 for chunk in paths.chunks(MAX_FILE_CONTENT_CLASSIFICATION_PATHS) {
2678 check_control(control)?;
2679 #[cfg(test)]
2680 analysis_test_observer::notify(
2681 analysis_test_observer::AnalysisPhaseEvent::ClassificationHydration,
2682 );
2683 let row_upper_bound = classification_rows_upper_bound(chunk, control)?;
2684 if relation_work
2685 .intermediate_bytes
2686 .checked_add(row_upper_bound)
2687 .is_none_or(|bytes| bytes > budget.intermediate_bytes())
2688 {
2689 return Ok(None);
2690 }
2691 let rows = store.file_content_classifications_for_paths(chunk)?;
2692 check_control(control)?;
2693 let decoded_bytes = classification_rows_bytes(&rows, control)?;
2694 let retained_bytes = classification_rows_bytes(&rows, control)?;
2695 let requested_rows =
2696 u32::try_from(chunk.len()).map_err(|_overflow| entrypoint_work_overflow())?;
2697 let returned_rows =
2698 u32::try_from(rows.len()).map_err(|_overflow| entrypoint_work_overflow())?;
2699 relation_work.database_requested_rows = relation_work
2700 .database_requested_rows
2701 .checked_add(requested_rows)
2702 .ok_or_else(entrypoint_work_overflow)?;
2703 relation_work.database_returned_rows = relation_work
2704 .database_returned_rows
2705 .checked_add(returned_rows)
2706 .ok_or_else(entrypoint_work_overflow)?;
2707 relation_work.database_decoded_bytes = relation_work
2708 .database_decoded_bytes
2709 .checked_add(decoded_bytes)
2710 .ok_or_else(entrypoint_work_overflow)?;
2711 relation_work.hydrated_classification_paths = relation_work
2712 .hydrated_classification_paths
2713 .checked_add(returned_rows)
2714 .ok_or_else(entrypoint_work_overflow)?;
2715 let retained_intermediate = relation_work
2716 .intermediate_bytes
2717 .checked_add(decoded_bytes)
2718 .and_then(|bytes| bytes.checked_add(retained_bytes))
2719 .ok_or_else(entrypoint_work_overflow)?;
2720 if retained_intermediate > budget.intermediate_bytes() {
2721 return Ok(None);
2722 }
2723 relation_work.intermediate_bytes = retained_intermediate;
2724 classifications.extend(rows.into_iter().map(|row| (row.path, row.classification)));
2725 check_control(control)?;
2726 }
2727 Ok(Some(classifications))
2728}
2729
2730#[cfg(test)]
2732fn classification_path_bytes(path: &str) -> ServiceResult<u64> {
2733 u64::try_from(path.len())
2734 .ok()
2735 .and_then(|bytes| bytes.checked_add(8))
2736 .ok_or_else(entrypoint_work_overflow)
2737}
2738
2739#[cfg(test)]
2741fn classification_rows_upper_bound(
2742 paths: &[String],
2743 control: Option<&IndexWorkControl>,
2744) -> ServiceResult<u64> {
2745 let maximum_classification_bytes = ContentClassification::ALL
2746 .iter()
2747 .map(|classification| classification.as_str().len())
2748 .max()
2749 .map_or(0, |bytes| u64::try_from(bytes).unwrap_or(u64::MAX));
2750 paths.iter().try_fold(0_u64, |bytes, path| {
2751 check_control(control)?;
2752 let row_bytes = u64::try_from(path.len())
2753 .ok()
2754 .and_then(|path_bytes| path_bytes.checked_add(maximum_classification_bytes))
2755 .and_then(|row_bytes| row_bytes.checked_add(8))
2756 .and_then(|row_bytes| row_bytes.checked_mul(2))
2757 .ok_or_else(entrypoint_work_overflow)?;
2758 bytes
2759 .checked_add(row_bytes)
2760 .ok_or_else(entrypoint_work_overflow)
2761 })
2762}
2763
2764#[cfg(test)]
2766fn classification_rows_bytes(
2767 rows: &[projectatlas_db::FileContentClassification],
2768 control: Option<&IndexWorkControl>,
2769) -> ServiceResult<u64> {
2770 rows.iter().try_fold(0_u64, |bytes, row| {
2771 check_control(control)?;
2772 let row_bytes = u64::try_from(row.path.len())
2773 .ok()
2774 .and_then(|path| {
2775 u64::try_from(row.classification.as_str().len())
2776 .ok()
2777 .and_then(|classification| path.checked_add(classification))
2778 })
2779 .and_then(|bytes| bytes.checked_add(8))
2780 .ok_or_else(entrypoint_work_overflow)?;
2781 bytes
2782 .checked_add(row_bytes)
2783 .ok_or_else(entrypoint_work_overflow)
2784 })
2785}
2786
2787fn entrypoint_anchor_budget(
2789 budget: DetailedRelationBudget,
2790 work: &DetailedRelationWork,
2791) -> ServiceResult<Result<DetailedRelationBudget, GraphLimitKind>> {
2792 let remaining_intermediate = budget
2793 .intermediate_bytes()
2794 .saturating_sub(work.intermediate_bytes);
2795 if remaining_intermediate < 64 * 1_024 {
2796 return Ok(Err(GraphLimitKind::IntermediateBytes));
2797 }
2798 Ok(Ok(budget.with_aggregate_limits(
2799 None,
2800 None,
2801 None,
2802 None,
2803 Some(remaining_intermediate),
2804 None,
2805 )?))
2806}
2807
2808fn entrypoint_occurrence_evidence_is_incomplete(
2810 store: &AtlasStore,
2811 report: &DetailedRelationReport,
2812 generation: projectatlas_core::IndexGeneration,
2813 budget: DetailedRelationBudget,
2814 relation_work: &mut DetailedRelationWork,
2815 control: Option<&IndexWorkControl>,
2816 include_report_rows: bool,
2817) -> ServiceResult<Result<bool, GraphLimitKind>> {
2818 let mut admitted_relations = if include_report_rows {
2819 report
2820 .rows
2821 .iter()
2822 .map(|row| row.relation.clone())
2823 .collect::<Vec<_>>()
2824 } else {
2825 Vec::new()
2826 };
2827 admitted_relations.extend(report.pruned_relations.iter().cloned());
2828 if admitted_relations.is_empty() {
2829 return Ok(Ok(false));
2830 }
2831 for chunk in admitted_relations.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
2832 check_control(control)?;
2833 let remaining_intermediate = budget
2834 .intermediate_bytes()
2835 .saturating_sub(relation_work.intermediate_bytes);
2836 if remaining_intermediate < 64 * 1_024 {
2837 return Ok(Err(GraphLimitKind::IntermediateBytes));
2838 }
2839 let relations = chunk.to_vec();
2840 let batch_rows = u32::try_from(relations.len()).map_err(|_overflow| {
2841 ServiceError::InvalidInput("occurrence evidence batch size overflowed".to_string())
2842 })?;
2843 let read_budget = RepositoryGraphReadBudget::new(
2844 batch_rows,
2845 batch_rows,
2846 remaining_intermediate.min(RepositoryGraphReadBudget::MAX_DECODED_BYTES),
2847 1,
2848 batch_rows.saturating_mul(2).max(1),
2849 )
2850 .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
2851 #[cfg(test)]
2852 analysis_test_observer::notify(
2853 analysis_test_observer::AnalysisPhaseEvent::OccurrenceProbeBeforeRead,
2854 );
2855 let batch = match store.repository_graph_occurrence_pages_bounded(
2856 &relations,
2857 1,
2858 read_budget,
2859 control,
2860 ) {
2861 Ok(batch) => batch,
2862 Err(DbError::GraphContract(
2863 projectatlas_core::graph::GraphContractError::InvalidLimits {
2864 reason: "graph read decoded bytes exceed the batch budget",
2865 },
2866 )) => return Ok(Err(GraphLimitKind::IntermediateBytes)),
2867 Err(
2868 DbError::GraphContract(
2869 projectatlas_core::graph::GraphContractError::GenerationMismatch { .. },
2870 )
2871 | DbError::GraphRowShape {
2872 table: "project_identity",
2873 reason: "typed graph generation does not match complete publication",
2874 },
2875 ) => {
2876 return Err(ServiceError::RelationCursorStale {
2877 field: "entrypoint graph generation",
2878 });
2879 }
2880 Err(error) => return Err(error.into()),
2881 };
2882 add_repository_read_work(relation_work, &batch.work)?;
2883 #[cfg(test)]
2884 analysis_test_observer::notify(analysis_test_observer::AnalysisPhaseEvent::OccurrenceProbe);
2885 let current_generation = match store.repository_graph_generation() {
2886 Ok(generation) => generation.ok_or_else(|| {
2887 ServiceError::InvalidInput(
2888 "repository graph has no complete generation for entrypoint analysis"
2889 .to_string(),
2890 )
2891 })?,
2892 Err(DbError::GraphRowShape {
2893 table: "project_identity",
2894 reason: "typed graph generation does not match complete publication",
2895 }) => {
2896 return Err(ServiceError::RelationCursorStale {
2897 field: "entrypoint graph generation",
2898 });
2899 }
2900 Err(error) => return Err(error.into()),
2901 };
2902 if current_generation != generation {
2903 return Err(ServiceError::RelationCursorStale {
2904 field: "entrypoint graph generation",
2905 });
2906 }
2907 if batch
2908 .pages
2909 .iter()
2910 .any(|page| page.truncated || !page.rows.is_empty())
2911 {
2912 return Ok(Ok(true));
2913 }
2914 }
2915 Ok(Ok(false))
2916}
2917
2918fn entrypoint_unavailable_node(
2920 entity: &GraphEntity,
2921 content_selection: ContentSelection,
2922) -> DetailedRelationNode {
2923 let path = entity_path(entity).map(str::to_string);
2924 DetailedRelationNode {
2925 entity: entity.clone(),
2926 classification: None,
2927 content_selection: content_selection
2928 .explicit_value()
2929 .map(|_| content_selection),
2930 purpose: RelationPurpose::Unavailable { path },
2931 coverage: Vec::new(),
2932 }
2933}
2934
2935fn protect_reachable_symbol_enclosures(
2937 reachable: &BTreeMap<String, DetailedRelationNode>,
2938 entities: &[GraphEntity],
2939 protected_keys: &mut BTreeSet<String>,
2940) {
2941 let mut pending = reachable
2942 .values()
2943 .filter_map(|node| match node.entity.selector() {
2944 EntitySelector::Symbol { symbol } => symbol.parent.as_ref().map(|parent| {
2945 (
2946 symbol.file.as_str().to_string(),
2947 parent.as_str().to_string(),
2948 )
2949 }),
2950 _ => None,
2951 })
2952 .collect::<VecDeque<_>>();
2953 while let Some((file, parent)) = pending.pop_front() {
2954 for entity in entities {
2955 let EntitySelector::Symbol { symbol } = entity.selector() else {
2956 continue;
2957 };
2958 let qualified_name_matches = symbol.name.as_str() == parent
2959 || symbol.parent.as_ref().is_some_and(|candidate_parent| {
2960 format!("{candidate_parent}::{name}", name = symbol.name.as_str()) == parent
2961 || format!("{candidate_parent}.{name}", name = symbol.name.as_str())
2962 == parent
2963 });
2964 if symbol.file.as_str() != file || !qualified_name_matches {
2965 continue;
2966 }
2967 let key = entity.key().canonical_identity().to_string();
2968 if protected_keys.insert(key)
2969 && let Some(next_parent) = symbol.parent.as_ref()
2970 {
2971 pending.push_back((file.clone(), next_parent.as_str().to_string()));
2972 }
2973 }
2974 }
2975}
2976
2977fn entrypoint_step_budget(
2979 budget: DetailedRelationBudget,
2980 work: &DetailedRelationWork,
2981 retained_nodes: usize,
2982 validated_candidates: usize,
2983 anchor_is_retained: bool,
2984 candidate_anchor_overhead: bool,
2985 include_occurrences: bool,
2986 remaining_edges_override: Option<u32>,
2987) -> ServiceResult<Result<DetailedRelationBudget, GraphLimitKind>> {
2988 let accounted_nodes = retained_nodes.saturating_add(validated_candidates);
2989 let accounted_nodes = u32::try_from(accounted_nodes).unwrap_or(u32::MAX);
2990 let validated_candidates = u32::try_from(validated_candidates).unwrap_or(u32::MAX);
2991 let remaining_edges = remaining_edges_override
2992 .unwrap_or_else(|| budget.edges().saturating_sub(work.inspected_edges));
2993 let remaining_nodes = budget.nodes().saturating_sub(accounted_nodes);
2994 let remaining_visited = budget.visited().saturating_sub(accounted_nodes);
2995 let step_nodes = if candidate_anchor_overhead {
2996 budget.nodes()
2997 } else if anchor_is_retained {
2998 budget.nodes().saturating_sub(validated_candidates)
2999 } else {
3000 remaining_nodes
3001 };
3002 let step_visited = if candidate_anchor_overhead {
3003 budget.visited()
3004 } else if anchor_is_retained {
3005 budget.visited().saturating_sub(validated_candidates)
3006 } else {
3007 remaining_visited
3008 };
3009 let remaining_occurrences = if include_occurrences {
3010 budget
3011 .occurrences_total()
3012 .saturating_sub(work.retained_occurrences)
3013 } else {
3014 budget.occurrences_total()
3015 };
3016 let remaining_intermediate = budget
3017 .intermediate_bytes()
3018 .saturating_sub(work.intermediate_bytes);
3019 let rows = budget.page_rows().min(remaining_edges);
3020 if remaining_edges == 0 {
3021 return Ok(Err(GraphLimitKind::Edges));
3022 }
3023 if remaining_nodes == 0 && !anchor_is_retained {
3024 return Ok(Err(GraphLimitKind::Nodes));
3025 }
3026 if remaining_visited == 0 && !anchor_is_retained {
3027 return Ok(Err(GraphLimitKind::Visited));
3028 }
3029 if remaining_intermediate < 64 * 1_024 {
3030 return Ok(Err(GraphLimitKind::IntermediateBytes));
3031 }
3032 if rows == 0 {
3033 return Ok(Err(GraphLimitKind::Rows));
3034 }
3035 let limits = GraphLimits::new(
3036 rows,
3037 budget.occurrences_per_relation(),
3038 1,
3039 budget.output_bytes(),
3040 )
3041 .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
3042 let step = DetailedRelationBudget::from_graph_limits(limits).with_aggregate_limits(
3043 Some(remaining_edges),
3044 Some(step_nodes),
3045 Some(step_visited),
3046 Some(remaining_occurrences),
3047 Some(remaining_intermediate),
3048 Some(budget.deadline_ms()),
3049 )?;
3050 Ok(Ok(step))
3051}
3052
3053fn entrypoint_work_overflow() -> ServiceError {
3055 ServiceError::InvalidInput("entrypoint relation work overflowed".to_string())
3056}
3057
3058fn entrypoint_filtered_edge_limit_is_terminal(
3060 store: &AtlasStore,
3061 generation: projectatlas_core::IndexGeneration,
3062 report: &DetailedRelationReport,
3063 relation: GraphRelationKind,
3064 minimum_confidence: ConfidenceClass,
3065 content_selection: ContentSelection,
3066 control: Option<&IndexWorkControl>,
3067) -> ServiceResult<bool> {
3068 if !report.reached_limits.contains(&GraphLimitKind::Edges)
3069 || report.reached_limits.iter().any(|limit| {
3070 *limit != GraphLimitKind::Edges
3071 && (report.rows.is_empty() || *limit != GraphLimitKind::Rows)
3072 })
3073 || report.pruned_incomplete_paths > 0
3074 || report.pruned_evidence_truncated
3075 {
3076 return Ok(false);
3077 }
3078 if let Some(continuation) = report.adjacency_continuation.as_ref() {
3079 let has_rows = store.repository_graph_adjacency_continuation_has_filtered_rows(
3080 continuation,
3081 minimum_confidence,
3082 content_selection,
3083 control,
3084 )?;
3085 validate_entrypoint_generation(generation, store.repository_graph_generation())?;
3086 #[cfg(test)]
3087 analysis_test_observer::notify(analysis_test_observer::AnalysisPhaseEvent::TerminalProbe);
3088 return Ok(!has_rows);
3089 }
3090 if !report.rows.is_empty() {
3091 return Ok(false);
3092 }
3093 entrypoint_terminal_adjacency_is_empty(
3094 store,
3095 generation,
3096 report.anchor.entity.key(),
3097 relation,
3098 minimum_confidence,
3099 content_selection,
3100 control,
3101 )
3102}
3103
3104fn generation_project(entity: &GraphEntity) -> ProjectInstanceId {
3106 entity.key().project()
3107}
3108
3109fn entrypoint_terminal_adjacency_is_empty(
3111 store: &AtlasStore,
3112 generation: projectatlas_core::IndexGeneration,
3113 key: &GraphEntityKey,
3114 relation: GraphRelationKind,
3115 minimum_confidence: ConfidenceClass,
3116 content_selection: ContentSelection,
3117 control: Option<&IndexWorkControl>,
3118) -> ServiceResult<bool> {
3119 let empty = store.repository_graph_adjacency_is_empty_filtered(
3120 key,
3121 RepositoryGraphDirection::Outbound,
3122 relation,
3123 minimum_confidence,
3124 content_selection,
3125 control,
3126 )?;
3127 #[cfg(test)]
3128 analysis_test_observer::notify(analysis_test_observer::AnalysisPhaseEvent::TerminalProbe);
3129 validate_entrypoint_generation(generation, store.repository_graph_generation())?;
3130 Ok(empty)
3131}
3132
3133fn load_entrypoint_terminal_candidate_coverage(
3135 store: &AtlasStore,
3136 entity: &GraphEntity,
3137 generation: projectatlas_core::IndexGeneration,
3138 budget: DetailedRelationBudget,
3139 relation_work: &mut DetailedRelationWork,
3140 content_selection: ContentSelection,
3141 control: Option<&IndexWorkControl>,
3142) -> ServiceResult<Result<DetailedRelationNode, GraphLimitKind>> {
3143 check_control(control)?;
3144 let remaining_intermediate = budget
3145 .intermediate_bytes()
3146 .saturating_sub(relation_work.intermediate_bytes);
3147 if remaining_intermediate < 64 * 1_024 {
3148 return Ok(Err(GraphLimitKind::IntermediateBytes));
3149 }
3150 let candidate_budget =
3151 budget.with_aggregate_limits(None, None, None, None, Some(remaining_intermediate), None)?;
3152 let (node, metadata_work) = match hydrate_single_detailed_node(
3153 store,
3154 entity,
3155 generation,
3156 content_selection,
3157 candidate_budget,
3158 control,
3159 ) {
3160 Ok(value) => value,
3161 Err(ServiceError::Db(DbError::GraphContract(
3162 projectatlas_core::graph::GraphContractError::InvalidLimits {
3163 reason: "graph read decoded bytes exceed the batch budget",
3164 },
3165 ))) => return Ok(Err(GraphLimitKind::IntermediateBytes)),
3166 Err(ServiceError::InvalidInput(reason))
3167 if reason == "detailed relation intermediate-byte budget is exhausted"
3168 || reason == "terminal node metadata exceeded the intermediate-byte budget" =>
3169 {
3170 return Ok(Err(GraphLimitKind::IntermediateBytes));
3171 }
3172 Err(ServiceError::Db(
3173 DbError::GraphContract(
3174 projectatlas_core::graph::GraphContractError::GenerationMismatch { .. },
3175 )
3176 | DbError::GraphRowShape {
3177 table: "project_identity",
3178 reason: "typed graph generation does not match complete publication",
3179 },
3180 )) => {
3181 return Err(ServiceError::RelationCursorStale {
3182 field: "entrypoint graph generation",
3183 });
3184 }
3185 Err(error) => return Err(error),
3186 };
3187 add_relation_work(relation_work, &metadata_work)?;
3188 #[cfg(test)]
3189 analysis_test_observer::notify(
3190 analysis_test_observer::AnalysisPhaseEvent::TerminalCandidateCoverageProbe {
3191 intermediate_bytes: relation_work.intermediate_bytes,
3192 },
3193 );
3194 validate_entrypoint_generation(generation, store.repository_graph_generation())?;
3195 check_control(control)?;
3196 Ok(Ok(node))
3197}
3198
3199fn trusted_node_coverage(
3201 node: &DetailedRelationNode,
3202 admitted_relations: &[GraphRelationKind],
3203) -> bool {
3204 let applies = |coverage: &CoverageRecord| {
3205 coverage
3206 .relation()
3207 .is_none_or(|relation| admitted_relations.contains(&relation))
3208 };
3209 let applicable = node
3210 .coverage
3211 .iter()
3212 .filter(|coverage| applies(coverage))
3213 .collect::<Vec<_>>();
3214 !applicable.is_empty()
3215 && applicable
3216 .iter()
3217 .all(|coverage| coverage_trust(coverage.state()) == CoverageTrustState::Trusted)
3218 && (applicable
3219 .iter()
3220 .any(|coverage| coverage.relation().is_none())
3221 || admitted_relations.iter().all(|relation| {
3222 applicable
3223 .iter()
3224 .any(|coverage| coverage.relation() == Some(*relation))
3225 }))
3226}
3227
3228fn trusted_relation_row(
3230 row: &DetailedRelationRow,
3231 admitted_relations: &[GraphRelationKind],
3232) -> bool {
3233 trusted_node_coverage(&row.source, admitted_relations)
3234 && row.target.as_ref().is_none_or(|target| {
3235 matches!(target.entity.selector(), EntitySelector::External { .. })
3236 || trusted_node_coverage(target, admitted_relations)
3237 })
3238 && row
3239 .path
3240 .iter()
3241 .all(|node| trusted_node_coverage(node, admitted_relations))
3242}
3243
3244fn entrypoint_report_complete(
3246 report: &DetailedRelationReport,
3247 admitted_relations: &[GraphRelationKind],
3248 filtered_edge_limit_is_terminal: bool,
3249) -> bool {
3250 (filtered_edge_limit_is_terminal
3251 || (matches!(
3252 report.total,
3253 RelationTotalState::Exact(total) if total == u64::from(report.returned)
3254 ) && !report.truncated
3255 && report.continuation.is_none()
3256 && report.reached_limits.is_empty()))
3257 && report.pruned_incomplete_paths == 0
3258 && !report.pruned_evidence_truncated
3259 && trusted_node_coverage(&report.anchor, admitted_relations)
3260 && report.rows.iter().all(|row| {
3261 matches!(
3262 row.relation.resolution(),
3263 RelationResolution::Resolved { .. } | RelationResolution::External { .. }
3264 ) && row.relation.completeness() == Completeness::Complete
3265 && trusted_relation_row(row, admitted_relations)
3266 })
3267}
3268
3269fn candidate_report_unretained_local_keys(
3271 report: &DetailedRelationReport,
3272 retained_keys: &BTreeSet<String>,
3273 content_selection: ContentSelection,
3274) -> BTreeSet<String> {
3275 let mut keys = BTreeSet::new();
3276 let mut retain = |node: &DetailedRelationNode| {
3277 if !matches!(node.entity.selector(), EntitySelector::External { .. })
3278 && (content_selection == ContentSelection::UnspecifiedLegacy
3279 || node
3280 .classification
3281 .is_some_and(|classification| content_selection.includes(classification)))
3282 && !retained_keys.contains(node.entity.key().canonical_identity())
3283 {
3284 keys.insert(node.entity.key().canonical_identity().to_string());
3285 }
3286 };
3287 retain(&report.anchor);
3288 for row in &report.rows {
3289 retain(&row.source);
3290 if let Some(target) = row.target.as_ref() {
3291 retain(target);
3292 }
3293 row.path.iter().for_each(&mut retain);
3294 }
3295 keys
3296}
3297
3298fn validate_entrypoint_generation(
3300 expected: IndexGeneration,
3301 current: Result<Option<IndexGeneration>, DbError>,
3302) -> ServiceResult<()> {
3303 match current {
3304 Ok(Some(current)) if current == expected => Ok(()),
3305 Ok(Some(_) | None)
3306 | Err(
3307 DbError::GraphPublicationUnavailable
3308 | DbError::GraphRowShape {
3309 table: "project_identity",
3310 reason: "typed graph generation does not match complete publication",
3311 }
3312 | DbError::GraphContract(
3313 projectatlas_core::graph::GraphContractError::GenerationMismatch { .. },
3314 ),
3315 ) => Err(ServiceError::RelationCursorStale {
3316 field: "entrypoint graph generation",
3317 }),
3318 Err(error) => Err(error.into()),
3319 }
3320}
3321
3322fn bounded_analysis_budget(
3324 budget: DetailedRelationBudget,
3325) -> ServiceResult<DetailedRelationBudget> {
3326 let limits = GraphLimits::new(
3327 budget.page_rows().min(MAX_ANALYSIS_NODES),
3328 budget.occurrences_per_relation(),
3329 budget.depth(),
3330 budget.output_bytes(),
3331 )
3332 .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
3333 DetailedRelationBudget::from_graph_limits(limits).with_aggregate_limits(
3334 Some(budget.edges().min(MAX_ANALYSIS_EDGES)),
3335 Some(budget.nodes().min(MAX_ANALYSIS_NODES)),
3336 Some(budget.visited().min(MAX_ANALYSIS_NODES)),
3337 Some(budget.occurrences_total()),
3338 Some(budget.intermediate_bytes()),
3339 Some(budget.deadline_ms()),
3340 )
3341}
3342
3343fn analysis_cursor_binding(
3345 query: &RelationAnalysisQuery,
3346 project_root: &CanonicalProjectRoot,
3347) -> ServiceResult<AnalysisCursorBinding> {
3348 Ok(AnalysisCursorBinding {
3349 root_digest: super::canonical_root_digest(ANALYSIS_ROOT_DOMAIN, project_root)?,
3350 anchor: query.relations.anchor.clone(),
3351 direction: query.relations.direction,
3352 relation: query.relations.relation,
3353 minimum_confidence: query.relations.minimum_confidence,
3354 resolution: query.relations.resolution,
3355 content_selection: query
3356 .relations
3357 .content_selection
3358 .explicit_value()
3359 .map(|_| query.relations.content_selection),
3360 options: AnalysisCursorOptions {
3361 relation_occurrences: query.relations.include_occurrences.into(),
3362 communities: query.include_communities.into(),
3363 cycles: query.include_cycles.into(),
3364 dead_code: query.include_dead_code.into(),
3365 },
3366 budget: query.relations.budget,
3367 algorithm_version: ANALYSIS_CURSOR_VERSION,
3368 ordering_version: 1,
3369 mode: query.mode,
3370 trace_target: query.trace_target.clone(),
3371 vcs: (query.mode == RelationAnalysisMode::Impact)
3372 .then(|| query.vcs.clone().unwrap_or(GitImpactSelection::WorkingTree)),
3373 entrypoint_profile: query.entrypoint_profile.clone(),
3374 })
3375}
3376
3377fn decode_analysis_cursor(
3379 encoded: &str,
3380 expected: &AnalysisCursorBinding,
3381) -> ServiceResult<AnalysisCursor> {
3382 if encoded.is_empty() || encoded.len() > ANALYSIS_CURSOR_MAX_BYTES {
3383 return Err(ServiceError::RelationCursorInvalid {
3384 reason: "analysis cursor length is empty or above the product ceiling",
3385 });
3386 }
3387 let cursor: AnalysisCursor = serde_json::from_str(encoded).map_err(|_malformed| {
3388 ServiceError::RelationCursorInvalid {
3389 reason: "analysis cursor JSON is malformed or contains unknown fields",
3390 }
3391 })?;
3392 if cursor.version != ANALYSIS_CURSOR_VERSION {
3393 return Err(ServiceError::RelationCursorStale {
3394 field: "analysis algorithm version",
3395 });
3396 }
3397 if cursor.binding != *expected {
3398 return Err(ServiceError::RelationCursorMismatched {
3399 field: "analysis query",
3400 });
3401 }
3402 Ok(cursor)
3403}
3404
3405fn encode_analysis_cursor(
3407 relation_cursor: Option<&str>,
3408 finding_offset: u32,
3409 binding: &AnalysisCursorBinding,
3410 snapshot: AnalysisCursorSnapshot,
3411 vcs_digest: Option<[u8; 32]>,
3412 budget: DetailedRelationBudget,
3413) -> ServiceResult<String> {
3414 let encoded = serde_json::to_string(&AnalysisCursor {
3415 version: ANALYSIS_CURSOR_VERSION,
3416 binding: binding.clone(),
3417 snapshot,
3418 relation_cursor: relation_cursor.map(str::to_string),
3419 finding_offset,
3420 vcs_digest,
3421 })?;
3422 if encoded.len() > ANALYSIS_CURSOR_MAX_BYTES
3423 || encoded.len() > budget.intermediate_bytes() as usize
3424 {
3425 return Err(ServiceError::RelationCursorInvalid {
3426 reason: "encoded analysis cursor exceeds the intermediate-state ceiling",
3427 });
3428 }
3429 Ok(encoded)
3430}
3431
3432fn collect_nodes(
3434 report: &DetailedRelationReport,
3435 control: Option<&IndexWorkControl>,
3436) -> ServiceResult<BTreeMap<String, DetailedRelationNode>> {
3437 let mut nodes = BTreeMap::new();
3438 insert_node(&mut nodes, &report.anchor);
3439 for row in &report.rows {
3440 check_control(control)?;
3441 insert_node(&mut nodes, &row.source);
3442 if let Some(target) = &row.target {
3443 insert_node(&mut nodes, target);
3444 }
3445 for node in &row.path {
3446 insert_node(&mut nodes, node);
3447 }
3448 }
3449 Ok(nodes)
3450}
3451
3452fn resolution_gap_findings(
3454 report: &DetailedRelationReport,
3455 control: Option<&IndexWorkControl>,
3456) -> ServiceResult<Vec<AnalysisFinding>> {
3457 let mut findings = Vec::new();
3458 for row in &report.rows {
3459 check_control(control)?;
3460 if matches!(
3461 row.relation.resolution(),
3462 RelationResolution::Ambiguous { .. } | RelationResolution::Unresolved { .. }
3463 ) {
3464 findings.push(resolution_gap_finding(&row.relation, &row.source));
3465 }
3466 }
3467 Ok(findings)
3468}
3469
3470fn resolution_gap_finding(
3472 relation: &projectatlas_core::graph::LogicalRelation,
3473 source: &DetailedRelationNode,
3474) -> AnalysisFinding {
3475 AnalysisFinding {
3476 kind: AnalysisFindingKind::ResolutionGap,
3477 status: AnalysisStatus::Inconclusive,
3478 summary: "ambiguous or unresolved relation blocks a closed structural conclusion"
3479 .to_string(),
3480 nodes: vec![analysis_node(source)],
3481 metric: None,
3482 evidence: Some(AnalysisRelationEvidence {
3483 relation: relation.clone(),
3484 next_call: relation_gap_next_call(relation, source),
3485 }),
3486 community: None,
3487 }
3488}
3489
3490fn resolution_gap_identity(finding: &AnalysisFinding) -> &str {
3492 finding
3493 .evidence
3494 .as_ref()
3495 .map_or("", |evidence| evidence.relation.key().canonical_identity())
3496}
3497
3498fn relation_gap_next_call(
3500 relation: &projectatlas_core::graph::LogicalRelation,
3501 source: &DetailedRelationNode,
3502) -> Option<RelationAnalysisNextCall> {
3503 let anchor = relation_anchor_for_entity(&source.entity)?;
3504 let resolution = match relation.resolution() {
3505 RelationResolution::Ambiguous { .. } => RelationResolutionFilter::Ambiguous,
3506 RelationResolution::Unresolved { .. } => RelationResolutionFilter::Unresolved,
3507 RelationResolution::Resolved { .. } => RelationResolutionFilter::Resolved,
3508 RelationResolution::External { .. } => RelationResolutionFilter::External,
3509 };
3510 Some(RelationAnalysisNextCall {
3511 anchor,
3512 direction: RelationDirection::Outbound,
3513 relation: relation.kind(),
3514 resolution,
3515 minimum_confidence: relation.confidence(),
3516 content_selection: source.content_selection,
3517 })
3518}
3519
3520fn relation_anchor_for_entity(entity: &GraphEntity) -> Option<RelationAnchor> {
3522 match entity.selector() {
3523 EntitySelector::File { path } => Some(RelationAnchor::File { file: path.clone() }),
3524 EntitySelector::Symbol { symbol } => Some(RelationAnchor::Symbol {
3525 file: symbol.file.clone(),
3526 name: symbol.name.as_str().to_string(),
3527 symbol_kind: Some(symbol.kind),
3528 parent: symbol
3529 .parent
3530 .as_ref()
3531 .map(|parent| parent.as_str().to_string()),
3532 signature: Some(symbol.signature.as_str().to_string()),
3533 }),
3534 EntitySelector::Project
3535 | EntitySelector::Folder { .. }
3536 | EntitySelector::Package { .. }
3537 | EntitySelector::External { .. } => None,
3538 }
3539}
3540
3541fn insert_node(nodes: &mut BTreeMap<String, DetailedRelationNode>, node: &DetailedRelationNode) {
3543 if !matches!(node.entity.selector(), EntitySelector::External { .. }) {
3544 nodes
3545 .entry(node.entity.key().canonical_identity().to_string())
3546 .or_insert_with(|| node.clone());
3547 }
3548}
3549
3550fn collect_report_edges(
3552 report: &DetailedRelationReport,
3553 control: Option<&IndexWorkControl>,
3554) -> ServiceResult<Vec<LocalEdge>> {
3555 let mut edges = Vec::new();
3556 for row in &report.rows {
3557 check_control(control)?;
3558 if let Some(edge) = local_edge(&row.relation, &row.source.entity, row.target.as_ref()) {
3559 edges.push(edge);
3560 }
3561 }
3562 Ok(edges)
3563}
3564
3565fn local_edge(
3567 relation: &projectatlas_core::graph::LogicalRelation,
3568 source: &GraphEntity,
3569 target: Option<&DetailedRelationNode>,
3570) -> Option<LocalEdge> {
3571 let target = target?;
3572 if matches!(target.entity.selector(), EntitySelector::External { .. }) {
3573 return None;
3574 }
3575 Some(LocalEdge {
3576 source: source.key().canonical_identity().to_string(),
3577 target: target.entity.key().canonical_identity().to_string(),
3578 kind: relation.kind(),
3579 complete: relation.completeness() == Completeness::Complete,
3580 })
3581}
3582
3583#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
3585enum CommunityClosureScope {
3586 #[default]
3588 Closed,
3589 Open,
3591}
3592
3593#[derive(Default)]
3595struct ClosureWork {
3596 complete: bool,
3598 deadline_reached: bool,
3600 induced_scope_closed: bool,
3602 community_scope: CommunityClosureScope,
3604 inspected_edges: u32,
3606 decoded_bytes: u64,
3608 resolution_gaps: Vec<AnalysisFinding>,
3610}
3611
3612fn close_induced_edges(
3614 store: &AtlasStore,
3615 query: &RelationAnalysisQuery,
3616 relation_work: &DetailedRelationWork,
3617 deadline: Instant,
3618 nodes: &BTreeMap<String, DetailedRelationNode>,
3619 edges: &mut Vec<LocalEdge>,
3620 control: Option<&IndexWorkControl>,
3621) -> ServiceResult<ClosureWork> {
3622 let mut work = ClosureWork {
3623 complete: true,
3624 induced_scope_closed: query.relations.direction == RelationDirection::Outbound,
3625 community_scope: if query.relations.direction == RelationDirection::Outbound {
3626 CommunityClosureScope::Closed
3627 } else {
3628 CommunityClosureScope::Open
3629 },
3630 ..ClosureWork::default()
3631 };
3632 let mut keys = Vec::with_capacity(nodes.len());
3633 let mut known = BTreeSet::new();
3634 for (key, node) in nodes {
3635 check_control(control)?;
3636 keys.push(node.entity.key().clone());
3637 known.insert(key.clone());
3638 }
3639 #[cfg(test)]
3640 analysis_test_observer::notify(analysis_test_observer::AnalysisPhaseEvent::Traversal);
3641 check_control(control)?;
3642 for chunk in keys.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
3643 let mut continuation: Option<RepositoryGraphAdjacencyContinuation> = None;
3644 loop {
3645 if Instant::now() >= deadline {
3646 work.complete = false;
3647 work.deadline_reached = true;
3648 break;
3649 }
3650 check_control(control)?;
3651 let remaining = query.relations.budget.edges().saturating_sub(
3652 relation_work
3653 .inspected_edges
3654 .saturating_add(work.inspected_edges),
3655 );
3656 if remaining == 0 {
3657 work.complete = false;
3658 break;
3659 }
3660 let page_limit = remaining.min(query.relations.budget.page_rows()).max(1);
3661 let decoded_remaining = query
3662 .relations
3663 .budget
3664 .intermediate_bytes()
3665 .saturating_sub(
3666 relation_work
3667 .intermediate_bytes
3668 .saturating_add(work.decoded_bytes),
3669 )
3670 .min(RepositoryGraphReadBudget::MAX_DECODED_BYTES);
3671 if decoded_remaining == 0 {
3672 work.complete = false;
3673 break;
3674 }
3675 let endpoints = page_limit.saturating_add(1).saturating_mul(2);
3676 let budget = RepositoryGraphReadBudget::new(
3677 u32::try_from(chunk.len()).map_err(|_overflow| {
3678 ServiceError::InvalidInput("analysis frontier overflowed".to_string())
3679 })?,
3680 page_limit,
3681 decoded_remaining,
3682 endpoints,
3683 endpoints,
3684 )
3685 .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
3686 let read = store.repository_graph_adjacency_page_filtered_bounded_with_documents(
3687 chunk,
3688 RepositoryGraphDirection::Outbound,
3689 query.relations.relation,
3690 super::relations::include_document_relations(&query.relations),
3691 continuation.as_ref(),
3692 page_limit,
3693 budget,
3694 control,
3695 )?;
3696 work.inspected_edges = work
3697 .inspected_edges
3698 .checked_add(u32::try_from(read.page.rows.len()).map_err(|_overflow| {
3699 ServiceError::InvalidInput("analysis edge work overflowed".to_string())
3700 })?)
3701 .ok_or_else(|| {
3702 ServiceError::InvalidInput("analysis edge work overflowed".to_string())
3703 })?;
3704 work.decoded_bytes = work.decoded_bytes.saturating_add(read.work.decoded_bytes);
3705 for row in read.page.rows {
3706 check_control(control)?;
3707 if !analysis_relation_matches(&row.detail.relation, &query.relations) {
3708 continue;
3709 }
3710 let community_relation =
3711 community_relation_weight(row.detail.relation.kind()).is_some();
3712 let source_key = row.detail.source.key().canonical_identity().to_string();
3713 if matches!(
3714 row.detail.relation.resolution(),
3715 RelationResolution::Ambiguous { .. } | RelationResolution::Unresolved { .. }
3716 ) {
3717 work.induced_scope_closed = false;
3718 if community_relation {
3719 work.community_scope = CommunityClosureScope::Open;
3720 }
3721 if let Some(source) = nodes.get(&source_key) {
3722 work.resolution_gaps
3723 .push(resolution_gap_finding(&row.detail.relation, source));
3724 }
3725 continue;
3726 }
3727 let Some(target) = row.detail.target else {
3728 work.induced_scope_closed = false;
3729 if community_relation {
3730 work.community_scope = CommunityClosureScope::Open;
3731 }
3732 continue;
3733 };
3734 let target_key = target.key().canonical_identity().to_string();
3735 if known.contains(&source_key) && known.contains(&target_key) {
3736 edges.push(LocalEdge {
3737 source: source_key,
3738 target: target_key,
3739 kind: row.detail.relation.kind(),
3740 complete: row.detail.relation.completeness() == Completeness::Complete,
3741 });
3742 } else {
3743 work.induced_scope_closed = false;
3744 if community_relation {
3745 work.community_scope = CommunityClosureScope::Open;
3746 }
3747 }
3748 }
3749 if read.page.truncated {
3750 continuation = read.page.continuation;
3751 if continuation.is_none() {
3752 return Err(ServiceError::InvalidInput(
3753 "truncated analysis closure omitted its continuation".to_string(),
3754 ));
3755 }
3756 } else {
3757 break;
3758 }
3759 }
3760 if !work.complete {
3761 break;
3762 }
3763 }
3764 edges.sort_by(|left, right| {
3765 (&left.source, &left.target, format!("{:?}", left.kind)).cmp(&(
3766 &right.source,
3767 &right.target,
3768 format!("{:?}", right.kind),
3769 ))
3770 });
3771 edges.dedup_by(|left, right| {
3772 left.source == right.source && left.target == right.target && left.kind == right.kind
3773 });
3774 work.resolution_gaps
3775 .sort_by(|left, right| resolution_gap_identity(left).cmp(resolution_gap_identity(right)));
3776 work.resolution_gaps
3777 .dedup_by(|left, right| resolution_gap_identity(left) == resolution_gap_identity(right));
3778 Ok(work)
3779}
3780
3781fn relation_evidence_complete(
3783 report: &DetailedRelationReport,
3784 nodes: &BTreeMap<String, DetailedRelationNode>,
3785 edges: &[LocalEdge],
3786 query: &RelationAnalysisQuery,
3787 closure: &ClosureWork,
3788 community_only: bool,
3789) -> bool {
3790 closure.complete
3791 && if community_only {
3792 closure.community_scope == CommunityClosureScope::Closed
3793 } else {
3794 closure.induced_scope_closed
3795 }
3796 && report.reached_limits.is_empty()
3797 && query.relations.direction == RelationDirection::Outbound
3798 && edges
3799 .iter()
3800 .filter(|edge| !community_only || community_relation_weight(edge.kind).is_some())
3801 .all(|edge| edge.complete)
3802 && nodes.values().all(|node| match node.entity.selector() {
3803 EntitySelector::Project => true,
3804 EntitySelector::Folder { .. }
3805 | EntitySelector::File { .. }
3806 | EntitySelector::Package { .. }
3807 | EntitySelector::Symbol { .. } => {
3808 !node.coverage.is_empty()
3809 && node.coverage.iter().all(|coverage| {
3810 coverage_trust(coverage.state()) == CoverageTrustState::Trusted
3811 })
3812 }
3813 EntitySelector::External { .. } => false,
3814 })
3815}
3816
3817fn dead_code_scope_complete(
3819 report: &DetailedRelationReport,
3820 query: &RelationAnalysisQuery,
3821) -> bool {
3822 let exact_symbol_anchor = matches!(
3823 &query.relations.anchor,
3824 RelationAnchor::Symbol {
3825 symbol_kind: Some(_),
3826 signature: Some(_),
3827 ..
3828 }
3829 );
3830 let exact_total = matches!(
3831 report.total,
3832 RelationTotalState::Exact(total) if total == u64::from(report.returned)
3833 );
3834 query.relations.direction == RelationDirection::Inbound
3835 && query.relations.relation.is_none()
3836 && query.relations.resolution == RelationResolutionFilter::Resolved
3837 && query.relations.minimum_confidence == ConfidenceClass::Low
3838 && exact_symbol_anchor
3839 && exact_total
3840 && !report.truncated
3841 && report.continuation.is_none()
3842 && report.reached_limits.is_empty()
3843 && !report.anchor.coverage.is_empty()
3844 && report
3845 .anchor
3846 .coverage
3847 .iter()
3848 .all(|coverage| coverage_trust(coverage.state()) == CoverageTrustState::Trusted)
3849 && report.rows.iter().all(|row| {
3850 matches!(
3851 row.relation.resolution(),
3852 RelationResolution::Resolved { .. }
3853 ) && row.relation.completeness() == Completeness::Complete
3854 })
3855}
3856
3857fn analysis_relation_matches(
3859 relation: &projectatlas_core::graph::LogicalRelation,
3860 query: &DetailedRelationQuery,
3861) -> bool {
3862 super::relations::relation_matches(relation, query)
3863}
3864
3865fn architecture_findings(
3867 store: &AtlasStore,
3868 nodes: &BTreeMap<String, DetailedRelationNode>,
3869 edges: &[LocalEdge],
3870 complete: bool,
3871 community_complete: bool,
3872 query: &RelationAnalysisQuery,
3873 symbol_byte_budget: u64,
3874 preceding_finding_count: usize,
3875 supplemental_work: &mut SupplementalWork,
3876 control: Option<&IndexWorkControl>,
3877) -> ServiceResult<Vec<AnalysisFinding>> {
3878 let mut findings = structural_findings(
3879 store,
3880 nodes,
3881 edges,
3882 complete,
3883 symbol_byte_budget,
3884 supplemental_work,
3885 control,
3886 )?;
3887 if !complete {
3888 findings.push(AnalysisFinding {
3889 kind: AnalysisFindingKind::Component,
3890 status: AnalysisStatus::Inconclusive,
3891 summary: "architecture candidates are incomplete because traversal or local coverage is partial"
3892 .to_string(),
3893 nodes: analysis_nodes_for(nodes, &nodes.keys().cloned().collect::<Vec<_>>()),
3894 metric: Some(nodes.len() as u64),
3895 evidence: None,
3896 community: None,
3897 });
3898 }
3899 let components = weak_components(nodes, edges, false);
3900 for component in &components {
3901 findings.push(AnalysisFinding {
3902 kind: AnalysisFindingKind::Component,
3903 status: AnalysisStatus::Candidate,
3904 summary: "component candidate from a weakly connected admitted relation set"
3905 .to_string(),
3906 nodes: analysis_nodes_for(nodes, component),
3907 metric: Some(component.len() as u64),
3908 evidence: None,
3909 community: None,
3910 });
3911 findings.push(purpose_finding(nodes, component, complete));
3912 }
3913 if query.include_communities {
3914 let mut existing_finding_bytes = serialized_bytes_controlled(&findings, control)?;
3915 let mut existing_finding_append_bytes = serialized_findings_append_bytes(
3916 existing_finding_bytes,
3917 findings.len(),
3918 preceding_finding_count,
3919 );
3920 if existing_finding_append_bytes > symbol_byte_budget {
3921 supplemental_work.composition_truncated = true;
3922 push_limit(
3923 &mut supplemental_work.reached_limits,
3924 GraphLimitKind::IntermediateBytes,
3925 );
3926 while existing_finding_append_bytes > symbol_byte_budget && findings.len() > 1 {
3927 check_control(control)?;
3928 findings.pop();
3929 existing_finding_bytes = serialized_bytes_controlled(&findings, control)?;
3930 existing_finding_append_bytes = serialized_findings_append_bytes(
3931 existing_finding_bytes,
3932 findings.len(),
3933 preceding_finding_count,
3934 );
3935 }
3936 if existing_finding_append_bytes > symbol_byte_budget {
3937 findings.clear();
3938 existing_finding_bytes = serialized_bytes_controlled(&findings, control)?;
3939 existing_finding_append_bytes = serialized_findings_append_bytes(
3940 existing_finding_bytes,
3941 findings.len(),
3942 preceding_finding_count,
3943 );
3944 }
3945 }
3946 let community_budget = symbol_byte_budget.saturating_sub(existing_finding_append_bytes);
3947 #[cfg(test)]
3948 analysis_test_observer::notify(
3949 analysis_test_observer::AnalysisPhaseEvent::CompositionBudget {
3950 symbol_byte_budget,
3951 existing_finding_append_bytes,
3952 community_budget,
3953 },
3954 );
3955 let existing_community_finding_count =
3956 preceding_finding_count.saturating_add(findings.len());
3957 let (community_findings, community_working_set_bytes, community_limits) =
3958 community_findings_with_budget(
3959 nodes,
3960 edges,
3961 community_complete,
3962 query,
3963 community_budget,
3964 existing_community_finding_count,
3965 control,
3966 )?;
3967 supplemental_work.community_working_set_bytes = supplemental_work
3968 .community_working_set_bytes
3969 .max(community_working_set_bytes);
3970 let community_truncated = !community_limits.is_empty();
3971 for limit in community_limits {
3972 push_limit(&mut supplemental_work.reached_limits, limit);
3973 }
3974 if community_truncated
3975 || community_findings.iter().any(|finding| {
3976 finding
3977 .community
3978 .as_ref()
3979 .is_some_and(|community| community.truncated)
3980 })
3981 {
3982 supplemental_work.composition_truncated = true;
3983 }
3984 findings.extend(community_findings);
3985 }
3986 if query.include_cycles {
3987 let dependency_edges = edges
3988 .iter()
3989 .filter(|edge| dependency_relation(edge.kind))
3990 .cloned()
3991 .collect::<Vec<_>>();
3992 let cycles = strongly_connected_components(nodes, &dependency_edges)
3993 .into_iter()
3994 .filter(|component| {
3995 component.len() > 1
3996 || dependency_edges.iter().any(|edge| {
3997 component.first() == Some(&edge.source) && edge.source == edge.target
3998 })
3999 })
4000 .collect::<Vec<_>>();
4001 if cycles.is_empty() {
4002 findings.push(AnalysisFinding {
4003 kind: AnalysisFindingKind::DependencyCycle,
4004 status: if complete {
4005 AnalysisStatus::Absent
4006 } else {
4007 AnalysisStatus::Inconclusive
4008 },
4009 summary: if complete {
4010 "no dependency cycle exists in the complete admitted bounded scope"
4011 } else {
4012 "no cycle was observed, but traversal or coverage is incomplete"
4013 }
4014 .to_string(),
4015 nodes: Vec::new(),
4016 metric: Some(0),
4017 evidence: None,
4018 community: None,
4019 });
4020 } else {
4021 for cycle in cycles {
4022 findings.push(AnalysisFinding {
4023 kind: AnalysisFindingKind::DependencyCycle,
4024 status: AnalysisStatus::Candidate,
4025 summary: "iterative dependency-family SCC found a static cycle candidate"
4026 .to_string(),
4027 nodes: analysis_nodes_for(nodes, &cycle),
4028 metric: Some(cycle.len() as u64),
4029 evidence: None,
4030 community: None,
4031 });
4032 }
4033 }
4034 }
4035 Ok(findings)
4036}
4037
4038fn dependency_relation(kind: GraphRelationKind) -> bool {
4040 matches!(
4041 kind,
4042 GraphRelationKind::Legacy(
4043 RelationKind::Imports | RelationKind::Calls | RelationKind::DependsOn
4044 ) | GraphRelationKind::Extended(
4045 ExtendedRelationKind::Tests
4046 | ExtendedRelationKind::RoutesTo
4047 | ExtendedRelationKind::Configures
4048 )
4049 )
4050}
4051
4052fn purpose_finding(
4054 nodes: &BTreeMap<String, DetailedRelationNode>,
4055 component: &[String],
4056 complete: bool,
4057) -> AnalysisFinding {
4058 let mut purposes = BTreeSet::new();
4059 let mut unavailable = false;
4060 for key in component {
4061 match nodes.get(key).map(|node| &node.purpose) {
4062 Some(RelationPurpose::Approved { purpose, .. }) => {
4063 purposes.insert(purpose.clone());
4064 }
4065 Some(RelationPurpose::Unavailable { .. } | RelationPurpose::NotApplicable) | None => {
4066 unavailable = true;
4067 }
4068 }
4069 }
4070 let (kind, status, summary) = if purposes.len() > 1 {
4071 (
4072 AnalysisFindingKind::PurposeDrift,
4073 AnalysisStatus::Candidate,
4074 "connected owners retain multiple approved purpose responsibilities",
4075 )
4076 } else if purposes.len() == 1 && !unavailable && complete {
4077 (
4078 AnalysisFindingKind::PurposeAlignment,
4079 AnalysisStatus::Confirmed,
4080 "connected owners share one approved purpose responsibility",
4081 )
4082 } else {
4083 (
4084 AnalysisFindingKind::PurposeAlignment,
4085 AnalysisStatus::Inconclusive,
4086 if complete {
4087 "purpose alignment is unavailable for at least one admitted owner"
4088 } else {
4089 "purpose alignment is inconclusive under partial traversal or coverage"
4090 },
4091 )
4092 };
4093 AnalysisFinding {
4094 kind,
4095 status,
4096 summary: summary.to_string(),
4097 nodes: analysis_nodes_for(nodes, component),
4098 metric: Some(purposes.len() as u64),
4099 evidence: None,
4100 community: None,
4101 }
4102}
4103
4104fn structural_findings(
4106 store: &AtlasStore,
4107 nodes: &BTreeMap<String, DetailedRelationNode>,
4108 edges: &[LocalEdge],
4109 topology_complete: bool,
4110 symbol_byte_budget: u64,
4111 supplemental_work: &mut SupplementalWork,
4112 control: Option<&IndexWorkControl>,
4113) -> ServiceResult<Vec<AnalysisFinding>> {
4114 let degrees = degrees(nodes, edges);
4115 let symbols_by_file = load_admitted_symbols(store, nodes, symbol_byte_budget, control)?;
4116 supplemental_work.hydrated_symbols = supplemental_work
4117 .hydrated_symbols
4118 .saturating_add(symbols_by_file.rows_retained);
4119 supplemental_work.hydrated_symbol_bytes = supplemental_work
4120 .hydrated_symbol_bytes
4121 .saturating_add(symbols_by_file.retained_bytes);
4122 supplemental_work.hydrated_symbol_peak_bytes = supplemental_work
4123 .hydrated_symbol_peak_bytes
4124 .max(symbols_by_file.peak_bytes);
4125 supplemental_work.symbol_hydration_truncated |= !symbols_by_file.complete;
4126 supplemental_work
4127 .reached_limits
4128 .extend(symbols_by_file.reached_limits.iter().copied());
4129 let mut candidates = Vec::new();
4130 for (key, node) in nodes {
4131 check_control(control)?;
4132 let Some((path, name, kind, parent, signature)) = symbol_identity(&node.entity) else {
4133 continue;
4134 };
4135 if let Some(symbol) = symbols_by_file.rows_for_path(path).and_then(|symbols| {
4136 symbols.iter().find(|candidate| {
4137 candidate.name == name
4138 && candidate.kind == kind
4139 && candidate.parent.as_deref() == parent
4140 && candidate.signature == signature
4141 })
4142 }) {
4143 let span = symbol
4144 .line_end
4145 .saturating_sub(symbol.line_start)
4146 .saturating_add(1) as u64;
4147 let degree = degrees.get(key).copied().unwrap_or_default() as u64;
4148 candidates.push((span, degree, key.clone()));
4149 }
4150 }
4151 let symbols_complete = symbols_by_file.complete;
4152 drop(symbols_by_file);
4153 let mut findings = Vec::new();
4154 if let Some((span, _degree, key)) = candidates.iter().max_by(std::cmp::Ord::cmp) {
4155 findings.push(AnalysisFinding {
4156 kind: AnalysisFindingKind::StructuralComplexity,
4157 status: if symbols_complete && topology_complete {
4158 AnalysisStatus::Candidate
4159 } else {
4160 AnalysisStatus::Inconclusive
4161 },
4162 summary: if symbols_complete && topology_complete {
4163 "largest language-valid declaration span in the admitted scope; not cyclomatic complexity"
4164 } else {
4165 "structural candidate observed, but bounded symbol hydration omitted admitted declarations"
4166 }
4167 .to_string(),
4168 nodes: analysis_nodes_for(nodes, std::slice::from_ref(key)),
4169 metric: Some(*span),
4170 evidence: None,
4171 community: None,
4172 });
4173 }
4174 if let Some((_span, degree, key)) = candidates
4175 .iter()
4176 .max_by(|left, right| (left.1, left.0, &left.2).cmp(&(right.1, right.0, &right.2)))
4177 {
4178 findings.push(AnalysisFinding {
4179 kind: AnalysisFindingKind::Bottleneck,
4180 status: if symbols_complete && topology_complete {
4181 AnalysisStatus::Candidate
4182 } else {
4183 AnalysisStatus::Inconclusive
4184 },
4185 summary: "highest admitted static fan-in plus fan-out junction".to_string(),
4186 nodes: analysis_nodes_for(nodes, std::slice::from_ref(key)),
4187 metric: Some(*degree),
4188 evidence: None,
4189 community: None,
4190 });
4191 }
4192 Ok(findings)
4193}
4194
4195struct AdmittedSymbols {
4197 rows: Vec<CodeSymbol>,
4199 ranges: Vec<SymbolPathRange>,
4201 complete: bool,
4203 rows_retained: u32,
4205 retained_bytes: u64,
4207 peak_bytes: u64,
4209 reached_limits: Vec<GraphLimitKind>,
4211}
4212
4213#[derive(Serialize)]
4214struct SymbolPathRange {
4216 path: String,
4218 start: usize,
4220 end: usize,
4222}
4223
4224impl AdmittedSymbols {
4225 fn rows_for_path(&self, path: &str) -> Option<&[CodeSymbol]> {
4227 let index = self
4228 .ranges
4229 .binary_search_by(|range| range.path.as_str().cmp(path))
4230 .ok()?;
4231 let range = &self.ranges[index];
4232 self.rows.get(range.start..range.end)
4233 }
4234}
4235
4236fn load_admitted_symbols(
4238 store: &AtlasStore,
4239 nodes: &BTreeMap<String, DetailedRelationNode>,
4240 byte_budget: u64,
4241 control: Option<&IndexWorkControl>,
4242) -> ServiceResult<AdmittedSymbols> {
4243 let path_limit = usize::try_from(MAX_SYMBOL_BATCH_PATHS).map_err(|source| {
4244 ServiceError::InvalidInput(format!("symbol path ceiling overflowed: {source}"))
4245 })?;
4246 let mut paths = Vec::new();
4247 let mut path_truncated = false;
4248 for node in nodes.values() {
4249 check_control(control)?;
4250 let EntitySelector::Symbol { symbol } = node.entity.selector() else {
4251 continue;
4252 };
4253 let path = symbol.file.as_str();
4254 let Err(index) = paths.binary_search_by(|candidate: &String| candidate.as_str().cmp(path))
4255 else {
4256 continue;
4257 };
4258 paths.insert(index, path.to_string());
4259 if paths.len() > path_limit {
4260 paths.pop();
4261 path_truncated = true;
4262 }
4263 }
4264 let paths = paths.into_boxed_slice().into_vec();
4265 let byte_limit = byte_budget.min(MAX_SYMBOL_BATCH_DECODED_BYTES);
4266 let mut reached_limits = Vec::new();
4267 if path_truncated {
4268 push_limit(&mut reached_limits, GraphLimitKind::Rows);
4269 }
4270 if paths.is_empty() {
4271 return Ok(AdmittedSymbols {
4272 rows: Vec::new(),
4273 ranges: Vec::new(),
4274 complete: !path_truncated,
4275 rows_retained: 0,
4276 retained_bytes: 0,
4277 peak_bytes: symbol_path_request_bytes(&paths, control)?,
4278 reached_limits,
4279 });
4280 }
4281 #[cfg(test)]
4282 analysis_test_observer::notify(analysis_test_observer::AnalysisPhaseEvent::SymbolHydration);
4283 check_control(control)?;
4284 let grouping_reserve = symbol_hydration_reserve_bytes(&paths, control)?;
4285 let decoded_byte_limit = byte_limit.saturating_sub(grouping_reserve);
4286 if decoded_byte_limit == 0 {
4287 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
4288 return Ok(AdmittedSymbols {
4289 rows: Vec::new(),
4290 ranges: Vec::new(),
4291 complete: false,
4292 rows_retained: 0,
4293 retained_bytes: 0,
4294 peak_bytes: symbol_path_request_bytes(&paths, control)?,
4295 reached_limits,
4296 });
4297 }
4298 let read = store.load_symbols_for_paths_bounded(
4299 &paths,
4300 SymbolBatchReadBudget::new(
4301 MAX_SYMBOL_BATCH_PATHS,
4302 MAX_SYMBOL_BATCH_ROWS,
4303 decoded_byte_limit.min(MAX_SYMBOL_BATCH_DECODED_BYTES),
4304 )?,
4305 control,
4306 )?;
4307 match read.reached_limit {
4308 Some(SymbolBatchReadLimit::Paths | SymbolBatchReadLimit::Rows) => {
4309 push_limit(&mut reached_limits, GraphLimitKind::Rows);
4310 }
4311 Some(SymbolBatchReadLimit::DecodedBytes) => {
4312 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
4313 }
4314 None => {}
4315 }
4316 let ranges = symbol_path_ranges(&read.rows, control)?;
4317 let retained_bytes = read
4318 .work
4319 .decoded_bytes
4320 .saturating_add(symbol_range_index_bytes(&ranges, control)?);
4321 let peak_bytes = retained_bytes.saturating_add(symbol_path_request_bytes(&paths, control)?);
4322 Ok(AdmittedSymbols {
4323 rows: read.rows,
4324 ranges,
4325 complete: !path_truncated && !read.truncated,
4326 rows_retained: read.work.returned_rows,
4327 retained_bytes,
4328 peak_bytes,
4329 reached_limits,
4330 })
4331}
4332
4333fn symbol_path_ranges(
4335 rows: &[CodeSymbol],
4336 control: Option<&IndexWorkControl>,
4337) -> ServiceResult<Vec<SymbolPathRange>> {
4338 let mut ranges = Vec::new();
4339 let mut start = 0;
4340 while start < rows.len() {
4341 check_control(control)?;
4342 let mut end = start.saturating_add(1);
4343 while end < rows.len() && rows[end].path == rows[start].path {
4344 check_control(control)?;
4345 end = end.saturating_add(1);
4346 }
4347 ranges.push(SymbolPathRange {
4348 path: rows[start].path.clone(),
4349 start,
4350 end,
4351 });
4352 start = end;
4353 }
4354 Ok(ranges.into_boxed_slice().into_vec())
4355}
4356
4357fn symbol_hydration_reserve_bytes(
4359 paths: &[String],
4360 control: Option<&IndexWorkControl>,
4361) -> ServiceResult<u64> {
4362 let mut ranges = Vec::with_capacity(paths.len());
4363 for path in paths {
4364 check_control(control)?;
4365 ranges.push(SymbolPathRange {
4366 path: path.clone(),
4367 start: 0,
4368 end: 0,
4369 });
4370 }
4371 Ok(symbol_path_request_bytes(paths, control)?
4372 .saturating_add(serialized_bytes_controlled(&ranges, control)?))
4373}
4374
4375fn symbol_path_request_bytes(
4377 paths: &[String],
4378 control: Option<&IndexWorkControl>,
4379) -> ServiceResult<u64> {
4380 serialized_bytes_controlled(paths, control)
4381}
4382
4383fn symbol_range_index_bytes(
4385 ranges: &[SymbolPathRange],
4386 control: Option<&IndexWorkControl>,
4387) -> ServiceResult<u64> {
4388 serialized_bytes_controlled(ranges, control)
4389}
4390
4391struct SerializedByteCounter<'a> {
4393 bytes: u64,
4395 control: Option<&'a IndexWorkControl>,
4397 interrupted: bool,
4399}
4400
4401impl Write for SerializedByteCounter<'_> {
4402 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
4403 if self
4404 .control
4405 .is_some_and(|control| control.check(IndexWorkStage::RepositoryTraversal).is_err())
4406 {
4407 self.interrupted = true;
4408 return Err(io::Error::other("analysis serialization interrupted"));
4412 }
4413 self.bytes = self
4414 .bytes
4415 .checked_add(u64::try_from(buffer.len()).unwrap_or(u64::MAX))
4416 .ok_or_else(|| io::Error::other("analysis serialized byte count overflowed"))?;
4417 Ok(buffer.len())
4418 }
4419
4420 fn flush(&mut self) -> io::Result<()> {
4421 Ok(())
4422 }
4423}
4424
4425fn serialized_bytes_controlled<T: Serialize + ?Sized>(
4427 value: &T,
4428 control: Option<&IndexWorkControl>,
4429) -> ServiceResult<u64> {
4430 let mut counter = SerializedByteCounter {
4431 bytes: 0,
4432 control,
4433 interrupted: false,
4434 };
4435 let result = serde_json::to_writer(&mut counter, value);
4436 if counter.interrupted {
4437 check_control(control)?;
4438 }
4439 result?;
4440 check_control(control)?;
4441 Ok(counter.bytes)
4442}
4443
4444const fn serialized_finding_append_bytes(
4450 finding_bytes: u64,
4451 preceding_finding_count: usize,
4452) -> u64 {
4453 finding_bytes.saturating_add(if preceding_finding_count == 0 {
4454 0
4455 } else {
4456 JSON_ARRAY_SEPARATOR_BYTES
4457 })
4458}
4459
4460const fn serialized_findings_append_bytes(
4462 findings_bytes: u64,
4463 finding_count: usize,
4464 preceding_finding_count: usize,
4465) -> u64 {
4466 if finding_count == 0 {
4467 return 0;
4468 }
4469 findings_bytes.saturating_sub(if preceding_finding_count == 0 {
4470 JSON_ARRAY_FRAMING_BYTES
4471 } else {
4472 JSON_ARRAY_SEPARATOR_BYTES
4473 })
4474}
4475
4476fn trace_findings(
4478 report: &DetailedRelationReport,
4479 target: Option<&RelationAnchor>,
4480 evidence_complete: bool,
4481) -> ServiceResult<Vec<AnalysisFinding>> {
4482 let target = target.ok_or_else(|| {
4483 ServiceError::InvalidInput("analysis trace requires an exact target".to_string())
4484 })?;
4485 let mut matching = BTreeSet::new();
4486 if entity_matches_anchor(&report.anchor.entity, target) {
4487 matching.insert(report.anchor.entity.key().canonical_identity().to_string());
4488 }
4489 for row in &report.rows {
4490 for node in &row.path {
4491 if entity_matches_anchor(&node.entity, target) {
4492 matching.insert(node.entity.key().canonical_identity().to_string());
4493 }
4494 }
4495 }
4496 if matching.len() > 1 {
4497 return Err(ServiceError::InvalidInput(
4498 "analysis trace target is ambiguous in the admitted graph scope".to_string(),
4499 ));
4500 }
4501 let target_key = matching.iter().next();
4502 if target_key.is_some_and(|key| key == report.anchor.entity.key().canonical_identity()) {
4503 return Ok(vec![AnalysisFinding {
4504 kind: AnalysisFindingKind::StaticTrace,
4505 status: AnalysisStatus::Confirmed,
4506 summary: "static trace target is the selected anchor".to_string(),
4507 nodes: vec![analysis_node(&report.anchor)],
4508 metric: Some(0),
4509 evidence: None,
4510 community: None,
4511 }]);
4512 }
4513 if let Some(row) = target_key.and_then(|target_key| {
4514 report.rows.iter().find(|row| {
4515 row.path
4516 .last()
4517 .is_some_and(|node| node.entity.key().canonical_identity() == target_key.as_str())
4518 })
4519 }) {
4520 return Ok(vec![AnalysisFinding {
4521 kind: AnalysisFindingKind::StaticTrace,
4522 status: AnalysisStatus::Confirmed,
4523 summary: "node-simple static relation path; not a runtime execution trace".to_string(),
4524 nodes: row.path.iter().map(analysis_node).collect(),
4525 metric: Some(u64::from(row.depth)),
4526 evidence: None,
4527 community: None,
4528 }]);
4529 }
4530 Ok(vec![AnalysisFinding {
4531 kind: AnalysisFindingKind::StaticTrace,
4532 status: if evidence_complete {
4533 AnalysisStatus::Absent
4534 } else {
4535 AnalysisStatus::Inconclusive
4536 },
4537 summary: if evidence_complete {
4538 "target is not reachable in the complete admitted static scope"
4539 } else {
4540 "target was not observed before the bounded traversal or coverage stopped"
4541 }
4542 .to_string(),
4543 nodes: Vec::new(),
4544 metric: None,
4545 evidence: None,
4546 community: None,
4547 }])
4548}
4549
4550fn community_relation_weights() -> Vec<CommunityRelationWeight> {
4552 GraphRelationKind::ALL
4553 .into_iter()
4554 .filter_map(|relation| {
4555 community_relation_weight(relation)
4556 .map(|weight| CommunityRelationWeight { relation, weight })
4557 })
4558 .collect()
4559}
4560
4561fn community_relation_weight(kind: GraphRelationKind) -> Option<u32> {
4563 match kind {
4564 GraphRelationKind::Legacy(RelationKind::Calls) => Some(8),
4565 GraphRelationKind::Legacy(RelationKind::Imports) => Some(5),
4566 GraphRelationKind::Legacy(RelationKind::DependsOn) => Some(3),
4567 GraphRelationKind::Extended(
4568 ExtendedRelationKind::Tests | ExtendedRelationKind::RoutesTo,
4569 ) => Some(4),
4570 GraphRelationKind::Extended(
4571 ExtendedRelationKind::References
4572 | ExtendedRelationKind::Configures
4573 | ExtendedRelationKind::Reads
4574 | ExtendedRelationKind::Writes,
4575 ) => Some(2),
4576 GraphRelationKind::Extended(
4577 ExtendedRelationKind::Documents | ExtendedRelationKind::Deploys,
4578 ) => Some(1),
4579 GraphRelationKind::Legacy(RelationKind::Contains) => None,
4580 }
4581}
4582
4583fn admitted_community_edge_weight(edge: &LocalEdge) -> Option<u32> {
4585 if !edge.complete {
4586 return None;
4587 }
4588 community_relation_weight(edge.kind)
4589}
4590
4591#[cfg(test)]
4593fn community_findings(
4594 nodes: &BTreeMap<String, DetailedRelationNode>,
4595 edges: &[LocalEdge],
4596 complete: bool,
4597 query: &RelationAnalysisQuery,
4598 control: Option<&IndexWorkControl>,
4599) -> ServiceResult<Vec<AnalysisFinding>> {
4600 Ok(community_findings_with_budget(
4601 nodes,
4602 edges,
4603 complete,
4604 query,
4605 query.relations.budget.intermediate_bytes(),
4606 0,
4607 control,
4608 )?
4609 .0)
4610}
4611
4612fn community_findings_with_budget(
4614 nodes: &BTreeMap<String, DetailedRelationNode>,
4615 edges: &[LocalEdge],
4616 complete: bool,
4617 query: &RelationAnalysisQuery,
4618 intermediate_bytes: u64,
4619 preceding_finding_count: usize,
4620 control: Option<&IndexWorkControl>,
4621) -> ServiceResult<(Vec<AnalysisFinding>, u64, Vec<GraphLimitKind>)> {
4622 check_control(control)?;
4623 let weights = community_relation_weights();
4624 let parameters = CommunityParameters {
4625 algorithm_version: COMMUNITY_ALGORITHM_VERSION,
4626 ordering_version: COMMUNITY_ORDERING_VERSION,
4627 max_iterations: COMMUNITY_MAX_ITERATIONS,
4628 node_limit: query.relations.budget.nodes().min(MAX_ANALYSIS_NODES),
4629 edge_limit: query.relations.budget.edges().min(MAX_ANALYSIS_EDGES),
4630 output_bytes: query.relations.budget.output_bytes(),
4631 relation: query.relations.relation,
4632 };
4633 let complete = complete
4634 && edges
4635 .iter()
4636 .filter(|edge| community_relation_weight(edge.kind).is_some())
4637 .all(|edge| edge.complete);
4638 let marker_coverage = if complete {
4639 CommunityCoverage::Complete
4640 } else {
4641 CommunityCoverage::Partial
4642 };
4643 let truncation_marker = community_truncation_finding(&weights, parameters, 0, marker_coverage);
4644 let truncation_marker_bytes = serialized_bytes_controlled(&truncation_marker, control)?;
4645 let truncation_marker_append_bytes =
4646 serialized_finding_append_bytes(truncation_marker_bytes, preceding_finding_count);
4647 let working_set_bytes = community_working_set_upper_bound(nodes, edges, control)?;
4648 if working_set_bytes.saturating_add(truncation_marker_append_bytes) > intermediate_bytes {
4649 let findings = if truncation_marker_append_bytes <= intermediate_bytes {
4650 vec![truncation_marker]
4651 } else {
4652 Vec::new()
4653 };
4654 return Ok((findings, 0, vec![GraphLimitKind::IntermediateBytes]));
4655 }
4656 let construction_bytes = intermediate_bytes.saturating_sub(working_set_bytes);
4657 let (keys, admitted_edges, resource_limits) =
4658 admitted_community_scope(nodes, edges, parameters);
4659 let resource_truncated = !resource_limits.is_empty();
4660 if !complete || resource_truncated {
4661 let mut evidence = admitted_edges;
4662 if resource_truncated {
4663 evidence.truncate(parameters.edge_limit as usize);
4664 }
4665 let metadata_fits = serialized_finding_append_bytes(
4666 community_finding_upper_bound(nodes, &keys, &evidence, &weights, parameters, control)?,
4667 preceding_finding_count,
4668 ) <= construction_bytes;
4669 let metadata_keys = if metadata_fits { keys.as_slice() } else { &[] };
4670 let metadata_evidence = if metadata_fits {
4671 evidence.as_slice()
4672 } else {
4673 &[]
4674 };
4675 let metadata_truncated = resource_truncated || !metadata_fits;
4676 let mut truncation_limits = resource_limits;
4677 if !metadata_fits {
4678 push_limit(&mut truncation_limits, GraphLimitKind::IntermediateBytes);
4679 }
4680 let metadata = community_metadata(
4681 metadata_keys,
4682 metadata_evidence,
4683 weights,
4684 parameters,
4685 0,
4686 CommunityConvergence::Inconclusive,
4687 if complete {
4688 CommunityCoverage::Complete
4689 } else {
4690 CommunityCoverage::Partial
4691 },
4692 metadata_truncated,
4693 nodes,
4694 );
4695 return Ok((
4696 vec![AnalysisFinding {
4697 kind: AnalysisFindingKind::Community,
4698 status: AnalysisStatus::Inconclusive,
4699 summary: if resource_truncated {
4700 "community projection crossed its fixed node or edge resource ceiling"
4701 } else {
4702 "community projection is inconclusive because relation coverage is partial"
4703 }
4704 .to_string(),
4705 nodes: analysis_nodes_for(nodes, metadata_keys),
4706 metric: Some(metadata_keys.len() as u64),
4707 evidence: None,
4708 community: Some(metadata),
4709 }],
4710 working_set_bytes,
4711 truncation_limits,
4712 ));
4713 }
4714
4715 let (labels, iteration, convergence) =
4716 propagate_community_labels(&keys, &admitted_edges, parameters.max_iterations, control)?;
4717 if convergence != CommunityConvergence::Converged {
4718 let metadata_fits = community_finding_upper_bound(
4719 nodes,
4720 &keys,
4721 &admitted_edges,
4722 &weights,
4723 parameters,
4724 control,
4725 )?;
4726 let metadata_fits = serialized_finding_append_bytes(metadata_fits, preceding_finding_count)
4727 <= construction_bytes;
4728 let metadata_keys = if metadata_fits { keys.as_slice() } else { &[] };
4729 let metadata_evidence = if metadata_fits {
4730 admitted_edges.as_slice()
4731 } else {
4732 &[]
4733 };
4734 let metadata_truncated = !metadata_fits;
4735 let mut truncation_limits = Vec::new();
4736 if !metadata_fits {
4737 truncation_limits.push(GraphLimitKind::IntermediateBytes);
4738 }
4739 let metadata = community_metadata(
4740 metadata_keys,
4741 metadata_evidence,
4742 weights,
4743 parameters,
4744 iteration,
4745 convergence,
4746 CommunityCoverage::Complete,
4747 metadata_truncated,
4748 nodes,
4749 );
4750 return Ok((
4751 vec![AnalysisFinding {
4752 kind: AnalysisFindingKind::Community,
4753 status: AnalysisStatus::Inconclusive,
4754 summary: "community label propagation reached its fixed iteration ceiling"
4755 .to_string(),
4756 nodes: analysis_nodes_for(nodes, metadata_keys),
4757 metric: Some(metadata_keys.len() as u64),
4758 evidence: None,
4759 community: Some(metadata),
4760 }],
4761 working_set_bytes,
4762 truncation_limits,
4763 ));
4764 }
4765
4766 let (findings, truncation_limits) = community_candidate_findings(
4767 nodes,
4768 &keys,
4769 &admitted_edges,
4770 &labels,
4771 &weights,
4772 parameters,
4773 iteration,
4774 convergence,
4775 control,
4776 preceding_finding_count,
4777 construction_bytes,
4778 )?;
4779 Ok((findings, working_set_bytes, truncation_limits))
4780}
4781
4782fn admitted_community_scope(
4784 nodes: &BTreeMap<String, DetailedRelationNode>,
4785 edges: &[LocalEdge],
4786 parameters: CommunityParameters,
4787) -> (Vec<String>, Vec<CommunityEdgeEvidence>, Vec<GraphLimitKind>) {
4788 let all_keys = nodes.keys().cloned().collect::<Vec<_>>();
4789 let keys = all_keys
4790 .iter()
4791 .take(parameters.node_limit as usize)
4792 .cloned()
4793 .collect::<Vec<_>>();
4794 let admitted_nodes = keys.iter().collect::<BTreeSet<_>>();
4795 let mut admitted_edges = Vec::new();
4796 let mut edge_count = 0_usize;
4797 for edge in edges {
4798 let Some(weight) = admitted_community_edge_weight(edge) else {
4799 continue;
4800 };
4801 if !admitted_nodes.contains(&edge.source) || !admitted_nodes.contains(&edge.target) {
4802 continue;
4803 }
4804 edge_count = edge_count.saturating_add(1);
4805 if edge_count <= parameters.edge_limit as usize {
4806 admitted_edges.push(CommunityEdgeEvidence {
4807 source: edge.source.clone(),
4808 target: edge.target.clone(),
4809 relation: edge.kind,
4810 weight,
4811 });
4812 }
4813 }
4814 admitted_edges.sort_by(|left, right| {
4815 (&left.source, &left.target, left.relation.as_str()).cmp(&(
4816 &right.source,
4817 &right.target,
4818 right.relation.as_str(),
4819 ))
4820 });
4821 admitted_edges.dedup_by(|left, right| {
4822 left.source == right.source
4823 && left.target == right.target
4824 && left.relation == right.relation
4825 });
4826 let mut reached_limits = Vec::new();
4827 if all_keys.len() > parameters.node_limit as usize {
4828 reached_limits.push(GraphLimitKind::Nodes);
4829 }
4830 if edge_count > parameters.edge_limit as usize {
4831 reached_limits.push(GraphLimitKind::Edges);
4832 }
4833 (keys, admitted_edges, reached_limits)
4834}
4835
4836fn propagate_community_labels(
4838 keys: &[String],
4839 edges: &[CommunityEdgeEvidence],
4840 max_iterations: u32,
4841 control: Option<&IndexWorkControl>,
4842) -> ServiceResult<(BTreeMap<String, String>, u32, CommunityConvergence)> {
4843 check_control(control)?;
4844 let mut adjacency = keys
4845 .iter()
4846 .map(|key| (key.clone(), Vec::<(String, u32)>::new()))
4847 .collect::<BTreeMap<_, _>>();
4848 for edge in edges {
4849 check_control(control)?;
4850 adjacency
4851 .entry(edge.source.clone())
4852 .or_default()
4853 .push((edge.target.clone(), edge.weight));
4854 adjacency
4855 .entry(edge.target.clone())
4856 .or_default()
4857 .push((edge.source.clone(), edge.weight));
4858 }
4859 for neighbors in adjacency.values_mut() {
4860 neighbors.sort();
4861 }
4862
4863 let mut labels = keys
4867 .iter()
4868 .map(|key| (key.clone(), key.clone()))
4869 .collect::<BTreeMap<_, _>>();
4870 let mut converged = keys.is_empty();
4871 let mut iteration = 0;
4872 while !converged && iteration < max_iterations {
4873 check_control(control)?;
4874 iteration += 1;
4875 let mut changed = false;
4876 for key in keys {
4877 check_control(control)?;
4878 let mut scores = BTreeMap::<String, u32>::new();
4879 scores.insert(key.clone(), COMMUNITY_SELF_WEIGHT);
4880 if let Some(neighbors) = adjacency.get(key) {
4881 for (neighbor, weight) in neighbors {
4882 check_control(control)?;
4883 if let Some(label) = labels.get(neighbor) {
4884 let score = scores.entry(label.clone()).or_default();
4885 *score = score.saturating_add(*weight);
4886 }
4887 }
4888 }
4889 let selected = select_community_label(key, scores);
4890 if labels.get(key) != Some(&selected) {
4891 labels.insert(key.clone(), selected);
4892 changed = true;
4893 }
4894 }
4895 converged = !changed;
4896 }
4897
4898 Ok((
4899 labels,
4900 iteration,
4901 if converged {
4902 CommunityConvergence::Converged
4903 } else {
4904 CommunityConvergence::IterationLimit
4905 },
4906 ))
4907}
4908
4909fn select_community_label(key: &str, scores: BTreeMap<String, u32>) -> String {
4911 let mut selected = key.to_string();
4912 let mut selected_score = 0;
4913 for (label, score) in scores {
4914 if score > selected_score || (score == selected_score && label < selected) {
4915 selected = label;
4916 selected_score = score;
4917 }
4918 }
4919 selected
4920}
4921
4922fn community_candidate_findings(
4924 nodes: &BTreeMap<String, DetailedRelationNode>,
4925 keys: &[String],
4926 admitted_edges: &[CommunityEdgeEvidence],
4927 labels: &BTreeMap<String, String>,
4928 weights: &[CommunityRelationWeight],
4929 parameters: CommunityParameters,
4930 iteration: u32,
4931 convergence: CommunityConvergence,
4932 control: Option<&IndexWorkControl>,
4933 preceding_finding_count: usize,
4934 intermediate_bytes: u64,
4935) -> ServiceResult<(Vec<AnalysisFinding>, Vec<GraphLimitKind>)> {
4936 let mut groups = BTreeMap::<String, Vec<String>>::new();
4937 for key in keys {
4938 check_control(control)?;
4939 let label = labels.get(key).cloned().unwrap_or_else(|| key.clone());
4940 groups.entry(label).or_default().push(key.clone());
4941 }
4942 let mut findings = Vec::new();
4943 let mut retained_append_bytes = 0_u64;
4944 let mut truncated = false;
4945 for members in groups.values() {
4946 check_control(control)?;
4947 let candidate_bound = community_candidate_upper_bound(
4948 nodes,
4949 members,
4950 admitted_edges,
4951 weights,
4952 parameters,
4953 control,
4954 )?;
4955 let candidate_append_bytes = serialized_finding_append_bytes(
4956 candidate_bound,
4957 preceding_finding_count.saturating_add(findings.len()),
4958 );
4959 if retained_append_bytes.saturating_add(candidate_append_bytes) > intermediate_bytes {
4960 truncated = true;
4961 break;
4962 }
4963 let evidence = admitted_edges
4964 .iter()
4965 .filter(|edge| {
4966 members.binary_search(&edge.source).is_ok()
4967 && members.binary_search(&edge.target).is_ok()
4968 })
4969 .cloned()
4970 .collect::<Vec<_>>();
4971 let metadata = community_metadata(
4972 members,
4973 &evidence,
4974 weights.to_vec(),
4975 parameters,
4976 iteration,
4977 convergence,
4978 CommunityCoverage::Complete,
4979 false,
4980 nodes,
4981 );
4982 let finding = AnalysisFinding {
4983 kind: AnalysisFindingKind::Community,
4984 status: AnalysisStatus::Candidate,
4985 summary: if members.len() == 1 {
4986 "singleton community from the complete admitted relation scope"
4987 } else {
4988 "deterministic weighted relationship community candidate"
4989 }
4990 .to_string(),
4991 nodes: analysis_nodes_for(nodes, members),
4992 metric: Some(members.len() as u64),
4993 evidence: None,
4994 community: Some(metadata),
4995 };
4996 let finding_bytes = serialized_bytes_controlled(&finding, control)?;
4997 let finding_append_bytes = serialized_finding_append_bytes(
4998 finding_bytes,
4999 preceding_finding_count.saturating_add(findings.len()),
5000 );
5001 if retained_append_bytes.saturating_add(finding_append_bytes) > intermediate_bytes {
5002 truncated = true;
5003 break;
5004 }
5005 retained_append_bytes = retained_append_bytes.saturating_add(finding_append_bytes);
5006 findings.push(finding);
5007 }
5008 if truncated {
5009 check_control(control)?;
5010 let marker = community_truncation_finding(
5011 weights,
5012 parameters,
5013 iteration,
5014 CommunityCoverage::Complete,
5015 );
5016 let marker_bytes = serialized_bytes_controlled(&marker, control)?;
5017 let marker_append_bytes = serialized_finding_append_bytes(
5018 marker_bytes,
5019 preceding_finding_count.saturating_add(findings.len()),
5020 );
5021 if retained_append_bytes.saturating_add(marker_append_bytes) > intermediate_bytes {
5022 findings.clear();
5023 } else {
5024 findings.push(marker);
5025 }
5026 }
5027 Ok((
5028 findings,
5029 truncated
5030 .then_some(vec![GraphLimitKind::IntermediateBytes])
5031 .unwrap_or_default(),
5032 ))
5033}
5034
5035fn community_working_set_upper_bound(
5041 nodes: &BTreeMap<String, DetailedRelationNode>,
5042 edges: &[LocalEdge],
5043 control: Option<&IndexWorkControl>,
5044) -> ServiceResult<u64> {
5045 let mut endpoint_bytes = 0_u64;
5046 for key in nodes.keys() {
5047 check_control(control)?;
5048 endpoint_bytes = endpoint_bytes
5049 .saturating_add(u64::try_from(key.len()).unwrap_or(u64::MAX))
5050 .saturating_add(std::mem::size_of::<String>() as u64);
5051 }
5052 let mut admitted_edge_count = 0_u64;
5053 for edge in edges {
5054 check_control(control)?;
5055 if admitted_community_edge_weight(edge).is_none() {
5056 continue;
5057 }
5058 admitted_edge_count = admitted_edge_count.saturating_add(1);
5059 endpoint_bytes = endpoint_bytes
5060 .saturating_add(u64::try_from(edge.source.len()).unwrap_or(u64::MAX))
5061 .saturating_add(u64::try_from(edge.target.len()).unwrap_or(u64::MAX))
5062 .saturating_add(std::mem::size_of::<String>() as u64 * 2)
5063 .saturating_add(std::mem::size_of::<CommunityEdgeEvidence>() as u64);
5064 }
5065 let entry_count = u64::try_from(nodes.len())
5066 .unwrap_or(u64::MAX)
5067 .saturating_add(admitted_edge_count);
5068 Ok(endpoint_bytes
5069 .saturating_mul(COMMUNITY_WORKING_SET_MULTIPLIER)
5070 .saturating_add(entry_count.saturating_mul(COMMUNITY_WORKING_SET_ENTRY_BYTES))
5071 .saturating_add(COMMUNITY_WORKING_SET_FIXED_BYTES))
5072}
5073
5074fn community_truncation_finding(
5076 weights: &[CommunityRelationWeight],
5077 parameters: CommunityParameters,
5078 iteration: u32,
5079 coverage: CommunityCoverage,
5080) -> AnalysisFinding {
5081 AnalysisFinding {
5082 kind: AnalysisFindingKind::Community,
5083 status: AnalysisStatus::Inconclusive,
5084 summary: "community projection crossed the intermediate-byte budget before all communities were constructed"
5085 .to_string(),
5086 nodes: Vec::new(),
5087 metric: None,
5088 evidence: None,
5089 community: Some(CommunityAnalysis {
5090 id: community_id(&[], weights, parameters),
5091 members: Vec::new(),
5092 evidence: Vec::new(),
5093 weights: weights.to_vec(),
5094 parameters,
5095 iteration,
5096 convergence: CommunityConvergence::Inconclusive,
5097 coverage,
5098 truncated: true,
5099 }),
5100 }
5101}
5102
5103fn community_finding_upper_bound(
5105 nodes: &BTreeMap<String, DetailedRelationNode>,
5106 members: &[String],
5107 evidence: &[CommunityEdgeEvidence],
5108 weights: &[CommunityRelationWeight],
5109 parameters: CommunityParameters,
5110 control: Option<&IndexWorkControl>,
5111) -> ServiceResult<u64> {
5112 let member_bytes = community_member_bytes(nodes, members, control)?;
5113 let evidence_bytes = serialized_bytes_controlled(evidence, control)?;
5114 let weights_bytes = serialized_bytes_controlled(weights, control)?;
5115 let parameter_bytes = serialized_bytes_controlled(¶meters, control)?;
5116 Ok(member_bytes
5117 .saturating_mul(2)
5118 .saturating_add(evidence_bytes)
5119 .saturating_add(weights_bytes)
5120 .saturating_add(parameter_bytes)
5121 .saturating_add((members.len() as u64).saturating_mul(2))
5122 .saturating_add(2_048))
5123}
5124
5125fn community_candidate_upper_bound(
5127 nodes: &BTreeMap<String, DetailedRelationNode>,
5128 members: &[String],
5129 admitted_edges: &[CommunityEdgeEvidence],
5130 weights: &[CommunityRelationWeight],
5131 parameters: CommunityParameters,
5132 control: Option<&IndexWorkControl>,
5133) -> ServiceResult<u64> {
5134 let member_bytes = community_member_bytes(nodes, members, control)?;
5135 let mut evidence_bytes = 0_u64;
5136 for edge in admitted_edges {
5137 check_control(control)?;
5138 if members.binary_search(&edge.source).is_ok()
5139 && members.binary_search(&edge.target).is_ok()
5140 {
5141 evidence_bytes = evidence_bytes
5142 .saturating_add(serialized_bytes_controlled(edge, control)?)
5143 .saturating_add(1);
5144 }
5145 }
5146 let weights_bytes = serialized_bytes_controlled(weights, control)?;
5147 let parameter_bytes = serialized_bytes_controlled(¶meters, control)?;
5148 Ok(member_bytes
5149 .saturating_mul(2)
5150 .saturating_add(evidence_bytes)
5151 .saturating_add(weights_bytes)
5152 .saturating_add(parameter_bytes)
5153 .saturating_add((members.len() as u64).saturating_mul(2))
5154 .saturating_add(2_048))
5155}
5156
5157fn community_member_bytes(
5159 nodes: &BTreeMap<String, DetailedRelationNode>,
5160 members: &[String],
5161 control: Option<&IndexWorkControl>,
5162) -> ServiceResult<u64> {
5163 let mut bytes = 0_u64;
5164 for key in members {
5165 check_control(control)?;
5166 if let Some(node) = nodes.get(key) {
5167 bytes =
5168 bytes.saturating_add(serialized_bytes_controlled(&analysis_node(node), control)?);
5169 }
5170 }
5171 Ok(bytes)
5172}
5173
5174fn community_metadata(
5176 members: &[String],
5177 evidence: &[CommunityEdgeEvidence],
5178 weights: Vec<CommunityRelationWeight>,
5179 parameters: CommunityParameters,
5180 iteration: u32,
5181 convergence: CommunityConvergence,
5182 coverage: CommunityCoverage,
5183 truncated: bool,
5184 nodes: &BTreeMap<String, DetailedRelationNode>,
5185) -> CommunityAnalysis {
5186 let mut members = members.to_vec();
5187 members.sort();
5188 let id = community_id(&members, &weights, parameters);
5189 let mut evidence = evidence.to_vec();
5190 evidence.sort_by(|left, right| {
5191 (&left.source, &left.target, left.relation.as_str()).cmp(&(
5192 &right.source,
5193 &right.target,
5194 right.relation.as_str(),
5195 ))
5196 });
5197 CommunityAnalysis {
5198 id,
5199 members: analysis_nodes_for(nodes, &members),
5200 evidence,
5201 weights,
5202 parameters,
5203 iteration,
5204 convergence,
5205 coverage,
5206 truncated,
5207 }
5208}
5209
5210fn community_id(
5212 members: &[String],
5213 weights: &[CommunityRelationWeight],
5214 parameters: CommunityParameters,
5215) -> String {
5216 let mut hasher = blake3::Hasher::new();
5217 hasher.update(b"projectatlas:architecture-community");
5218 hasher.update(&[0]);
5219 hasher.update(¶meters.algorithm_version.to_le_bytes());
5220 hasher.update(¶meters.ordering_version.to_le_bytes());
5221 hasher.update(¶meters.max_iterations.to_le_bytes());
5222 hasher.update(¶meters.node_limit.to_le_bytes());
5223 hasher.update(¶meters.edge_limit.to_le_bytes());
5224 hasher.update(¶meters.output_bytes.to_le_bytes());
5225 hasher.update(&COMMUNITY_SELF_WEIGHT.to_le_bytes());
5226 hasher.update(
5227 parameters
5228 .relation
5229 .map_or("none", GraphRelationKind::as_str)
5230 .as_bytes(),
5231 );
5232 hasher.update(&[0]);
5233 for entry in weights {
5234 hasher.update(entry.relation.as_str().as_bytes());
5235 hasher.update(&[0]);
5236 hasher.update(&entry.weight.to_le_bytes());
5237 }
5238 for member in members {
5239 hasher.update(member.as_bytes());
5240 hasher.update(&[0]);
5241 }
5242 format!(
5243 "community-v{}-{}",
5244 parameters.algorithm_version,
5245 hasher.finalize().to_hex()
5246 )
5247}
5248
5249fn weak_components(
5251 nodes: &BTreeMap<String, DetailedRelationNode>,
5252 edges: &[LocalEdge],
5253 exclude_contains: bool,
5254) -> Vec<Vec<String>> {
5255 let mut adjacency = nodes
5256 .keys()
5257 .map(|key| (key.clone(), Vec::new()))
5258 .collect::<BTreeMap<_, _>>();
5259 for edge in edges {
5260 if exclude_contains && edge.kind == GraphRelationKind::Legacy(RelationKind::Contains) {
5261 continue;
5262 }
5263 adjacency
5264 .entry(edge.source.clone())
5265 .or_default()
5266 .push(edge.target.clone());
5267 adjacency
5268 .entry(edge.target.clone())
5269 .or_default()
5270 .push(edge.source.clone());
5271 }
5272 let mut visited = BTreeSet::new();
5273 let mut components = Vec::new();
5274 for start in nodes.keys() {
5275 if !visited.insert(start.clone()) {
5276 continue;
5277 }
5278 let mut queue = VecDeque::from([start.clone()]);
5279 let mut component = Vec::new();
5280 while let Some(node) = queue.pop_front() {
5281 component.push(node.clone());
5282 if let Some(neighbors) = adjacency.get(&node) {
5283 for neighbor in neighbors {
5284 if visited.insert(neighbor.clone()) {
5285 queue.push_back(neighbor.clone());
5286 }
5287 }
5288 }
5289 }
5290 component.sort();
5291 components.push(component);
5292 }
5293 components
5294}
5295
5296fn strongly_connected_components(
5298 nodes: &BTreeMap<String, DetailedRelationNode>,
5299 edges: &[LocalEdge],
5300) -> Vec<Vec<String>> {
5301 let keys = nodes.keys().cloned().collect::<Vec<_>>();
5302 let indices = keys
5303 .iter()
5304 .enumerate()
5305 .map(|(index, key)| (key.clone(), index))
5306 .collect::<BTreeMap<_, _>>();
5307 let mut forward = vec![Vec::new(); keys.len()];
5308 let mut reverse = vec![Vec::new(); keys.len()];
5309 for edge in edges {
5310 let (Some(&source), Some(&target)) = (indices.get(&edge.source), indices.get(&edge.target))
5311 else {
5312 continue;
5313 };
5314 forward[source].push(target);
5315 reverse[target].push(source);
5316 }
5317 let mut seen = vec![false; keys.len()];
5318 let mut order = Vec::new();
5319 for start in 0..keys.len() {
5320 if seen[start] {
5321 continue;
5322 }
5323 let mut stack = vec![(start, false)];
5324 while let Some((node, expanded)) = stack.pop() {
5325 if expanded {
5326 order.push(node);
5327 continue;
5328 }
5329 if seen[node] {
5330 continue;
5331 }
5332 seen[node] = true;
5333 stack.push((node, true));
5334 for &next in forward[node].iter().rev() {
5335 if !seen[next] {
5336 stack.push((next, false));
5337 }
5338 }
5339 }
5340 }
5341 seen.fill(false);
5342 let mut components = Vec::new();
5343 for &start in order.iter().rev() {
5344 if seen[start] {
5345 continue;
5346 }
5347 seen[start] = true;
5348 let mut stack = vec![start];
5349 let mut component = Vec::new();
5350 while let Some(node) = stack.pop() {
5351 component.push(keys[node].clone());
5352 for &next in &reverse[node] {
5353 if !seen[next] {
5354 seen[next] = true;
5355 stack.push(next);
5356 }
5357 }
5358 }
5359 component.sort();
5360 components.push(component);
5361 }
5362 components
5363}
5364
5365fn degrees(
5367 nodes: &BTreeMap<String, DetailedRelationNode>,
5368 edges: &[LocalEdge],
5369) -> BTreeMap<String, usize> {
5370 let mut values = nodes
5371 .keys()
5372 .map(|key| (key.clone(), 0))
5373 .collect::<BTreeMap<_, _>>();
5374 for edge in edges {
5375 *values.entry(edge.source.clone()).or_default() += 1;
5376 *values.entry(edge.target.clone()).or_default() += 1;
5377 }
5378 values
5379}
5380
5381fn usage_indegrees(
5386 nodes: &BTreeMap<String, DetailedRelationNode>,
5387 edges: &[LocalEdge],
5388 control: Option<&IndexWorkControl>,
5389) -> ServiceResult<BTreeMap<String, usize>> {
5390 let mut values = BTreeMap::new();
5391 for key in nodes.keys() {
5392 check_control(control)?;
5393 values.insert(key.clone(), 0);
5394 }
5395 for edge in edges
5396 .iter()
5397 .filter(|edge| edge.kind != GraphRelationKind::Legacy(RelationKind::Contains))
5398 {
5399 check_control(control)?;
5400 *values.entry(edge.target.clone()).or_default() += 1;
5401 }
5402 Ok(values)
5403}
5404
5405fn analysis_nodes_for(
5407 nodes: &BTreeMap<String, DetailedRelationNode>,
5408 keys: &[String],
5409) -> Vec<AnalysisNode> {
5410 keys.iter()
5411 .filter_map(|key| nodes.get(key))
5412 .map(analysis_node)
5413 .collect()
5414}
5415
5416fn serialized_analysis_nodes_bytes<'node>(
5418 nodes: impl IntoIterator<Item = &'node DetailedRelationNode>,
5419 control: Option<&IndexWorkControl>,
5420) -> ServiceResult<u64> {
5421 let mut bytes = 0_u64;
5422 for (index, node) in nodes.into_iter().enumerate() {
5423 if index > 0 {
5424 bytes = bytes
5425 .checked_add(JSON_ARRAY_SEPARATOR_BYTES)
5426 .ok_or_else(entrypoint_work_overflow)?;
5427 }
5428 bytes = bytes
5429 .checked_add(serialized_bytes_controlled(&analysis_node(node), control)?)
5430 .ok_or_else(entrypoint_work_overflow)?;
5431 }
5432 Ok(bytes)
5433}
5434
5435fn analysis_node(node: &DetailedRelationNode) -> AnalysisNode {
5437 let next_call = super::relations::next_call_for_entity(
5438 &node.entity,
5439 node.content_selection
5440 .unwrap_or(ContentSelection::UnspecifiedLegacy),
5441 node.classification,
5442 );
5443 AnalysisNode {
5444 node: node.clone(),
5445 next_call,
5446 }
5447}
5448
5449fn entity_matches_anchor(entity: &GraphEntity, target: &RelationAnchor) -> bool {
5451 match (entity.selector(), target) {
5452 (EntitySelector::File { path }, RelationAnchor::File { file }) => path == file,
5453 (
5454 EntitySelector::Symbol { symbol },
5455 RelationAnchor::Symbol {
5456 file,
5457 name,
5458 symbol_kind,
5459 parent,
5460 signature,
5461 },
5462 ) => {
5463 symbol.file == *file
5464 && symbol.name.as_str() == name
5465 && symbol_kind.is_none_or(|kind| symbol.kind == kind)
5466 && symbol.parent.as_ref().map(GraphIdentityText::as_str) == parent.as_deref()
5467 && signature
5468 .as_deref()
5469 .is_none_or(|signature| symbol.signature.as_str() == signature)
5470 }
5471 _ => false,
5472 }
5473}
5474
5475fn entity_path(entity: &GraphEntity) -> Option<&str> {
5477 match entity.selector() {
5478 EntitySelector::Folder { path } => Some(path.as_str()),
5479 EntitySelector::File { path } => Some(path.as_str()),
5480 EntitySelector::Package { package } => Some(package.manifest.as_str()),
5481 EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
5482 EntitySelector::Project | EntitySelector::External { .. } => None,
5483 }
5484}
5485
5486fn symbol_identity(
5488 entity: &GraphEntity,
5489) -> Option<(
5490 &str,
5491 &str,
5492 projectatlas_core::symbols::SymbolKind,
5493 Option<&str>,
5494 &str,
5495)> {
5496 let EntitySelector::Symbol { symbol } = entity.selector() else {
5497 return None;
5498 };
5499 Some((
5500 symbol.file.as_str(),
5501 symbol.name.as_str(),
5502 symbol.kind,
5503 symbol.parent.as_ref().map(GraphIdentityText::as_str),
5504 symbol.signature.as_str(),
5505 ))
5506}
5507
5508fn analysis_prefix(
5510 report: &RelationAnalysisReport,
5511 rows: usize,
5512 reached_limits: impl IntoIterator<Item = GraphLimitKind>,
5513) -> RelationAnalysisReport {
5514 let mut candidate = report.clone();
5515 if rows < candidate.findings.len() {
5516 candidate.findings.truncate(rows);
5517 candidate.truncated = true;
5518 for limit in reached_limits {
5519 push_limit(&mut candidate.reached_limits, limit);
5520 }
5521 }
5522 candidate.returned = u32::try_from(candidate.findings.len()).unwrap_or(u32::MAX);
5523 candidate.work.rendered_output_bytes = 0;
5524 candidate
5525}
5526
5527fn push_limit(limits: &mut Vec<GraphLimitKind>, limit: GraphLimitKind) {
5529 if !limits.contains(&limit) {
5530 limits.push(limit);
5531 }
5532}
5533
5534fn check_control(control: Option<&IndexWorkControl>) -> ServiceResult<()> {
5536 if let Some(control) = control {
5537 control
5538 .check(IndexWorkStage::RepositoryTraversal)
5539 .map_err(DbError::from)?;
5540 }
5541 Ok(())
5542}
5543
5544#[cfg(test)]
5545#[path = "analysis/tests.rs"]
5546mod tests;