projectatlas_service/
relations.rs

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