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, SearchCase, SearchHit, SearchQuery, SearchRender, 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 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.11 of the pre-stable scope-query request.
158    #[serde(rename = "mant.scope-request/v0.11")]
159    V0Dot11,
160}
161
162impl ScopeRequestSchema {
163    /// Serialized identifier of the current request contract.
164    pub const ID: &'static str = "mant.scope-request/v0.11";
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    /// Collect independent evidence across loaded documents.
177    Explain {
178        /// Documented name, full form, exact entry ID/path, or bounded literal.
179        #[schemars(length(min = 1, max = MAX_SEMANTIC_ENTRY_CHARS))]
180        entry: String,
181        /// Global result pagination and copied-content budget.
182        #[serde(default)]
183        options: crate::ExplanationOptions,
184    },
185    /// Search visible or generated-Markdown text over the complete scope.
186    Search {
187        /// Literal or regular-expression search pattern.
188        #[schemars(length(min = 1, max = MAX_SEARCH_PATTERN_CHARS))]
189        pattern: String,
190        /// Pattern language.
191        #[serde(default)]
192        syntax: SearchSyntax,
193        /// Case-matching policy.
194        #[serde(default)]
195        case: SearchCase,
196        /// Semantic representation searched.
197        #[serde(default)]
198        scope: SearchScope,
199        /// Require Unicode-aware word boundaries.
200        #[serde(default)]
201        word: bool,
202        /// Neighboring rendered lines included around a match.
203        #[serde(default)]
204        #[schemars(range(max = 100))]
205        context_lines: u16,
206        /// Global maximum number of matching line groups returned.
207        #[serde(default = "default_search_limit")]
208        #[schemars(range(min = 1, max = 10000))]
209        limit: u32,
210        /// Global number of matching line groups skipped.
211        #[serde(default)]
212        offset: u32,
213    },
214}
215
216/// Native request for a bounded multi-document query.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
218#[serde(rename_all = "camelCase", deny_unknown_fields)]
219#[schemars(extend("$id" = "urn:mant:scope-request:v0.11"))]
220pub struct ScopeQueryRequest {
221    /// Exact request schema discriminator.
222    pub schema: ScopeRequestSchema,
223    /// Initial documents and traversal limits.
224    pub scope: DocumentScope,
225    /// Projection applied independently to resolved documents.
226    pub view: ScopeQueryView,
227}
228
229/// Exact schema marker for a resolved scope query.
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
231pub enum ScopeQuerySchema {
232    /// Version 0.11 of the pre-stable scope-query result.
233    #[serde(rename = "mant.scope-query/v0.11")]
234    V0Dot11,
235}
236
237impl ScopeQuerySchema {
238    /// Serialized identifier of the current result contract.
239    pub const ID: &'static str = "mant.scope-query/v0.11";
240}
241
242/// Typed cross-document edge retained in a resolved scope.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
244#[serde(rename_all = "kebab-case")]
245pub enum DocumentEdgeKind {
246    /// A relative Markdown link inside one registered namespace.
247    Document,
248    /// A semantic native-manual reference.
249    Manual,
250}
251
252/// Traversal bound that excluded an outbound logical link.
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
254#[serde(rename_all = "kebab-case")]
255pub enum TraversalLimit {
256    /// The maximum number of followed link edges was reached.
257    MaxDepth,
258    /// The maximum number of distinct loaded documents was reached.
259    MaxDocuments,
260    /// Retaining another normalized document would exceed the aggregate
261    /// semantic-content budget.
262    MaxContentBytes,
263}
264
265/// One typed outbound link excluded by a traversal bound.
266///
267/// A frontier retains the logical selector rather than requiring a resolved
268/// address: resolving a target may itself exceed the requested bound.
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
270#[serde(rename_all = "camelCase")]
271pub struct DocumentFrontier {
272    /// Address containing the excluded link.
273    pub from: DocumentAddress,
274    /// Logical target that would be resolved if traversal continued.
275    pub target: DocumentSelector,
276    /// Semantic link family.
277    pub kind: DocumentEdgeKind,
278    /// Bound that prevented traversal of this link.
279    pub limit: TraversalLimit,
280}
281
282/// One resolved edge in source order.
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
284#[serde(rename_all = "camelCase")]
285pub struct DocumentEdge {
286    /// Address containing the link.
287    pub from: DocumentAddress,
288    /// Resolved linked address.
289    pub to: DocumentAddress,
290    /// Semantic link family.
291    pub kind: DocumentEdgeKind,
292}
293
294/// One distinct document in breadth-first traversal order.
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
296#[serde(rename_all = "camelCase")]
297pub struct ScopedDocument {
298    /// Stable logical document identity.
299    pub address: DocumentAddress,
300    /// Minimum outbound-link distance from any initial document.
301    pub depth: u16,
302    /// Initial document positions that resolve to this address.
303    #[serde(default, skip_serializing_if = "Vec::is_empty")]
304    pub root_indices: Vec<u16>,
305    /// Distinct documents whose links reached this address.
306    #[serde(default, skip_serializing_if = "Vec::is_empty")]
307    pub reached_from: Vec<DocumentAddress>,
308}
309
310/// A seed or typed link that could not be resolved.
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
312#[serde(rename_all = "camelCase")]
313pub struct UnresolvedDocument {
314    /// Referring document, omitted for an initial selector.
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub from: Option<DocumentAddress>,
317    /// Original logical selector or link target.
318    pub selector: DocumentSelector,
319    /// Stable, concise resolution diagnostic.
320    pub reason: String,
321}
322
323/// Logical graph produced before applying a projection.
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
325#[serde(rename_all = "camelCase")]
326pub struct ResolvedDocumentScope {
327    /// Original normalized scope request.
328    pub query: DocumentScope,
329    /// Distinct documents in deterministic breadth-first order.
330    pub documents: Vec<ScopedDocument>,
331    /// Successfully resolved typed edges in source order.
332    pub edges: Vec<DocumentEdge>,
333    /// Typed outbound links excluded by depth, document, or content limits.
334    #[serde(default, skip_serializing_if = "Vec::is_empty")]
335    pub frontier: Vec<DocumentFrontier>,
336    /// Seeds and edges that could not resolve to a readable document.
337    #[serde(default, skip_serializing_if = "Vec::is_empty")]
338    pub unresolved: Vec<UnresolvedDocument>,
339    /// Documents whose outbound reference scan was incomplete. Missing edges
340    /// are unknown, not proof that these documents have no further links.
341    #[serde(default, skip_serializing_if = "Vec::is_empty")]
342    pub reference_limits: Vec<ScopeReferenceLimit>,
343}
344
345/// A bounded outbound scan that could not establish the complete edge set.
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
347#[serde(rename_all = "camelCase", deny_unknown_fields)]
348pub struct ScopeReferenceLimit {
349    /// Loaded logical source document, never a host filesystem path.
350    pub document: DocumentAddress,
351    /// Shared traversal accounting and first stop condition.
352    pub coverage: crate::ReferenceCoverage,
353    /// Distinct reference retention cap, when it caused the stop.
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub retention_limit: Option<crate::ReferencePageLimit>,
356}
357
358/// One document's search hits inside a globally paginated scope result.
359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
360#[serde(rename_all = "camelCase")]
361pub struct ScopedSearchDocument {
362    /// Stable logical document identity.
363    pub address: DocumentAddress,
364    /// Distance retained from the resolved scope.
365    pub depth: u16,
366    /// Canonical Markdown coordinate space for this document's hits.
367    pub render: SearchRender,
368    /// Matching line groups retained from the globally paginated result set.
369    /// Their ordinals are global across all documents in the scope.
370    pub matches: Vec<SearchHit>,
371}
372
373/// Globally paginated search over a resolved document scope.
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
375#[serde(rename_all = "camelCase")]
376pub struct ScopeSearch {
377    /// Normalized search configuration.
378    pub query: SearchQuery,
379    /// Matching line groups across all documents before pagination.
380    pub total: u32,
381    /// Matching line groups present in this response.
382    pub returned: u32,
383    /// Applied global zero-based offset.
384    pub offset: u32,
385    /// Whether additional matching line groups remain.
386    pub truncated: bool,
387    /// Global offset for the next page.
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub next_offset: Option<u32>,
390    /// Non-empty document groups in scope order.
391    pub documents: Vec<ScopedSearchDocument>,
392}
393
394/// One readable document's contribution to the evidence result.
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
396#[serde(rename_all = "camelCase", deny_unknown_fields)]
397pub struct ScopedExplanation {
398    /// Declaration context pool for evidence with this document index.
399    pub supports: Vec<crate::ExplanationSupport>,
400    /// Stable logical document identity.
401    pub address: DocumentAddress,
402    /// Distance retained from the resolved scope.
403    pub depth: u16,
404    /// Selected source label, independent of catalog identity.
405    pub label: String,
406    /// Parser and process provenance when available.
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub producer: Option<crate::Producer>,
409    /// Recoverable producer and shared invariant findings.
410    pub diagnostics: Vec<mant_ir::Diagnostic>,
411    /// Semantic validation, not evidence recall.
412    pub semantics_complete: bool,
413    /// Normal local evidence/no-evidence outcome before global pagination.
414    pub outcome: crate::ExplanationOutcome,
415    /// Local collected owners.
416    pub total: u32,
417    /// Local owners selected on the one global page.
418    pub returned: u32,
419    /// Local contributions to each global evidence category.
420    pub counts: crate::EvidenceCounts,
421    /// Local collection and copy truncation.
422    pub truncation: crate::ExplanationTruncation,
423}
424
425/// One global evidence record with an explicit source-report reference.
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
427#[serde(rename_all = "camelCase", deny_unknown_fields)]
428pub struct ScopedExplanationEvidence {
429    /// Zero-based index into this explanation's documents, not the scope graph.
430    pub document_index: usize,
431    /// The unique record; bodies are never duplicated in the document reports.
432    pub evidence: crate::ExplanationEvidence,
433}
434
435/// One per-document projection failure that does not invalidate other results.
436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
437#[serde(rename_all = "camelCase")]
438pub struct ScopedQueryFailure {
439    /// Stable logical document identity.
440    pub address: DocumentAddress,
441    /// Concise projection diagnostic.
442    pub reason: String,
443}
444
445/// Projection result carried by a scope-query response.
446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
447#[serde(
448    tag = "kind",
449    rename_all = "kebab-case",
450    rename_all_fields = "camelCase"
451)]
452pub enum ScopeQueryResult {
453    /// Semantic entries found across the scope.
454    Explain {
455        /// Globally bounded evidence with coverage separate from the scope graph.
456        explanation: ScopeExplanation,
457    },
458    /// Globally paginated text search.
459    Search {
460        /// Search result grouped by document.
461        search: ScopeSearch,
462    },
463}
464
465/// Global evidence page over a resolved scope. Source loading failures/frontier
466/// remain in `ScopeQueryResponse.scope`, independently of this normal outcome.
467#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
468#[serde(rename_all = "camelCase", deny_unknown_fields)]
469pub struct ScopeExplanation {
470    /// Normative global category/BFS/source ordering.
471    pub order: crate::EvidenceOrder,
472    /// Global per-class totals and page counts.
473    pub counts: crate::EvidenceCounts,
474    /// Original global pagination/content request.
475    pub query: crate::ExplanationQuery,
476    /// Evidence/no-evidence before pagination, never uniqueness or recall proof.
477    pub outcome: crate::ExplanationOutcome,
478    /// Sum of collected owner counts (a lower bound when collection is truncated).
479    pub total: u32,
480    /// Owners present on this global page.
481    pub returned: u32,
482    /// Next global result offset when more collected evidence remains.
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub next_offset: Option<u32>,
485    /// Independent bounds, combined across readable sources.
486    pub truncation: crate::ExplanationTruncation,
487    /// Readable documents, including normal zero-evidence contributions.
488    pub documents: Vec<ScopedExplanation>,
489    /// The only materialized evidence list, in global classification order.
490    pub evidence: Vec<ScopedExplanationEvidence>,
491    /// Unexpected unreadable loaded content, never normal multiple/zero hits.
492    pub failures: Vec<ScopedQueryFailure>,
493}
494
495/// Complete bounded multi-document response.
496#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
497#[serde(rename_all = "camelCase")]
498#[schemars(extend("$id" = "urn:mant:scope-query:v0.11"))]
499pub struct ScopeQueryResponse {
500    /// Exact response schema discriminator.
501    pub schema: ScopeQuerySchema,
502    /// Resolved logical graph, including missing links and truncation.
503    pub scope: ResolvedDocumentScope,
504    /// Requested projection over that graph.
505    pub result: ScopeQueryResult,
506}
507
508// Remote derive keeps the public schema closed while validating cross-field
509// references after structural decoding, without a JSON intermediate tree.
510#[derive(Deserialize)]
511#[serde(
512    remote = "ScopeExplanation",
513    rename_all = "camelCase",
514    deny_unknown_fields
515)]
516struct ScopeExplanationWire {
517    pub order: crate::EvidenceOrder,
518    pub counts: crate::EvidenceCounts,
519    pub query: crate::ExplanationQuery,
520    pub outcome: crate::ExplanationOutcome,
521    pub total: u32,
522    pub returned: u32,
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub next_offset: Option<u32>,
525    pub truncation: crate::ExplanationTruncation,
526    pub documents: Vec<ScopedExplanation>,
527    pub evidence: Vec<ScopedExplanationEvidence>,
528    pub failures: Vec<ScopedQueryFailure>,
529}
530impl<'de> Deserialize<'de> for ScopeExplanation {
531    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
532        let value = ScopeExplanationWire::deserialize(deserializer)?;
533        value
534            .validate_references()
535            .map_err(serde::de::Error::custom)?;
536        Ok(value)
537    }
538}