1use std::error::Error;
26use std::fmt;
27
28use serde::{Deserialize, Serialize};
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct EvidenceFragment {
32 pub ref_id: String,
33 pub text: String,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub source: Option<String>,
36}
37
38impl EvidenceFragment {
39 pub fn new(ref_id: impl Into<String>, text: impl Into<String>) -> Self {
40 Self {
41 ref_id: ref_id.into(),
42 text: text.into(),
43 source: None,
44 }
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct EvidenceInterpretationInput {
50 pub fragments: Vec<EvidenceFragment>,
51}
52
53impl EvidenceInterpretationInput {
54 pub fn new(fragments: Vec<EvidenceFragment>) -> Self {
55 Self { fragments }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct EvidenceInterpretationOutput {
61 pub plugin: String,
62 pub values: Vec<InterpretedValueMention>,
63 pub diagnostics: Vec<String>,
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub struct InterpretedValueMention {
68 pub plugin: String,
69 pub ref_id: String,
70 pub raw: String,
71 pub span: TextSpan,
72 pub value: InterpretedValue,
73 pub confidence: f64,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77pub struct TextSpan {
78 pub start: usize,
79 pub end: usize,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum EvidenceSegmentKind {
85 SourceCode,
86 Math,
87 Url,
88 Text,
89}
90
91impl EvidenceSegmentKind {
92 pub fn precedence(self) -> u8 {
93 match self {
94 Self::SourceCode => 0,
95 Self::Math => 1,
96 Self::Url => 2,
97 Self::Text => 3,
98 }
99 }
100
101 pub fn is_interpretable_text(self) -> bool {
102 self == Self::Text
103 }
104
105 pub fn is_protected(self) -> bool {
106 !self.is_interpretable_text()
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111#[serde(tag = "kind", rename_all = "snake_case")]
112pub enum InterpretedValue {
113 Money {
114 currency: CurrencyCode,
115 amount_minor: i64,
116 amount: f64,
117 },
118 Date {
119 date: CalendarDate,
120 },
121 Number {
122 value: f64,
123 #[serde(skip_serializing_if = "Option::is_none")]
124 unit: Option<String>,
125 },
126 MathExpression {
127 notation: MathExpressionNotation,
128 expression: String,
129 },
130 SourceCode {
131 #[serde(skip_serializing_if = "Option::is_none")]
132 language: Option<String>,
133 segment_kind: SourceCodeSegmentKind,
134 text: String,
135 },
136 Url {
137 url: String,
138 },
139}
140
141impl InterpretedValue {
142 pub fn number(value: f64, unit: Option<String>) -> Self {
143 Self::Number { value, unit }
144 }
145
146 pub fn math_expression(
147 notation: MathExpressionNotation,
148 expression: impl Into<String>,
149 ) -> Self {
150 Self::MathExpression {
151 notation,
152 expression: expression.into(),
153 }
154 }
155
156 pub fn source_code(
157 language: Option<String>,
158 segment_kind: SourceCodeSegmentKind,
159 text: impl Into<String>,
160 ) -> Self {
161 Self::SourceCode {
162 language,
163 segment_kind,
164 text: text.into(),
165 }
166 }
167
168 pub fn url(url: impl Into<String>) -> Self {
169 Self::Url { url: url.into() }
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "snake_case")]
175pub enum MathExpressionNotation {
176 InlineDollar,
177 DisplayDollar,
178 InlineParen,
179 DisplayBracket,
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum SourceCodeSegmentKind {
185 FencedBlock,
186 Inline,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
190pub struct CurrencyCode(String);
191
192impl CurrencyCode {
193 pub fn new(value: impl AsRef<str>) -> Result<Self, InterpretationError> {
194 let normalized = value.as_ref().trim().to_ascii_uppercase();
195 if normalized.len() != 3 || !normalized.chars().all(|char| char.is_ascii_uppercase()) {
196 return Err(InterpretationError::new(format!(
197 "invalid currency code `{}`",
198 value.as_ref()
199 )));
200 }
201 Ok(Self(normalized))
202 }
203
204 pub fn as_str(&self) -> &str {
205 &self.0
206 }
207}
208
209impl fmt::Display for CurrencyCode {
210 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
211 formatter.write_str(self.as_str())
212 }
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216pub struct CalendarDate {
217 pub year: i32,
218 pub month: u8,
219 pub day: u8,
220}
221
222impl CalendarDate {
223 pub fn new(year: i32, month: u8, day: u8) -> Result<Self, InterpretationError> {
224 if !(1..=12).contains(&month) {
225 return Err(InterpretationError::new(format!(
226 "invalid calendar month `{month}`"
227 )));
228 }
229 let max_day = days_in_month(year, month);
230 if day == 0 || day > max_day {
231 return Err(InterpretationError::new(format!(
232 "invalid calendar day `{day}` for {year:04}-{month:02}"
233 )));
234 }
235 Ok(Self { year, month, day })
236 }
237
238 pub fn ordinal_days(&self) -> i64 {
239 days_from_civil(self.year, self.month, self.day)
240 }
241}
242
243impl fmt::Display for CalendarDate {
244 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
245 write!(
246 formatter,
247 "{:04}-{:02}-{:02}",
248 self.year, self.month, self.day
249 )
250 }
251}
252
253pub trait EvidenceValuePlugin: Send + Sync {
254 fn id(&self) -> &'static str;
255
256 fn interpret(
257 &self,
258 input: &EvidenceInterpretationInput,
259 ) -> Result<EvidenceInterpretationOutput, InterpretationError>;
260}
261
262pub trait EvidenceDerivationPlugin: Send + Sync {
263 fn id(&self) -> &'static str;
264
265 fn derive(&self, request: &DerivationRequest) -> Result<DerivationResult, InterpretationError>;
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "snake_case")]
270pub enum OperandLabel {
271 Include,
272 Exclude,
273 Context,
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "snake_case")]
278pub enum OperandRole {
279 Addend,
280 AverageMember,
281 CountedItem,
282 Minuend,
283 Subtrahend,
284 Candidate,
285 Context,
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(rename_all = "snake_case")]
290pub enum DerivationOperation {
291 Sum,
292 Count,
293 Average,
294 Difference,
295 MaxBy,
296 List,
297 Unknown,
298}
299
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301pub struct DerivationOperand {
302 pub ref_id: String,
303 pub label: OperandLabel,
304 #[serde(skip_serializing_if = "Option::is_none")]
305 pub role: Option<OperandRole>,
306 #[serde(skip_serializing_if = "Option::is_none")]
307 pub entity: Option<String>,
308 #[serde(skip_serializing_if = "Option::is_none")]
309 pub value: Option<InterpretedValue>,
310 #[serde(skip_serializing_if = "Option::is_none")]
311 pub raw: Option<String>,
312 #[serde(skip_serializing_if = "Option::is_none")]
313 pub reason: Option<String>,
314}
315
316impl DerivationOperand {
317 pub fn included(ref_id: impl Into<String>, value: InterpretedValue) -> Self {
318 Self {
319 ref_id: ref_id.into(),
320 label: OperandLabel::Include,
321 role: None,
322 entity: None,
323 value: Some(value),
324 raw: None,
325 reason: None,
326 }
327 }
328
329 pub fn with_role(mut self, role: OperandRole) -> Self {
330 self.role = Some(role);
331 self
332 }
333
334 pub fn with_entity(mut self, entity: impl Into<String>) -> Self {
335 self.entity = Some(entity.into());
336 self
337 }
338}
339
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
341pub struct DerivationRequest {
342 pub question: String,
343 pub operation: DerivationOperation,
344 #[serde(skip_serializing_if = "Option::is_none")]
345 pub unit: Option<String>,
346 pub operands: Vec<DerivationOperand>,
347}
348
349#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
350pub struct DerivationResult {
351 pub plugin: String,
352 pub operation: DerivationOperation,
353 pub answer: Option<String>,
354 #[serde(skip_serializing_if = "Option::is_none")]
355 pub value: Option<InterpretedValue>,
356 pub included_refs: Vec<String>,
357 pub excluded_refs: Vec<String>,
358 pub diagnostics: Vec<String>,
359}
360
361#[derive(Debug, Clone, PartialEq, Eq)]
362pub struct InterpretationError {
363 message: String,
364}
365
366impl InterpretationError {
367 pub fn new(message: impl Into<String>) -> Self {
368 Self {
369 message: message.into(),
370 }
371 }
372}
373
374impl fmt::Display for InterpretationError {
375 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
376 formatter.write_str(&self.message)
377 }
378}
379
380impl Error for InterpretationError {}
381
382fn days_in_month(year: i32, month: u8) -> u8 {
383 match month {
384 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
385 4 | 6 | 9 | 11 => 30,
386 2 if is_leap_year(year) => 29,
387 2 => 28,
388 _ => 0,
389 }
390}
391
392fn is_leap_year(year: i32) -> bool {
393 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
394}
395
396fn days_from_civil(year: i32, month: u8, day: u8) -> i64 {
397 let adjusted_year = year - i32::from(month <= 2);
398 let era = if adjusted_year >= 0 {
399 adjusted_year
400 } else {
401 adjusted_year - 399
402 } / 400;
403 let year_of_era = adjusted_year - era * 400;
404 let month_i32 = i32::from(month);
405 let day_i32 = i32::from(day);
406 let day_of_year =
407 (153 * (month_i32 + if month_i32 > 2 { -3 } else { 9 }) + 2) / 5 + day_i32 - 1;
408 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
409 i64::from(era * 146_097 + day_of_era)
410}
411
412#[cfg(test)]
413mod tests {
414 use super::{
415 CalendarDate, CurrencyCode, DerivationOperand, EvidenceSegmentKind, InterpretedValue,
416 MathExpressionNotation, OperandLabel, OperandRole, SourceCodeSegmentKind,
417 };
418
419 #[test]
420 fn currency_code_normalizes_to_uppercase_iso_like_code() {
421 let code = CurrencyCode::new(" usd ").expect("currency code should be valid");
422
423 assert_eq!(code.as_str(), "USD");
424 }
425
426 #[test]
427 fn currency_code_rejects_non_iso_like_values() {
428 let error = CurrencyCode::new("US").expect_err("currency code should be invalid");
429
430 assert_eq!(error.to_string(), "invalid currency code `US`");
431 }
432
433 #[test]
434 fn calendar_date_rejects_invalid_day_for_month() {
435 let error = CalendarDate::new(2026, 2, 29).expect_err("date should be invalid");
436
437 assert_eq!(error.to_string(), "invalid calendar day `29` for 2026-02");
438 }
439
440 #[test]
441 fn derivation_operand_builder_preserves_explicit_role_and_entity() {
442 let operand = DerivationOperand::included(
443 "turn:42",
444 InterpretedValue::number(3.0, Some("items".to_string())),
445 )
446 .with_role(OperandRole::CountedItem)
447 .with_entity("payment-service");
448
449 assert_eq!(operand.ref_id, "turn:42");
450 assert_eq!(operand.label, OperandLabel::Include);
451 assert_eq!(operand.role, Some(OperandRole::CountedItem));
452 assert_eq!(operand.entity.as_deref(), Some("payment-service"));
453 }
454
455 #[test]
456 fn source_code_value_preserves_language_and_segment_kind() {
457 let value = InterpretedValue::source_code(
458 Some("rust".to_string()),
459 SourceCodeSegmentKind::FencedBlock,
460 "fn main() {}",
461 );
462
463 assert_eq!(
464 value,
465 InterpretedValue::SourceCode {
466 language: Some("rust".to_string()),
467 segment_kind: SourceCodeSegmentKind::FencedBlock,
468 text: "fn main() {}".to_string(),
469 }
470 );
471 }
472
473 #[test]
474 fn math_expression_value_preserves_notation_and_expression() {
475 let value =
476 InterpretedValue::math_expression(MathExpressionNotation::InlineDollar, "2n + 1");
477
478 assert_eq!(
479 value,
480 InterpretedValue::MathExpression {
481 notation: MathExpressionNotation::InlineDollar,
482 expression: "2n + 1".to_string(),
483 }
484 );
485 }
486
487 #[test]
488 fn url_value_preserves_url_text() {
489 let value = InterpretedValue::url("https://example.test/path");
490
491 assert_eq!(
492 value,
493 InterpretedValue::Url {
494 url: "https://example.test/path".to_string(),
495 }
496 );
497 }
498
499 #[test]
500 fn evidence_segment_kind_models_deterministic_precedence() {
501 assert!(
502 EvidenceSegmentKind::SourceCode.precedence() < EvidenceSegmentKind::Math.precedence()
503 );
504 assert!(EvidenceSegmentKind::Math.precedence() < EvidenceSegmentKind::Url.precedence());
505 assert!(EvidenceSegmentKind::Url.precedence() < EvidenceSegmentKind::Text.precedence());
506 assert!(EvidenceSegmentKind::SourceCode.is_protected());
507 assert!(EvidenceSegmentKind::Text.is_interpretable_text());
508 }
509}