Skip to main content

mant_query/scope_query/
input.rs

1//! Validate address/order alignment once before borrowing collection query input.
2use mant_ir::ResolvedContent;
3use mant_protocol::{
4    MAX_SCOPE_DEPTH, MAX_SCOPE_DOCUMENT_LIMIT, ResolvedDocumentScope, ScopedDocument,
5};
6use std::{collections::BTreeSet, error::Error, fmt};
7
8/// An immutable graph and its exact, ordered content snapshots.
9///
10/// Construction validates structural alignment without parsing or copying content.
11/// The caller retains authority over provenance and must supply one coherent
12/// snapshot; this is not verification of remote freshness or unvisited targets.
13#[derive(Debug, Clone, Copy)]
14pub struct QueryScopeView<'a> {
15    graph: &'a ResolvedDocumentScope,
16    documents: &'a [ResolvedContent],
17}
18
19/// Invalid association between a logical loading report and supplied content.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum ScopeInputError {
22    /// Graph records and content have different lengths.
23    LengthMismatch,
24    /// More documents than the supported collection-query ceiling.
25    TooManyDocuments,
26    /// The supplied content address is absent or does not match its graph slot.
27    AddressMismatch {
28        /// Zero-based content slot.
29        index: usize,
30    },
31    /// Two slots claim the same logical document.
32    DuplicateAddress {
33        /// Zero-based duplicate slot.
34        index: usize,
35    },
36    /// A source has invalid depth, root indices, or a non-BFS order.
37    InvalidSource {
38        /// Zero-based graph slot.
39        index: usize,
40    },
41    /// An edge, provenance or coverage record names a document outside the set.
42    UnknownGraphAddress,
43}
44
45impl fmt::Display for ScopeInputError {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        match self {
48            Self::LengthMismatch => f.write_str("scope graph and content lengths do not match"),
49            Self::TooManyDocuments => f.write_str("scope content exceeds the document ceiling"),
50            Self::AddressMismatch { index } => {
51                write!(f, "scope content address does not match graph slot {index}")
52            }
53            Self::DuplicateAddress { index } => {
54                write!(f, "scope graph repeats an address at slot {index}")
55            }
56            Self::InvalidSource { index } => write!(
57                f,
58                "scope source order or coordinates are invalid at slot {index}"
59            ),
60            Self::UnknownGraphAddress => {
61                f.write_str("scope graph refers to a document outside the loaded set")
62            }
63        }
64    }
65}
66impl Error for ScopeInputError {}
67
68impl<'a> QueryScopeView<'a> {
69    /// Borrow graph and content only after validating their positional contract.
70    /// No content is cloned, serialized, loaded or reinterpreted.
71    ///
72    /// # Errors
73    /// Rejects mismatched addresses/order/counts and graph references outside the set.
74    pub fn new(
75        graph: &'a ResolvedDocumentScope,
76        documents: &'a [ResolvedContent],
77    ) -> Result<Self, ScopeInputError> {
78        if graph.documents.len() != documents.len() {
79            return Err(ScopeInputError::LengthMismatch);
80        }
81        if documents.len() > MAX_SCOPE_DOCUMENT_LIMIT as usize {
82            return Err(ScopeInputError::TooManyDocuments);
83        }
84        let mut addresses = BTreeSet::new();
85        let mut depth = 0;
86        for (index, (source, content)) in graph.documents.iter().zip(documents).enumerate() {
87            if content.address.as_ref() != Some(&source.address) {
88                return Err(ScopeInputError::AddressMismatch { index });
89            }
90            if !addresses.insert(&source.address) {
91                return Err(ScopeInputError::DuplicateAddress { index });
92            }
93            if source.depth < depth
94                || source.depth > MAX_SCOPE_DEPTH
95                || source
96                    .root_indices
97                    .iter()
98                    .any(|root| usize::from(*root) >= graph.query.documents.len())
99                || (source.depth != 0 && !source.root_indices.is_empty())
100            {
101                return Err(ScopeInputError::InvalidSource { index });
102            }
103            depth = source.depth;
104        }
105        let known = |address: &mant_ir::DocumentAddress| addresses.contains(address);
106        if graph
107            .edges
108            .iter()
109            .any(|edge| !known(&edge.from) || !known(&edge.to))
110            || graph
111                .documents
112                .iter()
113                .any(|source| source.reached_from.iter().any(|from| !known(from)))
114            || graph.frontier.iter().any(|item| !known(&item.from))
115            || graph
116                .unresolved
117                .iter()
118                .any(|item| item.from.as_ref().is_some_and(|from| !known(from)))
119            || graph
120                .reference_limits
121                .iter()
122                .any(|item| !known(&item.document))
123        {
124            return Err(ScopeInputError::UnknownGraphAddress);
125        }
126        Ok(Self { graph, documents })
127    }
128
129    /// The borrowed logical graph and its loading/coverage report.
130    #[must_use]
131    pub const fn graph(self) -> &'a ResolvedDocumentScope {
132        self.graph
133    }
134
135    /// Borrow original content in the validated graph order.
136    #[must_use]
137    pub const fn documents(self) -> &'a [ResolvedContent] {
138        self.documents
139    }
140
141    /// Paired records cannot silently truncate or refer to another content order.
142    #[must_use]
143    pub fn iter(self) -> impl ExactSizeIterator<Item = (&'a ScopedDocument, &'a ResolvedContent)> {
144        self.graph.documents.iter().zip(self.documents)
145    }
146}