use super::error::DecisionError;
use super::question::{Question, QuestionKind};
pub fn distribution_confidence<I: IntoIterator<Item = f64>>(probabilities: I) -> f64 {
let mut n = 0usize;
let mut max = f64::NEG_INFINITY;
for p in probabilities {
if p.is_finite() {
n += 1;
max = max.max(p);
}
}
match n {
0 => 0.0,
1 => 1.0,
n => ((n as f64 * max - 1.0) / (n as f64 - 1.0)).clamp(0.0, 1.0),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct NoulAnswer {
p_true: f64,
confidence: f64,
}
impl NoulAnswer {
pub fn new(p_true: f64) -> Self {
Self {
p_true,
confidence: distribution_confidence([p_true, 1.0 - p_true]),
}
}
pub fn with_confidence(mut self, confidence: f64) -> Self {
self.confidence = confidence;
self
}
pub fn p_true(&self) -> f64 {
self.p_true
}
pub fn confidence(&self) -> f64 {
self.confidence
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ChoiceAnswer {
choice: String,
probabilities: Vec<(String, f64)>,
confidence: f64,
}
impl ChoiceAnswer {
pub fn new<I, S>(probabilities: I) -> Self
where
I: IntoIterator<Item = (S, f64)>,
S: Into<String>,
{
let probabilities: Vec<(String, f64)> = probabilities
.into_iter()
.map(|(o, p)| (o.into(), p))
.collect();
let mut choice = String::new();
let mut best = f64::NEG_INFINITY;
for (option, p) in &probabilities {
if *p > best {
best = *p;
choice = option.clone();
}
}
let confidence = distribution_confidence(probabilities.iter().map(|(_, p)| *p));
Self {
choice,
probabilities,
confidence,
}
}
pub fn with_choice(mut self, choice: impl Into<String>) -> Self {
self.choice = choice.into();
self
}
pub fn with_confidence(mut self, confidence: f64) -> Self {
self.confidence = confidence;
self
}
pub fn choice(&self) -> &str {
&self.choice
}
pub fn confidence(&self) -> f64 {
self.confidence
}
pub fn probabilities(&self) -> impl Iterator<Item = (&str, f64)> + '_ {
self.probabilities.iter().map(|(o, p)| (o.as_str(), *p))
}
pub fn probability(&self, option: &str) -> f64 {
self.probabilities
.iter()
.find(|(o, _)| o == option)
.map(|(_, p)| *p)
.unwrap_or(0.0)
}
pub fn ranked(&self) -> Vec<(&str, f64)> {
let mut v: Vec<(&str, f64)> = self.probabilities().collect();
v.sort_by(|a, b| b.1.total_cmp(&a.1));
v
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ScoreAnswer {
score: f64,
legend: Vec<String>,
probabilities: Vec<f64>,
confidence: f64,
}
impl ScoreAnswer {
pub fn new(legend: Vec<String>, probabilities: Vec<f64>) -> Self {
let score = probabilities
.iter()
.enumerate()
.map(|(i, p)| i as f64 * p)
.sum();
let confidence = distribution_confidence(probabilities.iter().copied());
Self {
score,
legend,
probabilities,
confidence,
}
}
pub fn with_score(mut self, score: f64) -> Self {
self.score = score;
self
}
pub fn with_confidence(mut self, confidence: f64) -> Self {
self.confidence = confidence;
self
}
pub fn score(&self) -> f64 {
self.score
}
pub fn legend(&self) -> &[String] {
&self.legend
}
pub fn probabilities(&self) -> &[f64] {
&self.probabilities
}
pub fn confidence(&self) -> f64 {
self.confidence
}
pub fn level(&self) -> usize {
let mut best = 0;
for (i, p) in self.probabilities.iter().enumerate() {
if *p > self.probabilities[best] {
best = i;
}
}
best
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Answer {
Noul(NoulAnswer),
Choice(ChoiceAnswer),
Score(ScoreAnswer),
}
impl Answer {
pub fn kind(&self) -> QuestionKind {
match self {
Self::Noul(_) => QuestionKind::Noul,
Self::Choice(_) => QuestionKind::Choice,
Self::Score(_) => QuestionKind::Score,
}
}
pub fn confidence(&self) -> f64 {
match self {
Self::Noul(a) => a.confidence,
Self::Choice(a) => a.confidence,
Self::Score(a) => a.confidence,
}
}
pub fn as_noul(&self) -> Option<&NoulAnswer> {
match self {
Self::Noul(a) => Some(a),
_ => None,
}
}
pub fn as_choice(&self) -> Option<&ChoiceAnswer> {
match self {
Self::Choice(a) => Some(a),
_ => None,
}
}
pub fn as_score(&self) -> Option<&ScoreAnswer> {
match self {
Self::Score(a) => Some(a),
_ => None,
}
}
pub(crate) fn validate(&self, id: &str, question: &Question) -> Result<(), DecisionError> {
let bad = |what: String| Err(DecisionError::BadResponse(format!("answers.{id}: {what}")));
if self.kind() != question.kind() {
return bad(format!(
"a {} answer to a {} question",
self.kind(),
question.kind()
));
}
check_unit(id, "confidence", self.confidence())?;
match self {
Self::Noul(a) => check_unit(id, "noul", a.p_true),
Self::Choice(a) => {
let options = question.options().unwrap_or_default();
if !options.contains(&a.choice.as_str()) {
return bad(format!("choice {:?} is not one of the options", a.choice));
}
let mut seen = std::collections::HashSet::new();
for (option, p) in &a.probabilities {
if !options.contains(&option.as_str()) {
return bad(format!(
"probability for {option:?}, not one of the options"
));
}
if !seen.insert(option.as_str()) {
return bad(format!("two probabilities for {option:?}"));
}
check_unit(id, &format!("probabilities.{option}"), *p)?;
}
if let Some(missing) = options.iter().find(|o| !seen.contains(**o)) {
return bad(format!("no probability for option {missing:?}"));
}
check_sum(id, a.probabilities.iter().map(|(_, p)| *p))
}
Self::Score(a) => {
let n = question.levels().map_or(0, <[_]>::len);
if a.probabilities.len() != n || a.legend.len() != n {
return bad(format!(
"{} probabilities and {} legend entries for {n} levels",
a.probabilities.len(),
a.legend.len()
));
}
for (i, p) in a.probabilities.iter().enumerate() {
check_unit(id, &format!("probabilities[{i}]"), *p)?;
}
check_sum(id, a.probabilities.iter().copied())?;
let top = n.saturating_sub(1) as f64;
if !(a.score.is_finite() && a.score >= -EPS && a.score <= top + EPS) {
return bad(format!("score {} is outside 0..={top}", a.score));
}
Ok(())
}
}
}
}
const EPS: f64 = 1e-6;
pub(crate) fn sum_tolerance(n: usize) -> f64 {
(n as f64 * 0.005 + 1e-9).max(0.02)
}
fn check_sum(id: &str, probabilities: impl Iterator<Item = f64>) -> Result<(), DecisionError> {
let (mut n, mut sum) = (0usize, 0.0f64);
for p in probabilities {
n += 1;
sum += p;
}
if (sum - 1.0).abs() <= sum_tolerance(n) {
Ok(())
} else {
Err(DecisionError::BadResponse(format!(
"answers.{id}.probabilities: sum to {sum}, not 1"
)))
}
}
fn check_unit(id: &str, field: &str, x: f64) -> Result<(), DecisionError> {
if x.is_finite() && (-EPS..=1.0 + EPS).contains(&x) {
Ok(())
} else {
Err(DecisionError::BadResponse(format!(
"answers.{id}.{field}: {x} is not in [0, 1]"
)))
}
}
impl From<NoulAnswer> for Answer {
fn from(a: NoulAnswer) -> Self {
Self::Noul(a)
}
}
impl From<ChoiceAnswer> for Answer {
fn from(a: ChoiceAnswer) -> Self {
Self::Choice(a)
}
}
impl From<ScoreAnswer> for Answer {
fn from(a: ScoreAnswer) -> Self {
Self::Score(a)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct DecisionUsage {
pub input_tokens: u64,
pub output_tokens: u64,
}
impl DecisionUsage {
pub fn new(input_tokens: u64, output_tokens: u64) -> Self {
Self {
input_tokens,
output_tokens,
}
}
pub fn to_usage(&self) -> crate::Usage {
crate::Usage {
input: self.input_tokens,
output: self.output_tokens,
total_tokens: self.input_tokens + self.output_tokens,
..Default::default()
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Evaluation {
pub(crate) model: String,
pub(crate) answers: Vec<(String, Answer)>,
pub(crate) usage: DecisionUsage,
pub(crate) usage_reported: bool,
pub(crate) cost_usd: Option<f64>,
}
impl Evaluation {
pub fn new(model: impl Into<String>, usage: DecisionUsage) -> Self {
Self {
model: model.into(),
answers: Vec::new(),
usage,
usage_reported: true,
cost_usd: None,
}
}
pub fn with_answer(mut self, id: impl Into<String>, answer: impl Into<Answer>) -> Self {
let id = id.into();
let answer = answer.into();
match self.answers.iter_mut().find(|(k, _)| *k == id) {
Some(slot) => slot.1 = answer,
None => self.answers.push((id, answer)),
}
self
}
pub fn with_cost_usd(mut self, cost_usd: Option<f64>) -> Self {
self.cost_usd = cost_usd;
self
}
pub fn model(&self) -> &str {
&self.model
}
pub fn usage(&self) -> DecisionUsage {
self.usage
}
pub fn cost_usd(&self) -> Option<f64> {
self.cost_usd
}
pub fn answers(&self) -> impl Iterator<Item = (&str, &Answer)> + '_ {
self.answers.iter().map(|(k, a)| (k.as_str(), a))
}
pub fn get(&self, id: &str) -> Option<&Answer> {
self.answers.iter().find(|(k, _)| k == id).map(|(_, a)| a)
}
pub fn noul(&self, id: &str) -> Option<&NoulAnswer> {
self.get(id).and_then(Answer::as_noul)
}
pub fn p_true(&self, id: &str) -> Option<f64> {
self.noul(id).map(NoulAnswer::p_true)
}
pub fn choice(&self, id: &str) -> Option<&ChoiceAnswer> {
self.get(id).and_then(Answer::as_choice)
}
pub fn score(&self, id: &str) -> Option<&ScoreAnswer> {
self.get(id).and_then(Answer::as_score)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn confidence_matches_typesafe_formula() {
let c = distribution_confidence([0.88, 0.12, 0.0]);
assert!((c - 0.82).abs() < 1e-9, "{c}");
assert_eq!(distribution_confidence([1.0 / 3.0; 3]), 0.0);
assert_eq!(distribution_confidence([1.0, 0.0]), 1.0);
assert_eq!(distribution_confidence([0.2, 0.2, 0.2, 0.2, 0.2]), 0.0);
assert_eq!(distribution_confidence([0.7]), 1.0);
assert_eq!(distribution_confidence(Vec::<f64>::new()), 0.0);
}
#[test]
fn noul_confidence_is_distance_from_even() {
assert!((NoulAnswer::new(0.95).confidence() - 0.9).abs() < 1e-9);
assert!((NoulAnswer::new(0.05).confidence() - 0.9).abs() < 1e-9);
assert_eq!(NoulAnswer::new(0.5).confidence(), 0.0);
}
#[test]
fn score_is_probability_weighted() {
let a = ScoreAnswer::new(
vec!["Calm".into(), "Frustrated".into(), "Very angry".into()],
vec![0.0, 0.95, 0.05],
);
assert!((a.score() - 1.05).abs() < 1e-9);
assert_eq!(a.level(), 1);
assert!((a.confidence() - 0.925).abs() < 1e-9);
}
#[test]
fn choice_keeps_option_order_and_ranks() {
let a = ChoiceAnswer::new([("technical", 0.12), ("billing", 0.88), ("sales", 0.0)]);
assert_eq!(a.choice(), "billing");
let order: Vec<&str> = a.probabilities().map(|(o, _)| o).collect();
assert_eq!(order, ["technical", "billing", "sales"]);
assert_eq!(a.ranked()[0], ("billing", 0.88));
assert_eq!(a.probability("nope"), 0.0);
}
#[test]
fn validation_rejects_malformed_answers() {
let noul = Question::noul("q?");
assert!(Answer::from(NoulAnswer::new(0.5))
.validate("q", &noul)
.is_ok());
for bad in [f64::NAN, -0.2, 1.3, f64::INFINITY] {
assert!(
Answer::from(NoulAnswer::new(bad))
.validate("q", &noul)
.is_err(),
"{bad}"
);
}
assert!(Answer::from(NoulAnswer::new(0.5).with_confidence(f64::NAN))
.validate("q", &noul)
.is_err());
let choice = Question::choice("which?", ["a", "b"]);
assert!(Answer::from(ChoiceAnswer::new([("a", 0.3), ("b", 0.7)]))
.validate("q", &choice)
.is_ok());
assert!(Answer::from(ChoiceAnswer::new([("a", 0.3), ("c", 0.7)]))
.validate("q", &choice)
.is_err());
assert!(
Answer::from(ChoiceAnswer::new([("a", 0.3), ("b", 0.7)]).with_choice("z"))
.validate("q", &choice)
.is_err()
);
let score = Question::score("how?", ["lo", "hi"]);
assert!(Answer::from(ScoreAnswer::new(
vec!["lo".into(), "hi".into()],
vec![0.5, 0.5]
))
.validate("q", &score)
.is_ok());
assert!(
Answer::from(ScoreAnswer::new(vec!["lo".into()], vec![0.5, 0.5]))
.validate("q", &score)
.is_err()
);
assert!(Answer::from(ScoreAnswer::new(
vec!["lo".into(), "hi".into(), "x".into()],
vec![0.2, 0.2, 0.6]
))
.validate("q", &score)
.is_err());
assert!(Answer::from(NoulAnswer::new(0.5))
.validate("q", &score)
.is_err());
}
}