Skip to main content

projectatlas_service/
relations.rs

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