use car_inference::{InferredOutcome, OutcomeTracker};
use crate::episode::{assign_credit, Credit, TurnCredit};
use crate::outcome_signal::ConversationTurn;
fn credit_to_inferred(credit: &TurnCredit) -> Option<(String, InferredOutcome)> {
let trace_id = credit.trace_id.clone()?;
let outcome = match credit.credit {
Credit::Success => InferredOutcome::Accepted {
confidence: credit.confidence,
},
Credit::Failure => InferredOutcome::Rejected {
confidence: credit.confidence,
},
};
Some((trace_id, outcome))
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CreditTally {
pub submitted: usize,
pub resolved: usize,
}
pub fn record_episode_credits(tracker: &mut OutcomeTracker, credits: &[TurnCredit]) -> CreditTally {
let mut tally = CreditTally::default();
for credit in credits {
if let Some((trace_id, outcome)) = credit_to_inferred(credit) {
tally.submitted += 1;
if tracker.has_pending(&trace_id) {
tally.resolved += 1;
}
tracker.record_inferred_outcome(&trace_id, outcome);
}
}
if tally.resolved < tally.submitted {
tracing::debug!(
submitted = tally.submitted,
resolved = tally.resolved,
"outcome_bridge: {} conversation credit(s) hit no pending trace (already \
resolved or swept) — lost signal",
tally.submitted - tally.resolved
);
}
tally
}
pub fn record_conversation_outcomes(
tracker: &mut OutcomeTracker,
turns: &[ConversationTurn],
) -> CreditTally {
let credits = assign_credit(turns);
record_episode_credits(tracker, &credits)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::episode::EpisodeResolution;
use car_inference::InferenceTask;
fn credit(
trace_id: Option<&str>,
kind: Credit,
model_id: Option<&str>,
conf: f64,
) -> TurnCredit {
TurnCredit {
turn_ref: "r".into(),
model_id: model_id.map(Into::into),
trace_id: trace_id.map(Into::into),
credit: kind,
confidence: conf,
episode_id: 0,
resolution: EpisodeResolution::Ongoing,
}
}
#[test]
fn failure_credit_reverses_mechanical_success() {
let mut t = OutcomeTracker::new();
let tid = t.record_start("gpt-5.4", InferenceTask::Generate, "test");
t.record_complete(&tid, 100, 10, 20); assert_eq!(t.profile("gpt-5.4").unwrap().success_count, 1);
let tally = record_episode_credits(
&mut t,
&[credit(Some(&tid), Credit::Failure, Some("gpt-5.4"), 0.85)],
);
assert_eq!(tally.resolved, 1);
let p = t.profile("gpt-5.4").unwrap();
assert_eq!(p.success_count, 0, "mechanical success must be reversed");
assert_eq!(p.fail_count, 1, "failure must be booked");
}
#[test]
fn success_credit_keeps_completion_success() {
let mut t = OutcomeTracker::new();
let tid = t.record_start("m2", InferenceTask::Generate, "test");
t.record_complete(&tid, 50, 5, 10);
record_episode_credits(
&mut t,
&[credit(Some(&tid), Credit::Success, Some("m2"), 0.6)],
);
let p = t.profile("m2").unwrap();
assert_eq!(p.success_count, 1, "already-credited success stays counted");
assert_eq!(p.fail_count, 0);
}
#[test]
fn observer_turns_without_trace_id_are_skipped() {
let mut t = OutcomeTracker::new();
let tally =
record_episode_credits(&mut t, &[credit(None, Credit::Failure, Some("m"), 0.85)]);
assert_eq!(tally.submitted, 0);
assert_eq!(tally.resolved, 0);
}
#[test]
fn re_feeding_resolved_trace_is_idempotent_noop() {
let mut t = OutcomeTracker::new();
let tid = t.record_start("m", InferenceTask::Generate, "test");
t.record_complete(&tid, 10, 1, 2);
record_episode_credits(
&mut t,
&[credit(Some(&tid), Credit::Failure, Some("m"), 0.85)],
);
let after_first = {
let p = t.profile("m").unwrap();
(p.success_count, p.fail_count)
};
record_episode_credits(
&mut t,
&[credit(Some(&tid), Credit::Failure, Some("m"), 0.85)],
);
let p = t.profile("m").unwrap();
assert_eq!(
(p.success_count, p.fail_count),
after_first,
"re-feed must be a no-op"
);
}
#[test]
fn submitted_but_unresolved_trace_changes_nothing() {
let mut t = OutcomeTracker::new();
let tally = record_episode_credits(
&mut t,
&[credit(
Some("never-started-trace"),
Credit::Failure,
Some("ghost"),
0.85,
)],
);
assert_eq!(tally.submitted, 1, "has trace_id → submitted");
assert_eq!(tally.resolved, 0, "no pending trace → resolved nothing");
assert!(
t.profile("ghost").is_none(),
"no pending → no profile mutation"
);
}
#[test]
fn end_to_end_records_attributed_turns() {
let mut t = OutcomeTracker::new();
let tid = t.record_start("m1", InferenceTask::Generate, "test");
t.record_complete(&tid, 10, 1, 2);
let turns = vec![
ConversationTurn::user("convert to async", "u1"),
ConversationTurn::assistant(
"threaded version",
"a1",
Some("m1".into()),
Some(tid.clone()),
),
ConversationTurn::user("no, that's not what i asked", "u2"),
ConversationTurn::assistant("async version", "a2", Some("m1".into()), None),
];
let tally = record_conversation_outcomes(&mut t, &turns);
assert!(tally.resolved >= 1);
let p = t.profile("m1").unwrap();
assert_eq!(
p.fail_count, 1,
"the circled-on attributed turn is booked a failure"
);
assert_eq!(p.success_count, 0);
}
}