Skip to main content

mant_protocol/
scope.rs

1//! Stable contracts for bounded queries over a linked set of documents.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    DocumentAddress, QueryExcerpt, QuerySearch, SearchCase, SearchQuery, SearchScope, SearchSyntax,
8    default_search_limit,
9};
10
11/// Maximum number of initial documents accepted by the native scope contract.
12pub const MAX_SCOPE_DOCUMENTS: usize = 16;
13/// Default maximum number of link edges followed from an initial document.
14pub const DEFAULT_SCOPE_DEPTH: u16 = 8;
15/// Hard maximum number of link edges accepted by the native scope contract.
16pub const MAX_SCOPE_DEPTH: u16 = 32;
17/// Default maximum number of distinct documents in one resolved scope.
18pub const DEFAULT_SCOPE_DOCUMENT_LIMIT: u32 = 64;
19/// Hard maximum number of distinct documents in one resolved scope.
20pub const MAX_SCOPE_DOCUMENT_LIMIT: u32 = 256;
21/// Maximum aggregate normalized-document payload retained by one scope.
22///
23/// Scope resolution keeps each parsed document in memory so later search,
24/// explanation, and interactive navigation observe one consistent graph. This
25/// independent guard prevents a small number of individually valid documents
26/// from creating an unbounded aggregate allocation.
27pub const MAX_SCOPE_CONTENT_BYTES: u64 = 64 * 1024 * 1024;
28/// Maximum UTF-8 byte length of one logical document selector.
29pub const MAX_DOCUMENT_SELECTOR_BYTES: usize = 1024;
30/// Maximum UTF-8 byte length of one semantic-entry selector.
31pub const MAX_SEMANTIC_ENTRY_BYTES: usize = 512;
32
33/// One violated runtime constraint shared by scope-query request adapters.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ScopeTextError {
36    /// The value was empty after trimming surrounding whitespace.
37    Empty,
38    /// The value contained a terminal or structural control character.
39    ControlCharacter,
40    /// The UTF-8 byte length exceeded the declared maximum.
41    TooLong {
42        /// Inclusive maximum accepted byte length.
43        maximum: usize,
44    },
45}
46
47/// Validate one bounded logical selector at the native request boundary.
48///
49/// JSON Schema advertises the same limits, but native `--request-json` callers
50/// do not pass through a schema validator, so the runtime contract must check
51/// them independently.
52///
53/// # Errors
54///
55/// Returns the precise empty, control-character, or byte-length violation.
56pub fn validate_scope_text(value: &str, maximum: usize) -> Result<(), ScopeTextError> {
57    if value.trim().is_empty() {
58        return Err(ScopeTextError::Empty);
59    }
60    if value.chars().any(char::is_control) {
61        return Err(ScopeTextError::ControlCharacter);
62    }
63    if value.len() > maximum {
64        return Err(ScopeTextError::TooLong { maximum });
65    }
66    Ok(())
67}
68
69/// One logical document selector before catalog resolution.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
71#[serde(rename_all = "camelCase", deny_unknown_fields)]
72pub struct DocumentSelector {
73    /// Unqualified name or complete catalog path.
74    #[schemars(length(min = 1, max = MAX_DOCUMENT_SELECTOR_BYTES))]
75    pub selector: String,
76    /// Optional configured Markdown source for an unqualified selector.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub source: Option<String>,
79    /// Optional native manual category for an unqualified selector.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub manual_section: Option<String>,
82}
83
84/// Bounded traversal applied after resolving the initial documents.
85#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
86#[serde(rename_all = "camelCase", deny_unknown_fields)]
87pub struct DocumentTraversal {
88    /// Follow typed links to other registered documents.
89    #[serde(default)]
90    pub follow_links: bool,
91    /// Optional maximum number of link edges from an initial document.
92    ///
93    /// Omission selects [`DEFAULT_SCOPE_DEPTH`] when [`Self::follow_links`] is
94    /// true. The field is invalid when link traversal is disabled.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    #[schemars(range(max = 32))]
97    pub max_depth: Option<u16>,
98    /// Optional maximum number of distinct documents, including roots.
99    ///
100    /// Omission selects [`DEFAULT_SCOPE_DOCUMENT_LIMIT`] when
101    /// [`Self::follow_links`] is true. The field is invalid when link traversal
102    /// is disabled.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    #[schemars(range(min = 1, max = 256))]
105    pub max_documents: Option<u32>,
106}
107
108impl DocumentTraversal {
109    /// Effective edge limit after applying the native default.
110    #[must_use]
111    pub fn effective_max_depth(self) -> u16 {
112        self.max_depth.unwrap_or(DEFAULT_SCOPE_DEPTH)
113    }
114
115    /// Effective document budget after applying the native default.
116    #[must_use]
117    pub fn effective_max_documents(self) -> u32 {
118        self.max_documents.unwrap_or(DEFAULT_SCOPE_DOCUMENT_LIMIT)
119    }
120}
121
122/// Return [`DEFAULT_SCOPE_DEPTH`].
123#[must_use]
124pub const fn default_scope_depth() -> u16 {
125    DEFAULT_SCOPE_DEPTH
126}
127
128/// Return [`DEFAULT_SCOPE_DOCUMENT_LIMIT`].
129#[must_use]
130pub const fn default_scope_document_limit() -> u32 {
131    DEFAULT_SCOPE_DOCUMENT_LIMIT
132}
133
134/// Initial documents and the link policy used to expand them.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
136#[serde(rename_all = "camelCase", deny_unknown_fields)]
137pub struct DocumentScope {
138    /// Ordered initial documents. The first one is the initial TUI page.
139    #[schemars(length(min = 1, max = 16))]
140    pub documents: Vec<DocumentSelector>,
141    /// Deterministic outbound-link traversal policy.
142    #[serde(default)]
143    pub traversal: DocumentTraversal,
144}
145
146/// Exact schema marker for a scope-query request.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
148pub enum ScopeRequestSchema {
149    /// Version 0.9 of the pre-stable scope-query request.
150    #[serde(rename = "mant.scope-request/v0.9")]
151    V0Dot9,
152}
153
154impl ScopeRequestSchema {
155    /// Serialized identifier of the current request contract.
156    pub const ID: &'static str = "mant.scope-request/v0.9";
157}
158
159/// Query projection supported over a document set.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
161#[serde(
162    tag = "kind",
163    rename_all = "kebab-case",
164    rename_all_fields = "camelCase",
165    deny_unknown_fields
166)]
167pub enum ScopeQueryView {
168    /// Resolve one semantic entry independently in every document.
169    Explain {
170        /// Exact alias, outline path, or stable ID.
171        #[schemars(length(min = 1, max = MAX_SEMANTIC_ENTRY_BYTES))]
172        entry: String,
173    },
174    /// Search visible or generated-Markdown text over the complete scope.
175    Search {
176        /// Literal or regular-expression search pattern.
177        #[schemars(length(min = 1, max = 4096))]
178        pattern: String,
179        /// Pattern language.
180        #[serde(default)]
181        syntax: SearchSyntax,
182        /// Case-matching policy.
183        #[serde(default)]
184        case: SearchCase,
185        /// Semantic representation searched.
186        #[serde(default)]
187        scope: SearchScope,
188        /// Require Unicode-aware word boundaries.
189        #[serde(default)]
190        word: bool,
191        /// Neighboring rendered lines included around a match.
192        #[serde(default)]
193        #[schemars(range(max = 100))]
194        context_lines: u16,
195        /// Global maximum number of matching line groups returned.
196        #[serde(default = "default_search_limit")]
197        #[schemars(range(min = 1, max = 10000))]
198        limit: u32,
199        /// Global number of matching line groups skipped.
200        #[serde(default)]
201        offset: u32,
202    },
203}
204
205/// Native request for a bounded multi-document query.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
207#[serde(rename_all = "camelCase", deny_unknown_fields)]
208#[schemars(extend("$id" = "urn:mant:scope-request:v0.9"))]
209pub struct ScopeQueryRequest {
210    /// Exact request schema discriminator.
211    pub schema: ScopeRequestSchema,
212    /// Initial documents and traversal limits.
213    pub scope: DocumentScope,
214    /// Projection applied independently to resolved documents.
215    pub view: ScopeQueryView,
216}
217
218/// Exact schema marker for a resolved scope query.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
220pub enum ScopeQuerySchema {
221    /// Version 0.9 of the pre-stable scope-query result.
222    #[serde(rename = "mant.scope-query/v0.9")]
223    V0Dot9,
224}
225
226impl ScopeQuerySchema {
227    /// Serialized identifier of the current result contract.
228    pub const ID: &'static str = "mant.scope-query/v0.9";
229}
230
231/// Typed cross-document edge retained in a resolved scope.
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
233#[serde(rename_all = "kebab-case")]
234pub enum DocumentEdgeKind {
235    /// A relative Markdown link inside one registered namespace.
236    Document,
237    /// A semantic native-manual reference.
238    Manual,
239}
240
241/// Traversal bound that excluded an outbound logical link.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
243#[serde(rename_all = "kebab-case")]
244pub enum TraversalLimit {
245    /// The maximum number of followed link edges was reached.
246    MaxDepth,
247    /// The maximum number of distinct loaded documents was reached.
248    MaxDocuments,
249    /// Retaining another normalized document would exceed the aggregate
250    /// semantic-content budget.
251    MaxContentBytes,
252}
253
254/// One typed outbound link excluded by a traversal bound.
255///
256/// A frontier retains the logical selector rather than requiring a resolved
257/// address: resolving a target may itself exceed the requested bound.
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
259#[serde(rename_all = "camelCase")]
260pub struct DocumentFrontier {
261    /// Address containing the excluded link.
262    pub from: DocumentAddress,
263    /// Logical target that would be resolved if traversal continued.
264    pub target: DocumentSelector,
265    /// Semantic link family.
266    pub kind: DocumentEdgeKind,
267    /// Bound that prevented traversal of this link.
268    pub limit: TraversalLimit,
269}
270
271/// One resolved edge in source order.
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
273#[serde(rename_all = "camelCase")]
274pub struct DocumentEdge {
275    /// Address containing the link.
276    pub from: DocumentAddress,
277    /// Resolved linked address.
278    pub to: DocumentAddress,
279    /// Semantic link family.
280    pub kind: DocumentEdgeKind,
281}
282
283/// One distinct document in breadth-first traversal order.
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
285#[serde(rename_all = "camelCase")]
286pub struct ScopedDocument {
287    /// Stable logical document identity.
288    pub address: DocumentAddress,
289    /// Minimum outbound-link distance from any initial document.
290    pub depth: u16,
291    /// Initial document positions that resolve to this address.
292    #[serde(default, skip_serializing_if = "Vec::is_empty")]
293    pub root_indices: Vec<u16>,
294    /// Distinct documents whose links reached this address.
295    #[serde(default, skip_serializing_if = "Vec::is_empty")]
296    pub reached_from: Vec<DocumentAddress>,
297}
298
299/// A seed or typed link that could not be resolved.
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
301#[serde(rename_all = "camelCase")]
302pub struct UnresolvedDocument {
303    /// Referring document, omitted for an initial selector.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub from: Option<DocumentAddress>,
306    /// Original logical selector or link target.
307    pub selector: DocumentSelector,
308    /// Stable, concise resolution diagnostic.
309    pub reason: String,
310}
311
312/// Logical graph produced before applying a projection.
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
314#[serde(rename_all = "camelCase")]
315pub struct ResolvedDocumentScope {
316    /// Original normalized scope request.
317    pub query: DocumentScope,
318    /// Distinct documents in deterministic breadth-first order.
319    pub documents: Vec<ScopedDocument>,
320    /// Successfully resolved typed edges in source order.
321    pub edges: Vec<DocumentEdge>,
322    /// Typed outbound links excluded by depth, document, or content limits.
323    #[serde(default, skip_serializing_if = "Vec::is_empty")]
324    pub frontier: Vec<DocumentFrontier>,
325    /// Seeds and edges that could not resolve to a readable document.
326    #[serde(default, skip_serializing_if = "Vec::is_empty")]
327    pub unresolved: Vec<UnresolvedDocument>,
328}
329
330/// One document's search hits inside a globally paginated scope result.
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
332#[serde(rename_all = "camelCase")]
333pub struct ScopedSearchDocument {
334    /// Stable logical document identity.
335    pub address: DocumentAddress,
336    /// Distance retained from the resolved scope.
337    pub depth: u16,
338    /// Matching line groups retained for this global page together with their
339    /// document-local coordinate contract.
340    pub search: QuerySearch,
341}
342
343/// Globally paginated search over a resolved document scope.
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
345#[serde(rename_all = "camelCase")]
346pub struct ScopeSearch {
347    /// Normalized search configuration.
348    pub query: SearchQuery,
349    /// Matching line groups across all documents before pagination.
350    pub total: u32,
351    /// Matching line groups present in this response.
352    pub returned: u32,
353    /// Applied global zero-based offset.
354    pub offset: u32,
355    /// Whether additional matching line groups remain.
356    pub truncated: bool,
357    /// Global offset for the next page.
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub next_offset: Option<u32>,
360    /// Non-empty document groups in scope order.
361    pub documents: Vec<ScopedSearchDocument>,
362}
363
364/// One successful semantic-entry selection in a document scope.
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
366#[serde(rename_all = "camelCase")]
367pub struct ScopedExplanation {
368    /// Stable logical document identity.
369    pub address: DocumentAddress,
370    /// Distance retained from the resolved scope.
371    pub depth: u16,
372    /// Complete selected semantic entry or ambiguity candidates.
373    pub excerpt: QueryExcerpt,
374}
375
376/// One per-document projection failure that does not invalidate other results.
377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
378#[serde(rename_all = "camelCase")]
379pub struct ScopedQueryFailure {
380    /// Stable logical document identity.
381    pub address: DocumentAddress,
382    /// Concise projection diagnostic.
383    pub reason: String,
384}
385
386/// Projection result carried by a scope-query response.
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
388#[serde(
389    tag = "kind",
390    rename_all = "kebab-case",
391    rename_all_fields = "camelCase"
392)]
393pub enum ScopeQueryResult {
394    /// Semantic entries found across the scope.
395    Explain {
396        /// Requested entry selector.
397        entry: String,
398        /// Documents with one or more exact candidates.
399        matches: Vec<ScopedExplanation>,
400        /// Resolved documents in which the entry was not present.
401        missed: u32,
402        /// Ambiguity or projection failures, excluding ordinary misses.
403        #[serde(default, skip_serializing_if = "Vec::is_empty")]
404        failures: Vec<ScopedQueryFailure>,
405    },
406    /// Globally paginated text search.
407    Search {
408        /// Search result grouped by document.
409        search: ScopeSearch,
410    },
411}
412
413/// Complete bounded multi-document response.
414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
415#[serde(rename_all = "camelCase")]
416#[schemars(extend("$id" = "urn:mant:scope-query:v0.9"))]
417pub struct ScopeQueryResponse {
418    /// Exact response schema discriminator.
419    pub schema: ScopeQuerySchema,
420    /// Resolved logical graph, including missing links and truncation.
421    pub scope: ResolvedDocumentScope,
422    /// Requested projection over that graph.
423    pub result: ScopeQueryResult,
424}