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