Skip to main content

ironflow_core/decision/
output.rs

1//! Answer and output types returned by a [`DecisionProvider`](super::DecisionProvider).
2
3use std::collections::BTreeMap;
4
5use rust_decimal::Decimal;
6use serde::{Deserialize, Serialize};
7use strum::IntoStaticStr;
8
9use crate::decision::DecisionModel;
10use crate::error::DecisionError;
11
12/// Jev (System One) input price in USD per million tokens.
13///
14/// Output tokens are unmetered for System One models (no autoregressive decoding),
15/// so only input tokens are billed. See <https://typesafe.ai>.
16pub const JEV_INPUT_USD_PER_MTOK: f64 = 0.042;
17
18/// The typed answer to a [`DecisionQuestion::Noul`](super::DecisionQuestion::Noul).
19///
20/// # Examples
21///
22/// ```
23/// use ironflow_core::decision::NoulAnswer;
24///
25/// let a = NoulAnswer { noul: 0.92 };
26/// assert!(a.noul > 0.9);
27/// ```
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct NoulAnswer {
30    /// Probability that the answer is "yes", in `[0, 1]`.
31    pub noul: f64,
32}
33
34/// The typed answer to a [`DecisionQuestion::Choice`](super::DecisionQuestion::Choice).
35///
36/// # Examples
37///
38/// ```
39/// use ironflow_core::decision::ChoiceAnswer;
40/// use std::collections::BTreeMap;
41///
42/// let a = ChoiceAnswer {
43///     choice: "technical".to_string(),
44///     probabilities: BTreeMap::from([("technical".to_string(), 0.85)]),
45///     confidence: 0.82,
46/// };
47/// assert_eq!(a.choice, "technical");
48/// ```
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct ChoiceAnswer {
51    /// The selected option.
52    pub choice: String,
53    /// Probability mass over every option.
54    #[serde(default)]
55    pub probabilities: BTreeMap<String, f64>,
56    /// Calibrated confidence in `[0, 1]`, derived from the distribution.
57    pub confidence: f64,
58}
59
60/// The typed answer to a [`DecisionQuestion::Score`](super::DecisionQuestion::Score).
61///
62/// # Examples
63///
64/// ```
65/// use ironflow_core::decision::ScoreAnswer;
66/// use std::collections::BTreeMap;
67///
68/// let a = ScoreAnswer {
69///     score: 1.6,
70///     legend: BTreeMap::from([("2".to_string(), "Very angry".to_string())]),
71///     probabilities: BTreeMap::from([("2".to_string(), 0.65)]),
72///     confidence: 0.78,
73/// };
74/// assert!(a.score > 1.5);
75/// ```
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct ScoreAnswer {
78    /// Probability-weighted score across the levels.
79    pub score: f64,
80    /// Level index (as a string) -> description.
81    #[serde(default)]
82    pub legend: BTreeMap<String, String>,
83    /// Probability mass over each level index.
84    #[serde(default)]
85    pub probabilities: BTreeMap<String, f64>,
86    /// Calibrated confidence in `[0, 1]`, derived from the distribution.
87    pub confidence: f64,
88}
89
90/// A typed answer to one question, tagged by its kind.
91///
92/// # Examples
93///
94/// ```
95/// use ironflow_core::decision::{DecisionAnswer, NoulAnswer};
96///
97/// // A noul answer's confidence is derived: 2 * |p - 0.5|.
98/// let a = DecisionAnswer::Noul(NoulAnswer { noul: 0.92 });
99/// assert!((a.confidence() - 0.84).abs() < 1e-9);
100/// ```
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, IntoStaticStr)]
102#[serde(tag = "type", rename_all = "snake_case")]
103#[strum(serialize_all = "snake_case")]
104pub enum DecisionAnswer {
105    /// A yes/no probability answer.
106    Noul(NoulAnswer),
107    /// A selection answer.
108    Choice(ChoiceAnswer),
109    /// A rating answer.
110    Score(ScoreAnswer),
111}
112
113impl DecisionAnswer {
114    /// The calibrated confidence of this answer, in `[0, 1]`.
115    ///
116    /// For [`Choice`](DecisionAnswer::Choice) and [`Score`](DecisionAnswer::Score)
117    /// this is the provider-reported `confidence`. For [`Noul`](DecisionAnswer::Noul),
118    /// which reports only a probability `p`, confidence is derived as `2 * |p - 0.5|`:
119    /// `p = 0.5` yields `0` (maximally uncertain), `p = 0` or `p = 1` yields `1`.
120    ///
121    /// # Examples
122    ///
123    /// ```
124    /// use ironflow_core::decision::{DecisionAnswer, NoulAnswer};
125    ///
126    /// let coin = DecisionAnswer::Noul(NoulAnswer { noul: 0.5 });
127    /// assert_eq!(coin.confidence(), 0.0);
128    /// ```
129    pub fn confidence(&self) -> f64 {
130        match self {
131            DecisionAnswer::Noul(a) => 2.0 * (a.noul - 0.5).abs(),
132            DecisionAnswer::Choice(a) => a.confidence,
133            DecisionAnswer::Score(a) => a.confidence,
134        }
135    }
136
137    /// The kind name of this answer (`"noul"`, `"choice"`, or `"score"`).
138    fn kind(&self) -> &'static str {
139        self.into()
140    }
141}
142
143/// Token usage reported by the provider.
144///
145/// # Examples
146///
147/// ```
148/// use ironflow_core::decision::DecisionUsage;
149///
150/// let usage = DecisionUsage { input_tokens: 312, output_tokens: 0 };
151/// assert_eq!(usage.input_tokens, 312);
152/// ```
153#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
154pub struct DecisionUsage {
155    /// Number of input tokens billed.
156    #[serde(default)]
157    pub input_tokens: u64,
158    /// Number of output tokens (unmetered for System One models).
159    #[serde(default)]
160    pub output_tokens: u64,
161}
162
163impl DecisionUsage {
164    /// The USD cost of this usage, imputed from input tokens at the Jev rate
165    /// ([`JEV_INPUT_USD_PER_MTOK`]).
166    ///
167    /// Output tokens are unmetered for System One models, so they do not
168    /// contribute to the cost.
169    ///
170    /// # Examples
171    ///
172    /// ```
173    /// use ironflow_core::decision::DecisionUsage;
174    /// use rust_decimal::Decimal;
175    ///
176    /// // 1_000_000 input tokens at $0.042 / M = $0.042.
177    /// let usage = DecisionUsage { input_tokens: 1_000_000, output_tokens: 0 };
178    /// assert_eq!(usage.cost_usd(), Decimal::try_from(0.042).unwrap());
179    /// assert_eq!(DecisionUsage::default().cost_usd(), Decimal::ZERO);
180    /// ```
181    pub fn cost_usd(&self) -> Decimal {
182        let usd = self.input_tokens as f64 * JEV_INPUT_USD_PER_MTOK / 1_000_000.0;
183        Decimal::try_from(usd).unwrap_or(Decimal::ZERO)
184    }
185}
186
187/// The typed result of a decision: answers keyed by question name, plus usage.
188///
189/// # Examples
190///
191/// ```
192/// use ironflow_core::decision::{DecisionOutput, DecisionAnswer, NoulAnswer, DecisionUsage};
193/// use std::collections::BTreeMap;
194///
195/// let output = DecisionOutput {
196///     model: Some("jev-latest".into()),
197///     answers: BTreeMap::from([
198///         ("is_urgent".to_string(), DecisionAnswer::Noul(NoulAnswer { noul: 0.92 })),
199///     ]),
200///     usage: DecisionUsage::default(),
201/// };
202/// assert_eq!(output.noul("is_urgent").unwrap(), 0.92);
203/// ```
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205pub struct DecisionOutput {
206    /// Model that performed the evaluation, if the provider reported one.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub model: Option<DecisionModel>,
209    /// Answers keyed by the question names from the request.
210    pub answers: BTreeMap<String, DecisionAnswer>,
211    /// Token usage.
212    #[serde(default)]
213    pub usage: DecisionUsage,
214}
215
216impl DecisionOutput {
217    /// Look up an answer by name.
218    ///
219    /// # Examples
220    ///
221    /// ```
222    /// use ironflow_core::decision::{DecisionOutput, DecisionAnswer, NoulAnswer, DecisionUsage};
223    /// use std::collections::BTreeMap;
224    ///
225    /// let output = DecisionOutput {
226    ///     model: None,
227    ///     answers: BTreeMap::from([("q".to_string(), DecisionAnswer::Noul(NoulAnswer { noul: 0.1 }))]),
228    ///     usage: DecisionUsage::default(),
229    /// };
230    /// assert!(output.answer("q").is_some());
231    /// assert!(output.answer("missing").is_none());
232    /// ```
233    pub fn answer(&self, name: &str) -> Option<&DecisionAnswer> {
234        self.answers.get(name)
235    }
236
237    /// The probability of a [`Noul`](DecisionAnswer::Noul) answer.
238    ///
239    /// # Errors
240    ///
241    /// Returns [`DecisionError::NotFound`] if no answer has that name, or
242    /// [`DecisionError::TypeMismatch`] if the answer is not a noul.
243    ///
244    /// # Examples
245    ///
246    /// ```
247    /// use ironflow_core::decision::{DecisionOutput, DecisionAnswer, NoulAnswer, DecisionUsage};
248    /// use std::collections::BTreeMap;
249    ///
250    /// let output = DecisionOutput {
251    ///     model: None,
252    ///     answers: BTreeMap::from([("q".to_string(), DecisionAnswer::Noul(NoulAnswer { noul: 0.7 }))]),
253    ///     usage: DecisionUsage::default(),
254    /// };
255    /// assert_eq!(output.noul("q").unwrap(), 0.7);
256    /// ```
257    pub fn noul(&self, name: &str) -> Result<f64, DecisionError> {
258        match self.require(name)? {
259            DecisionAnswer::Noul(a) => Ok(a.noul),
260            other => Err(mismatch(name, "noul", other)),
261        }
262    }
263
264    /// The [`ChoiceAnswer`] for a choice question.
265    ///
266    /// # Errors
267    ///
268    /// Returns [`DecisionError::NotFound`] if no answer has that name, or
269    /// [`DecisionError::TypeMismatch`] if the answer is not a choice.
270    ///
271    /// # Examples
272    ///
273    /// ```
274    /// use ironflow_core::decision::{DecisionOutput, DecisionAnswer, ChoiceAnswer, DecisionUsage};
275    /// use std::collections::BTreeMap;
276    ///
277    /// let output = DecisionOutput {
278    ///     model: None,
279    ///     answers: BTreeMap::from([("dept".to_string(), DecisionAnswer::Choice(ChoiceAnswer {
280    ///         choice: "billing".to_string(),
281    ///         probabilities: BTreeMap::new(),
282    ///         confidence: 0.9,
283    ///     }))]),
284    ///     usage: DecisionUsage::default(),
285    /// };
286    /// assert_eq!(output.choice("dept").unwrap().choice, "billing");
287    /// ```
288    pub fn choice(&self, name: &str) -> Result<&ChoiceAnswer, DecisionError> {
289        match self.require(name)? {
290            DecisionAnswer::Choice(a) => Ok(a),
291            other => Err(mismatch(name, "choice", other)),
292        }
293    }
294
295    /// The [`ScoreAnswer`] for a score question.
296    ///
297    /// # Errors
298    ///
299    /// Returns [`DecisionError::NotFound`] if no answer has that name, or
300    /// [`DecisionError::TypeMismatch`] if the answer is not a score.
301    ///
302    /// # Examples
303    ///
304    /// ```
305    /// use ironflow_core::decision::{DecisionOutput, DecisionAnswer, ScoreAnswer, DecisionUsage};
306    /// use std::collections::BTreeMap;
307    ///
308    /// let output = DecisionOutput {
309    ///     model: None,
310    ///     answers: BTreeMap::from([("mood".to_string(), DecisionAnswer::Score(ScoreAnswer {
311    ///         score: 1.6,
312    ///         legend: BTreeMap::new(),
313    ///         probabilities: BTreeMap::new(),
314    ///         confidence: 0.78,
315    ///     }))]),
316    ///     usage: DecisionUsage::default(),
317    /// };
318    /// assert!(output.score("mood").unwrap().score > 1.5);
319    /// ```
320    pub fn score(&self, name: &str) -> Result<&ScoreAnswer, DecisionError> {
321        match self.require(name)? {
322            DecisionAnswer::Score(a) => Ok(a),
323            other => Err(mismatch(name, "score", other)),
324        }
325    }
326
327    /// The lowest confidence across every answer, or `None` when there are no answers.
328    ///
329    /// Used by the engine to decide escalation: a run escalates when the minimum
330    /// confidence falls below the configured threshold.
331    ///
332    /// # Examples
333    ///
334    /// ```
335    /// use ironflow_core::decision::{DecisionOutput, DecisionAnswer, NoulAnswer, DecisionUsage};
336    /// use std::collections::BTreeMap;
337    ///
338    /// let output = DecisionOutput {
339    ///     model: None,
340    ///     answers: BTreeMap::from([("q".to_string(), DecisionAnswer::Noul(NoulAnswer { noul: 0.5 }))]),
341    ///     usage: DecisionUsage::default(),
342    /// };
343    /// assert_eq!(output.min_confidence(), Some(0.0));
344    /// ```
345    pub fn min_confidence(&self) -> Option<f64> {
346        self.answers
347            .values()
348            .map(DecisionAnswer::confidence)
349            .fold(None, |acc, c| Some(acc.map_or(c, |a: f64| a.min(c))))
350    }
351
352    fn require(&self, name: &str) -> Result<&DecisionAnswer, DecisionError> {
353        self.answers
354            .get(name)
355            .ok_or_else(|| DecisionError::NotFound(name.to_string()))
356    }
357}
358
359fn mismatch(name: &str, expected: &'static str, got: &DecisionAnswer) -> DecisionError {
360    DecisionError::TypeMismatch {
361        name: name.to_string(),
362        expected,
363        actual: got.kind(),
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use serde_json::json;
371
372    #[test]
373    fn noul_confidence_is_derived_from_probability() {
374        assert_eq!(
375            DecisionAnswer::Noul(NoulAnswer { noul: 0.5 }).confidence(),
376            0.0
377        );
378        assert_eq!(
379            DecisionAnswer::Noul(NoulAnswer { noul: 1.0 }).confidence(),
380            1.0
381        );
382        assert_eq!(
383            DecisionAnswer::Noul(NoulAnswer { noul: 0.0 }).confidence(),
384            1.0
385        );
386        assert!((DecisionAnswer::Noul(NoulAnswer { noul: 0.92 }).confidence() - 0.84).abs() < 1e-9);
387    }
388
389    #[test]
390    fn choice_and_score_confidence_passthrough() {
391        let choice = DecisionAnswer::Choice(ChoiceAnswer {
392            choice: "a".to_string(),
393            probabilities: BTreeMap::new(),
394            confidence: 0.7,
395        });
396        assert_eq!(choice.confidence(), 0.7);
397
398        let score = DecisionAnswer::Score(ScoreAnswer {
399            score: 1.0,
400            legend: BTreeMap::new(),
401            probabilities: BTreeMap::new(),
402            confidence: 0.6,
403        });
404        assert_eq!(score.confidence(), 0.6);
405    }
406
407    #[test]
408    fn min_confidence_picks_lowest() {
409        let output = DecisionOutput {
410            model: None,
411            answers: BTreeMap::from([
412                (
413                    "a".to_string(),
414                    DecisionAnswer::Noul(NoulAnswer { noul: 1.0 }),
415                ),
416                (
417                    "b".to_string(),
418                    DecisionAnswer::Choice(ChoiceAnswer {
419                        choice: "x".to_string(),
420                        probabilities: BTreeMap::new(),
421                        confidence: 0.3,
422                    }),
423                ),
424            ]),
425            usage: DecisionUsage::default(),
426        };
427        assert_eq!(output.min_confidence(), Some(0.3));
428    }
429
430    #[test]
431    fn cost_is_zero_for_no_tokens() {
432        assert_eq!(DecisionUsage::default().cost_usd(), Decimal::ZERO);
433    }
434
435    #[test]
436    fn cost_scales_with_input_tokens() {
437        // 500_000 tokens = half a million = $0.021.
438        let usage = DecisionUsage {
439            input_tokens: 500_000,
440            output_tokens: 0,
441        };
442        assert_eq!(usage.cost_usd(), Decimal::try_from(0.021).unwrap());
443    }
444
445    #[test]
446    fn cost_ignores_output_tokens() {
447        let usage = DecisionUsage {
448            input_tokens: 0,
449            output_tokens: 1_000_000,
450        };
451        assert_eq!(usage.cost_usd(), Decimal::ZERO);
452    }
453
454    #[test]
455    fn min_confidence_none_when_empty() {
456        let output = DecisionOutput {
457            model: None,
458            answers: BTreeMap::new(),
459            usage: DecisionUsage::default(),
460        };
461        assert_eq!(output.min_confidence(), None);
462    }
463
464    #[test]
465    fn accessors_return_typed_errors() {
466        let output = DecisionOutput {
467            model: None,
468            answers: BTreeMap::from([(
469                "q".to_string(),
470                DecisionAnswer::Noul(NoulAnswer { noul: 0.7 }),
471            )]),
472            usage: DecisionUsage::default(),
473        };
474        assert_eq!(output.noul("q").unwrap(), 0.7);
475        assert!(matches!(
476            output.noul("missing"),
477            Err(DecisionError::NotFound(_))
478        ));
479        assert!(matches!(
480            output.choice("q"),
481            Err(DecisionError::TypeMismatch {
482                expected: "choice",
483                actual: "noul",
484                ..
485            })
486        ));
487    }
488
489    #[test]
490    fn response_deserializes_from_wire_format() {
491        let wire = json!({
492            "model": "jev-latest",
493            "answers": {
494                "is_urgent": { "type": "noul", "noul": 0.92 },
495                "department": {
496                    "type": "choice",
497                    "choice": "technical",
498                    "probabilities": { "billing": 0.08, "technical": 0.85, "sales": 0.07 },
499                    "confidence": 0.82
500                },
501                "frustration": {
502                    "type": "score",
503                    "score": 1.6,
504                    "legend": { "0": "Calm", "1": "Frustrated", "2": "Very angry" },
505                    "probabilities": { "0": 0.05, "1": 0.3, "2": 0.65 },
506                    "confidence": 0.78
507                }
508            },
509            "usage": { "input_tokens": 312, "output_tokens": 48 }
510        });
511        let output: DecisionOutput = serde_json::from_value(wire).unwrap();
512        assert_eq!(output.noul("is_urgent").unwrap(), 0.92);
513        assert_eq!(output.choice("department").unwrap().choice, "technical");
514        assert!((output.score("frustration").unwrap().score - 1.6).abs() < 1e-9);
515        assert_eq!(output.usage.input_tokens, 312);
516    }
517}