Skip to main content

kmp_plugin_api/
lib.rs

1//! Public plugin API for the Rehydration Kernel.
2//!
3//! This crate is intentionally small. A plugin implementation can depend on it
4//! without depending on kernel domain aggregates, ports, adapters, storage
5//! clients, gRPC, MCP, or runtime infrastructure.
6//!
7//! The kernel owns this contract. Runtime crates may re-export it through
8//! `kmp_domain::plugins`, but external plugin crates should prefer
9//! depending on `kmp-plugin-api` directly.
10//!
11//! Current plugin contracts are compile-time Rust traits. They are reusable
12//! crate boundaries, not a dynamic ABI or runtime plugin registry.
13//!
14//! Architecture:
15//!
16//! - value plugins implement [`EvidenceValuePlugin`] and convert retrieved
17//!   evidence fragments into typed mentions;
18//! - derivation plugins implement [`EvidenceDerivationPlugin`] and compute
19//!   deterministic results from explicit operands;
20//! - readers or agents decide which mentions are included, excluded, or kept as
21//!   context for the current question;
22//! - the kernel remains responsible for storage, traversal, refs, provenance,
23//!   trace, and inspect, not for domain arithmetic or preference semantics.
24
25use 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}