use anyhow::Result;
use serde::Serialize;
use crate::server::Bridge;
use crate::wire::{Row, ScoredAnswer};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Perturbation {
Reversed,
}
impl Perturbation {
pub const ALL: [Perturbation; 1] = [Perturbation::Reversed];
pub fn name(self) -> &'static str {
match self {
Perturbation::Reversed => "reversed-options",
}
}
pub fn apply(self, row: &Row) -> Row {
let mut changed = row.clone();
match self {
Perturbation::Reversed => changed.options.reverse(),
}
changed
}
}
#[derive(Debug, Serialize)]
pub struct FlipRow {
pub id: String,
pub perturbation: String,
pub baseline: String,
pub perturbed: String,
pub flipped: bool,
pub baseline_probability: f64,
pub perturbed_probability: f64,
}
#[derive(Debug, Serialize)]
pub struct FlipReport {
pub rows: usize,
pub flips: usize,
pub flip_rate: f64,
pub results: Vec<FlipRow>,
}
pub async fn run(bridge: &Bridge, rows: &[Row]) -> Result<FlipReport> {
let mut results = Vec::new();
for row in rows {
let baseline = bridge.score_row(row).await?;
let winner = argmax(&baseline.answer)?;
for perturbation in Perturbation::ALL {
let perturbed = bridge.score_row(&perturbation.apply(row)).await?;
let perturbed_winner = argmax(&perturbed.answer)?;
results.push(FlipRow {
id: row.id.clone(),
perturbation: perturbation.name().to_string(),
baseline: winner.clone(),
perturbed: perturbed_winner.clone(),
flipped: winner != perturbed_winner,
baseline_probability: probability_of(&baseline.answer, &winner)?,
perturbed_probability: probability_of(&perturbed.answer, &winner)?,
});
}
}
let flips = results.iter().filter(|row| row.flipped).count();
Ok(FlipReport {
rows: results.len(),
flips,
flip_rate: if results.is_empty() {
0.0
} else {
flips as f64 / results.len() as f64
},
results,
})
}
fn argmax(answer: &ScoredAnswer) -> Result<String> {
let index = answer
.probabilities
.iter()
.enumerate()
.max_by(|left, right| left.1.total_cmp(right.1))
.map(|(index, _)| index)
.ok_or_else(|| anyhow::anyhow!("a scored answer carries no probabilities"))?;
Ok(answer.option_ids[index].clone())
}
fn probability_of(answer: &ScoredAnswer, option: &str) -> Result<f64> {
let index = answer
.option_ids
.iter()
.position(|id| id == option)
.ok_or_else(|| anyhow::anyhow!("option {option:?} is missing from the scored answer"))?;
Ok(answer.probabilities[index])
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn row() -> Row {
serde_json::from_value(json!({
"id": "r1",
"state": "The customer was charged twice.",
"question": "Which department?",
"options": [
{"id": "billing", "description": "charges"},
{"id": "sales", "description": "purchases"},
],
}))
.expect("a well-formed row")
}
#[test]
fn reversing_keeps_the_same_options_in_the_opposite_order() {
let original = row();
let reversed = Perturbation::Reversed.apply(&original);
assert_eq!(
reversed
.options
.iter()
.map(|option| option.id.as_str())
.collect::<Vec<_>>(),
vec!["sales", "billing"]
);
assert_eq!(original.options[0].id, "billing");
}
#[test]
fn the_winner_is_the_largest_probability_wherever_it_sits() {
let answer = ScoredAnswer {
id: "r1".to_string(),
option_ids: vec!["sales".to_string(), "billing".to_string()],
probabilities: vec![0.2, 0.8],
input_tokens: 0,
prompt_version: None,
};
assert_eq!(argmax(&answer).unwrap(), "billing");
assert_eq!(probability_of(&answer, "billing").unwrap(), 0.8);
assert!(probability_of(&answer, "missing").is_err());
}
}