use crate::outcome_signal::{classify_turns, AssistantTurnOutcome, ConversationTurn, TurnOutcome};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Credit {
Success,
Failure,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EpisodeResolution {
Resolved,
Abandoned,
Ambiguous,
Ongoing,
}
#[derive(Debug, Clone)]
pub struct TurnCredit {
pub turn_ref: String,
pub model_id: Option<String>,
pub trace_id: Option<String>,
pub credit: Credit,
pub confidence: f64,
pub episode_id: usize,
pub resolution: EpisodeResolution,
}
const RECOVERED_DISCOUNT: f64 = 0.7;
const ABANDONED_BOOST: f64 = 1.15;
pub fn assign_credit(turns: &[ConversationTurn]) -> Vec<TurnCredit> {
assign_credit_from_outcomes(&classify_turns(turns))
}
pub fn assign_credit_from_outcomes(outcomes: &[AssistantTurnOutcome]) -> Vec<TurnCredit> {
let mut credits = Vec::new();
let mut episode_id = 0usize;
let mut start = 0usize;
for (i, o) in outcomes.iter().enumerate() {
if o.outcome != TurnOutcome::Circle {
close_episode(&outcomes[start..=i], episode_id, &mut credits);
episode_id += 1;
start = i + 1;
}
}
if start < outcomes.len() {
close_episode(&outcomes[start..], episode_id, &mut credits);
}
credits
}
fn close_episode(ep: &[AssistantTurnOutcome], episode_id: usize, credits: &mut Vec<TurnCredit>) {
let resolution = match ep.last().unwrap().outcome {
TurnOutcome::Advance | TurnOutcome::CleanExit => EpisodeResolution::Resolved,
TurnOutcome::FrustratedExit => EpisodeResolution::Abandoned,
TurnOutcome::AmbiguousExit => EpisodeResolution::Ambiguous,
TurnOutcome::Circle => EpisodeResolution::Ongoing,
};
let last = ep.len() - 1;
for (i, o) in ep.iter().enumerate() {
let is_terminator = i == last;
let (credit, confidence) = match resolution {
EpisodeResolution::Resolved => {
if is_terminator {
(Credit::Success, o.confidence)
} else {
(Credit::Failure, o.confidence * RECOVERED_DISCOUNT)
}
}
EpisodeResolution::Abandoned => {
(Credit::Failure, (o.confidence * ABANDONED_BOOST).min(1.0))
}
EpisodeResolution::Ambiguous => {
if is_terminator {
continue; }
(Credit::Failure, o.confidence)
}
EpisodeResolution::Ongoing => (Credit::Failure, o.confidence),
};
credits.push(TurnCredit {
turn_ref: o.turn_ref.clone(),
model_id: o.model_id.clone(),
trace_id: o.trace_id.clone(),
credit,
confidence,
episode_id,
resolution,
});
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ConversationMetrics {
pub episodes: usize,
pub resolved: usize,
pub abandoned: usize,
pub ambiguous: usize,
pub ongoing: usize,
pub first_turn_resolutions: usize,
pub first_turn_resolution_rate: f64,
pub avg_turns_to_resolution: f64,
pub churn_rate: f64,
}
pub fn conversation_metrics(credits: &[TurnCredit]) -> ConversationMetrics {
use std::collections::HashMap;
let mut episodes: HashMap<usize, (usize, EpisodeResolution)> = HashMap::new();
for c in credits {
let e = episodes.entry(c.episode_id).or_insert((0, c.resolution));
e.0 += 1;
}
let mut m = ConversationMetrics::default();
let mut resolved_turns = 0usize;
for (count, resolution) in episodes.values() {
m.episodes += 1;
match resolution {
EpisodeResolution::Resolved => {
m.resolved += 1;
resolved_turns += count;
if *count == 1 {
m.first_turn_resolutions += 1;
}
}
EpisodeResolution::Abandoned => m.abandoned += 1,
EpisodeResolution::Ambiguous => m.ambiguous += 1,
EpisodeResolution::Ongoing => m.ongoing += 1,
}
}
if m.resolved > 0 {
m.avg_turns_to_resolution = resolved_turns as f64 / m.resolved as f64;
m.first_turn_resolution_rate = m.first_turn_resolutions as f64 / m.resolved as f64;
}
if m.episodes > 0 {
m.churn_rate = m.abandoned as f64 / m.episodes as f64;
}
m
}
#[cfg(test)]
mod tests {
use super::*;
use crate::outcome_signal::ConversationTurn;
fn u(text: &str, r: &str) -> ConversationTurn {
ConversationTurn::user(text, r)
}
fn a(text: &str, r: &str, model: &str) -> ConversationTurn {
ConversationTurn::assistant(text, r, Some(model.into()), Some(format!("trace-{r}")))
}
#[test]
fn clean_single_answer_is_resolved_success() {
let turns = [u("what's 2+2", "u1"), a("4", "t1", "m1")];
let c = assign_credit(&turns);
assert_eq!(c.len(), 1);
assert_eq!(c[0].credit, Credit::Success);
assert_eq!(c[0].resolution, EpisodeResolution::Resolved);
assert_eq!(c[0].episode_id, 0);
}
#[test]
fn recovery_debits_circle_credits_resolver_across_models() {
let turns = [
u("convert to async", "u1"),
a("threaded version", "t1", "m1"),
u("no, that's not what i asked, i said async", "u2"),
a("async version", "t2", "m2"),
u("thanks, now add tests", "u3"),
a("tests added", "t3", "m2"),
];
let c = assign_credit(&turns);
let t1 = c.iter().find(|x| x.turn_ref == "t1").unwrap();
assert_eq!(t1.credit, Credit::Failure);
assert_eq!(t1.model_id.as_deref(), Some("m1"));
assert!(t1.confidence < 0.85, "recovered failure is discounted");
assert_eq!(t1.resolution, EpisodeResolution::Resolved);
let t2 = c.iter().find(|x| x.turn_ref == "t2").unwrap();
assert_eq!(t2.credit, Credit::Success);
assert_eq!(t2.model_id.as_deref(), Some("m2"));
assert_eq!(t1.episode_id, t2.episode_id);
let t3 = c.iter().find(|x| x.turn_ref == "t3").unwrap();
assert!(t3.episode_id > t2.episode_id);
assert_eq!(t3.credit, Credit::Success);
}
#[test]
fn abandoned_episode_debits_all_attempts_boosted() {
let turns = [
u("fix the deploy", "u1"),
a("attempt 1", "t1", "m1"),
u("no that's wrong", "u2"),
a("attempt 2", "t2", "m1"),
u("forget it, this is useless", "u3"),
];
let c = assign_credit(&turns);
assert!(c.iter().all(|x| x.credit == Credit::Failure));
assert!(c
.iter()
.all(|x| x.resolution == EpisodeResolution::Abandoned));
assert!(c.iter().all(|x| x.confidence <= 1.0));
}
#[test]
fn ambiguous_terminal_turn_is_deferred() {
let turns = [
u("fix the test", "u1"),
a("attempt 1", "t1", "m1"),
u("that didn't work, still failing", "u2"),
a("attempt 2 (maybe fixed)", "t2", "m1"),
];
let c = assign_credit(&turns);
assert!(c
.iter()
.any(|x| x.turn_ref == "t1" && x.credit == Credit::Failure));
assert!(
!c.iter().any(|x| x.turn_ref == "t2"),
"ambiguous terminal turn must be deferred"
);
}
#[test]
fn ongoing_loop_debits_known_circles() {
let turns = [
u("do X", "u1"),
a("wrong 1", "t1", "m1"),
u("no, try again", "u2"),
a("wrong 2", "t2", "m1"),
u("still wrong, that failed", "u3"),
];
let c = assign_credit(&turns);
assert_eq!(c.len(), 2);
assert!(c.iter().all(|x| x.credit == Credit::Failure));
assert!(c.iter().all(|x| x.resolution == EpisodeResolution::Ongoing));
}
#[test]
fn distinct_intents_are_separate_episodes() {
let turns = [
u("question one", "u1"),
a("answer one", "t1", "m1"),
u("unrelated question two", "u2"),
a("answer two", "t2", "m1"),
];
let c = assign_credit(&turns);
assert_eq!(c.len(), 2);
assert_ne!(c[0].episode_id, c[1].episode_id);
assert!(c.iter().all(|x| x.credit == Credit::Success));
}
#[test]
fn empty_is_safe() {
assert!(assign_credit(&[]).is_empty());
assert_eq!(conversation_metrics(&[]), ConversationMetrics::default());
}
#[test]
fn metrics_one_shot_resolution() {
let turns = [u("what's 2+2", "u1"), a("4", "t1", "m1")];
let m = conversation_metrics(&assign_credit(&turns));
assert_eq!(m.episodes, 1);
assert_eq!(m.resolved, 1);
assert_eq!(m.first_turn_resolutions, 1);
assert!((m.first_turn_resolution_rate - 1.0).abs() < 1e-9);
assert!((m.avg_turns_to_resolution - 1.0).abs() < 1e-9);
assert!((m.churn_rate - 0.0).abs() < 1e-9);
}
#[test]
fn metrics_count_turns_to_resolution_across_episodes() {
let turns = [
u("convert to async", "u1"),
a("threaded", "t1", "m1"),
u("no, that's not what i asked", "u2"),
a("async", "t2", "m2"),
u("thanks, now a new question", "u3"),
a("answer", "t3", "m2"),
];
let m = conversation_metrics(&assign_credit(&turns));
assert_eq!(m.resolved, 2);
assert_eq!(m.first_turn_resolutions, 1, "only ep1 was one-shot");
assert!((m.avg_turns_to_resolution - 1.5).abs() < 1e-9);
assert!((m.first_turn_resolution_rate - 0.5).abs() < 1e-9);
}
#[test]
fn metrics_ongoing_loop_is_not_churn() {
let turns = [
u("do X", "u1"),
a("wrong", "t1", "m1"),
u("no, try again", "u2"),
a("wrong2", "t2", "m1"),
u("still wrong, that failed", "u3"),
];
let m = conversation_metrics(&assign_credit(&turns));
assert_eq!(m.episodes, 1);
assert_eq!(m.ongoing, 1);
assert_eq!(m.resolved, 0);
assert_eq!(m.abandoned, 0);
assert!((m.churn_rate - 0.0).abs() < 1e-9);
}
#[test]
fn metrics_churn_on_abandonment() {
let turns = [
u("fix it", "u1"),
a("attempt", "t1", "m1"),
u("no that's wrong", "u2"),
a("attempt2", "t2", "m1"),
u("forget it, useless", "u3"),
];
let m = conversation_metrics(&assign_credit(&turns));
assert_eq!(m.episodes, 1);
assert_eq!(m.abandoned, 1);
assert_eq!(m.resolved, 0);
assert!((m.churn_rate - 1.0).abs() < 1e-9);
}
#[test]
fn mixed_resolution_episodes_coexist() {
let turns = [
u("q1", "u1"),
a("ans1", "t1", "m1"),
u("thanks, now fix the deploy", "u2"),
a("attempt", "t2", "m1"),
u("no that's wrong", "u3"),
a("attempt2", "t3", "m1"),
u("forget it, useless", "u4"),
];
let c = assign_credit(&turns);
let t1 = c.iter().find(|x| x.turn_ref == "t1").unwrap();
assert_eq!(t1.resolution, EpisodeResolution::Resolved);
assert_eq!(t1.credit, Credit::Success);
let t2 = c.iter().find(|x| x.turn_ref == "t2").unwrap();
let t3 = c.iter().find(|x| x.turn_ref == "t3").unwrap();
assert_eq!(t2.resolution, EpisodeResolution::Abandoned);
assert_eq!(t3.resolution, EpisodeResolution::Abandoned);
assert_ne!(t1.episode_id, t2.episode_id);
assert_eq!(t2.episode_id, t3.episode_id);
}
#[test]
fn multi_circle_then_ambiguous_emits_all_circles() {
let turns = [
u("fix the test", "u1"),
a("attempt 1", "t1", "m1"),
u("no, that's wrong", "u2"),
a("attempt 2", "t2", "m1"),
u("still failing, didn't work", "u3"),
a("attempt 3 (maybe)", "t3", "m1"),
];
let c = assign_credit(&turns);
assert!(c
.iter()
.any(|x| x.turn_ref == "t1" && x.credit == Credit::Failure));
assert!(c
.iter()
.any(|x| x.turn_ref == "t2" && x.credit == Credit::Failure));
assert!(!c.iter().any(|x| x.turn_ref == "t3"));
assert_eq!(c.len(), 2);
}
#[test]
fn confidence_discount_and_boost_are_exact() {
let resolved = [
u("convert to async", "u1"),
a("threaded", "t1", "m1"),
u("no, that's not what i asked", "u2"),
a("async", "t2", "m2"),
u("thanks, new question", "u3"),
a("answer", "t3", "m2"),
];
let rc = assign_credit(&resolved);
let t1 = rc.iter().find(|x| x.turn_ref == "t1").unwrap();
assert!((t1.confidence - 0.85 * 0.7).abs() < 1e-9);
let abandoned = [
u("fix it", "u1"),
a("attempt", "t1", "m1"),
u("no that's wrong", "u2"),
a("attempt2", "t2", "m1"),
u("forget it, useless", "u3"),
];
let ac = assign_credit(&abandoned);
let at1 = ac.iter().find(|x| x.turn_ref == "t1").unwrap();
assert!((at1.confidence - (0.85 * 1.15)).abs() < 1e-9);
assert!(ac.iter().all(|x| x.confidence <= 1.0));
}
#[test]
fn episode_id_advances_even_when_terminal_deferred() {
let turns = [
u("q1", "u1"),
a("a1", "t1", "m1"),
u("now fix the bug", "u2"),
a("fix1", "t2", "m1"),
u("still broken, didn't work", "u3"),
a("fix2", "t3", "m1"),
];
let c = assign_credit(&turns);
let t1 = c.iter().find(|x| x.turn_ref == "t1").unwrap();
let t2 = c.iter().find(|x| x.turn_ref == "t2").unwrap();
assert_eq!(t1.episode_id, 0);
assert_eq!(
t2.episode_id, 1,
"episode_id advances despite deferred terminal"
);
assert!(
!c.iter().any(|x| x.turn_ref == "t3"),
"ambiguous terminal deferred"
);
}
}