use std::sync::Arc;
use async_trait::async_trait;
use futures::StreamExt as _;
use turnframe_core::case::CaseKey;
use turnframe_core::flow::WorkflowRegistry;
use turnframe_core::ids::{CaseId, ConversationId, OriginToken, TurnId};
use turnframe_core::locale::Locale;
use turnframe_core::turn::{ActorContext, InteractionResponse, OriginRef, TurnInput};
use turnframe_runtime::orchestrator::{Orchestrator, error_code};
use turnframe_store::interaction::InteractionReader;
use crate::assertions::check;
use crate::config::EvalConfig;
use crate::control::ControlRun;
use crate::corpus::{CardReplySpec, EvalItem, ExternalSpec, Suite, TurnSpec};
use crate::judge::{CriterionOutcome, Judge, JudgeInput};
use crate::observation::Observation;
use crate::report::{EvalReport, ItemReport, SampleReport};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SampleIndex(pub u32);
impl SampleIndex {
#[must_use]
pub const fn index(self) -> u32 {
self.0
}
#[must_use]
pub const fn number(self) -> u32 {
self.0 + 1
}
#[must_use]
pub const fn is_first(self) -> bool {
self.0 == 0
}
}
impl std::fmt::Display for SampleIndex {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.number())
}
}
pub struct PreparedRun {
pub orchestrator: Arc<Orchestrator>,
pub workflows: Arc<WorkflowRegistry>,
pub actor: ActorContext,
pub conversation_id: ConversationId,
pub turn_id: TurnId,
}
impl std::fmt::Debug for PreparedRun {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PreparedRun")
.field("account", &self.actor.account_id)
.field("turn_id", &self.turn_id)
.finish_non_exhaustive()
}
}
#[async_trait]
pub trait EvalHarness: Send + Sync {
async fn prepare(
&self,
item: &EvalItem,
sample: SampleIndex,
) -> Result<PreparedRun, HarnessError>;
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum HarnessError {
#[error("the harness could not prepare the run: {message}")]
Setup {
message: String,
},
#[error("the item names workflow `{workflow}`, which the harness did not register")]
UnknownWorkflow {
workflow: String,
},
#[error("case {case} has no blocking card for the item's reply")]
NoBlockingCard {
case: String,
},
#[error("the interaction store could not be read: {message}")]
Store {
message: String,
},
}
impl HarnessError {
#[must_use]
pub fn setup(message: impl Into<String>) -> Self {
Self::Setup {
message: message.into(),
}
}
}
pub struct Runner {
config: EvalConfig,
judge: Option<Arc<Judge>>,
}
impl std::fmt::Debug for Runner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Runner")
.field("samples_per_item", &self.config.execution.samples_per_item)
.field(
"sample_concurrency",
&self.config.execution.sample_concurrency,
)
.field("votes_per_sample", &self.config.judging.votes_per_sample)
.field("judge", &self.judge.is_some())
.finish()
}
}
impl Runner {
#[must_use]
pub fn new(config: EvalConfig) -> Self {
Self {
config,
judge: None,
}
}
#[must_use]
pub fn with_judge(mut self, judge: Arc<Judge>) -> Self {
self.judge = Some(judge);
self
}
#[must_use]
pub const fn config(&self) -> &EvalConfig {
&self.config
}
pub async fn run(&self, suite: &Suite, harness: &dyn EvalHarness) -> EvalReport {
let mut items = Vec::new();
let mut failed_samples = 0_u32;
for item in suite.select(&self.config.selection) {
let report = self.run_item(item, harness).await;
failed_samples +=
u32::try_from(report.total_samples() - report.samples_passed()).unwrap_or(u32::MAX);
items.push(report);
if self
.config
.execution
.stop_after_failures
.is_some_and(|budget| failed_samples >= budget)
{
break;
}
}
EvalReport::new(
suite.name.clone(),
chrono::Utc::now(),
self.config.clone(),
items,
)
}
pub async fn run_control(&self, suite: &Suite, harness: &dyn EvalHarness) -> ControlRun {
let first = self.run(suite, harness).await;
let second = self.run(suite, harness).await;
ControlRun::new(first, second)
}
pub async fn run_item(&self, item: &EvalItem, harness: &dyn EvalHarness) -> ItemReport {
let count = self.config.execution.samples_per_item.max(1);
let in_flight =
usize::try_from(self.config.execution.sample_concurrency.max(1)).unwrap_or(usize::MAX);
let samples = futures::stream::iter(
(0..count).map(|index| self.run_sample(item, harness, SampleIndex(index))),
)
.buffered(in_flight)
.collect::<Vec<_>>()
.await;
ItemReport {
id: item.id.clone(),
name: item.name.clone(),
tags: item.tags.clone(),
fingerprint: item.fingerprint(),
samples,
}
}
pub async fn run_sample(
&self,
item: &EvalItem,
harness: &dyn EvalHarness,
sample: SampleIndex,
) -> SampleReport {
let prepared = match harness.prepare(item, sample).await {
Ok(prepared) => prepared,
Err(error) => return unmeasured(sample, &error.to_string()),
};
let mut earlier = Vec::new();
for spec in &item.before {
if let Some(external) = &spec.external {
if let Err(error) = outside(&prepared, external).await {
return unmeasured(sample, &error);
}
continue;
}
let turn_id = TurnId::new();
let input = match build_input(&prepared, spec, turn_id).await {
Ok(input) => input,
Err(error) => return unmeasured(sample, &error.to_string()),
};
let _ = prepared.orchestrator.handle_turn(input).await;
earlier.push(turn_id);
}
let input = match build_input(&prepared, &item.turn, prepared.turn_id).await {
Ok(input) => input,
Err(error) => return unmeasured(sample, &error.to_string()),
};
let outcome = prepared.orchestrator.handle_turn(input).await;
let abandoned = outcome.as_ref().map_or(true, |turn| turn.blocks.is_empty());
let observed = Observation::collect_bounded(
prepared.orchestrator.stores(),
prepared.workflows.as_ref(),
&prepared.actor.account_id,
prepared.turn_id,
&item.setup.cases,
outcome.as_ref().map_err(error_code),
self.config.execution.max_observed_events,
)
.await
.with_conversation_cases(
prepared.orchestrator.stores(),
prepared.workflows.as_ref(),
&prepared.actor.account_id,
&earlier,
)
.await;
let failures = check(&item.expect, &observed);
let judge = self.judge_sample(item, sample, &observed).await;
SampleReport {
sample: sample.number(),
failures,
harness_error: None,
signature: observed.signature(),
judge,
acts_proposed: observed.acts.len(),
acts_refused: observed
.acts
.iter()
.filter(|act| act.outcome.as_deref() == Some("rejected"))
.count(),
commands_journaled: observed.commands.len(),
provider_failures: observed.provider_failures,
cards_created: observed.cards_created,
abandoned,
discarded_answers: observed
.discard_codes()
.into_iter()
.map(str::to_owned)
.collect(),
answer: if self.config.execution.record_answers {
observed.answer.clone()
} else {
String::new()
},
tasks: item
.expect
.understanding
.as_ref()
.zip(item.turn.text.as_deref())
.map(|(expected, text)| expected.score(text, &observed))
.unwrap_or_default(),
}
}
async fn judge_sample(
&self,
item: &EvalItem,
sample: SampleIndex,
observed: &Observation,
) -> Vec<CriterionOutcome> {
let Some(judge) = self.judge.as_ref() else {
return Vec::new();
};
if item.judge.is_empty() {
return Vec::new();
}
if !self.config.judging.judge_every_sample && !sample.is_first() {
return Vec::new();
}
let input = JudgeInput::new(question_of(&item.turn), observed.answer.clone())
.with_committed(observed.events.clone());
if input.is_empty() {
return item
.judge
.iter()
.map(|criterion| CriterionOutcome::empty(*criterion))
.collect();
}
let mut outcomes = Vec::new();
for criterion in &item.judge {
outcomes.push(
judge
.poll(*criterion, &input, self.config.judging.votes_per_sample)
.await,
);
}
outcomes
}
}
async fn outside(prepared: &PreparedRun, external: &ExternalSpec) -> Result<(), String> {
use turnframe_core::case::CaseRef;
use turnframe_core::command::{
AtomicityScope, CommandBatch, CommandEnvelope, CommandOrigin, IdempotencyKey,
};
use turnframe_core::ids::{BatchId, CommandId};
use turnframe_core::understanding::{ActId, UnitId};
let registered = prepared
.workflows
.require(&external.workflow)
.map_err(|error| error.to_string())?;
let account = &prepared.actor.account_id;
let loaded = registered
.executor
.load(account, &external.case_id)
.await
.map_err(|error| error.to_string())?;
let case_ref = CaseRef::new(
external.workflow.clone(),
external.case_id.clone(),
loaded.revision,
);
let turn_id = TurnId::new();
let origin = CommandOrigin::ExternalCallback {
callback_id: "eval.external".to_owned(),
signature_verified: true,
};
let idempotency_key =
IdempotencyKey::derive(account, &turn_id, &case_ref, &origin, &external.command)
.map_err(|error| error.to_string())?;
let batch = CommandBatch {
batch_id: BatchId::derive(&turn_id, &case_ref.key(), &AtomicityScope::PerCase),
scope: AtomicityScope::PerCase,
envelopes: vec![CommandEnvelope {
command_id: CommandId::derive(&turn_id, ActId::new(UnitId(1), 1), 0),
turn_id,
actor: ActorContext::new(account.clone(), "external"),
case_ref,
idempotency_key,
origin,
command: external.command.clone(),
}],
};
registered
.executor
.execute(batch)
.await
.map(|_| ())
.map_err(|error| error.to_string())
}
fn unmeasured(sample: SampleIndex, message: &str) -> SampleReport {
SampleReport {
sample: sample.number(),
failures: Vec::new(),
harness_error: Some(message.to_owned()),
signature: format!("harness_error={message}"),
judge: Vec::new(),
acts_proposed: 0,
acts_refused: 0,
commands_journaled: 0,
provider_failures: 0,
cards_created: 0,
abandoned: true,
discarded_answers: Vec::new(),
answer: String::new(),
tasks: Default::default(),
}
}
fn question_of(spec: &TurnSpec) -> String {
match (&spec.text, &spec.reply) {
(Some(text), _) => text.clone(),
(None, Some(reply)) => format!(
"(the user chose `{}` on {}/{})",
reply.option,
reply.workflow,
reply
.case_id
.as_ref()
.map_or("the open card", CaseId::as_str)
),
(None, None) => String::new(),
}
}
async fn build_input(
prepared: &PreparedRun,
spec: &TurnSpec,
turn_id: TurnId,
) -> Result<TurnInput, HarnessError> {
let mut actor = prepared.actor.clone();
if let Some(user_id) = &spec.user_id {
actor.user_id = turnframe_core::ids::UserId::new(user_id.clone());
}
let interaction_response = match &spec.reply {
Some(reply) => Some(resolve_card(prepared, reply).await?),
None => None,
};
Ok(TurnInput {
turn_id,
conversation_id: prepared.conversation_id,
actor,
text: spec.text.clone(),
interaction_response,
attachments: Vec::new(),
origin: spec.origin.as_ref().map(|origin| OriginRef {
origin_token: OriginToken::from(origin.token.as_str()),
signature: None,
surface: origin.surface.clone(),
}),
locale: spec.locale.clone().unwrap_or_else(|| Locale::from("en")),
effort: None,
})
}
async fn resolve_card(
prepared: &PreparedRun,
reply: &CardReplySpec,
) -> Result<InteractionResponse, HarnessError> {
let store = prepared.orchestrator.stores().interactions();
let open = match &reply.case_id {
Some(case_id) => {
let case = CaseKey::new(reply.workflow.clone(), case_id.clone());
InteractionReader::list_open_for_case(store.as_ref(), &prepared.actor.account_id, &case)
.await
}
None => {
InteractionReader::list_open_for_conversation(
store.as_ref(),
&prepared.actor.account_id,
&prepared.conversation_id,
)
.await
}
}
.map_err(|error| HarnessError::Store {
message: error.to_string(),
})?;
let card = open
.into_iter()
.find(|card| card.blocking && card.case_ref.workflow == reply.workflow)
.ok_or_else(|| HarnessError::NoBlockingCard {
case: format!(
"{}/{}",
reply.workflow,
reply.case_id.as_ref().map_or("*", CaseId::as_str)
),
})?;
Ok(InteractionResponse {
interaction_id: card.id,
option_id: reply.option.clone(),
expected_case_revision: card.case_ref.expected_revision,
freeform_input: reply.freeform.clone(),
})
}