Skip to main content

simple_agent_type/
coercion.rs

1//! Coercion types for tracking response healing.
2//!
3//! Provides transparency into how LLM responses were transformed.
4
5use serde::{Deserialize, Serialize};
6
7/// Flag indicating a specific coercion/healing operation.
8///
9/// These flags provide full transparency into how a response was modified
10/// to make it parseable or conform to expected types.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(tag = "type", rename_all = "snake_case")]
13pub enum CoercionFlag {
14    /// Stripped markdown code fences
15    StrippedMarkdown,
16
17    /// Fixed trailing comma in JSON
18    FixedTrailingComma,
19
20    /// Fixed mismatched quotes
21    FixedQuotes,
22
23    /// Matched field name using fuzzy matching
24    FuzzyFieldMatch {
25        /// Expected field name
26        expected: String,
27        /// Actual field name found
28        found: String,
29    },
30
31    /// Coerced value from one type to another
32    TypeCoercion {
33        /// Source type
34        from: String,
35        /// Target type
36        to: String,
37    },
38
39    /// Used default value for missing field
40    UsedDefaultValue {
41        /// Field name
42        field: String,
43    },
44
45    /// Truncated malformed JSON
46    TruncatedJson,
47
48    /// Fixed unquoted object keys
49    FixedUnquotedKeys,
50
51    /// Fixed control characters
52    FixedControlCharacters,
53
54    /// Removed BOM (byte order mark)
55    RemovedBom,
56
57    /// A required field was defaulted to null due to truncated JSON (lenient mode).
58    /// In strict mode this would be an error instead.
59    RequiredFieldDefaultedToNull {
60        /// Field name that was set to null
61        field: String,
62    },
63}
64
65impl CoercionFlag {
66    /// Get a human-readable description of this coercion.
67    ///
68    /// # Example
69    /// ```
70    /// use simple_agent_type::coercion::CoercionFlag;
71    ///
72    /// let flag = CoercionFlag::StrippedMarkdown;
73    /// assert_eq!(flag.description(), "Stripped markdown code fences");
74    ///
75    /// let flag = CoercionFlag::TypeCoercion {
76    ///     from: "string".to_string(),
77    ///     to: "number".to_string(),
78    /// };
79    /// assert!(flag.description().contains("string"));
80    /// assert!(flag.description().contains("number"));
81    /// ```
82    pub fn description(&self) -> String {
83        match self {
84            Self::StrippedMarkdown => "Stripped markdown code fences".to_string(),
85            Self::FixedTrailingComma => "Fixed trailing comma in JSON".to_string(),
86            Self::FixedQuotes => "Fixed mismatched quotes".to_string(),
87            Self::FuzzyFieldMatch { expected, found } => {
88                format!("Matched field '{}' as '{}'", found, expected)
89            }
90            Self::TypeCoercion { from, to } => {
91                format!("Coerced type from {} to {}", from, to)
92            }
93            Self::UsedDefaultValue { field } => {
94                format!("Used default value for field '{}'", field)
95            }
96            Self::TruncatedJson => "Truncated malformed JSON".to_string(),
97            Self::FixedUnquotedKeys => "Fixed unquoted object keys".to_string(),
98            Self::FixedControlCharacters => "Fixed control characters".to_string(),
99            Self::RemovedBom => "Removed byte order mark (BOM)".to_string(),
100            Self::RequiredFieldDefaultedToNull { field } => {
101                format!(
102                    "Required field '{}' defaulted to null due to truncated JSON",
103                    field
104                )
105            }
106        }
107    }
108
109    /// Check if this coercion is considered "major" (potentially changes semantics).
110    pub fn is_major(&self) -> bool {
111        matches!(
112            self,
113            Self::TypeCoercion { .. }
114                | Self::UsedDefaultValue { .. }
115                | Self::TruncatedJson
116                | Self::FuzzyFieldMatch { .. }
117                | Self::RequiredFieldDefaultedToNull { .. }
118        )
119    }
120}
121
122/// Result of a coercion operation with transparency.
123///
124/// Tracks the value, all coercions applied, and confidence score.
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126pub struct CoercionResult<T> {
127    /// The coerced value
128    pub value: T,
129    /// All coercion flags applied
130    pub flags: Vec<CoercionFlag>,
131    /// Confidence score (0.0-1.0)
132    pub confidence: f32,
133}
134
135impl<T> CoercionResult<T> {
136    /// Create a new coercion result with perfect confidence.
137    ///
138    /// # Example
139    /// ```
140    /// use simple_agent_type::coercion::CoercionResult;
141    ///
142    /// let result = CoercionResult::new(42);
143    /// assert_eq!(result.value, 42);
144    /// assert_eq!(result.confidence, 1.0);
145    /// assert!(result.flags.is_empty());
146    /// ```
147    pub fn new(value: T) -> Self {
148        Self {
149            value,
150            flags: Vec::new(),
151            confidence: 1.0,
152        }
153    }
154
155    /// Create a result with a specific confidence.
156    pub fn with_confidence(value: T, confidence: f32) -> Self {
157        Self {
158            value,
159            flags: Vec::new(),
160            confidence: confidence.clamp(0.0, 1.0),
161        }
162    }
163
164    /// Set the confidence score (builder pattern).
165    pub fn set_confidence(mut self, confidence: f32) -> Self {
166        self.confidence = confidence.clamp(0.0, 1.0);
167        self
168    }
169
170    /// Add a coercion flag.
171    pub fn with_flag(mut self, flag: CoercionFlag) -> Self {
172        self.flags.push(flag);
173        self
174    }
175
176    /// Add multiple coercion flags.
177    pub fn with_flags(mut self, flags: Vec<CoercionFlag>) -> Self {
178        self.flags.extend(flags);
179        self
180    }
181
182    /// Check if any coercions were applied.
183    ///
184    /// # Example
185    /// ```
186    /// use simple_agent_type::coercion::{CoercionResult, CoercionFlag};
187    ///
188    /// let result = CoercionResult::new(42);
189    /// assert!(!result.was_coerced());
190    ///
191    /// let result = result.with_flag(CoercionFlag::StrippedMarkdown);
192    /// assert!(result.was_coerced());
193    /// ```
194    pub fn was_coerced(&self) -> bool {
195        !self.flags.is_empty()
196    }
197
198    /// Check if confidence meets a threshold.
199    ///
200    /// # Example
201    /// ```
202    /// use simple_agent_type::coercion::CoercionResult;
203    ///
204    /// let result = CoercionResult::with_confidence(42, 0.8);
205    /// assert!(result.is_confident(0.7));
206    /// assert!(!result.is_confident(0.9));
207    /// ```
208    pub fn is_confident(&self, threshold: f32) -> bool {
209        self.confidence >= threshold
210    }
211
212    /// Check if any major coercions were applied.
213    pub fn has_major_coercions(&self) -> bool {
214        self.flags.iter().any(|f| f.is_major())
215    }
216
217    /// Map the value while preserving flags and confidence.
218    pub fn map<U, F>(self, f: F) -> CoercionResult<U>
219    where
220        F: FnOnce(T) -> U,
221    {
222        CoercionResult {
223            value: f(self.value),
224            flags: self.flags,
225            confidence: self.confidence,
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_coercion_flag_description() {
236        let flag = CoercionFlag::StrippedMarkdown;
237        assert!(!flag.description().is_empty());
238
239        let flag = CoercionFlag::TypeCoercion {
240            from: "string".to_string(),
241            to: "number".to_string(),
242        };
243        assert!(flag.description().contains("string"));
244        assert!(flag.description().contains("number"));
245    }
246
247    #[test]
248    fn test_coercion_flag_is_major() {
249        assert!(!CoercionFlag::StrippedMarkdown.is_major());
250        assert!(!CoercionFlag::FixedTrailingComma.is_major());
251        assert!(!CoercionFlag::FixedQuotes.is_major());
252
253        assert!(CoercionFlag::TypeCoercion {
254            from: "string".to_string(),
255            to: "number".to_string(),
256        }
257        .is_major());
258
259        assert!(CoercionFlag::UsedDefaultValue {
260            field: "test".to_string()
261        }
262        .is_major());
263
264        assert!(CoercionFlag::TruncatedJson.is_major());
265    }
266
267    #[test]
268    fn test_coercion_result_new() {
269        let result = CoercionResult::new(42);
270        assert_eq!(result.value, 42);
271        assert_eq!(result.confidence, 1.0);
272        assert!(result.flags.is_empty());
273        assert!(!result.was_coerced());
274    }
275
276    #[test]
277    fn test_coercion_result_with_confidence() {
278        let result = CoercionResult::with_confidence(42, 0.8);
279        assert_eq!(result.value, 42);
280        assert_eq!(result.confidence, 0.8);
281        assert!(result.is_confident(0.7));
282        assert!(!result.is_confident(0.9));
283    }
284
285    #[test]
286    fn test_coercion_result_confidence_clamped() {
287        let result = CoercionResult::with_confidence(42, 1.5);
288        assert_eq!(result.confidence, 1.0);
289
290        let result = CoercionResult::with_confidence(42, -0.5);
291        assert_eq!(result.confidence, 0.0);
292    }
293
294    #[test]
295    fn test_coercion_result_with_flag() {
296        let result = CoercionResult::new(42).with_flag(CoercionFlag::StrippedMarkdown);
297        assert!(result.was_coerced());
298        assert_eq!(result.flags.len(), 1);
299    }
300
301    #[test]
302    fn test_coercion_result_with_flags() {
303        let flags = vec![
304            CoercionFlag::StrippedMarkdown,
305            CoercionFlag::FixedTrailingComma,
306        ];
307        let result = CoercionResult::new(42).with_flags(flags);
308        assert_eq!(result.flags.len(), 2);
309    }
310
311    #[test]
312    fn test_coercion_result_has_major_coercions() {
313        let result = CoercionResult::new(42).with_flag(CoercionFlag::StrippedMarkdown);
314        assert!(!result.has_major_coercions());
315
316        let result = CoercionResult::new(42).with_flag(CoercionFlag::TruncatedJson);
317        assert!(result.has_major_coercions());
318    }
319
320    #[test]
321    fn test_coercion_result_map() {
322        let result = CoercionResult::new(42)
323            .with_flag(CoercionFlag::StrippedMarkdown)
324            .set_confidence(0.8);
325
326        let mapped = result.map(|x: i32| x.to_string());
327        assert_eq!(mapped.value, "42");
328        assert_eq!(mapped.flags.len(), 1);
329        assert_eq!(mapped.confidence, 0.8);
330    }
331
332    #[test]
333    fn test_coercion_flag_serialization() {
334        let flag = CoercionFlag::StrippedMarkdown;
335        let json = serde_json::to_string(&flag).unwrap();
336        let parsed: CoercionFlag = serde_json::from_str(&json).unwrap();
337        assert_eq!(flag, parsed);
338
339        let flag = CoercionFlag::TypeCoercion {
340            from: "string".to_string(),
341            to: "number".to_string(),
342        };
343        let json = serde_json::to_string(&flag).unwrap();
344        let parsed: CoercionFlag = serde_json::from_str(&json).unwrap();
345        assert_eq!(flag, parsed);
346    }
347
348    #[test]
349    fn test_coercion_result_serialization() {
350        let result = CoercionResult::new(42)
351            .with_flag(CoercionFlag::StrippedMarkdown)
352            .set_confidence(0.8);
353
354        let json = serde_json::to_string(&result).unwrap();
355        let parsed: CoercionResult<i32> = serde_json::from_str(&json).unwrap();
356        assert_eq!(result.value, parsed.value);
357        assert_eq!(result.flags, parsed.flags);
358        assert_eq!(result.confidence, parsed.confidence);
359    }
360}