use crate::graph::{MemKind, MemNode};
use crate::reflection::turn_ref;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
User,
Assistant,
}
#[derive(Debug, Clone)]
pub struct ConversationTurn {
pub role: Role,
pub text: String,
pub turn_ref: String,
pub model_id: Option<String>,
pub trace_id: Option<String>,
}
impl ConversationTurn {
pub fn user(text: impl Into<String>, turn_ref: impl Into<String>) -> Self {
Self {
role: Role::User,
text: text.into(),
turn_ref: turn_ref.into(),
model_id: None,
trace_id: None,
}
}
pub fn assistant(
text: impl Into<String>,
turn_ref: impl Into<String>,
model_id: Option<String>,
trace_id: Option<String>,
) -> Self {
Self {
role: Role::Assistant,
text: text.into(),
turn_ref: turn_ref.into(),
model_id,
trace_id,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurnOutcome {
Advance,
Circle,
CleanExit,
AmbiguousExit,
FrustratedExit,
}
impl TurnOutcome {
pub fn is_success(self) -> bool {
matches!(self, TurnOutcome::Advance | TurnOutcome::CleanExit)
}
}
#[derive(Debug, Clone)]
pub struct AssistantTurnOutcome {
pub turn_ref: String,
pub outcome: TurnOutcome,
pub confidence: f64,
pub model_id: Option<String>,
pub trace_id: Option<String>,
}
const CONF_MARKER_CIRCLE: f64 = 0.85; const CONF_ABANDON_EXIT: f64 = 0.80; const CONF_SIM_CIRCLE: f64 = 0.60; const CONF_ADVANCE: f64 = 0.60; const CONF_CLEAN_EXIT: f64 = 0.55; const CONF_AMBIGUOUS_EXIT: f64 = 0.45; const CONF_NO_EVIDENCE: f64 = 0.40;
const REPAIR_PHRASES: &[&str] = &[
"no, ",
"no not",
"not what i",
"that's wrong",
"thats wrong",
"that's not right",
"that's not what",
"wrong approach",
"actually,",
"instead,",
"i said",
"i meant",
"i already",
"like i said",
"as i mentioned",
"still not",
"still doesn't",
"still failing",
"doesn't work",
"didn't work",
"not working",
"does not work",
"try again",
"that failed",
"you didn't",
"you missed",
];
const REPAIR_WORDS: &[&str] = &["again", "undo", "revert", "incorrect"];
const ABANDON_PHRASES: &[&str] = &[
"forget it",
"forget this",
"never mind",
"nevermind",
"give up",
"giving up",
"this is useless",
"useless",
"not helpful",
"waste of time",
"waste of my time",
];
const SIMILARITY_THRESHOLD: f64 = 0.6;
pub fn classify_turns(turns: &[ConversationTurn]) -> Vec<AssistantTurnOutcome> {
let mut out = Vec::new();
for i in 0..turns.len() {
if turns[i].role != Role::Assistant {
continue;
}
let prev_user = turns[..i]
.iter()
.rev()
.find(|t| t.role == Role::User)
.map(|t| t.text.as_str());
let next_user = turns[i + 1..]
.iter()
.find(|t| t.role == Role::User)
.map(|t| t.text.as_str());
let (outcome, confidence) = match next_user {
Some(next) => classify_followup(prev_user, next),
None => classify_exit(prev_user),
};
out.push(AssistantTurnOutcome {
turn_ref: turns[i].turn_ref.clone(),
outcome,
confidence,
model_id: turns[i].model_id.clone(),
trace_id: turns[i].trace_id.clone(),
});
}
out
}
pub fn classify_outcomes(nodes: &[&MemNode]) -> Vec<AssistantTurnOutcome> {
let turns: Vec<ConversationTurn> = nodes
.iter()
.filter(|n| n.kind == MemKind::Conversation)
.filter_map(|n| {
role_of(n).map(|role| ConversationTurn {
role,
text: content(n).to_string(),
turn_ref: turn_ref(n),
model_id: None,
trace_id: None,
})
})
.collect();
if turns.is_empty() && !nodes.is_empty() {
tracing::debug!(
nodes = nodes.len(),
"outcome_signal: no user/assistant-labeled turns; free-form/multi-speaker \
conversations are out of scope (need model attribution)"
);
}
classify_turns(&turns)
}
fn classify_followup(prev_user: Option<&str>, next_user: &str) -> (TurnOutcome, f64) {
let lower = next_user.to_lowercase();
if ABANDON_PHRASES.iter().any(|m| lower.contains(m)) {
return (TurnOutcome::FrustratedExit, CONF_ABANDON_EXIT);
}
if has_repair(next_user) {
return (TurnOutcome::Circle, CONF_MARKER_CIRCLE);
}
if let Some(prev) = prev_user {
if jaccard(prev, next_user) >= SIMILARITY_THRESHOLD {
return (TurnOutcome::Circle, CONF_SIM_CIRCLE);
}
}
(TurnOutcome::Advance, CONF_ADVANCE)
}
fn classify_exit(prev_user: Option<&str>) -> (TurnOutcome, f64) {
let Some(prev) = prev_user else {
return (TurnOutcome::CleanExit, CONF_NO_EVIDENCE);
};
let lower = prev.to_lowercase();
if ABANDON_PHRASES.iter().any(|m| lower.contains(m)) {
return (TurnOutcome::FrustratedExit, CONF_ABANDON_EXIT);
}
if has_repair(prev) {
return (TurnOutcome::AmbiguousExit, CONF_AMBIGUOUS_EXIT);
}
(TurnOutcome::CleanExit, CONF_CLEAN_EXIT)
}
fn has_repair(text: &str) -> bool {
let lower = text.to_lowercase();
if REPAIR_PHRASES.iter().any(|m| lower.contains(m)) {
return true;
}
lower
.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty())
.any(|w| REPAIR_WORDS.contains(&w))
}
fn role_of(node: &MemNode) -> Option<Role> {
let lower = node.value.to_lowercase();
if lower.starts_with("user:") || node.key == "user" {
Some(Role::User)
} else if lower.starts_with("assistant:") || node.key == "assistant" {
Some(Role::Assistant)
} else {
None
}
}
fn content(node: &MemNode) -> &str {
let v = node.value.trim();
for prefix in ["user:", "assistant:"] {
if v.len() >= prefix.len() && v[..prefix.len()].eq_ignore_ascii_case(prefix) {
return v[prefix.len()..].trim_start();
}
}
v
}
fn jaccard(a: &str, b: &str) -> f64 {
use std::collections::HashSet;
let toks = |s: &str| -> HashSet<String> {
s.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.len() > 2)
.map(|w| w.to_string())
.collect()
};
let sa = toks(a);
let sb = toks(b);
if sa.len() < 3 || sb.len() < 3 {
return 0.0;
}
let inter = sa.intersection(&sb).count() as f64;
let union = sa.union(&sb).count() as f64;
if union == 0.0 {
0.0
} else {
inter / union
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::{ContentType, FactMetadata, MemKind, MemNode};
use chrono::Utc;
fn node(key: &str, value: &str) -> MemNode {
MemNode {
kind: MemKind::Conversation,
layer: 3,
key: key.to_string(),
value: value.to_string(),
fact_id: None,
scope: "global".to_string(),
authority: "observed".to_string(),
is_constraint: false,
created_at: Utc::now(),
expires_at: None,
content_type: ContentType::NaturalLanguage,
metadata: FactMetadata::default(),
}
}
fn turn(role_value: &str) -> MemNode {
let key = if role_value.to_lowercase().starts_with("user:") {
"user"
} else {
"assistant"
};
node(key, role_value)
}
fn classify(nodes: &[MemNode]) -> Vec<AssistantTurnOutcome> {
let refs: Vec<&MemNode> = nodes.iter().collect();
classify_outcomes(&refs)
}
#[test]
fn typed_path_carries_model_attribution() {
let turns = vec![
ConversationTurn::user("convert this to async", "t1"),
ConversationTurn::assistant(
"here's a threaded version",
"t2",
Some("gpt-5.4".into()),
Some("trace-abc".into()),
),
ConversationTurn::user("no, that's not what i asked", "t3"),
ConversationTurn::assistant(
"async version",
"t4",
Some("claude-sonnet-4-6".into()),
Some("trace-def".into()),
),
];
let o = classify_turns(&turns);
assert_eq!(o[0].outcome, TurnOutcome::Circle);
assert_eq!(o[0].model_id.as_deref(), Some("gpt-5.4"));
assert_eq!(o[0].trace_id.as_deref(), Some("trace-abc"));
}
#[test]
fn typed_abandonment_credits_the_failed_turn() {
let turns = vec![
ConversationTurn::user("fix the deploy", "t1"),
ConversationTurn::assistant(
"<wrong fix>",
"t2",
Some("gpt-5.4".into()),
Some("trace-x".into()),
),
ConversationTurn::user("forget it, this is useless", "t3"),
];
let o = classify_turns(&turns);
assert_eq!(o.len(), 1);
assert_eq!(o[0].outcome, TurnOutcome::FrustratedExit);
assert!(!o[0].outcome.is_success());
assert_eq!(o[0].model_id.as_deref(), Some("gpt-5.4"));
assert_eq!(o[0].trace_id.as_deref(), Some("trace-x"));
}
#[test]
fn typed_scan_skips_intervening_assistant_turns() {
let turns = vec![
ConversationTurn::user("what's the weather in Tokyo", "t1"),
ConversationTurn::assistant(
"<calls weather tool>",
"t2",
Some("m1".into()),
Some("tr2".into()),
),
ConversationTurn::assistant(
"it's sunny in Toronto",
"t3",
Some("m2".into()),
Some("tr3".into()),
),
ConversationTurn::user("no, i said Tokyo not Toronto", "t4"),
];
let o = classify_turns(&turns);
assert_eq!(o.len(), 2);
assert_eq!(o[0].outcome, TurnOutcome::Circle);
assert_eq!(o[0].trace_id.as_deref(), Some("tr2"));
assert_eq!(o[1].outcome, TurnOutcome::Circle);
assert_eq!(o[1].trace_id.as_deref(), Some("tr3"));
}
#[test]
fn typed_path_trusts_explicit_roles_no_sniffing() {
let turns = vec![
ConversationTurn::user("ship it friday?", "t1"),
ConversationTurn::assistant("done, scheduled", "t2", Some("m".into()), None),
];
let o = classify_turns(&turns);
assert_eq!(o.len(), 1);
assert_eq!(o[0].outcome, TurnOutcome::CleanExit);
assert_eq!(o[0].model_id.as_deref(), Some("m"));
}
#[test]
fn advance_when_user_moves_to_new_intent() {
let nodes = [
turn("user: what's the capital of France?"),
turn("assistant: Paris."),
turn("user: great, now book me a flight to Tokyo next week"),
turn("assistant: ...booked..."),
];
let o = classify(&nodes);
assert_eq!(o[0].outcome, TurnOutcome::Advance);
assert!(o[0].outcome.is_success());
assert!(
o[0].model_id.is_none(),
"observer path carries no attribution"
);
}
#[test]
fn circle_on_explicit_repair_marker() {
let nodes = [
turn("user: convert this to async"),
turn("assistant: here is a threaded version"),
turn("user: no, that's not what i asked, i said async"),
turn("assistant: here is the async version"),
];
let o = classify(&nodes);
assert_eq!(o[0].outcome, TurnOutcome::Circle);
assert!(o[0].confidence >= 0.8);
}
#[test]
fn against_is_not_a_repair_marker() {
let nodes = [
turn("user: review my plan"),
turn("assistant: here is feedback"),
turn("user: I'd advise against rushing, but this looks great, ship it"),
turn("assistant: shipping"),
];
let o = classify(&nodes);
assert_eq!(
o[0].outcome,
TurnOutcome::Advance,
"'against' must not read as 'again'"
);
}
#[test]
fn circle_on_restated_request_without_marker() {
let nodes = [
turn("user: summarize the quarterly revenue report for me please"),
turn("assistant: <unrelated answer about weather>"),
turn("user: please summarize the quarterly revenue report"),
turn("assistant: <summary>"),
];
assert_eq!(classify(&nodes)[0].outcome, TurnOutcome::Circle);
}
#[test]
fn clean_exit_when_smooth_session_ends() {
let nodes = [turn("user: what's 2+2?"), turn("assistant: 4")];
let o = classify(&nodes);
assert_eq!(o[0].outcome, TurnOutcome::CleanExit);
assert!(o[0].outcome.is_success());
}
#[test]
fn repair_then_silence_is_ambiguous_not_confident_churn() {
let nodes = [
turn("user: fix the failing test"),
turn("assistant: <attempt 1>"),
turn("user: that didn't work, still failing"),
turn("assistant: <attempt 2 — may have fixed it>"),
];
let last = classify(&nodes).pop().unwrap();
assert_eq!(last.outcome, TurnOutcome::AmbiguousExit);
assert!(!last.outcome.is_success());
assert!(last.confidence < CONF_MARKER_CIRCLE);
}
#[test]
fn frustrated_exit_only_on_explicit_abandonment() {
let nodes = [
turn("user: fix it"),
turn("assistant: <wrong>"),
turn("user: forget it, this is useless"),
turn("assistant: sorry to hear that"),
];
let last = classify(&nodes).pop().unwrap();
assert_eq!(last.outcome, TurnOutcome::FrustratedExit);
assert!(!last.outcome.is_success());
}
#[test]
fn key_only_role_without_prefix_is_recognized() {
let nodes = [node("user", "what time is it"), node("assistant", "3pm")];
let o = classify(&nodes);
assert_eq!(o.len(), 1);
assert_eq!(o[0].outcome, TurnOutcome::CleanExit);
}
#[test]
fn free_form_speakers_are_dropped_by_observer_adapter() {
let nodes = [
node("alice", "alice: shall we ship friday?"),
node("bob", "bob: yes, let's do it"),
node("ui-agent/chat", "ui-agent/chat: noted"),
];
assert!(classify(&nodes).is_empty());
}
#[test]
fn empty_and_single_user_turn_are_safe() {
assert!(classify(&[]).is_empty());
assert!(classify(&[turn("user: hello?")]).is_empty());
}
#[test]
fn assistant_first_with_no_prior_user_advances() {
let nodes = [
turn("assistant: welcome! how can I help?"),
turn("user: what's the weather"),
turn("assistant: sunny"),
];
assert_eq!(classify(&nodes)[0].outcome, TurnOutcome::Advance);
}
#[test]
fn only_assistant_turns_are_judged() {
let nodes = [
turn("user: a"),
turn("assistant: b"),
turn("user: c"),
turn("assistant: d"),
];
assert_eq!(classify(&nodes).len(), 2);
}
}