projectatlas_service/
analysis.rs

1//! Closed, bounded architecture, impact, and static-trace projections.
2
3mod impact;
4
5#[cfg(test)]
6mod analysis_test_observer {
7    use std::cell::RefCell;
8
9    /// Named production phase reached by one synchronous analysis request.
10    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
11    pub(super) enum AnalysisPhaseEvent {
12        /// Induced relationship closure is about to traverse its first bounded frontier.
13        Traversal,
14        /// Admitted symbol hydration is about to enter bounded storage work.
15        SymbolHydration,
16        /// Topology and finding composition is about to retain aggregate output state.
17        Composition,
18        /// Optional dead-code candidate discovery has entered complete-scope work.
19        DeadCodeDiscovery,
20        /// Adapter-specific output fitting has begun under the retained request control.
21        OutputRendering,
22    }
23
24    /// Thread-local callback used only while one unit test owns observation.
25    type Observer = Box<dyn FnMut(AnalysisPhaseEvent)>;
26
27    thread_local! {
28        /// Observer scoped to the synchronous analysis test thread.
29        static OBSERVER: RefCell<Option<Observer>> = RefCell::new(None);
30    }
31
32    /// Restores a prior nested observer on every return or unwind path.
33    struct ObserverGuard {
34        /// Observer replaced for the current scope.
35        previous: Option<Observer>,
36    }
37
38    impl Drop for ObserverGuard {
39        fn drop(&mut self) {
40            OBSERVER.with(|slot| {
41                drop(slot.replace(self.previous.take()));
42            });
43        }
44    }
45
46    /// Run one operation while observing named production analysis phases.
47    pub(super) fn observe_analysis_phase<T>(
48        observer: impl FnMut(AnalysisPhaseEvent) + 'static,
49        operation: impl FnOnce() -> T,
50    ) -> T {
51        let previous = OBSERVER.with(|slot| slot.replace(Some(Box::new(observer))));
52        let _guard = ObserverGuard { previous };
53        operation()
54    }
55
56    /// Notify the current thread-local observer when one is installed.
57    pub(super) fn notify(event: AnalysisPhaseEvent) {
58        OBSERVER.with(|slot| {
59            if let Some(observer) = slot.borrow_mut().as_mut() {
60                observer(event);
61            }
62        });
63    }
64}
65
66use super::relations::{
67    ExternalRelationIdentity, external_relation_identities, load_detailed_relations,
68};
69use super::{
70    DetailedRelationBudget, DetailedRelationNode, DetailedRelationQuery, DetailedRelationReport,
71    DetailedRelationWork, RelationAnchor, RelationDirection, RelationNextCall, RelationPurpose,
72    RelationResolutionFilter, RelationTotalState, ServiceError, ServiceResult,
73    selected_project_binding,
74};
75use impact::{LoadedVcs, digest_vcs_paths, impact_findings, load_vcs_paths};
76use projectatlas_core::graph::{
77    Completeness, ConfidenceClass, CoverageState, EntitySelector, ExtendedRelationKind,
78    GraphEntity, GraphIdentityText, GraphLimitKind, GraphLimits, GraphRelationKind,
79    ProjectInstanceId, RelationResolution,
80};
81use projectatlas_core::symbols::{CodeSymbol, RelationKind};
82use projectatlas_core::{IndexCancellation, IndexWorkControl, IndexWorkStage};
83use projectatlas_db::{
84    AtlasStore, DbError, MAX_REPOSITORY_GRAPH_FRONTIER, MAX_SYMBOL_BATCH_DECODED_BYTES,
85    MAX_SYMBOL_BATCH_PATHS, MAX_SYMBOL_BATCH_ROWS, RepositoryGraphAdjacencyContinuation,
86    RepositoryGraphDirection, RepositoryGraphReadBudget, SymbolBatchReadBudget,
87    SymbolBatchReadLimit,
88};
89use serde::{Deserialize, Serialize};
90use std::collections::{BTreeMap, BTreeSet, VecDeque};
91use std::io::{self, Write};
92use std::path::Path;
93use std::time::{Duration, Instant};
94
95/// Current closed relation-analysis cursor schema.
96const ANALYSIS_CURSOR_VERSION: u16 = 1;
97/// Maximum decoded cursor bytes accepted from an adapter.
98const ANALYSIS_CURSOR_MAX_BYTES: usize = 256 * 1024;
99/// Maximum nodes retained by one analysis projection.
100const MAX_ANALYSIS_NODES: u32 = 512;
101/// Maximum edges retained by one analysis projection.
102const MAX_ANALYSIS_EDGES: u32 = 2_048;
103
104/// Closed analysis projection selected on the existing relation route.
105#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
106#[serde(rename_all = "snake_case")]
107pub enum RelationAnalysisMode {
108    /// Components, communities, cycles, purpose, complexity, and bottleneck candidates.
109    Architecture,
110    /// Version-control-aware affected nodes and conservative dead-code candidates.
111    Impact,
112    /// One node-simple static relationship path to an exact target label.
113    Trace,
114}
115
116/// Version-control scope used by the impact projection.
117#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
118#[serde(tag = "kind", rename_all = "snake_case")]
119pub enum GitImpactSelection {
120    /// Uncommitted tracked and untracked working-tree changes.
121    WorkingTree,
122    /// Changes staged in the index.
123    Index,
124    /// Files changed between two exact revision expressions.
125    RevisionRange {
126        /// Older revision expression.
127        base: String,
128        /// Newer revision expression.
129        head: String,
130    },
131}
132
133/// Complete request for one bounded closed analysis projection.
134#[derive(Clone, Debug)]
135pub struct RelationAnalysisQuery {
136    /// Existing normalized relation traversal, filters, budgets, and cursor.
137    pub relations: DetailedRelationQuery,
138    /// Closed projection to compute over the bounded traversal.
139    pub mode: RelationAnalysisMode,
140    /// Exact file or symbol selector required by static trace mode.
141    pub trace_target: Option<RelationAnchor>,
142    /// Optional VCS scope; impact defaults to the working tree.
143    pub vcs: Option<GitImpactSelection>,
144    /// Include weak communities that exclude containment edges.
145    pub include_communities: bool,
146    /// Include iterative strongly-connected-component findings.
147    pub include_cycles: bool,
148    /// Include conservative non-exported dead-code candidates.
149    pub include_dead_code: bool,
150}
151
152/// Confidence disposition of one analysis finding.
153#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
154#[serde(rename_all = "snake_case")]
155pub enum AnalysisStatus {
156    /// The bounded evidence establishes the stated structural fact.
157    Confirmed,
158    /// The evidence is useful for review but is not a semantic proof.
159    Candidate,
160    /// Complete bounded evidence establishes a negative result.
161    Absent,
162    /// A declared coverage or traversal boundary prevents a safe conclusion.
163    Inconclusive,
164}
165
166/// Closed finding families emitted by relation analysis.
167#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
168#[serde(rename_all = "snake_case")]
169pub enum AnalysisFindingKind {
170    /// Weak component over every admitted local relation.
171    Component,
172    /// Weak community with containment edges excluded.
173    Community,
174    /// Strongly connected dependency cycle.
175    DependencyCycle,
176    /// Owners share one approved purpose.
177    PurposeAlignment,
178    /// Connected owners retain conflicting approved purposes.
179    PurposeDrift,
180    /// Declaration span and graph-degree structural candidate.
181    StructuralComplexity,
182    /// High-degree or cross-owner dependency junction.
183    Bottleneck,
184    /// Node affected by the selected VCS path set.
185    Impact,
186    /// Conservative non-exported declaration with no trusted inbound relation.
187    DeadCode,
188    /// Node-simple static relationship path.
189    StaticTrace,
190    /// Ambiguous or unresolved static relation that prevents a closed conclusion.
191    ResolutionGap,
192}
193
194/// One deterministic bounded analysis finding.
195#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
196pub struct AnalysisFinding {
197    /// Closed finding family.
198    pub kind: AnalysisFindingKind,
199    /// Evidence disposition.
200    pub status: AnalysisStatus,
201    /// Concise interpretation that does not overclaim semantic proof.
202    pub summary: String,
203    /// Typed reusable nodes with purpose, coverage, and next-call routing.
204    pub nodes: Vec<AnalysisNode>,
205    /// Optional exact structural metric, such as degree or declaration span.
206    pub metric: Option<u64>,
207    /// Exact relation evidence when this finding is caused by a resolution gap.
208    pub evidence: Option<AnalysisRelationEvidence>,
209}
210
211/// One reusable analysis node retaining its authoritative navigation evidence.
212#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
213pub struct AnalysisNode {
214    /// Exact entity, current approved purpose, and path coverage.
215    pub node: DetailedRelationNode,
216    /// Existing public call that can consume this selector directly.
217    pub next_call: Option<RelationNextCall>,
218}
219
220/// Exact ambiguous or unresolved relation retained inside its fitted finding.
221#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
222pub struct AnalysisRelationEvidence {
223    /// Full normalized relation, including resolution reference and confidence.
224    pub relation: projectatlas_core::graph::LogicalRelation,
225    /// Exact detailed-relation request that can inspect this gap directly.
226    pub next_call: Option<RelationAnalysisNextCall>,
227}
228
229/// Exact existing relation call that can inspect one resolution gap directly.
230#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
231pub struct RelationAnalysisNextCall {
232    /// Exact local source anchor.
233    pub anchor: RelationAnchor,
234    /// Direction that retains the source-side unresolved or ambiguous relation.
235    pub direction: RelationDirection,
236    /// Exact legacy or extended relation family.
237    pub relation: GraphRelationKind,
238    /// Exact resolution class retained by the finding.
239    pub resolution: RelationResolutionFilter,
240    /// Minimum confidence that retains the exact relation trust class.
241    pub minimum_confidence: ConfidenceClass,
242}
243
244/// Typed VCS availability retained in impact responses.
245#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
246#[serde(tag = "state", rename_all = "snake_case")]
247pub enum VcsImpact {
248    /// Architecture and trace modes did not request VCS evidence.
249    NotRequested,
250    /// Git returned a bounded normalized path set.
251    Available {
252        /// Exact selector used by the request.
253        selection: GitImpactSelection,
254        /// Exact number of normalized repository-relative changed paths.
255        changed_path_count: u64,
256    },
257    /// Git is unavailable, the root is not a worktree, or the command failed.
258    Unavailable {
259        /// Exact selector that could not be evaluated.
260        selection: GitImpactSelection,
261        /// Bounded actionable reason.
262        reason: String,
263    },
264}
265
266/// Exact work performed in addition to the existing detailed traversal.
267#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
268pub struct RelationAnalysisWork {
269    /// Existing detailed traversal and hydration work.
270    pub relations: DetailedRelationWork,
271    /// Adjacency rows inspected while closing edges among admitted nodes.
272    pub closure_inspected_edges: u32,
273    /// Raw `SQLite` bytes decoded by induced-edge closure reads.
274    pub closure_decoded_bytes: u64,
275    /// Git stdout bytes retained while normalizing impact evidence.
276    pub vcs_retained_bytes: u64,
277    /// Number of admitted entities analyzed.
278    pub analyzed_nodes: u32,
279    /// Number of unique admitted local edges analyzed.
280    pub analyzed_edges: u32,
281    /// Symbols retained by bounded per-file exact-identity hydration.
282    pub hydrated_symbols: u32,
283    /// Decoded symbol and range-index bytes inspected during finding computation.
284    pub hydrated_symbol_bytes: u64,
285    /// Whether symbol path, row, or byte limits omitted candidates.
286    pub symbol_hydration_truncated: bool,
287    /// Exact serialized-equivalent analysis-owned bytes retained at return.
288    pub retained_composition_bytes: u64,
289    /// Peak aggregate relation, closure, VCS, symbol, topology, and finding bytes.
290    pub peak_intermediate_bytes: u64,
291    /// Whether composition limits omitted supported analysis projections.
292    pub composition_truncated: bool,
293    /// Exact bytes emitted by the selected adapter envelope.
294    pub rendered_output_bytes: u64,
295}
296
297/// Bounded analysis report returned through the existing CLI and MCP relation route.
298#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
299pub struct RelationAnalysisReport {
300    /// Closed projection selected by the caller.
301    pub mode: RelationAnalysisMode,
302    /// Existing exact relation anchor with authoritative purpose and coverage.
303    pub anchor: DetailedRelationNode,
304    /// Complete graph generation used by every relation row.
305    pub generation: projectatlas_core::IndexGeneration,
306    /// Accepted purpose revision used by every projected node.
307    pub authored_purpose_revision: u64,
308    /// Existing generation- and query-bound traversal continuation.
309    pub continuation: Option<String>,
310    /// Findings retained in this fitted response.
311    pub returned: u32,
312    /// Exact or lower-bound finding cardinality before output-prefix fitting.
313    pub total: RelationTotalState,
314    /// Whether traversal, closure, or output fitting omitted supported evidence.
315    pub truncated: bool,
316    /// Stable unique hard limits reached by traversal, closure, or rendering.
317    pub reached_limits: Vec<GraphLimitKind>,
318    /// Typed VCS evidence for impact mode.
319    pub vcs: VcsImpact,
320    /// Exact bounded work retained by this analysis.
321    pub work: RelationAnalysisWork,
322    /// Deterministically ordered structural findings.
323    pub findings: Vec<AnalysisFinding>,
324}
325
326/// Unrendered analysis report that can fit an adapter's exact final envelope.
327pub struct RelationAnalysisDraft {
328    /// Fully hydrated maximum candidate report.
329    report: RelationAnalysisReport,
330    /// Exact encoded-output ceiling requested by the caller.
331    output_bytes: u32,
332    /// Result-defining traversal budget bound into continuations.
333    budget: DetailedRelationBudget,
334    /// Normalized query identity bound into continuations.
335    cursor_binding: AnalysisCursorBinding,
336    /// Repository and authored-purpose generation bound into continuations.
337    cursor_snapshot: AnalysisCursorSnapshot,
338    /// Relation cursor replayed when output fitting omits finding rows.
339    replay_relation_cursor: Option<String>,
340    /// Findings emitted before this draft began.
341    finding_offset: u32,
342    /// Optional normalized VCS evidence identity.
343    vcs_digest: Option<[u8; 32]>,
344    /// Exact external identities reached by the bounded detailed traversal.
345    external_relation_identities: BTreeSet<ExternalRelationIdentity>,
346    /// Shared request cancellation and deadline retained through rendering.
347    control: IndexWorkControl,
348}
349
350impl RelationAnalysisDraft {
351    /// Fully hydrated report before adapter-specific output fitting.
352    #[must_use]
353    pub const fn candidate_report(&self) -> &RelationAnalysisReport {
354        &self.report
355    }
356
357    /// Borrow the exact request control retained through output rendering.
358    pub(super) const fn control(&self) -> &IndexWorkControl {
359        &self.control
360    }
361
362    /// Move the call-scoped rendezvous identities out before output fitting.
363    pub(super) fn take_external_relation_identities(
364        &mut self,
365    ) -> BTreeSet<ExternalRelationIdentity> {
366        std::mem::take(&mut self.external_relation_identities)
367    }
368
369    /// Fit one complete adapter envelope by retaining the largest finding prefix.
370    ///
371    /// # Errors
372    ///
373    /// Returns an adapter error when rendering fails or even the empty finding
374    /// envelope exceeds the declared output or aggregate intermediate ceiling.
375    pub fn fit_output<F, E, O>(self, mut encode: F) -> Result<(RelationAnalysisReport, O), E>
376    where
377        F: FnMut(&RelationAnalysisReport, &IndexWorkControl) -> Result<O, E>,
378        E: From<ServiceError>,
379        O: AsRef<[u8]>,
380    {
381        check_control(Some(&self.control)).map_err(E::from)?;
382        #[cfg(test)]
383        analysis_test_observer::notify(analysis_test_observer::AnalysisPhaseEvent::OutputRendering);
384        let original_report_bytes =
385            serialized_bytes_controlled(&self.report, Some(&self.control)).map_err(E::from)?;
386        let construction_peak = self.report.work.peak_intermediate_bytes;
387        let mut low = 0;
388        let mut high = self.report.findings.len();
389        let mut best = None;
390        let mut output_limited = false;
391        let mut intermediate_limited = false;
392        let mut empty_output_oversized = false;
393        let mut empty_intermediate_oversized = false;
394        while low <= high {
395            check_control(Some(&self.control)).map_err(E::from)?;
396            let middle = low + (high - low) / 2;
397            let fit_limits = [
398                output_limited.then_some(GraphLimitKind::OutputBytes),
399                intermediate_limited.then_some(GraphLimitKind::IntermediateBytes),
400            ];
401            let mut candidate =
402                analysis_prefix(&self.report, middle, fit_limits.into_iter().flatten());
403            if middle < self.report.findings.len() {
404                let middle = u32::try_from(middle).map_err(|_overflow| {
405                    E::from(ServiceError::InvalidInput(
406                        "analysis finding offset overflowed".to_string(),
407                    ))
408                })?;
409                let finding_offset = self.finding_offset.checked_add(middle).ok_or_else(|| {
410                    E::from(ServiceError::InvalidInput(
411                        "analysis finding offset overflowed".to_string(),
412                    ))
413                })?;
414                candidate.continuation = Some(
415                    encode_analysis_cursor(
416                        self.replay_relation_cursor.as_deref(),
417                        finding_offset,
418                        &self.cursor_binding,
419                        self.cursor_snapshot,
420                        self.vcs_digest,
421                        self.budget,
422                    )
423                    .map_err(E::from)?,
424                );
425            }
426            check_control(Some(&self.control)).map_err(E::from)?;
427            let mut encoded = encode(&candidate, &self.control)?;
428            check_control(Some(&self.control)).map_err(E::from)?;
429            let mut stable = false;
430            for _ in 0..8 {
431                check_control(Some(&self.control)).map_err(E::from)?;
432                let rendered = u64::try_from(encoded.as_ref().len()).map_err(|source| {
433                    E::from(ServiceError::InvalidInput(format!(
434                        "analysis rendered byte count overflowed: {source}"
435                    )))
436                })?;
437                let candidate_report_bytes =
438                    serialized_bytes_controlled(&candidate, Some(&self.control))
439                        .map_err(E::from)?;
440                let fitting_peak = original_report_bytes
441                    .checked_add(candidate_report_bytes)
442                    .and_then(|bytes| bytes.checked_add(rendered))
443                    .ok_or_else(|| {
444                        E::from(ServiceError::InvalidInput(
445                            "analysis output fitting byte count overflowed".to_string(),
446                        ))
447                    })?;
448                let peak = construction_peak.max(fitting_peak);
449                if candidate.work.rendered_output_bytes == rendered
450                    && candidate.work.peak_intermediate_bytes == peak
451                {
452                    stable = true;
453                    break;
454                }
455                candidate.work.rendered_output_bytes = rendered;
456                candidate.work.peak_intermediate_bytes = peak;
457                drop(encoded);
458                encoded = encode(&candidate, &self.control)?;
459                check_control(Some(&self.control)).map_err(E::from)?;
460            }
461            if !stable {
462                return Err(E::from(ServiceError::InvalidInput(
463                    "analysis output accounting did not stabilize".to_string(),
464                )));
465            }
466            let output_fits = encoded.as_ref().len() <= self.output_bytes as usize;
467            let intermediate_fits =
468                candidate.work.peak_intermediate_bytes <= self.budget.intermediate_bytes();
469            if output_fits && intermediate_fits {
470                best = Some((candidate, encoded));
471                low = middle.saturating_add(1);
472            } else {
473                if middle == 0 {
474                    empty_output_oversized = !output_fits;
475                    empty_intermediate_oversized = !intermediate_fits;
476                    break;
477                }
478                let newly_output_limited = !output_fits && !output_limited;
479                let newly_intermediate_limited = !intermediate_fits && !intermediate_limited;
480                output_limited |= !output_fits;
481                intermediate_limited |= !intermediate_fits;
482                if newly_output_limited || newly_intermediate_limited {
483                    best = None;
484                    low = 0;
485                }
486                high = middle - 1;
487            }
488        }
489        check_control(Some(&self.control)).map_err(E::from)?;
490        best.ok_or_else(|| {
491            let message = if empty_intermediate_oversized {
492                "empty analysis envelope exceeds the aggregate intermediate-byte budget"
493            } else if empty_output_oversized {
494                "graph output byte limit is too small for the empty analysis envelope"
495            } else if intermediate_limited {
496                "analysis output fitting exceeds the aggregate intermediate-byte budget"
497            } else {
498                "graph output byte limit is too small for the empty analysis envelope"
499            };
500            E::from(ServiceError::InvalidInput(message.to_string()))
501        })
502    }
503}
504
505#[derive(Clone, Serialize)]
506/// One local normalized edge admitted to topology algorithms.
507struct LocalEdge {
508    /// Canonical source entity identity.
509    source: String,
510    /// Canonical target entity identity.
511    target: String,
512    /// Closed relation family.
513    kind: GraphRelationKind,
514    /// Whether the owning relation evidence is complete.
515    complete: bool,
516}
517
518#[derive(Default)]
519/// Analysis-owned work accumulated beyond the detailed relation traversal.
520struct SupplementalWork {
521    /// Persisted symbols retained for declaration-aware findings.
522    hydrated_symbols: u32,
523    /// Decoded rows plus serialized-equivalent range-index bytes retained.
524    hydrated_symbol_bytes: u64,
525    /// Peak symbol hydration bytes including request-path auxiliaries.
526    hydrated_symbol_peak_bytes: u64,
527    /// Whether a declared hydration bound omitted candidates.
528    symbol_hydration_truncated: bool,
529    /// Stable limits reached by supplemental work.
530    reached_limits: Vec<GraphLimitKind>,
531    /// Analysis-owned bytes retained in the composed report.
532    retained_composition_bytes: u64,
533    /// Whether composition fitting omitted supported evidence.
534    composition_truncated: bool,
535}
536
537#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
538/// Normalized result-defining request identity encoded in every cursor.
539struct AnalysisCursorBinding {
540    /// Digest of the selected canonical project root.
541    root_digest: [u8; 32],
542    /// Exact normalized traversal anchor.
543    anchor: RelationAnchor,
544    /// Traversal direction.
545    direction: RelationDirection,
546    /// Optional relation-family filter.
547    relation: Option<GraphRelationKind>,
548    /// Minimum admitted trust class.
549    minimum_confidence: ConfidenceClass,
550    /// Resolution-class filter.
551    resolution: RelationResolutionFilter,
552    /// Closed feature selections that affect results.
553    options: AnalysisCursorOptions,
554    /// Exact traversal resource envelope.
555    budget: DetailedRelationBudget,
556    /// Detailed relation algorithm contract version.
557    algorithm_version: u16,
558    /// Detailed relation ordering contract version.
559    ordering_version: u16,
560    /// Closed analysis projection.
561    mode: RelationAnalysisMode,
562    /// Optional exact trace target.
563    trace_target: Option<RelationAnchor>,
564    /// Optional VCS selector.
565    vcs: Option<GitImpactSelection>,
566}
567
568#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
569#[serde(rename_all = "snake_case")]
570/// Typed inclusion state for cursor-bound optional analysis behavior.
571enum AnalysisFeatureSelection {
572    /// Feature output is excluded.
573    Excluded,
574    /// Feature output is included.
575    Included,
576}
577
578impl From<bool> for AnalysisFeatureSelection {
579    fn from(included: bool) -> Self {
580        if included {
581            Self::Included
582        } else {
583            Self::Excluded
584        }
585    }
586}
587
588#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
589/// Closed feature selections grouped into one cursor option contract.
590struct AnalysisCursorOptions {
591    /// Retain exact relation occurrence spans.
592    relation_occurrences: AnalysisFeatureSelection,
593    /// Emit weak community candidates.
594    communities: AnalysisFeatureSelection,
595    /// Emit dependency-cycle candidates.
596    cycles: AnalysisFeatureSelection,
597    /// Emit conservative dead-code candidates.
598    dead_code: AnalysisFeatureSelection,
599}
600
601#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
602/// Repository state that must remain stable while a cursor is replayed.
603struct AnalysisCursorSnapshot {
604    /// Stable selected project instance.
605    project: ProjectInstanceId,
606    /// Complete repository-graph generation.
607    generation: projectatlas_core::IndexGeneration,
608    /// Accepted authored-purpose revision.
609    authored_purpose_revision: u64,
610}
611
612#[derive(Deserialize, Serialize)]
613#[serde(deny_unknown_fields)]
614/// Bounded continuation for relation traversal and finding-prefix replay.
615struct AnalysisCursor {
616    /// Cursor schema version.
617    version: u16,
618    /// Result-defining normalized request identity.
619    binding: AnalysisCursorBinding,
620    /// Repository state captured by the first page.
621    snapshot: AnalysisCursorSnapshot,
622    /// Optional underlying relation traversal continuation.
623    relation_cursor: Option<String>,
624    /// Findings emitted before the next fitted page.
625    finding_offset: u32,
626    /// Optional normalized VCS evidence digest.
627    vcs_digest: Option<[u8; 32]>,
628}
629
630/// Load one closed analysis view over the existing bounded relation service.
631///
632/// # Errors
633///
634/// Returns the existing detailed-relation errors plus bounded VCS, closure, and
635/// output validation failures.
636pub fn load_relation_analysis(
637    store: &AtlasStore,
638    query: &RelationAnalysisQuery,
639    control: Option<&IndexWorkControl>,
640) -> ServiceResult<RelationAnalysisDraft> {
641    load_relation_analysis_with_closure_deadline(store, query, control, None, false)
642}
643
644/// Load analysis while retaining its bounded external traversal identities.
645pub(super) fn load_relation_analysis_for_federation(
646    store: &AtlasStore,
647    query: &RelationAnalysisQuery,
648    control: Option<&IndexWorkControl>,
649) -> ServiceResult<RelationAnalysisDraft> {
650    load_relation_analysis_with_closure_deadline(store, query, control, None, true)
651}
652
653/// Load analysis with an optional earlier closure-stage deadline ceiling.
654fn load_relation_analysis_with_closure_deadline(
655    store: &AtlasStore,
656    query: &RelationAnalysisQuery,
657    control: Option<&IndexWorkControl>,
658    closure_deadline_ceiling: Option<Instant>,
659    retain_external_relation_identities: bool,
660) -> ServiceResult<RelationAnalysisDraft> {
661    validate_analysis_query(query)?;
662    let started = Instant::now();
663    let deadline = started
664        .checked_add(Duration::from_millis(query.relations.budget.deadline_ms()))
665        .unwrap_or(started);
666    let deadline = control
667        .and_then(IndexWorkControl::deadline)
668        .map_or(deadline, |caller_deadline| caller_deadline.min(deadline));
669    let analysis_control = control.map_or_else(
670        || IndexWorkControl::with_deadline(IndexCancellation::new(), deadline),
671        |caller| {
672            caller.with_timeout_ceiling(deadline.saturating_duration_since(caller.started_at()))
673        },
674    );
675    let control = Some(&analysis_control);
676    check_control(control)?;
677    let selected_binding = selected_project_binding(store)?;
678    let cursor_binding = analysis_cursor_binding(query, &selected_binding.project_root);
679    let decoded_cursor = query
680        .relations
681        .cursor
682        .as_deref()
683        .map(|cursor| decode_analysis_cursor(cursor, &cursor_binding))
684        .transpose()?;
685    let mut relation_query = query.relations.clone();
686    relation_query.budget = bounded_analysis_budget(query.relations.budget)?;
687    relation_query.cursor = decoded_cursor
688        .as_ref()
689        .and_then(|cursor| cursor.relation_cursor.clone());
690    let replay_relation_cursor = relation_query.cursor.clone();
691    let finding_offset = decoded_cursor
692        .as_ref()
693        .map_or(0, |cursor| cursor.finding_offset);
694    let relations = load_detailed_relations(store, &relation_query, control)?;
695    let external_relation_identities = if retain_external_relation_identities {
696        external_relation_identities(&relations)
697    } else {
698        BTreeSet::new()
699    };
700    let external_relation_identity_bytes =
701        serialized_bytes_controlled(&external_relation_identities, control)?;
702    let cursor_snapshot = AnalysisCursorSnapshot {
703        project: relations.anchor.entity.key().project(),
704        generation: relations.generation,
705        authored_purpose_revision: relations.authored_purpose_revision,
706    };
707    if decoded_cursor
708        .as_ref()
709        .is_some_and(|cursor| cursor.snapshot != cursor_snapshot)
710    {
711        return Err(ServiceError::RelationCursorStale {
712            field: "analysis snapshot",
713        });
714    }
715    check_control(control)?;
716    let mut nodes = collect_nodes(&relations, control)?;
717    let mut edges = collect_report_edges(&relations, control)?;
718    let mut closure_query = query.clone();
719    closure_query.relations = relation_query;
720    let closure = close_induced_edges(
721        store,
722        &closure_query,
723        &relations.work,
724        closure_deadline_ceiling.map_or(deadline, |ceiling| ceiling.min(deadline)),
725        &nodes,
726        &mut edges,
727        control,
728    )?;
729    check_control(control)?;
730    let evidence_complete = relation_evidence_complete(&relations, &nodes, &edges, query, &closure);
731    let dead_code_scope_complete = dead_code_scope_complete(&relations, query);
732    check_control(control)?;
733    let vcs_load = if query.mode == RelationAnalysisMode::Impact {
734        let selection = query.vcs.clone().unwrap_or(GitImpactSelection::WorkingTree);
735        load_vcs_paths(
736            Path::new(&selected_binding.project_root),
737            selection,
738            query.relations.budget.intermediate_bytes().saturating_sub(
739                relations
740                    .work
741                    .intermediate_bytes
742                    .saturating_add(closure.decoded_bytes)
743                    .saturating_add(external_relation_identity_bytes),
744            ),
745            deadline,
746            control,
747        )
748    } else {
749        LoadedVcs {
750            report: VcsImpact::NotRequested,
751            changed_paths: Vec::new(),
752            retained_bytes: 0,
753        }
754    };
755    let vcs = vcs_load.report;
756    let vcs_digest = (query.mode == RelationAnalysisMode::Impact)
757        .then(|| digest_vcs_paths(&vcs_load.changed_paths, control))
758        .transpose()?;
759    if decoded_cursor
760        .as_ref()
761        .is_some_and(|cursor| cursor.vcs_digest != vcs_digest)
762    {
763        return Err(ServiceError::RelationCursorStale {
764            field: "VCS evidence",
765        });
766    }
767    let mut supplemental_work = SupplementalWork::default();
768    let analysis_allowance = query.relations.budget.intermediate_bytes().saturating_sub(
769        relations
770            .work
771            .intermediate_bytes
772            .saturating_add(closure.decoded_bytes)
773            .saturating_add(external_relation_identity_bytes),
774    );
775    let projection_allowance = analysis_allowance.saturating_sub(vcs_load.retained_bytes);
776    #[cfg(test)]
777    analysis_test_observer::notify(analysis_test_observer::AnalysisPhaseEvent::Composition);
778    let mut topology_bytes =
779        serialized_bytes_controlled(&(nodes.values().collect::<Vec<_>>(), &edges), control)?;
780    let mut gaps = resolution_gap_findings(&relations, control)?;
781    gaps.extend(closure.resolution_gaps.iter().cloned());
782    check_control(control)?;
783    gaps.sort_by(|left, right| resolution_gap_identity(left).cmp(resolution_gap_identity(right)));
784    gaps.dedup_by(|left, right| resolution_gap_identity(left) == resolution_gap_identity(right));
785    check_control(control)?;
786    let gap_bytes = serialized_bytes_controlled(&gaps, control)?;
787    let projection_safe = topology_bytes.saturating_add(gap_bytes) <= projection_allowance;
788    let mut findings = if projection_safe {
789        gaps
790    } else {
791        supplemental_work.composition_truncated = true;
792        push_limit(
793            &mut supplemental_work.reached_limits,
794            GraphLimitKind::IntermediateBytes,
795        );
796        edges.clear();
797        nodes.retain(|_, node| node.entity.key() == relations.anchor.entity.key());
798        topology_bytes =
799            serialized_bytes_controlled(&(nodes.values().collect::<Vec<_>>(), &edges), control)?;
800        vec![AnalysisFinding {
801            kind: AnalysisFindingKind::Component,
802            status: AnalysisStatus::Inconclusive,
803            summary: "analysis composition crossed the shared intermediate-byte budget".to_string(),
804            nodes: vec![analysis_node(&relations.anchor)],
805            metric: None,
806            evidence: None,
807        }]
808    };
809    let initial_finding_bytes = serialized_bytes_controlled(&findings, control)?;
810    let symbol_byte_budget = projection_allowance
811        .saturating_sub(topology_bytes)
812        .saturating_sub(initial_finding_bytes);
813    if projection_safe {
814        findings.extend(match query.mode {
815            RelationAnalysisMode::Architecture => architecture_findings(
816                store,
817                &nodes,
818                &edges,
819                evidence_complete,
820                query,
821                symbol_byte_budget,
822                &mut supplemental_work,
823                control,
824            )?,
825            RelationAnalysisMode::Impact => impact_findings(
826                store,
827                &nodes,
828                &edges,
829                evidence_complete,
830                dead_code_scope_complete,
831                &vcs,
832                &vcs_load.changed_paths,
833                query,
834                symbol_byte_budget,
835                &mut supplemental_work,
836                control,
837            )?,
838            RelationAnalysisMode::Trace => {
839                trace_findings(&relations, query.trace_target.as_ref(), evidence_complete)?
840            }
841        });
842    }
843    let generated_finding_count = u32::try_from(findings.len()).map_err(|_overflow| {
844        ServiceError::InvalidInput("analysis finding count overflowed".to_string())
845    })?;
846    let mut finding_bytes = serialized_bytes_controlled(&findings, control)?;
847    supplemental_work.retained_composition_bytes = vcs_load
848        .retained_bytes
849        .saturating_add(topology_bytes)
850        .saturating_add(finding_bytes);
851    while supplemental_work.retained_composition_bytes > analysis_allowance && findings.len() > 1 {
852        check_control(control)?;
853        findings.pop();
854        supplemental_work.composition_truncated = true;
855        push_limit(
856            &mut supplemental_work.reached_limits,
857            GraphLimitKind::IntermediateBytes,
858        );
859        finding_bytes = serialized_bytes_controlled(&findings, control)?;
860        supplemental_work.retained_composition_bytes = vcs_load
861            .retained_bytes
862            .saturating_add(topology_bytes)
863            .saturating_add(finding_bytes);
864    }
865    if supplemental_work.retained_composition_bytes > analysis_allowance {
866        findings.clear();
867        supplemental_work.composition_truncated = true;
868        push_limit(
869            &mut supplemental_work.reached_limits,
870            GraphLimitKind::IntermediateBytes,
871        );
872        finding_bytes = serialized_bytes_controlled(&findings, control)?;
873        supplemental_work.retained_composition_bytes = vcs_load
874            .retained_bytes
875            .saturating_add(topology_bytes)
876            .saturating_add(finding_bytes);
877    }
878    let hydration_peak = relations
879        .work
880        .intermediate_bytes
881        .saturating_add(closure.decoded_bytes)
882        .saturating_add(vcs_load.retained_bytes)
883        .saturating_add(topology_bytes)
884        .saturating_add(initial_finding_bytes)
885        .saturating_add(external_relation_identity_bytes)
886        .saturating_add(supplemental_work.hydrated_symbol_peak_bytes);
887    let final_peak = relations
888        .work
889        .intermediate_bytes
890        .saturating_add(closure.decoded_bytes)
891        .saturating_add(supplemental_work.retained_composition_bytes)
892        .saturating_add(external_relation_identity_bytes);
893    let peak_intermediate_bytes = hydration_peak.max(final_peak);
894    let full_finding_count = u32::try_from(findings.len()).map_err(|_overflow| {
895        ServiceError::InvalidInput("analysis finding count overflowed".to_string())
896    })?;
897    if finding_offset > full_finding_count {
898        return Err(ServiceError::RelationCursorInvalid {
899            reason: "analysis finding offset exceeds the recomputed page",
900        });
901    }
902    findings.drain(..finding_offset as usize);
903    let mut reached_limits = relations.reached_limits.clone();
904    if !closure.complete {
905        push_limit(
906            &mut reached_limits,
907            if closure.deadline_reached {
908                GraphLimitKind::Deadline
909            } else {
910                GraphLimitKind::Edges
911            },
912        );
913    }
914    for limit in &supplemental_work.reached_limits {
915        push_limit(&mut reached_limits, *limit);
916    }
917    let work = RelationAnalysisWork {
918        relations: relations.work.clone(),
919        closure_inspected_edges: closure.inspected_edges,
920        closure_decoded_bytes: closure.decoded_bytes,
921        vcs_retained_bytes: vcs_load.retained_bytes,
922        analyzed_nodes: u32::try_from(nodes.len()).unwrap_or(u32::MAX),
923        analyzed_edges: u32::try_from(edges.len()).unwrap_or(u32::MAX),
924        hydrated_symbols: supplemental_work.hydrated_symbols,
925        hydrated_symbol_bytes: supplemental_work.hydrated_symbol_bytes,
926        symbol_hydration_truncated: supplemental_work.symbol_hydration_truncated,
927        retained_composition_bytes: supplemental_work.retained_composition_bytes,
928        peak_intermediate_bytes,
929        composition_truncated: supplemental_work.composition_truncated,
930        rendered_output_bytes: 0,
931    };
932    let analysis_truncated = (!evidence_complete && relations.truncated)
933        || !closure.complete
934        || supplemental_work.symbol_hydration_truncated
935        || supplemental_work.composition_truncated;
936    let returned = u32::try_from(findings.len()).unwrap_or(u32::MAX);
937    let total = if analysis_truncated {
938        RelationTotalState::AtLeast(u64::from(generated_finding_count))
939    } else {
940        RelationTotalState::Exact(u64::from(full_finding_count))
941    };
942    let continuation = if evidence_complete {
943        None
944    } else {
945        relations
946            .continuation
947            .as_deref()
948            .map(|cursor| {
949                encode_analysis_cursor(
950                    Some(cursor),
951                    0,
952                    &cursor_binding,
953                    cursor_snapshot,
954                    vcs_digest,
955                    query.relations.budget,
956                )
957            })
958            .transpose()?
959    };
960    let report = RelationAnalysisReport {
961        mode: query.mode,
962        anchor: relations.anchor,
963        generation: relations.generation,
964        authored_purpose_revision: relations.authored_purpose_revision,
965        continuation,
966        returned,
967        total,
968        truncated: analysis_truncated,
969        reached_limits,
970        vcs,
971        work,
972        findings,
973    };
974    nodes.clear();
975    check_control(control)?;
976    Ok(RelationAnalysisDraft {
977        report,
978        output_bytes: query.relations.budget.output_bytes(),
979        budget: query.relations.budget,
980        cursor_binding,
981        cursor_snapshot,
982        replay_relation_cursor,
983        finding_offset,
984        vcs_digest,
985        external_relation_identities,
986        control: analysis_control,
987    })
988}
989
990/// Validate closed mode, selector, option, and budget combinations.
991fn validate_analysis_query(query: &RelationAnalysisQuery) -> ServiceResult<()> {
992    if query.mode == RelationAnalysisMode::Trace {
993        let Some(target) = query.trace_target.as_ref() else {
994            return Err(ServiceError::InvalidInput(
995                "analysis trace requires an exact file or symbol target".to_string(),
996            ));
997        };
998        if matches!(
999            target,
1000            RelationAnchor::Symbol {
1001                symbol_kind: None,
1002                ..
1003            }
1004        ) || matches!(
1005            target,
1006            RelationAnchor::Symbol {
1007                signature: None,
1008                ..
1009            }
1010        ) {
1011            return Err(ServiceError::InvalidInput(
1012                "analysis trace symbol targets require exact kind and signature".to_string(),
1013            ));
1014        }
1015    } else if query.trace_target.is_some() {
1016        return Err(ServiceError::InvalidInput(
1017            "trace_target is valid only for static trace analysis".to_string(),
1018        ));
1019    }
1020    if query.mode != RelationAnalysisMode::Impact && query.vcs.is_some() {
1021        return Err(ServiceError::InvalidInput(
1022            "VCS selection is valid only for impact analysis".to_string(),
1023        ));
1024    }
1025    if query.mode != RelationAnalysisMode::Architecture
1026        && (query.include_communities || query.include_cycles)
1027    {
1028        return Err(ServiceError::InvalidInput(
1029            "community and cycle controls are valid only for architecture analysis".to_string(),
1030        ));
1031    }
1032    if query.mode != RelationAnalysisMode::Impact && query.include_dead_code {
1033        return Err(ServiceError::InvalidInput(
1034            "dead-code controls are valid only for impact analysis".to_string(),
1035        ));
1036    }
1037    Ok(())
1038}
1039
1040/// Clamp the existing traversal budget to analysis product ceilings.
1041fn bounded_analysis_budget(
1042    budget: DetailedRelationBudget,
1043) -> ServiceResult<DetailedRelationBudget> {
1044    let limits = GraphLimits::new(
1045        budget.page_rows().min(MAX_ANALYSIS_NODES),
1046        budget.occurrences_per_relation(),
1047        budget.depth(),
1048        budget.output_bytes(),
1049    )
1050    .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
1051    DetailedRelationBudget::from_graph_limits(limits).with_aggregate_limits(
1052        Some(budget.edges().min(MAX_ANALYSIS_EDGES)),
1053        Some(budget.nodes().min(MAX_ANALYSIS_NODES)),
1054        Some(budget.visited().min(MAX_ANALYSIS_NODES)),
1055        Some(budget.occurrences_total()),
1056        Some(budget.intermediate_bytes()),
1057        Some(budget.deadline_ms()),
1058    )
1059}
1060
1061/// Normalize every result-defining request field for cursor identity.
1062fn analysis_cursor_binding(
1063    query: &RelationAnalysisQuery,
1064    project_root: &str,
1065) -> AnalysisCursorBinding {
1066    AnalysisCursorBinding {
1067        root_digest: *blake3::hash(
1068            format!("projectatlas:analysis-root:v1\0{project_root}").as_bytes(),
1069        )
1070        .as_bytes(),
1071        anchor: query.relations.anchor.clone(),
1072        direction: query.relations.direction,
1073        relation: query.relations.relation,
1074        minimum_confidence: query.relations.minimum_confidence,
1075        resolution: query.relations.resolution,
1076        options: AnalysisCursorOptions {
1077            relation_occurrences: query.relations.include_occurrences.into(),
1078            communities: query.include_communities.into(),
1079            cycles: query.include_cycles.into(),
1080            dead_code: query.include_dead_code.into(),
1081        },
1082        budget: query.relations.budget,
1083        algorithm_version: ANALYSIS_CURSOR_VERSION,
1084        ordering_version: 1,
1085        mode: query.mode,
1086        trace_target: query.trace_target.clone(),
1087        vcs: (query.mode == RelationAnalysisMode::Impact)
1088            .then(|| query.vcs.clone().unwrap_or(GitImpactSelection::WorkingTree)),
1089    }
1090}
1091
1092/// Decode and validate one analysis continuation against its request.
1093fn decode_analysis_cursor(
1094    encoded: &str,
1095    expected: &AnalysisCursorBinding,
1096) -> ServiceResult<AnalysisCursor> {
1097    if encoded.is_empty() || encoded.len() > ANALYSIS_CURSOR_MAX_BYTES {
1098        return Err(ServiceError::RelationCursorInvalid {
1099            reason: "analysis cursor length is empty or above the product ceiling",
1100        });
1101    }
1102    let cursor: AnalysisCursor = serde_json::from_str(encoded).map_err(|_malformed| {
1103        ServiceError::RelationCursorInvalid {
1104            reason: "analysis cursor JSON is malformed or contains unknown fields",
1105        }
1106    })?;
1107    if cursor.version != ANALYSIS_CURSOR_VERSION {
1108        return Err(ServiceError::RelationCursorStale {
1109            field: "analysis algorithm version",
1110        });
1111    }
1112    if cursor.binding != *expected {
1113        return Err(ServiceError::RelationCursorMismatched {
1114            field: "analysis query",
1115        });
1116    }
1117    Ok(cursor)
1118}
1119
1120/// Encode one bounded analysis continuation.
1121fn encode_analysis_cursor(
1122    relation_cursor: Option<&str>,
1123    finding_offset: u32,
1124    binding: &AnalysisCursorBinding,
1125    snapshot: AnalysisCursorSnapshot,
1126    vcs_digest: Option<[u8; 32]>,
1127    budget: DetailedRelationBudget,
1128) -> ServiceResult<String> {
1129    let encoded = serde_json::to_string(&AnalysisCursor {
1130        version: ANALYSIS_CURSOR_VERSION,
1131        binding: binding.clone(),
1132        snapshot,
1133        relation_cursor: relation_cursor.map(str::to_string),
1134        finding_offset,
1135        vcs_digest,
1136    })?;
1137    if encoded.len() > ANALYSIS_CURSOR_MAX_BYTES
1138        || encoded.len() > budget.intermediate_bytes() as usize
1139    {
1140        return Err(ServiceError::RelationCursorInvalid {
1141            reason: "encoded analysis cursor exceeds the intermediate-state ceiling",
1142        });
1143    }
1144    Ok(encoded)
1145}
1146
1147/// Collect unique non-external nodes from a detailed relation page.
1148fn collect_nodes(
1149    report: &DetailedRelationReport,
1150    control: Option<&IndexWorkControl>,
1151) -> ServiceResult<BTreeMap<String, DetailedRelationNode>> {
1152    let mut nodes = BTreeMap::new();
1153    insert_node(&mut nodes, &report.anchor);
1154    for row in &report.rows {
1155        check_control(control)?;
1156        insert_node(&mut nodes, &row.source);
1157        if let Some(target) = &row.target {
1158            insert_node(&mut nodes, target);
1159        }
1160        for node in &row.path {
1161            insert_node(&mut nodes, node);
1162        }
1163    }
1164    Ok(nodes)
1165}
1166
1167/// Project unresolved and ambiguous relation rows into typed findings.
1168fn resolution_gap_findings(
1169    report: &DetailedRelationReport,
1170    control: Option<&IndexWorkControl>,
1171) -> ServiceResult<Vec<AnalysisFinding>> {
1172    let mut findings = Vec::new();
1173    for row in &report.rows {
1174        check_control(control)?;
1175        if matches!(
1176            row.relation.resolution(),
1177            RelationResolution::Ambiguous { .. } | RelationResolution::Unresolved { .. }
1178        ) {
1179            findings.push(resolution_gap_finding(&row.relation, &row.source));
1180        }
1181    }
1182    Ok(findings)
1183}
1184
1185/// Build one exact resolution-gap finding from a detailed relation row.
1186fn resolution_gap_finding(
1187    relation: &projectatlas_core::graph::LogicalRelation,
1188    source: &DetailedRelationNode,
1189) -> AnalysisFinding {
1190    AnalysisFinding {
1191        kind: AnalysisFindingKind::ResolutionGap,
1192        status: AnalysisStatus::Inconclusive,
1193        summary: "ambiguous or unresolved relation blocks a closed structural conclusion"
1194            .to_string(),
1195        nodes: vec![analysis_node(source)],
1196        metric: None,
1197        evidence: Some(AnalysisRelationEvidence {
1198            relation: relation.clone(),
1199            next_call: relation_gap_next_call(relation, &source.entity),
1200        }),
1201    }
1202}
1203
1204/// Return the canonical relation identity used to sort and deduplicate gaps.
1205fn resolution_gap_identity(finding: &AnalysisFinding) -> &str {
1206    finding
1207        .evidence
1208        .as_ref()
1209        .map_or("", |evidence| evidence.relation.key().canonical_identity())
1210}
1211
1212/// Build an exact existing relation request for one source-side gap.
1213fn relation_gap_next_call(
1214    relation: &projectatlas_core::graph::LogicalRelation,
1215    source: &GraphEntity,
1216) -> Option<RelationAnalysisNextCall> {
1217    let anchor = relation_anchor_for_entity(source)?;
1218    let resolution = match relation.resolution() {
1219        RelationResolution::Ambiguous { .. } => RelationResolutionFilter::Ambiguous,
1220        RelationResolution::Unresolved { .. } => RelationResolutionFilter::Unresolved,
1221        RelationResolution::Resolved { .. } => RelationResolutionFilter::Resolved,
1222        RelationResolution::External { .. } => RelationResolutionFilter::External,
1223    };
1224    Some(RelationAnalysisNextCall {
1225        anchor,
1226        direction: RelationDirection::Outbound,
1227        relation: relation.kind(),
1228        resolution,
1229        minimum_confidence: relation.confidence(),
1230    })
1231}
1232
1233/// Convert a locally addressable graph entity into a relation anchor.
1234fn relation_anchor_for_entity(entity: &GraphEntity) -> Option<RelationAnchor> {
1235    match entity.selector() {
1236        EntitySelector::File { path } => Some(RelationAnchor::File { file: path.clone() }),
1237        EntitySelector::Symbol { symbol } => Some(RelationAnchor::Symbol {
1238            file: symbol.file.clone(),
1239            name: symbol.name.as_str().to_string(),
1240            symbol_kind: Some(symbol.kind),
1241            parent: symbol
1242                .parent
1243                .as_ref()
1244                .map(|parent| parent.as_str().to_string()),
1245            signature: Some(symbol.signature.as_str().to_string()),
1246        }),
1247        EntitySelector::Project
1248        | EntitySelector::Folder { .. }
1249        | EntitySelector::Package { .. }
1250        | EntitySelector::External { .. } => None,
1251    }
1252}
1253
1254/// Insert one local node once by canonical identity.
1255fn insert_node(nodes: &mut BTreeMap<String, DetailedRelationNode>, node: &DetailedRelationNode) {
1256    if !matches!(node.entity.selector(), EntitySelector::External { .. }) {
1257        nodes
1258            .entry(node.entity.key().canonical_identity().to_string())
1259            .or_insert_with(|| node.clone());
1260    }
1261}
1262
1263/// Collect local edges from one detailed relation page.
1264fn collect_report_edges(
1265    report: &DetailedRelationReport,
1266    control: Option<&IndexWorkControl>,
1267) -> ServiceResult<Vec<LocalEdge>> {
1268    let mut edges = Vec::new();
1269    for row in &report.rows {
1270        check_control(control)?;
1271        if let Some(edge) = local_edge(&row.relation, &row.source.entity, row.target.as_ref()) {
1272            edges.push(edge);
1273        }
1274    }
1275    Ok(edges)
1276}
1277
1278/// Convert a resolved local relation row into an algorithm edge.
1279fn local_edge(
1280    relation: &projectatlas_core::graph::LogicalRelation,
1281    source: &GraphEntity,
1282    target: Option<&DetailedRelationNode>,
1283) -> Option<LocalEdge> {
1284    let target = target?;
1285    if matches!(target.entity.selector(), EntitySelector::External { .. }) {
1286        return None;
1287    }
1288    Some(LocalEdge {
1289        source: source.key().canonical_identity().to_string(),
1290        target: target.entity.key().canonical_identity().to_string(),
1291        kind: relation.kind(),
1292        complete: relation.completeness() == Completeness::Complete,
1293    })
1294}
1295
1296/// Work and evidence retained while closing edges among admitted nodes.
1297#[derive(Default)]
1298struct ClosureWork {
1299    /// Whether every admitted frontier completed under all bounds.
1300    complete: bool,
1301    /// Whether the closure-stage deadline stopped otherwise valid work.
1302    deadline_reached: bool,
1303    /// Whether the induced node scope was closed in the requested direction.
1304    induced_scope_closed: bool,
1305    /// Database adjacency rows inspected by closure.
1306    inspected_edges: u32,
1307    /// Database-decoded bytes retained during closure.
1308    decoded_bytes: u64,
1309    /// Resolution gaps discovered after the first relation page.
1310    resolution_gaps: Vec<AnalysisFinding>,
1311}
1312
1313/// Close bounded local edges among the nodes admitted by detailed traversal.
1314fn close_induced_edges(
1315    store: &AtlasStore,
1316    query: &RelationAnalysisQuery,
1317    relation_work: &DetailedRelationWork,
1318    deadline: Instant,
1319    nodes: &BTreeMap<String, DetailedRelationNode>,
1320    edges: &mut Vec<LocalEdge>,
1321    control: Option<&IndexWorkControl>,
1322) -> ServiceResult<ClosureWork> {
1323    let mut work = ClosureWork {
1324        complete: true,
1325        induced_scope_closed: query.relations.direction == RelationDirection::Outbound,
1326        ..ClosureWork::default()
1327    };
1328    let mut keys = Vec::with_capacity(nodes.len());
1329    let mut known = BTreeSet::new();
1330    for (key, node) in nodes {
1331        check_control(control)?;
1332        keys.push(node.entity.key().clone());
1333        known.insert(key.clone());
1334    }
1335    #[cfg(test)]
1336    analysis_test_observer::notify(analysis_test_observer::AnalysisPhaseEvent::Traversal);
1337    check_control(control)?;
1338    for chunk in keys.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
1339        let mut continuation: Option<RepositoryGraphAdjacencyContinuation> = None;
1340        loop {
1341            if Instant::now() >= deadline {
1342                work.complete = false;
1343                work.deadline_reached = true;
1344                break;
1345            }
1346            check_control(control)?;
1347            let remaining = query.relations.budget.edges().saturating_sub(
1348                relation_work
1349                    .inspected_edges
1350                    .saturating_add(work.inspected_edges),
1351            );
1352            if remaining == 0 {
1353                work.complete = false;
1354                break;
1355            }
1356            let page_limit = remaining.min(query.relations.budget.page_rows()).max(1);
1357            let decoded_remaining = query
1358                .relations
1359                .budget
1360                .intermediate_bytes()
1361                .saturating_sub(
1362                    relation_work
1363                        .intermediate_bytes
1364                        .saturating_add(work.decoded_bytes),
1365                )
1366                .min(RepositoryGraphReadBudget::MAX_DECODED_BYTES);
1367            if decoded_remaining == 0 {
1368                work.complete = false;
1369                break;
1370            }
1371            let endpoints = page_limit.saturating_add(1).saturating_mul(2);
1372            let budget = RepositoryGraphReadBudget::new(
1373                u32::try_from(chunk.len()).map_err(|_overflow| {
1374                    ServiceError::InvalidInput("analysis frontier overflowed".to_string())
1375                })?,
1376                page_limit,
1377                decoded_remaining,
1378                endpoints,
1379                endpoints,
1380            )
1381            .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
1382            let read = store.repository_graph_adjacency_page_filtered_bounded(
1383                chunk,
1384                RepositoryGraphDirection::Outbound,
1385                query.relations.relation,
1386                continuation.as_ref(),
1387                page_limit,
1388                budget,
1389                control,
1390            )?;
1391            work.inspected_edges = work
1392                .inspected_edges
1393                .checked_add(u32::try_from(read.page.rows.len()).map_err(|_overflow| {
1394                    ServiceError::InvalidInput("analysis edge work overflowed".to_string())
1395                })?)
1396                .ok_or_else(|| {
1397                    ServiceError::InvalidInput("analysis edge work overflowed".to_string())
1398                })?;
1399            work.decoded_bytes = work.decoded_bytes.saturating_add(read.work.decoded_bytes);
1400            for row in read.page.rows {
1401                check_control(control)?;
1402                if !analysis_relation_matches(&row.detail.relation, &query.relations) {
1403                    continue;
1404                }
1405                let source_key = row.detail.source.key().canonical_identity().to_string();
1406                if matches!(
1407                    row.detail.relation.resolution(),
1408                    RelationResolution::Ambiguous { .. } | RelationResolution::Unresolved { .. }
1409                ) {
1410                    work.induced_scope_closed = false;
1411                    if let Some(source) = nodes.get(&source_key) {
1412                        work.resolution_gaps
1413                            .push(resolution_gap_finding(&row.detail.relation, source));
1414                    }
1415                    continue;
1416                }
1417                let Some(target) = row.detail.target else {
1418                    work.induced_scope_closed = false;
1419                    continue;
1420                };
1421                let target_key = target.key().canonical_identity().to_string();
1422                if known.contains(&source_key) && known.contains(&target_key) {
1423                    edges.push(LocalEdge {
1424                        source: source_key,
1425                        target: target_key,
1426                        kind: row.detail.relation.kind(),
1427                        complete: row.detail.relation.completeness() == Completeness::Complete,
1428                    });
1429                } else {
1430                    work.induced_scope_closed = false;
1431                }
1432            }
1433            if read.page.truncated {
1434                continuation = read.page.continuation;
1435                if continuation.is_none() {
1436                    return Err(ServiceError::InvalidInput(
1437                        "truncated analysis closure omitted its continuation".to_string(),
1438                    ));
1439                }
1440            } else {
1441                break;
1442            }
1443        }
1444        if !work.complete {
1445            break;
1446        }
1447    }
1448    edges.sort_by(|left, right| {
1449        (&left.source, &left.target, format!("{:?}", left.kind)).cmp(&(
1450            &right.source,
1451            &right.target,
1452            format!("{:?}", right.kind),
1453        ))
1454    });
1455    edges.dedup_by(|left, right| {
1456        left.source == right.source && left.target == right.target && left.kind == right.kind
1457    });
1458    work.resolution_gaps
1459        .sort_by(|left, right| resolution_gap_identity(left).cmp(resolution_gap_identity(right)));
1460    work.resolution_gaps
1461        .dedup_by(|left, right| resolution_gap_identity(left) == resolution_gap_identity(right));
1462    Ok(work)
1463}
1464
1465/// Determine whether topology findings have complete admitted evidence.
1466fn relation_evidence_complete(
1467    report: &DetailedRelationReport,
1468    nodes: &BTreeMap<String, DetailedRelationNode>,
1469    edges: &[LocalEdge],
1470    query: &RelationAnalysisQuery,
1471    closure: &ClosureWork,
1472) -> bool {
1473    closure.complete
1474        && closure.induced_scope_closed
1475        && report.reached_limits.is_empty()
1476        && query.relations.direction == RelationDirection::Outbound
1477        && edges.iter().all(|edge| edge.complete)
1478        && nodes.values().all(|node| match node.entity.selector() {
1479            EntitySelector::Project => true,
1480            EntitySelector::Folder { .. }
1481            | EntitySelector::File { .. }
1482            | EntitySelector::Package { .. }
1483            | EntitySelector::Symbol { .. } => {
1484                !node.coverage.is_empty()
1485                    && node
1486                        .coverage
1487                        .iter()
1488                        .all(|coverage| coverage.state() == CoverageState::Complete)
1489            }
1490            EntitySelector::External { .. } => false,
1491        })
1492}
1493
1494/// Determine whether the exact inbound scope can support dead-code candidates.
1495fn dead_code_scope_complete(
1496    report: &DetailedRelationReport,
1497    query: &RelationAnalysisQuery,
1498) -> bool {
1499    let exact_symbol_anchor = matches!(
1500        &query.relations.anchor,
1501        RelationAnchor::Symbol {
1502            symbol_kind: Some(_),
1503            signature: Some(_),
1504            ..
1505        }
1506    );
1507    let exact_total = matches!(
1508        report.total,
1509        RelationTotalState::Exact(total) if total == u64::from(report.returned)
1510    );
1511    query.relations.direction == RelationDirection::Inbound
1512        && query.relations.relation.is_none()
1513        && query.relations.resolution == RelationResolutionFilter::Resolved
1514        && query.relations.minimum_confidence == ConfidenceClass::Low
1515        && exact_symbol_anchor
1516        && exact_total
1517        && !report.truncated
1518        && report.continuation.is_none()
1519        && report.reached_limits.is_empty()
1520        && !report.anchor.coverage.is_empty()
1521        && report
1522            .anchor
1523            .coverage
1524            .iter()
1525            .all(|coverage| coverage.state() == CoverageState::Complete)
1526        && report.rows.iter().all(|row| {
1527            matches!(
1528                row.relation.resolution(),
1529                RelationResolution::Resolved { .. }
1530            ) && row.relation.completeness() == Completeness::Complete
1531        })
1532}
1533
1534/// Apply detailed relation family, trust, and resolution filters.
1535fn analysis_relation_matches(
1536    relation: &projectatlas_core::graph::LogicalRelation,
1537    query: &DetailedRelationQuery,
1538) -> bool {
1539    query.relation.is_none_or(|kind| relation.kind() == kind)
1540        && confidence_rank(relation.confidence()) >= confidence_rank(query.minimum_confidence)
1541        && match query.resolution {
1542            RelationResolutionFilter::Any => true,
1543            RelationResolutionFilter::Resolved => {
1544                matches!(relation.resolution(), RelationResolution::Resolved { .. })
1545            }
1546            RelationResolutionFilter::Ambiguous => {
1547                matches!(relation.resolution(), RelationResolution::Ambiguous { .. })
1548            }
1549            RelationResolutionFilter::Unresolved => {
1550                matches!(relation.resolution(), RelationResolution::Unresolved { .. })
1551            }
1552            RelationResolutionFilter::External => {
1553                matches!(relation.resolution(), RelationResolution::External { .. })
1554            }
1555        }
1556}
1557
1558/// Rank the closed confidence classes for inclusive threshold comparison.
1559const fn confidence_rank(value: ConfidenceClass) -> u8 {
1560    match value {
1561        ConfidenceClass::Exact => 4,
1562        ConfidenceClass::High => 3,
1563        ConfidenceClass::Medium => 2,
1564        ConfidenceClass::Low => 1,
1565    }
1566}
1567
1568/// Compute bounded architecture findings over admitted topology and symbols.
1569fn architecture_findings(
1570    store: &AtlasStore,
1571    nodes: &BTreeMap<String, DetailedRelationNode>,
1572    edges: &[LocalEdge],
1573    complete: bool,
1574    query: &RelationAnalysisQuery,
1575    symbol_byte_budget: u64,
1576    supplemental_work: &mut SupplementalWork,
1577    control: Option<&IndexWorkControl>,
1578) -> ServiceResult<Vec<AnalysisFinding>> {
1579    let mut findings = structural_findings(
1580        store,
1581        nodes,
1582        edges,
1583        complete,
1584        symbol_byte_budget,
1585        supplemental_work,
1586        control,
1587    )?;
1588    if !complete {
1589        findings.push(AnalysisFinding {
1590            kind: AnalysisFindingKind::Component,
1591            status: AnalysisStatus::Inconclusive,
1592            summary: "architecture candidates are incomplete because traversal or local coverage is partial"
1593                .to_string(),
1594            nodes: analysis_nodes_for(nodes, &nodes.keys().cloned().collect::<Vec<_>>()),
1595            metric: Some(nodes.len() as u64),
1596            evidence: None,
1597        });
1598    }
1599    let components = weak_components(nodes, edges, false);
1600    for component in &components {
1601        findings.push(AnalysisFinding {
1602            kind: AnalysisFindingKind::Component,
1603            status: AnalysisStatus::Candidate,
1604            summary: "component candidate from a weakly connected admitted relation set"
1605                .to_string(),
1606            nodes: analysis_nodes_for(nodes, component),
1607            metric: Some(component.len() as u64),
1608            evidence: None,
1609        });
1610        findings.push(purpose_finding(nodes, component, complete));
1611    }
1612    if query.include_communities {
1613        for community in weak_components(nodes, edges, true) {
1614            findings.push(AnalysisFinding {
1615                kind: AnalysisFindingKind::Community,
1616                status: AnalysisStatus::Candidate,
1617                summary: "relationship-derived community with containment edges excluded"
1618                    .to_string(),
1619                nodes: analysis_nodes_for(nodes, &community),
1620                metric: Some(community.len() as u64),
1621                evidence: None,
1622            });
1623        }
1624    }
1625    if query.include_cycles {
1626        let dependency_edges = edges
1627            .iter()
1628            .filter(|edge| dependency_relation(edge.kind))
1629            .cloned()
1630            .collect::<Vec<_>>();
1631        let cycles = strongly_connected_components(nodes, &dependency_edges)
1632            .into_iter()
1633            .filter(|component| {
1634                component.len() > 1
1635                    || dependency_edges.iter().any(|edge| {
1636                        component.first() == Some(&edge.source) && edge.source == edge.target
1637                    })
1638            })
1639            .collect::<Vec<_>>();
1640        if cycles.is_empty() {
1641            findings.push(AnalysisFinding {
1642                kind: AnalysisFindingKind::DependencyCycle,
1643                status: if complete {
1644                    AnalysisStatus::Absent
1645                } else {
1646                    AnalysisStatus::Inconclusive
1647                },
1648                summary: if complete {
1649                    "no dependency cycle exists in the complete admitted bounded scope"
1650                } else {
1651                    "no cycle was observed, but traversal or coverage is incomplete"
1652                }
1653                .to_string(),
1654                nodes: Vec::new(),
1655                metric: Some(0),
1656                evidence: None,
1657            });
1658        } else {
1659            for cycle in cycles {
1660                findings.push(AnalysisFinding {
1661                    kind: AnalysisFindingKind::DependencyCycle,
1662                    status: AnalysisStatus::Candidate,
1663                    summary: "iterative dependency-family SCC found a static cycle candidate"
1664                        .to_string(),
1665                    nodes: analysis_nodes_for(nodes, &cycle),
1666                    metric: Some(cycle.len() as u64),
1667                    evidence: None,
1668                });
1669            }
1670        }
1671    }
1672    Ok(findings)
1673}
1674
1675/// Return whether a relation participates in dependency SCC and impact flow.
1676fn dependency_relation(kind: GraphRelationKind) -> bool {
1677    matches!(
1678        kind,
1679        GraphRelationKind::Legacy(
1680            RelationKind::Imports | RelationKind::Calls | RelationKind::DependsOn
1681        ) | GraphRelationKind::Extended(
1682            ExtendedRelationKind::Tests
1683                | ExtendedRelationKind::RoutesTo
1684                | ExtendedRelationKind::Configures
1685        )
1686    )
1687}
1688
1689/// Classify purpose alignment for one connected component.
1690fn purpose_finding(
1691    nodes: &BTreeMap<String, DetailedRelationNode>,
1692    component: &[String],
1693    complete: bool,
1694) -> AnalysisFinding {
1695    let mut purposes = BTreeSet::new();
1696    let mut unavailable = false;
1697    for key in component {
1698        match nodes.get(key).map(|node| &node.purpose) {
1699            Some(RelationPurpose::Approved { purpose, .. }) => {
1700                purposes.insert(purpose.clone());
1701            }
1702            Some(RelationPurpose::Unavailable { .. } | RelationPurpose::NotApplicable) | None => {
1703                unavailable = true;
1704            }
1705        }
1706    }
1707    let (kind, status, summary) = if purposes.len() > 1 {
1708        (
1709            AnalysisFindingKind::PurposeDrift,
1710            AnalysisStatus::Candidate,
1711            "connected owners retain multiple approved purpose responsibilities",
1712        )
1713    } else if purposes.len() == 1 && !unavailable && complete {
1714        (
1715            AnalysisFindingKind::PurposeAlignment,
1716            AnalysisStatus::Confirmed,
1717            "connected owners share one approved purpose responsibility",
1718        )
1719    } else {
1720        (
1721            AnalysisFindingKind::PurposeAlignment,
1722            AnalysisStatus::Inconclusive,
1723            if complete {
1724                "purpose alignment is unavailable for at least one admitted owner"
1725            } else {
1726                "purpose alignment is inconclusive under partial traversal or coverage"
1727            },
1728        )
1729    };
1730    AnalysisFinding {
1731        kind,
1732        status,
1733        summary: summary.to_string(),
1734        nodes: analysis_nodes_for(nodes, component),
1735        metric: Some(purposes.len() as u64),
1736        evidence: None,
1737    }
1738}
1739
1740/// Compute declaration-span and graph-degree structural candidates.
1741fn structural_findings(
1742    store: &AtlasStore,
1743    nodes: &BTreeMap<String, DetailedRelationNode>,
1744    edges: &[LocalEdge],
1745    topology_complete: bool,
1746    symbol_byte_budget: u64,
1747    supplemental_work: &mut SupplementalWork,
1748    control: Option<&IndexWorkControl>,
1749) -> ServiceResult<Vec<AnalysisFinding>> {
1750    let degrees = degrees(nodes, edges);
1751    let symbols_by_file = load_admitted_symbols(store, nodes, symbol_byte_budget, control)?;
1752    supplemental_work.hydrated_symbols = supplemental_work
1753        .hydrated_symbols
1754        .saturating_add(symbols_by_file.rows_retained);
1755    supplemental_work.hydrated_symbol_bytes = supplemental_work
1756        .hydrated_symbol_bytes
1757        .saturating_add(symbols_by_file.retained_bytes);
1758    supplemental_work.hydrated_symbol_peak_bytes = supplemental_work
1759        .hydrated_symbol_peak_bytes
1760        .max(symbols_by_file.peak_bytes);
1761    supplemental_work.symbol_hydration_truncated |= !symbols_by_file.complete;
1762    supplemental_work
1763        .reached_limits
1764        .extend(symbols_by_file.reached_limits.iter().copied());
1765    let mut candidates = Vec::new();
1766    for (key, node) in nodes {
1767        check_control(control)?;
1768        let Some((path, name, kind, parent, signature)) = symbol_identity(&node.entity) else {
1769            continue;
1770        };
1771        if let Some(symbol) = symbols_by_file.rows_for_path(path).and_then(|symbols| {
1772            symbols.iter().find(|candidate| {
1773                candidate.name == name
1774                    && candidate.kind == kind
1775                    && candidate.parent.as_deref() == parent
1776                    && candidate.signature == signature
1777            })
1778        }) {
1779            let span = symbol
1780                .line_end
1781                .saturating_sub(symbol.line_start)
1782                .saturating_add(1) as u64;
1783            let degree = degrees.get(key).copied().unwrap_or_default() as u64;
1784            candidates.push((span, degree, key.clone()));
1785        }
1786    }
1787    let symbols_complete = symbols_by_file.complete;
1788    drop(symbols_by_file);
1789    let mut findings = Vec::new();
1790    if let Some((span, _degree, key)) = candidates.iter().max_by(std::cmp::Ord::cmp) {
1791        findings.push(AnalysisFinding {
1792            kind: AnalysisFindingKind::StructuralComplexity,
1793            status: if symbols_complete && topology_complete {
1794                AnalysisStatus::Candidate
1795            } else {
1796                AnalysisStatus::Inconclusive
1797            },
1798            summary: if symbols_complete && topology_complete {
1799                "largest language-valid declaration span in the admitted scope; not cyclomatic complexity"
1800            } else {
1801                "structural candidate observed, but bounded symbol hydration omitted admitted declarations"
1802            }
1803            .to_string(),
1804            nodes: analysis_nodes_for(nodes, std::slice::from_ref(key)),
1805            metric: Some(*span),
1806            evidence: None,
1807        });
1808    }
1809    if let Some((_span, degree, key)) = candidates
1810        .iter()
1811        .max_by(|left, right| (left.1, left.0, &left.2).cmp(&(right.1, right.0, &right.2)))
1812    {
1813        findings.push(AnalysisFinding {
1814            kind: AnalysisFindingKind::Bottleneck,
1815            status: if symbols_complete && topology_complete {
1816                AnalysisStatus::Candidate
1817            } else {
1818                AnalysisStatus::Inconclusive
1819            },
1820            summary: "highest admitted static fan-in plus fan-out junction".to_string(),
1821            nodes: analysis_nodes_for(nodes, std::slice::from_ref(key)),
1822            metric: Some(*degree),
1823            evidence: None,
1824        });
1825    }
1826    Ok(findings)
1827}
1828
1829/// Bounded persisted symbols plus a compact exact-path range index.
1830struct AdmittedSymbols {
1831    /// Deterministically sorted symbols returned by the database.
1832    rows: Vec<CodeSymbol>,
1833    /// Sorted exact-path ranges into `rows`.
1834    ranges: Vec<SymbolPathRange>,
1835    /// Whether every admitted path, row, and byte fit the envelope.
1836    complete: bool,
1837    /// Number of persisted symbols retained.
1838    rows_retained: u32,
1839    /// Database-decoded rows plus serialized-equivalent range-index bytes.
1840    retained_bytes: u64,
1841    /// Retained bytes plus serialized-equivalent request-path auxiliaries.
1842    peak_bytes: u64,
1843    /// Stable public limits that omitted symbol candidates.
1844    reached_limits: Vec<GraphLimitKind>,
1845}
1846
1847#[derive(Serialize)]
1848/// One sorted exact-path slice into the retained symbol vector.
1849struct SymbolPathRange {
1850    /// Exact repository-relative owning path.
1851    path: String,
1852    /// Inclusive symbol-vector offset.
1853    start: usize,
1854    /// Exclusive symbol-vector offset.
1855    end: usize,
1856}
1857
1858impl AdmittedSymbols {
1859    /// Return the retained persisted symbols for one exact path.
1860    fn rows_for_path(&self, path: &str) -> Option<&[CodeSymbol]> {
1861        let index = self
1862            .ranges
1863            .binary_search_by(|range| range.path.as_str().cmp(path))
1864            .ok()?;
1865        let range = &self.ranges[index];
1866        self.rows.get(range.start..range.end)
1867    }
1868}
1869
1870/// Load one bounded indexed symbol batch and build its compact path ranges.
1871fn load_admitted_symbols(
1872    store: &AtlasStore,
1873    nodes: &BTreeMap<String, DetailedRelationNode>,
1874    byte_budget: u64,
1875    control: Option<&IndexWorkControl>,
1876) -> ServiceResult<AdmittedSymbols> {
1877    let path_limit = usize::try_from(MAX_SYMBOL_BATCH_PATHS).map_err(|source| {
1878        ServiceError::InvalidInput(format!("symbol path ceiling overflowed: {source}"))
1879    })?;
1880    let mut paths = Vec::new();
1881    let mut path_truncated = false;
1882    for node in nodes.values() {
1883        check_control(control)?;
1884        let EntitySelector::Symbol { symbol } = node.entity.selector() else {
1885            continue;
1886        };
1887        let path = symbol.file.as_str();
1888        let Err(index) = paths.binary_search_by(|candidate: &String| candidate.as_str().cmp(path))
1889        else {
1890            continue;
1891        };
1892        paths.insert(index, path.to_string());
1893        if paths.len() > path_limit {
1894            paths.pop();
1895            path_truncated = true;
1896        }
1897    }
1898    let paths = paths.into_boxed_slice().into_vec();
1899    let byte_limit = byte_budget.min(MAX_SYMBOL_BATCH_DECODED_BYTES);
1900    let mut reached_limits = Vec::new();
1901    if path_truncated {
1902        push_limit(&mut reached_limits, GraphLimitKind::Rows);
1903    }
1904    if paths.is_empty() {
1905        return Ok(AdmittedSymbols {
1906            rows: Vec::new(),
1907            ranges: Vec::new(),
1908            complete: !path_truncated,
1909            rows_retained: 0,
1910            retained_bytes: 0,
1911            peak_bytes: symbol_path_request_bytes(&paths, control)?,
1912            reached_limits,
1913        });
1914    }
1915    #[cfg(test)]
1916    analysis_test_observer::notify(analysis_test_observer::AnalysisPhaseEvent::SymbolHydration);
1917    check_control(control)?;
1918    let grouping_reserve = symbol_hydration_reserve_bytes(&paths, control)?;
1919    let decoded_byte_limit = byte_limit.saturating_sub(grouping_reserve);
1920    if decoded_byte_limit == 0 {
1921        push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
1922        return Ok(AdmittedSymbols {
1923            rows: Vec::new(),
1924            ranges: Vec::new(),
1925            complete: false,
1926            rows_retained: 0,
1927            retained_bytes: 0,
1928            peak_bytes: symbol_path_request_bytes(&paths, control)?,
1929            reached_limits,
1930        });
1931    }
1932    let read = store.load_symbols_for_paths_bounded(
1933        &paths,
1934        SymbolBatchReadBudget::new(
1935            MAX_SYMBOL_BATCH_PATHS,
1936            MAX_SYMBOL_BATCH_ROWS,
1937            decoded_byte_limit.min(MAX_SYMBOL_BATCH_DECODED_BYTES),
1938        )?,
1939        control,
1940    )?;
1941    match read.reached_limit {
1942        Some(SymbolBatchReadLimit::Paths | SymbolBatchReadLimit::Rows) => {
1943            push_limit(&mut reached_limits, GraphLimitKind::Rows);
1944        }
1945        Some(SymbolBatchReadLimit::DecodedBytes) => {
1946            push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
1947        }
1948        None => {}
1949    }
1950    let ranges = symbol_path_ranges(&read.rows, control)?;
1951    let retained_bytes = read
1952        .work
1953        .decoded_bytes
1954        .saturating_add(symbol_range_index_bytes(&ranges, control)?);
1955    let peak_bytes = retained_bytes.saturating_add(symbol_path_request_bytes(&paths, control)?);
1956    Ok(AdmittedSymbols {
1957        rows: read.rows,
1958        ranges,
1959        complete: !path_truncated && !read.truncated,
1960        rows_retained: read.work.returned_rows,
1961        retained_bytes,
1962        peak_bytes,
1963        reached_limits,
1964    })
1965}
1966
1967/// Build sorted non-overlapping exact-path ranges over sorted symbol rows.
1968fn symbol_path_ranges(
1969    rows: &[CodeSymbol],
1970    control: Option<&IndexWorkControl>,
1971) -> ServiceResult<Vec<SymbolPathRange>> {
1972    let mut ranges = Vec::new();
1973    let mut start = 0;
1974    while start < rows.len() {
1975        check_control(control)?;
1976        let mut end = start.saturating_add(1);
1977        while end < rows.len() && rows[end].path == rows[start].path {
1978            check_control(control)?;
1979            end = end.saturating_add(1);
1980        }
1981        ranges.push(SymbolPathRange {
1982            path: rows[start].path.clone(),
1983            start,
1984            end,
1985        });
1986        start = end;
1987    }
1988    Ok(ranges.into_boxed_slice().into_vec())
1989}
1990
1991/// Count serialized request-path and worst-case path-range auxiliaries.
1992fn symbol_hydration_reserve_bytes(
1993    paths: &[String],
1994    control: Option<&IndexWorkControl>,
1995) -> ServiceResult<u64> {
1996    let mut ranges = Vec::with_capacity(paths.len());
1997    for path in paths {
1998        check_control(control)?;
1999        ranges.push(SymbolPathRange {
2000            path: path.clone(),
2001            start: 0,
2002            end: 0,
2003        });
2004    }
2005    Ok(symbol_path_request_bytes(paths, control)?
2006        .saturating_add(serialized_bytes_controlled(&ranges, control)?))
2007}
2008
2009/// Count serialized-equivalent request-path bytes.
2010fn symbol_path_request_bytes(
2011    paths: &[String],
2012    control: Option<&IndexWorkControl>,
2013) -> ServiceResult<u64> {
2014    serialized_bytes_controlled(paths, control)
2015}
2016
2017/// Count serialized-equivalent retained path-range bytes.
2018fn symbol_range_index_bytes(
2019    ranges: &[SymbolPathRange],
2020    control: Option<&IndexWorkControl>,
2021) -> ServiceResult<u64> {
2022    serialized_bytes_controlled(ranges, control)
2023}
2024
2025/// Allocation-free serialized-equivalent byte counter.
2026struct SerializedByteCounter<'a> {
2027    /// Bytes that the serializer would write.
2028    bytes: u64,
2029    /// Shared request control checked between serializer writes.
2030    control: Option<&'a IndexWorkControl>,
2031    /// Whether serialization stopped on cancellation or deadline.
2032    interrupted: bool,
2033}
2034
2035impl Write for SerializedByteCounter<'_> {
2036    fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
2037        if self
2038            .control
2039            .is_some_and(|control| control.check(IndexWorkStage::RepositoryTraversal).is_err())
2040        {
2041            self.interrupted = true;
2042            // `Write::write_all` retries `Interrupted` indefinitely. Use a
2043            // non-retriable transport error and translate it back to the
2044            // control's typed terminal failure after serialization returns.
2045            return Err(io::Error::other("analysis serialization interrupted"));
2046        }
2047        self.bytes = self
2048            .bytes
2049            .checked_add(u64::try_from(buffer.len()).unwrap_or(u64::MAX))
2050            .ok_or_else(|| io::Error::other("analysis serialized byte count overflowed"))?;
2051        Ok(buffer.len())
2052    }
2053
2054    fn flush(&mut self) -> io::Result<()> {
2055        Ok(())
2056    }
2057}
2058
2059/// Measure one bounded value while retaining the request deadline and cancellation.
2060fn serialized_bytes_controlled<T: Serialize + ?Sized>(
2061    value: &T,
2062    control: Option<&IndexWorkControl>,
2063) -> ServiceResult<u64> {
2064    let mut counter = SerializedByteCounter {
2065        bytes: 0,
2066        control,
2067        interrupted: false,
2068    };
2069    let result = serde_json::to_writer(&mut counter, value);
2070    if counter.interrupted {
2071        check_control(control)?;
2072    }
2073    result?;
2074    check_control(control)?;
2075    Ok(counter.bytes)
2076}
2077
2078/// Find one node-simple static relation path to an exact target.
2079fn trace_findings(
2080    report: &DetailedRelationReport,
2081    target: Option<&RelationAnchor>,
2082    evidence_complete: bool,
2083) -> ServiceResult<Vec<AnalysisFinding>> {
2084    let target = target.ok_or_else(|| {
2085        ServiceError::InvalidInput("analysis trace requires an exact target".to_string())
2086    })?;
2087    let mut matching = BTreeSet::new();
2088    if entity_matches_anchor(&report.anchor.entity, target) {
2089        matching.insert(report.anchor.entity.key().canonical_identity().to_string());
2090    }
2091    for row in &report.rows {
2092        for node in &row.path {
2093            if entity_matches_anchor(&node.entity, target) {
2094                matching.insert(node.entity.key().canonical_identity().to_string());
2095            }
2096        }
2097    }
2098    if matching.len() > 1 {
2099        return Err(ServiceError::InvalidInput(
2100            "analysis trace target is ambiguous in the admitted graph scope".to_string(),
2101        ));
2102    }
2103    let target_key = matching.iter().next();
2104    if target_key.is_some_and(|key| key == report.anchor.entity.key().canonical_identity()) {
2105        return Ok(vec![AnalysisFinding {
2106            kind: AnalysisFindingKind::StaticTrace,
2107            status: AnalysisStatus::Confirmed,
2108            summary: "static trace target is the selected anchor".to_string(),
2109            nodes: vec![analysis_node(&report.anchor)],
2110            metric: Some(0),
2111            evidence: None,
2112        }]);
2113    }
2114    if let Some(row) = target_key.and_then(|target_key| {
2115        report.rows.iter().find(|row| {
2116            row.path
2117                .last()
2118                .is_some_and(|node| node.entity.key().canonical_identity() == target_key.as_str())
2119        })
2120    }) {
2121        return Ok(vec![AnalysisFinding {
2122            kind: AnalysisFindingKind::StaticTrace,
2123            status: AnalysisStatus::Confirmed,
2124            summary: "node-simple static relation path; not a runtime execution trace".to_string(),
2125            nodes: row.path.iter().map(analysis_node).collect(),
2126            metric: Some(u64::from(row.depth)),
2127            evidence: None,
2128        }]);
2129    }
2130    Ok(vec![AnalysisFinding {
2131        kind: AnalysisFindingKind::StaticTrace,
2132        status: if evidence_complete {
2133            AnalysisStatus::Absent
2134        } else {
2135            AnalysisStatus::Inconclusive
2136        },
2137        summary: if evidence_complete {
2138            "target is not reachable in the complete admitted static scope"
2139        } else {
2140            "target was not observed before the bounded traversal or coverage stopped"
2141        }
2142        .to_string(),
2143        nodes: Vec::new(),
2144        metric: None,
2145        evidence: None,
2146    }])
2147}
2148
2149/// Compute deterministic weak components with optional containment exclusion.
2150fn weak_components(
2151    nodes: &BTreeMap<String, DetailedRelationNode>,
2152    edges: &[LocalEdge],
2153    exclude_contains: bool,
2154) -> Vec<Vec<String>> {
2155    let mut adjacency = nodes
2156        .keys()
2157        .map(|key| (key.clone(), Vec::new()))
2158        .collect::<BTreeMap<_, _>>();
2159    for edge in edges {
2160        if exclude_contains && edge.kind == GraphRelationKind::Legacy(RelationKind::Contains) {
2161            continue;
2162        }
2163        adjacency
2164            .entry(edge.source.clone())
2165            .or_default()
2166            .push(edge.target.clone());
2167        adjacency
2168            .entry(edge.target.clone())
2169            .or_default()
2170            .push(edge.source.clone());
2171    }
2172    let mut visited = BTreeSet::new();
2173    let mut components = Vec::new();
2174    for start in nodes.keys() {
2175        if !visited.insert(start.clone()) {
2176            continue;
2177        }
2178        let mut queue = VecDeque::from([start.clone()]);
2179        let mut component = Vec::new();
2180        while let Some(node) = queue.pop_front() {
2181            component.push(node.clone());
2182            if let Some(neighbors) = adjacency.get(&node) {
2183                for neighbor in neighbors {
2184                    if visited.insert(neighbor.clone()) {
2185                        queue.push_back(neighbor.clone());
2186                    }
2187                }
2188            }
2189        }
2190        component.sort();
2191        components.push(component);
2192    }
2193    components
2194}
2195
2196/// Compute iterative deterministic SCCs over dependency relations.
2197fn strongly_connected_components(
2198    nodes: &BTreeMap<String, DetailedRelationNode>,
2199    edges: &[LocalEdge],
2200) -> Vec<Vec<String>> {
2201    let keys = nodes.keys().cloned().collect::<Vec<_>>();
2202    let indices = keys
2203        .iter()
2204        .enumerate()
2205        .map(|(index, key)| (key.clone(), index))
2206        .collect::<BTreeMap<_, _>>();
2207    let mut forward = vec![Vec::new(); keys.len()];
2208    let mut reverse = vec![Vec::new(); keys.len()];
2209    for edge in edges {
2210        let (Some(&source), Some(&target)) = (indices.get(&edge.source), indices.get(&edge.target))
2211        else {
2212            continue;
2213        };
2214        forward[source].push(target);
2215        reverse[target].push(source);
2216    }
2217    let mut seen = vec![false; keys.len()];
2218    let mut order = Vec::new();
2219    for start in 0..keys.len() {
2220        if seen[start] {
2221            continue;
2222        }
2223        let mut stack = vec![(start, false)];
2224        while let Some((node, expanded)) = stack.pop() {
2225            if expanded {
2226                order.push(node);
2227                continue;
2228            }
2229            if seen[node] {
2230                continue;
2231            }
2232            seen[node] = true;
2233            stack.push((node, true));
2234            for &next in forward[node].iter().rev() {
2235                if !seen[next] {
2236                    stack.push((next, false));
2237                }
2238            }
2239        }
2240    }
2241    seen.fill(false);
2242    let mut components = Vec::new();
2243    for &start in order.iter().rev() {
2244        if seen[start] {
2245            continue;
2246        }
2247        seen[start] = true;
2248        let mut stack = vec![start];
2249        let mut component = Vec::new();
2250        while let Some(node) = stack.pop() {
2251            component.push(keys[node].clone());
2252            for &next in &reverse[node] {
2253                if !seen[next] {
2254                    seen[next] = true;
2255                    stack.push(next);
2256                }
2257            }
2258        }
2259        component.sort();
2260        components.push(component);
2261    }
2262    components
2263}
2264
2265/// Count admitted local fan-in plus fan-out for every node.
2266fn degrees(
2267    nodes: &BTreeMap<String, DetailedRelationNode>,
2268    edges: &[LocalEdge],
2269) -> BTreeMap<String, usize> {
2270    let mut values = nodes
2271        .keys()
2272        .map(|key| (key.clone(), 0))
2273        .collect::<BTreeMap<_, _>>();
2274    for edge in edges {
2275        *values.entry(edge.source.clone()).or_default() += 1;
2276        *values.entry(edge.target.clone()).or_default() += 1;
2277    }
2278    values
2279}
2280
2281/// Count trusted inbound usage relations for every node.
2282///
2283/// Structural containment establishes declaration ownership, not runtime or
2284/// compile-time use, so it cannot by itself suppress a dead-code candidate.
2285fn usage_indegrees(
2286    nodes: &BTreeMap<String, DetailedRelationNode>,
2287    edges: &[LocalEdge],
2288    control: Option<&IndexWorkControl>,
2289) -> ServiceResult<BTreeMap<String, usize>> {
2290    let mut values = BTreeMap::new();
2291    for key in nodes.keys() {
2292        check_control(control)?;
2293        values.insert(key.clone(), 0);
2294    }
2295    for edge in edges
2296        .iter()
2297        .filter(|edge| edge.kind != GraphRelationKind::Legacy(RelationKind::Contains))
2298    {
2299        check_control(control)?;
2300        *values.entry(edge.target.clone()).or_default() += 1;
2301    }
2302    Ok(values)
2303}
2304
2305/// Project selected canonical identities into reusable analysis nodes.
2306fn analysis_nodes_for(
2307    nodes: &BTreeMap<String, DetailedRelationNode>,
2308    keys: &[String],
2309) -> Vec<AnalysisNode> {
2310    keys.iter()
2311        .filter_map(|key| nodes.get(key))
2312        .map(analysis_node)
2313        .collect()
2314}
2315
2316/// Preserve one detailed node and its exact reusable next call.
2317fn analysis_node(node: &DetailedRelationNode) -> AnalysisNode {
2318    let next_call = match node.entity.selector() {
2319        EntitySelector::Folder { path } => Some(RelationNextCall::Files {
2320            folder: path.clone(),
2321        }),
2322        EntitySelector::File { path } => Some(RelationNextCall::Summary { file: path.clone() }),
2323        EntitySelector::Package { package } => Some(RelationNextCall::Summary {
2324            file: package.manifest.clone(),
2325        }),
2326        EntitySelector::Symbol { symbol } => Some(RelationNextCall::SymbolSlice {
2327            symbol: symbol.clone(),
2328        }),
2329        EntitySelector::Project | EntitySelector::External { .. } => None,
2330    };
2331    AnalysisNode {
2332        node: node.clone(),
2333        next_call,
2334    }
2335}
2336
2337/// Compare a normalized graph entity with an exact public relation anchor.
2338fn entity_matches_anchor(entity: &GraphEntity, target: &RelationAnchor) -> bool {
2339    match (entity.selector(), target) {
2340        (EntitySelector::File { path }, RelationAnchor::File { file }) => path == file,
2341        (
2342            EntitySelector::Symbol { symbol },
2343            RelationAnchor::Symbol {
2344                file,
2345                name,
2346                symbol_kind,
2347                parent,
2348                signature,
2349            },
2350        ) => {
2351            symbol.file == *file
2352                && symbol.name.as_str() == name
2353                && symbol_kind.is_none_or(|kind| symbol.kind == kind)
2354                && symbol.parent.as_ref().map(GraphIdentityText::as_str) == parent.as_deref()
2355                && signature
2356                    .as_deref()
2357                    .is_none_or(|signature| symbol.signature.as_str() == signature)
2358        }
2359        _ => false,
2360    }
2361}
2362
2363/// Return the owning repository path for a file or symbol entity.
2364fn entity_path(entity: &GraphEntity) -> Option<&str> {
2365    match entity.selector() {
2366        EntitySelector::Folder { path } => Some(path.as_str()),
2367        EntitySelector::File { path } => Some(path.as_str()),
2368        EntitySelector::Package { package } => Some(package.manifest.as_str()),
2369        EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
2370        EntitySelector::Project | EntitySelector::External { .. } => None,
2371    }
2372}
2373
2374/// Borrow the exact declaration identity from one symbol entity.
2375fn symbol_identity(
2376    entity: &GraphEntity,
2377) -> Option<(
2378    &str,
2379    &str,
2380    projectatlas_core::symbols::SymbolKind,
2381    Option<&str>,
2382    &str,
2383)> {
2384    let EntitySelector::Symbol { symbol } = entity.selector() else {
2385        return None;
2386    };
2387    Some((
2388        symbol.file.as_str(),
2389        symbol.name.as_str(),
2390        symbol.kind,
2391        symbol.parent.as_ref().map(GraphIdentityText::as_str),
2392        symbol.signature.as_str(),
2393    ))
2394}
2395
2396/// Clone one fitted finding prefix and update its returned work.
2397fn analysis_prefix(
2398    report: &RelationAnalysisReport,
2399    rows: usize,
2400    reached_limits: impl IntoIterator<Item = GraphLimitKind>,
2401) -> RelationAnalysisReport {
2402    let mut candidate = report.clone();
2403    if rows < candidate.findings.len() {
2404        candidate.findings.truncate(rows);
2405        candidate.truncated = true;
2406        for limit in reached_limits {
2407            push_limit(&mut candidate.reached_limits, limit);
2408        }
2409    }
2410    candidate.returned = u32::try_from(candidate.findings.len()).unwrap_or(u32::MAX);
2411    candidate.work.rendered_output_bytes = 0;
2412    candidate
2413}
2414
2415/// Insert one stable limit once while preserving encounter order.
2416fn push_limit(limits: &mut Vec<GraphLimitKind>, limit: GraphLimitKind) {
2417    if !limits.contains(&limit) {
2418        limits.push(limit);
2419    }
2420}
2421
2422/// Check shared cancellation and deadline state at traversal work boundaries.
2423fn check_control(control: Option<&IndexWorkControl>) -> ServiceResult<()> {
2424    if let Some(control) = control {
2425        control
2426            .check(IndexWorkStage::RepositoryTraversal)
2427            .map_err(DbError::from)?;
2428    }
2429    Ok(())
2430}
2431
2432#[cfg(test)]
2433#[path = "analysis/tests.rs"]
2434mod tests;