Skip to main content

mant_protocol/
explanation.rs

1//! Bounded semantic evidence, deliberately separate from strict navigation.
2mod classification;
3mod locations;
4mod matches;
5mod support;
6use crate::{OutlineTrail, Producer};
7pub use classification::*;
8pub use locations::ExplanationTextRoot;
9use mant_ir::{
10    Diagnostic, DocumentAddress, EntryKind, Inline, NameCase, NodeId, SourceSpan, ValueDomain,
11};
12pub use matches::*;
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15pub use support::*;
16
17/// Maximum evidence owners materialized in one page.
18pub const MAX_EXPLANATION_RESULTS: u32 = 256;
19/// Maximum matching owners indexed per document before reporting incomplete recall.
20pub const MAX_EXPLANATION_CANDIDATES: usize = 10_000;
21/// Maximum serialized content bytes copied into one response page.
22pub const MAX_EXPLANATION_CONTENT_BYTES: u32 = 4 * 1024 * 1024;
23/// Maximum explicit relationship edges followed per document.
24pub const MAX_EXPLANATION_RELATIONS: usize = 4096;
25/// Maximum edges in one returned relationship chain.
26pub const MAX_EXPLANATION_RELATION_DEPTH: usize = 32;
27
28/// Request-local semantic pagination and materialization controls.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31pub struct ExplanationOptions {
32    /// Maximum evidence records on this page (default 50, maximum 256).
33    #[serde(default = "default_explanation_limit")]
34    #[schemars(range(min = 1, max = 256))]
35    pub limit: u32,
36    /// Number of owners skipped in global class-then-source order.
37    #[serde(default)]
38    pub offset: u32,
39    /// Aggregate JSON byte budget for original facts, match previews and bodies.
40    /// Oversized bodies are omitted atomically, with location retained for reads.
41    #[serde(default = "default_explanation_content_bytes")]
42    #[schemars(range(min = 1, max = 4_194_304))]
43    pub content_bytes: u32,
44}
45impl Default for ExplanationOptions {
46    fn default() -> Self {
47        Self {
48            limit: default_explanation_limit(),
49            offset: 0,
50            content_bytes: default_explanation_content_bytes(),
51        }
52    }
53}
54/// Default number of explanation records (50).
55#[must_use]
56pub const fn default_explanation_limit() -> u32 {
57    50
58}
59/// Default shared forms/body budget (1 MiB).
60#[must_use]
61pub const fn default_explanation_content_bytes() -> u32 {
62    1024 * 1024
63}
64
65/// A literal evidence request, not executable syntax or a unique selector.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "camelCase", deny_unknown_fields)]
68pub struct ExplanationQuery {
69    /// Documented name, complete form, exact owner coordinate, or bounded literal.
70    #[schemars(length(min = 1, max = 512))]
71    pub entry: String,
72    /// Semantic result and content pagination, independent of MCP character paging.
73    #[serde(default)]
74    pub options: ExplanationOptions,
75}
76
77/// Exact marker for the independent explanation result contract.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
79pub enum ExplanationSchema {
80    /// Unreleased v0.11 explanation family.
81    #[serde(rename = "mant.explanation/v0.11")]
82    V0Dot11,
83}
84impl ExplanationSchema {
85    /// Serialized discriminator.
86    pub const ID: &'static str = "mant.explanation/v0.11";
87}
88
89/// A normal result outcome, independent of pagination and source coverage.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
91#[serde(rename_all = "kebab-case")]
92pub enum ExplanationOutcome {
93    /// At least one supporting owner was found before pagination.
94    Evidence,
95    /// No owner was found by the bounded rules; not proof of absence of behavior.
96    NoEvidence,
97}
98
99/// Why this owner is included. Multiple bases do not duplicate its content.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
101#[serde(
102    tag = "kind",
103    rename_all = "kebab-case",
104    rename_all_fields = "camelCase",
105    deny_unknown_fields
106)]
107pub enum EvidenceBasis {
108    /// Exact documented name under the owner's declared case policy.
109    Name {
110        /// Exact matched spellings and their available projected occurrences.
111        matches: Vec<ExplanationNameMatch>,
112    },
113    /// Complete authored form under the owner's declared case policy.
114    Form {
115        /// Exact complete forms accepted by the collector, not frontend guesses.
116        matches: Vec<ExplanationFormMatch>,
117    },
118    /// Literal visible content with finite token boundaries, always case-sensitive.
119    Literal,
120    /// Exact entry ID or structural coordinate (no alias/shorthand resolver).
121    Identity {
122        /// Matched outline fields; values are carried by the same evidence.
123        fields: Vec<ExplanationIdentityField>,
124    },
125    /// A directly matched name participates in a validated explicit alias group.
126    AliasGroup {
127        /// Exact member spellings; there is no canonical first member.
128        members: Vec<String>,
129    },
130    /// Evidence connected by explicit same-document aliasOf edges.
131    Related {
132        /// Starting directly matched owner.
133        from: NodeId,
134        /// Declaration owners in traversal order, each supplying one aliasOf edge.
135        declarations: Vec<NodeId>,
136    },
137}
138
139// Serde's internally tagged unit variants ignore extra fields, even with
140// deny_unknown_fields. Empty struct variants close the deserialization boundary
141// for Literal. Name/Form/Identity require their new payloads; the old unit
142// shapes are not accepted as apparently complete match records.
143#[derive(Deserialize)]
144#[serde(
145    tag = "kind",
146    rename_all = "kebab-case",
147    rename_all_fields = "camelCase",
148    deny_unknown_fields
149)]
150enum ClosedEvidenceBasis {
151    Name {
152        matches: Vec<ExplanationNameMatch>,
153    },
154    Form {
155        matches: Vec<ExplanationFormMatch>,
156    },
157    Literal {},
158    Identity {
159        fields: Vec<ExplanationIdentityField>,
160    },
161    AliasGroup {
162        members: Vec<String>,
163    },
164    Related {
165        from: NodeId,
166        declarations: Vec<NodeId>,
167    },
168}
169
170impl<'de> Deserialize<'de> for EvidenceBasis {
171    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
172        Ok(match ClosedEvidenceBasis::deserialize(deserializer)? {
173            ClosedEvidenceBasis::Name { matches } => Self::Name { matches },
174            ClosedEvidenceBasis::Form { matches } => Self::Form { matches },
175            ClosedEvidenceBasis::Literal {} => Self::Literal,
176            ClosedEvidenceBasis::Identity { fields } => Self::Identity { fields },
177            ClosedEvidenceBasis::AliasGroup { members } => Self::AliasGroup { members },
178            ClosedEvidenceBasis::Related { from, declarations } => {
179                Self::Related { from, declarations }
180            }
181        })
182    }
183}
184
185/// Original semantic facts and forms; no executable argument grammar is implied.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
187#[serde(rename_all = "camelCase", deny_unknown_fields)]
188pub struct ExplanationEntry {
189    /// Source-neutral role.
190    pub kind: EntryKind,
191    /// Matching policy for documented names/forms.
192    pub case: NameCase,
193    /// Documented selectable names, not implicit equivalence groups.
194    pub names: Vec<String>,
195    /// Original visible forms projected through validated content bindings.
196    pub forms: Vec<Vec<Inline>>,
197    /// Ordinary validated name locations, independent of the actual query match.
198    pub name_bindings: Vec<ExplanationNameBinding>,
199    /// Explicit same-owner equivalence groups.
200    pub alias_groups: Vec<Vec<String>>,
201    /// Explicit independent same-document subject relation.
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub alias_of: Option<NodeId>,
204    /// Local choices or remote entry set; never implicitly inherited/resolved.
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub value_domain: Option<ValueDomain>,
207}
208
209/// One independently addressable evidence owner.
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
211// Independent payload omissions can coexist; they are not exclusive states.
212#[allow(clippy::struct_excessive_bools)]
213#[serde(rename_all = "camelCase", deny_unknown_fields)]
214pub struct ExplanationEvidence {
215    /// Response-local declaration context; in scope results the pool belongs
216    /// to this evidence's document report. This is not an alias edge.
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub support: Option<usize>,
219    /// A known declaration context could not fit the copy budget.
220    pub support_omitted: bool,
221    /// Exclusive category determined before pagination or content copying.
222    pub class: EvidenceClass,
223    /// Zero-based ordinal before result pagination.
224    pub ordinal: u32,
225    /// Real owner/containing section; prose is never assigned a synthetic entry.
226    pub outline: OutlineTrail,
227    /// IR block/item/cell coordinate for ordinary supporting content.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub block_path: Option<String>,
230    /// Original source coordinates, when known.
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub source: Option<SourceSpan>,
233    /// All retained reasons for this owner's inclusion.
234    pub bases: Vec<EvidenceBasis>,
235    /// At most two representative matched blocks, in their source order.
236    pub previews: Vec<ExplanationPreview>,
237    /// A representative match window did not fit the remaining copy budget.
238    pub previews_omitted: bool,
239    /// Semantic metadata, absent for prose or when its copy exceeds the budget.
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub entry: Option<ExplanationEntry>,
242    /// Original owner body, omitted atomically rather than silently clipped.
243    /// Prose retains only its matched block, never an invented section/entry.
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub content: Option<ExplanationContent>,
246    /// Original forms/facts were too large for the remaining copy budget.
247    pub details_omitted: bool,
248    /// Some actual Name/Form records or applicable matched positions were omitted.
249    pub match_details_omitted: bool,
250    /// Some ordinary display bindings applicable to returned targets were omitted.
251    pub name_bindings_omitted: bool,
252    /// Original body was too large for the remaining copy budget.
253    pub content_omitted: bool,
254}
255
256impl ExplanationEvidence {
257    /// Whether any selected facts, representative preview or complete body was
258    /// omitted by the shared copy budget (not ordinary window clipping).
259    #[must_use]
260    pub const fn has_omitted_content(&self) -> bool {
261        self.content_omitted
262            || self.support_omitted
263            || self.details_omitted
264            || self.previews_omitted
265            || self.match_details_omitted
266            || self.name_bindings_omitted
267    }
268}
269
270/// Original content copied for one evidence owner, not a navigation selection.
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
272#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
273pub enum ExplanationContent {
274    /// Physical owner stored once because nested selected contexts also use
275    /// its body; not a declaration-group or alias relationship.
276    SharedEntry {
277        /// Document-local index of an `owned-entry` source fragment.
278        support: usize,
279        /// Typed path from the owned source fragment to the owner's list.
280        path: Vec<ExplanationBlockStep>,
281        /// Owner in that list; zero for a standalone single-entry fragment.
282        #[serde(rename = "itemIndex")]
283        item_index: usize,
284    },
285    /// Original owner already present in the document-local support pool.
286    /// Positions remain owner-local (outer item zero), resolved through this
287    /// reference rather than reinterpreted against the whole group.
288    DeclarationMember {
289        /// Index in the containing document response's support pool.
290        support: usize,
291        /// Zero-based member in the returned group, not the original list.
292        #[serde(rename = "itemIndex")]
293        item_index: usize,
294    },
295    /// One original entry in a single-item list retaining its numbering/layout.
296    Entry {
297        /// Complete original owner, including independently addressable children.
298        block: mant_ir::Block,
299    },
300    /// One literal-support block belonging to the reported root/section.
301    Block {
302        /// Original ordinary IR content, without synthetic semantic facts.
303        block: mant_ir::Block,
304    },
305}
306
307/// One readable document's independently collected explanation evidence.
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
309#[serde(rename_all = "camelCase", deny_unknown_fields)]
310#[schemars(extend("$id" = "urn:mant:explanation:v0.11"))]
311pub struct QueryExplanation {
312    /// Source-qualified context shared by the direct owners on this page.
313    pub supports: Vec<ExplanationSupport>,
314    /// Normative category-first ordering, before result pagination.
315    pub order: EvidenceOrder,
316    /// Per-class collected and returned owners; zero categories remain present.
317    pub counts: EvidenceCounts,
318    /// Exact result family.
319    pub schema: ExplanationSchema,
320    /// Normalized request and applied bounds.
321    pub query: ExplanationQuery,
322    /// Selected document label.
323    pub label: String,
324    /// Logical identity, absent for explicit local input.
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub address: Option<DocumentAddress>,
327    /// Parser/process provenance, when available.
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub producer: Option<Producer>,
330    /// Evidence versus no-evidence before page slicing.
331    pub outcome: ExplanationOutcome,
332    /// Number of collected matching owners before pagination; a lower bound
333    /// when candidate or relationship traversal is truncated.
334    pub total: u32,
335    /// Matching owners returned on this page.
336    pub returned: u32,
337    /// More already-collected owners remain after this page.
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub next_offset: Option<u32>,
340    /// Independent collection, relation traversal and body-copy bounds.
341    pub truncation: ExplanationTruncation,
342    /// Semantic validation coverage, never a recall-completeness claim.
343    pub semantics_complete: bool,
344    /// Original recoverable validation/parser findings.
345    pub diagnostics: Vec<Diagnostic>,
346    /// Records in class-then-source order, with independent owners never merged.
347    pub evidence: Vec<ExplanationEvidence>,
348}
349
350/// Independent reasons that a bounded explanation may omit material.
351#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
352#[serde(rename_all = "camelCase", deny_unknown_fields)]
353pub struct ExplanationTruncation {
354    /// Collection hit its matching-owner budget.
355    pub candidates: bool,
356    /// Relation traversal hit its edge or depth budget.
357    pub relations: bool,
358    /// Some selected body, details or previews were omitted by the copy budget.
359    pub content: bool,
360}
361
362// Remote derive keeps the public schema closed while validating cross-field
363// references after structural decoding, without a JSON intermediate tree.
364#[derive(Deserialize)]
365#[serde(
366    remote = "QueryExplanation",
367    rename_all = "camelCase",
368    deny_unknown_fields
369)]
370struct QueryExplanationWire {
371    pub supports: Vec<ExplanationSupport>,
372    pub order: EvidenceOrder,
373    pub counts: EvidenceCounts,
374    pub schema: ExplanationSchema,
375    pub query: ExplanationQuery,
376    pub label: String,
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub address: Option<DocumentAddress>,
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub producer: Option<Producer>,
381    pub outcome: ExplanationOutcome,
382    pub total: u32,
383    pub returned: u32,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub next_offset: Option<u32>,
386    pub truncation: ExplanationTruncation,
387    pub semantics_complete: bool,
388    pub diagnostics: Vec<Diagnostic>,
389    pub evidence: Vec<ExplanationEvidence>,
390}
391impl<'de> Deserialize<'de> for QueryExplanation {
392    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
393        let value = QueryExplanationWire::deserialize(deserializer)?;
394        value
395            .validate_references()
396            .map_err(serde::de::Error::custom)?;
397        Ok(value)
398    }
399}