Skip to main content

projectatlas_service/
relations.rs

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