Skip to main content

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