Skip to main content

systemprompt_evaluation/services/
loop_runner.rs

1//! Scheduler loop that picks up and executes pending evaluation runs.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use serde_json::Value;
7use systemprompt_identifiers::EvalRunId;
8
9use crate::error::{EvaluationError, Result};
10use crate::models::{
11    DimensionScore, NewResultParams, Rubric, SampleFilter, SampledRequest, Verdict,
12};
13use crate::repository::{EvalResultRepository, EvalRunRepository};
14use crate::services::judge::{JudgeService, JudgeTarget, ScoredVerdict};
15use crate::services::replay::ReplayService;
16use crate::services::sampler::SamplerService;
17
18const EXCERPT_CHARS: usize = 500;
19
20#[derive(Debug, Clone, Copy)]
21pub struct LoopLimits {
22    pub budget_microdollars: Option<i64>,
23}
24
25#[derive(Debug, Clone, Copy, Default)]
26pub struct LoopReport {
27    pub scored: u32,
28    pub failed: u32,
29    pub replayed: u32,
30    pub repaired: u32,
31    pub judge_cost_microdollars: i64,
32}
33
34/// Sample → judge → repair-hint → replay → re-judge, one pass.
35pub struct AutoImproveLoop {
36    pub(super) sampler: SamplerService,
37    pub(super) judge: JudgeService,
38    pub(super) replay: ReplayService,
39    pub(super) runs: EvalRunRepository,
40    pub(super) results: EvalResultRepository,
41}
42
43impl std::fmt::Debug for AutoImproveLoop {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("AutoImproveLoop")
46            .field("judge", &self.judge)
47            .finish_non_exhaustive()
48    }
49}
50
51impl AutoImproveLoop {
52    pub async fn run(
53        &self,
54        run_id: &EvalRunId,
55        rubric: &Rubric,
56        filter: &SampleFilter,
57        limits: LoopLimits,
58    ) -> Result<LoopReport> {
59        let mut report = LoopReport::default();
60        let sampled = self.sampler.sample(filter).await?;
61
62        for request in &sampled {
63            check_budget(&report, limits)?;
64            match self.judge_one(run_id, rubric, request, &mut report).await {
65                Ok(()) => {},
66                Err(e) => {
67                    tracing::warn!(
68                        error = %e,
69                        ai_request_id = %request.ai_request_id,
70                        "judge pass failed for sampled request"
71                    );
72                },
73            }
74        }
75        Ok(report)
76    }
77
78    async fn judge_one(
79        &self,
80        run_id: &EvalRunId,
81        rubric: &Rubric,
82        request: &SampledRequest,
83        report: &mut LoopReport,
84    ) -> Result<()> {
85        let target = judge_target(request);
86        let scored = self.judge.score(rubric, &target).await?;
87        report.scored += 1;
88        report.judge_cost_microdollars += scored.judge_cost_microdollars;
89
90        let failed = matches!(scored.outcome, Verdict::Fail | Verdict::Partial);
91        if failed {
92            report.failed += 1;
93        }
94        let result_id = self
95            .results
96            .insert(&result_params(run_id, request, &scored, None))
97            .await?;
98        self.runs
99            .record_scored(run_id, failed, scored.judge_cost_microdollars)
100            .await?;
101
102        if !failed {
103            return Ok(());
104        }
105        let Some(repair_hint) = scored.verdict.repair_hint.clone().filter(|h| !h.is_empty()) else {
106            return Ok(());
107        };
108
109        let prompt = request.canonical_prompt();
110        let replayed = self.replay.replay(&prompt, &repair_hint).await?;
111        report.replayed += 1;
112
113        let repaired_target = JudgeTarget {
114            transcript: target.transcript,
115            response: replayed.content.clone(),
116            expectation: target.expectation,
117        };
118        let rescored = self.judge.score(rubric, &repaired_target).await?;
119        report.judge_cost_microdollars += rescored.judge_cost_microdollars;
120
121        let mut params = result_params(run_id, request, &rescored, Some(result_id.clone()));
122        params.response_excerpt = Some(excerpt(&replayed.content));
123        self.results.insert(&params).await?;
124        self.runs
125            .record_scored(
126                run_id,
127                matches!(rescored.outcome, Verdict::Fail | Verdict::Partial),
128                rescored.judge_cost_microdollars,
129            )
130            .await?;
131
132        if matches!(rescored.outcome, Verdict::Pass) {
133            self.results.mark_repaired(&result_id).await?;
134            report.repaired += 1;
135        }
136        Ok(())
137    }
138}
139
140const fn check_budget(report: &LoopReport, limits: LoopLimits) -> Result<()> {
141    match limits.budget_microdollars {
142        Some(budget) if report.judge_cost_microdollars >= budget => {
143            Err(EvaluationError::BudgetExhausted {
144                spent: report.judge_cost_microdollars,
145                budget,
146            })
147        },
148        _ => Ok(()),
149    }
150}
151
152fn judge_target(request: &SampledRequest) -> JudgeTarget {
153    let transcript = request
154        .messages
155        .iter()
156        .map(|m| format!("{}: {}", m.role, m.content))
157        .collect::<Vec<_>>()
158        .join("\n");
159    JudgeTarget {
160        transcript,
161        response: request.response_text.clone().unwrap_or_default(),
162        expectation: None,
163    }
164}
165
166fn result_params(
167    run_id: &EvalRunId,
168    request: &SampledRequest,
169    scored: &ScoredVerdict,
170    replay_of: Option<systemprompt_identifiers::EvalResultId>,
171) -> NewResultParams {
172    NewResultParams {
173        run_id: run_id.clone(),
174        ai_request_id: Some(request.ai_request_id.clone()),
175        case_id: None,
176        provider: request.provider.clone(),
177        model: request.model.clone(),
178        overall_score: Some(scored.verdict.overall_score),
179        dimension_scores: dimension_scores_json(&scored.verdict.dimension_scores),
180        verdict: scored.outcome,
181        rationale: Some(scored.verdict.rationale.clone()),
182        repair_hint: scored.verdict.repair_hint.clone().filter(|h| !h.is_empty()),
183        prompt_excerpt: request.messages.first().map(|m| excerpt(&m.content)),
184        response_excerpt: request.response_text.as_deref().map(excerpt),
185        judge_cost_microdollars: scored.judge_cost_microdollars,
186        repaired: replay_of.is_some(),
187        replay_of_result_id: replay_of,
188        judge_ai_request_id: Some(scored.judge_ai_request_id.clone()),
189    }
190}
191
192fn dimension_scores_json(scores: &[DimensionScore]) -> Value {
193    match serde_json::to_value(scores) {
194        Ok(value) => value,
195        Err(e) => {
196            tracing::warn!(error = %e, "Failed to serialize dimension scores");
197            Value::Object(serde_json::Map::new())
198        },
199    }
200}
201
202fn excerpt(text: &str) -> String {
203    text.chars().take(EXCERPT_CHARS).collect()
204}