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}