use std::collections::BTreeMap;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use strum::IntoStaticStr;
use crate::decision::DecisionModel;
use crate::error::DecisionError;
pub const JEV_INPUT_USD_PER_MTOK: f64 = 0.042;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NoulAnswer {
pub noul: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChoiceAnswer {
pub choice: String,
#[serde(default)]
pub probabilities: BTreeMap<String, f64>,
pub confidence: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ScoreAnswer {
pub score: f64,
#[serde(default)]
pub legend: BTreeMap<String, String>,
#[serde(default)]
pub probabilities: BTreeMap<String, f64>,
pub confidence: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, IntoStaticStr)]
#[serde(tag = "type", rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum DecisionAnswer {
Noul(NoulAnswer),
Choice(ChoiceAnswer),
Score(ScoreAnswer),
}
impl DecisionAnswer {
pub fn confidence(&self) -> f64 {
match self {
DecisionAnswer::Noul(a) => 2.0 * (a.noul - 0.5).abs(),
DecisionAnswer::Choice(a) => a.confidence,
DecisionAnswer::Score(a) => a.confidence,
}
}
fn kind(&self) -> &'static str {
self.into()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DecisionUsage {
#[serde(default)]
pub input_tokens: u64,
#[serde(default)]
pub output_tokens: u64,
}
impl DecisionUsage {
pub fn cost_usd(&self) -> Decimal {
let usd = self.input_tokens as f64 * JEV_INPUT_USD_PER_MTOK / 1_000_000.0;
Decimal::try_from(usd).unwrap_or(Decimal::ZERO)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DecisionOutput {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<DecisionModel>,
pub answers: BTreeMap<String, DecisionAnswer>,
#[serde(default)]
pub usage: DecisionUsage,
}
impl DecisionOutput {
pub fn answer(&self, name: &str) -> Option<&DecisionAnswer> {
self.answers.get(name)
}
pub fn noul(&self, name: &str) -> Result<f64, DecisionError> {
match self.require(name)? {
DecisionAnswer::Noul(a) => Ok(a.noul),
other => Err(mismatch(name, "noul", other)),
}
}
pub fn choice(&self, name: &str) -> Result<&ChoiceAnswer, DecisionError> {
match self.require(name)? {
DecisionAnswer::Choice(a) => Ok(a),
other => Err(mismatch(name, "choice", other)),
}
}
pub fn score(&self, name: &str) -> Result<&ScoreAnswer, DecisionError> {
match self.require(name)? {
DecisionAnswer::Score(a) => Ok(a),
other => Err(mismatch(name, "score", other)),
}
}
pub fn min_confidence(&self) -> Option<f64> {
self.answers
.values()
.map(DecisionAnswer::confidence)
.fold(None, |acc, c| Some(acc.map_or(c, |a: f64| a.min(c))))
}
fn require(&self, name: &str) -> Result<&DecisionAnswer, DecisionError> {
self.answers
.get(name)
.ok_or_else(|| DecisionError::NotFound(name.to_string()))
}
}
fn mismatch(name: &str, expected: &'static str, got: &DecisionAnswer) -> DecisionError {
DecisionError::TypeMismatch {
name: name.to_string(),
expected,
actual: got.kind(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn noul_confidence_is_derived_from_probability() {
assert_eq!(
DecisionAnswer::Noul(NoulAnswer { noul: 0.5 }).confidence(),
0.0
);
assert_eq!(
DecisionAnswer::Noul(NoulAnswer { noul: 1.0 }).confidence(),
1.0
);
assert_eq!(
DecisionAnswer::Noul(NoulAnswer { noul: 0.0 }).confidence(),
1.0
);
assert!((DecisionAnswer::Noul(NoulAnswer { noul: 0.92 }).confidence() - 0.84).abs() < 1e-9);
}
#[test]
fn choice_and_score_confidence_passthrough() {
let choice = DecisionAnswer::Choice(ChoiceAnswer {
choice: "a".to_string(),
probabilities: BTreeMap::new(),
confidence: 0.7,
});
assert_eq!(choice.confidence(), 0.7);
let score = DecisionAnswer::Score(ScoreAnswer {
score: 1.0,
legend: BTreeMap::new(),
probabilities: BTreeMap::new(),
confidence: 0.6,
});
assert_eq!(score.confidence(), 0.6);
}
#[test]
fn min_confidence_picks_lowest() {
let output = DecisionOutput {
model: None,
answers: BTreeMap::from([
(
"a".to_string(),
DecisionAnswer::Noul(NoulAnswer { noul: 1.0 }),
),
(
"b".to_string(),
DecisionAnswer::Choice(ChoiceAnswer {
choice: "x".to_string(),
probabilities: BTreeMap::new(),
confidence: 0.3,
}),
),
]),
usage: DecisionUsage::default(),
};
assert_eq!(output.min_confidence(), Some(0.3));
}
#[test]
fn cost_is_zero_for_no_tokens() {
assert_eq!(DecisionUsage::default().cost_usd(), Decimal::ZERO);
}
#[test]
fn cost_scales_with_input_tokens() {
let usage = DecisionUsage {
input_tokens: 500_000,
output_tokens: 0,
};
assert_eq!(usage.cost_usd(), Decimal::try_from(0.021).unwrap());
}
#[test]
fn cost_ignores_output_tokens() {
let usage = DecisionUsage {
input_tokens: 0,
output_tokens: 1_000_000,
};
assert_eq!(usage.cost_usd(), Decimal::ZERO);
}
#[test]
fn min_confidence_none_when_empty() {
let output = DecisionOutput {
model: None,
answers: BTreeMap::new(),
usage: DecisionUsage::default(),
};
assert_eq!(output.min_confidence(), None);
}
#[test]
fn accessors_return_typed_errors() {
let output = DecisionOutput {
model: None,
answers: BTreeMap::from([(
"q".to_string(),
DecisionAnswer::Noul(NoulAnswer { noul: 0.7 }),
)]),
usage: DecisionUsage::default(),
};
assert_eq!(output.noul("q").unwrap(), 0.7);
assert!(matches!(
output.noul("missing"),
Err(DecisionError::NotFound(_))
));
assert!(matches!(
output.choice("q"),
Err(DecisionError::TypeMismatch {
expected: "choice",
actual: "noul",
..
})
));
}
#[test]
fn response_deserializes_from_wire_format() {
let wire = json!({
"model": "jev-latest",
"answers": {
"is_urgent": { "type": "noul", "noul": 0.92 },
"department": {
"type": "choice",
"choice": "technical",
"probabilities": { "billing": 0.08, "technical": 0.85, "sales": 0.07 },
"confidence": 0.82
},
"frustration": {
"type": "score",
"score": 1.6,
"legend": { "0": "Calm", "1": "Frustrated", "2": "Very angry" },
"probabilities": { "0": 0.05, "1": 0.3, "2": 0.65 },
"confidence": 0.78
}
},
"usage": { "input_tokens": 312, "output_tokens": 48 }
});
let output: DecisionOutput = serde_json::from_value(wire).unwrap();
assert_eq!(output.noul("is_urgent").unwrap(), 0.92);
assert_eq!(output.choice("department").unwrap().choice, "technical");
assert!((output.score("frustration").unwrap().score - 1.6).abs() < 1e-9);
assert_eq!(output.usage.input_tokens, 312);
}
}