Skip to main content

mant_protocol/explanation/
classification.rs

1//! Shared evidence ordering, per-class accounting and literal preview coordinates.
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4
5/// Mutually exclusive owner category, in normative presentation/page order.
6/// This is a source-evidence distinction, not a confidence or relevance score.
7#[derive(
8    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
9)]
10#[serde(rename_all = "kebab-case")]
11pub enum EvidenceClass {
12    /// A semantic owner with an exact name, complete form or identity match.
13    DirectEntry,
14    /// An independent owner reached through a validated explicit relationship.
15    RelatedEntry,
16    /// An owner included only because its original content mentions the query.
17    EntryMention,
18    /// An ordinary block, without a semantic owner, mentioning the query.
19    ContextMention,
20}
21
22impl EvidenceClass {
23    /// All categories in normative order, including categories with zero results.
24    pub const ALL: [Self; 4] = [
25        Self::DirectEntry,
26        Self::RelatedEntry,
27        Self::EntryMention,
28        Self::ContextMention,
29    ];
30
31    /// Stable plain-text group title shared by all host presentations.
32    #[must_use]
33    pub const fn title(self) -> &'static str {
34        match self {
35            Self::DirectEntry => "Direct entries",
36            Self::RelatedEntry => "Explicitly related entries",
37            Self::EntryMention => "Mentions in other entries",
38            Self::ContextMention => "Mentions in ordinary content",
39        }
40    }
41}
42
43/// Fixed explanation ordering; no legacy/source-only ordering mode exists.
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
45#[serde(rename_all = "kebab-case")]
46pub enum EvidenceOrder {
47    /// Class, resolved-document BFS position, then original IR owner/block order.
48    #[default]
49    ClassThenSource,
50}
51
52/// Collected lower-bound total and the count returned on the current page.
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
54#[serde(rename_all = "camelCase", deny_unknown_fields)]
55pub struct EvidenceCount {
56    /// Collected owners before pagination (not exhaustive recall).
57    pub total: u32,
58    /// Owners included on the current page.
59    pub returned: u32,
60}
61
62/// Fixed four-class counts. Each column sums to the response total/returned.
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
64#[serde(rename_all = "camelCase", deny_unknown_fields)]
65pub struct EvidenceCounts {
66    /// Direct semantic matches.
67    pub direct_entry: EvidenceCount,
68    /// Explicitly related semantic owners.
69    pub related_entry: EvidenceCount,
70    /// Other semantic owners with literal mentions.
71    pub entry_mention: EvidenceCount,
72    /// Ordinary content blocks with literal mentions.
73    pub context_mention: EvidenceCount,
74}
75impl EvidenceCounts {
76    /// Inspect a class without deriving it from optional materialized details.
77    #[must_use]
78    pub const fn get(&self, class: EvidenceClass) -> EvidenceCount {
79        match class {
80            EvidenceClass::DirectEntry => self.direct_entry,
81            EvidenceClass::RelatedEntry => self.related_entry,
82            EvidenceClass::EntryMention => self.entry_mention,
83            EvidenceClass::ContextMention => self.context_mention,
84        }
85    }
86    /// Account for one collected owner and, optionally, its returned record.
87    pub fn record(&mut self, class: EvidenceClass, returned: bool) {
88        let count = match class {
89            EvidenceClass::DirectEntry => &mut self.direct_entry,
90            EvidenceClass::RelatedEntry => &mut self.related_entry,
91            EvidenceClass::EntryMention => &mut self.entry_mention,
92            EvidenceClass::ContextMention => &mut self.context_mention,
93        };
94        count.total = count.total.saturating_add(1);
95        count.returned = count.returned.saturating_add(u32::from(returned));
96    }
97}
98
99/// One representative literal window, not a replacement IR block or a summary.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
101#[serde(rename_all = "camelCase", deny_unknown_fields)]
102pub struct ExplanationPreview {
103    /// Final IR coordinate rooted at "root" or "sections/sN[/sN...]";
104    /// bN selects a block, iN/dN a list/definition item, and rN/cN a table cell.
105    /// All indices are zero-based; this is not a source/Markdown byte offset.
106    pub block_path: String,
107    /// Matched block source position, when known (never a substituted entry span).
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub source: Option<mant_ir::SourceSpan>,
110    /// Safely projected original text, at most 1024 Unicode scalar values.
111    pub text: String,
112    /// Start of the complete match in text, in zero-based Unicode scalars.
113    pub match_start_char: u32,
114    /// Exclusive end of the complete match in text, in Unicode scalars.
115    pub match_end_char: u32,
116    /// Positions of this reported match in an available returned body, not
117    /// offsets in this clipped window. Empty when the body is unavailable.
118    pub content_ranges: Vec<super::ExplanationContentRange>,
119    /// The representative window excludes preceding block text.
120    pub clipped_before: bool,
121    /// The representative window excludes following block text.
122    pub clipped_after: bool,
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    #[test]
129    fn class_order_counts_and_closed_preview_are_explicit() {
130        assert!(EvidenceClass::ALL.windows(2).all(|w| w[0] < w[1]));
131        let mut counts = EvidenceCounts::default();
132        counts.record(EvidenceClass::DirectEntry, true);
133        counts.record(EvidenceClass::ContextMention, false);
134        assert_eq!(
135            counts.direct_entry,
136            EvidenceCount {
137                total: 1,
138                returned: 1
139            }
140        );
141        assert_eq!(
142            counts.context_mention,
143            EvidenceCount {
144                total: 1,
145                returned: 0
146            }
147        );
148        assert_eq!(
149            serde_json::to_value(EvidenceOrder::default()).unwrap(),
150            "class-then-source"
151        );
152        assert!(
153            serde_json::from_value::<EvidenceCounts>(
154                serde_json::json!({"directEntry":{"total":0,"returned":0}})
155            )
156            .is_err()
157        );
158        assert!(
159            serde_json::from_value::<ExplanationPreview>(serde_json::json!({
160                "blockPath":"root/b0", "text":"日本", "matchStartChar":0, "matchEndChar":2,
161                "clippedBefore":false, "clippedAfter":false, "unknown":true
162            }))
163            .is_err()
164        );
165    }
166}