blendtutor_core/run.rs
1//! The student run loop: execute a submission, grade it, ask the LLM for a
2//! verdict, and bundle the result into a typed [`RunReport`].
3//!
4//! This is the effect-shell that composes the [`runner`](crate::runner),
5//! [`grade`](crate::grade), and [`llm`](crate::llm) layers through their public
6//! types only (§2.4, §3.3): it executes the submission for its captured output,
7//! grades the lesson's checks, builds the prompt, and requests a verdict. It adds
8//! no domain logic of its own — it neither builds prompts nor talks to a provider
9//! directly (those are the `llm` layer's), and it does not render output or map
10//! exit codes (those belong to the `cli` edge). The result is a [`RunReport`], a
11//! typed value carrying the [`Verdict`], the per-check outcomes, and the captured
12//! output, with a stable JSON form for `--format json`.
13
14use std::error::Error;
15use std::fmt;
16
17use serde::{Deserialize, Serialize};
18
19use crate::grade::{CheckOutcome, run_checks, select_runner};
20use crate::lesson::Lesson;
21use crate::llm::{
22 ExecResults, FeedbackError, ProviderChoice, Submission, Verdict, build_prompt, request_feedback,
23};
24use crate::runner::Runner;
25
26/// The result of running one submission against a lesson: the graded verdict, the
27/// per-check outcomes, and the submission's captured output.
28///
29/// A typed value (§1.1) the `run` command computes and then renders. The verdict
30/// is the [`Verdict`] sum type — carrying its learner-facing message — so a
31/// "correct with no feedback" or contradictory state is unrepresentable; the
32/// JSON consumer's flat `{verdict, feedback}` pair is a serialization detail, not
33/// a second source of truth. Its JSON form is defined once here (via the private
34/// `RunDocument`), so `core` owns the report's canonical wire shape the way it
35/// owns the lesson's; the cli only chooses human-vs-json and the output stream.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(into = "RunDocument", from = "RunDocument")]
38pub struct RunReport {
39 verdict: Verdict,
40 checks: Vec<CheckOutcome>,
41 /// The submission's captured standard output, from running it on its own (see
42 /// [`run_lesson`]).
43 output: String,
44}
45
46impl RunReport {
47 /// The graded verdict — what the renderer reads to report correct/incorrect
48 /// and the feedback message. The exit-code mapping reads it too, so "what
49 /// happened" stays one typed value rather than a stringly-typed field.
50 pub fn verdict(&self) -> &Verdict {
51 &self.verdict
52 }
53}
54
55/// The stable JSON shape of a [`RunReport`]: a flat document with a string
56/// `verdict` discriminant, the `feedback` message split out of the verdict, the
57/// `checks` array, and the captured `output`.
58///
59/// Kept separate from the domain type (the [`Verdict`] sum type) so the wire
60/// shape — what `--format json` and its consumers depend on — is decoupled from
61/// the internal representation. The conversions are total and inverse, so a
62/// [`RunReport`] round-trips through this document losslessly.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64struct RunDocument {
65 verdict: VerdictTag,
66 feedback: String,
67 checks: Vec<CheckOutcome>,
68 output: String,
69}
70
71/// The verdict discriminant as it appears in JSON: the variant tag without the
72/// message (which travels in `feedback`). Lowercase on the wire.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "lowercase")]
75enum VerdictTag {
76 Correct,
77 Incorrect,
78}
79
80impl From<RunReport> for RunDocument {
81 fn from(report: RunReport) -> Self {
82 let RunReport {
83 verdict,
84 checks,
85 output,
86 } = report;
87 let (verdict, feedback) = match verdict {
88 Verdict::Correct { message } => (VerdictTag::Correct, message),
89 Verdict::Incorrect { message } => (VerdictTag::Incorrect, message),
90 };
91 Self {
92 verdict,
93 feedback,
94 checks,
95 output,
96 }
97 }
98}
99
100impl From<RunDocument> for RunReport {
101 fn from(doc: RunDocument) -> Self {
102 let RunDocument {
103 verdict,
104 feedback,
105 checks,
106 output,
107 } = doc;
108 let verdict = match verdict {
109 VerdictTag::Correct => Verdict::Correct { message: feedback },
110 VerdictTag::Incorrect => Verdict::Incorrect { message: feedback },
111 };
112 Self {
113 verdict,
114 checks,
115 output,
116 }
117 }
118}
119
120/// Why a run could not produce a report.
121///
122/// A typed error (§1.2) keeping the two failure stages distinct: the submission
123/// could not be executed at all, or the feedback request failed. `core` stays
124/// `anyhow`-free (ADR-0001); the cli maps this to its exit code at the edge.
125#[derive(Debug)]
126pub enum RunError {
127 /// The interpreter could not run the submission to capture its output (a
128 /// spawn or IO failure — not a program that ran and exited non-zero).
129 Run(crate::runner::RunnerError),
130 /// The LLM feedback request failed — see [`FeedbackError`] for the kind.
131 Feedback(FeedbackError),
132}
133
134impl fmt::Display for RunError {
135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 match self {
137 RunError::Run(e) => write!(f, "could not run the submission: {e}"),
138 RunError::Feedback(e) => write!(f, "{e}"),
139 }
140 }
141}
142
143impl Error for RunError {
144 fn source(&self) -> Option<&(dyn Error + 'static)> {
145 match self {
146 RunError::Run(e) => Some(e),
147 RunError::Feedback(e) => Some(e),
148 }
149 }
150}
151
152/// Run `submission` against `lesson` and produce a [`RunReport`].
153///
154/// The orchestrator (§2.4): it selects the lesson's runner, executes the
155/// submission **on its own** to capture the output the report carries and the
156/// prompt shows, grades the lesson's checks, builds the feedback prompt, and asks
157/// `provider` for a verdict — composing each layer through its public type and
158/// adding no domain logic of its own. `base_url_override` points the provider at
159/// a stub in tests (the [`request_feedback`] seam) and is `None` in production.
160/// Short and linear: the only branch is error propagation.
161///
162/// The output-capturing run is separate from grading: when the lesson has checks,
163/// [`run_checks`] runs the submission again as its own gate (see
164/// [`grade`](crate::grade)), so a submission with side-effecting or
165/// nondeterministic output could in principle diverge between the captured output
166/// and what the checks graded against. This is benign under v0's trusted-local,
167/// deterministic-submission model, and the common LLM-only lesson has no checks —
168/// so the submission runs exactly once. Folding the gate's output back into
169/// grading (to run once even with checks) is a `grade`-layer change left to a
170/// later slice.
171pub async fn run_lesson(
172 lesson: &Lesson,
173 submission: &Submission,
174 provider: ProviderChoice,
175 base_url_override: Option<&str>,
176) -> Result<RunReport, RunError> {
177 let runner = select_runner(&lesson.language, &lesson.packages);
178 // Run the submission on its own to capture what the learner's code produced;
179 // that output feeds both the feedback prompt and the report. A launch failure
180 // is a `RunError::Run`, never a verdict.
181 let output = runner
182 .execute(&submission.code, &[])
183 .await
184 .map_err(RunError::Run)?;
185 let outcomes = run_checks(runner, &submission.code, &lesson.checks).await;
186
187 let results = ExecResults { output, outcomes };
188 let prompt = build_prompt(lesson, submission, &results);
189 let verdict = request_feedback(provider, &prompt, base_url_override)
190 .await
191 .map_err(RunError::Feedback)?;
192
193 let ExecResults { output, outcomes } = results;
194 Ok(RunReport {
195 verdict,
196 checks: outcomes,
197 output: output.stdout,
198 })
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 fn report(verdict: Verdict, checks: Vec<CheckOutcome>, output: &str) -> RunReport {
206 RunReport {
207 verdict,
208 checks,
209 output: output.to_string(),
210 }
211 }
212
213 #[test]
214 fn run_report_json_roundtrip_preserves_value() {
215 // The report survives a JSON round-trip unchanged — the flat wire document
216 // and the typed domain value are inverse, so neither the verdict/feedback
217 // split nor the checks array loses information (pattern from
218 // `lesson::lesson_json_roundtrip_preserves_value`).
219 let original = report(
220 Verdict::Incorrect {
221 message: "Not quite — check the return value.".to_string(),
222 },
223 vec![
224 CheckOutcome::Pass,
225 CheckOutcome::Fail {
226 detail: "expected 5".to_string(),
227 },
228 ],
229 "8 \n",
230 );
231 let json = serde_json::to_string(&original).expect("a report serializes to JSON");
232 let roundtripped: RunReport =
233 serde_json::from_str(&json).expect("a report deserializes from JSON");
234 assert_eq!(roundtripped, original);
235 }
236
237 #[test]
238 fn json_splits_the_verdict_into_a_string_tag_and_feedback() {
239 // The wire shape the `--format json` consumers depend on: a string
240 // `verdict` discriminant (not the nested enum), the message in `feedback`,
241 // and `checks` as an array — pinned independently of the round-trip.
242 let json = serde_json::to_string(&report(
243 Verdict::Correct {
244 message: "well done".to_string(),
245 },
246 vec![],
247 "",
248 ))
249 .expect("a report serializes to JSON");
250 let value: serde_json::Value = serde_json::from_str(&json).expect("the json parses");
251
252 assert_eq!(value["verdict"], "correct", "verdict is a string tag");
253 assert_eq!(value["feedback"], "well done", "feedback is the message");
254 assert!(
255 value["checks"].is_array(),
256 "checks is an array, got {value}"
257 );
258 assert!(
259 value["checks"].as_array().expect("array").is_empty(),
260 "no checks serializes to [], not a stringified empty list"
261 );
262 assert!(
263 value["output"].is_string(),
264 "output is a string, got {value}"
265 );
266 }
267
268 #[test]
269 fn json_tags_an_incorrect_verdict_distinctly() {
270 // The twin of the correct tag: an incorrect verdict serializes to the
271 // `"incorrect"` discriminant, so the two are never conflated on the wire.
272 let json = serde_json::to_string(&report(
273 Verdict::Incorrect {
274 message: "try again".to_string(),
275 },
276 vec![],
277 "",
278 ))
279 .expect("a report serializes to JSON");
280 let value: serde_json::Value = serde_json::from_str(&json).expect("the json parses");
281 assert_eq!(value["verdict"], "incorrect");
282 }
283
284 #[test]
285 fn run_error_labels_each_stage_and_exposes_its_source() {
286 use crate::runner::RunnerError;
287
288 // The Run arm labels itself as a run failure and keeps the underlying
289 // RunnerError reachable as its source — so the cli can report what failed.
290 let run = RunError::Run(RunnerError::new(
291 "spawn Rscript",
292 std::io::Error::new(std::io::ErrorKind::NotFound, "boom"),
293 ));
294 assert!(
295 run.to_string().contains("could not run the submission"),
296 "Run labels itself a run failure, got: {run}"
297 );
298 assert!(
299 Error::source(&run).is_some(),
300 "Run exposes the RunnerError as its source"
301 );
302
303 // The Feedback arm defers to the FeedbackError's own message (no prefix)
304 // and exposes it as the source.
305 let feedback = RunError::Feedback(FeedbackError::Client("bad client".to_string()));
306 assert!(
307 feedback.to_string().contains("bad client"),
308 "Feedback surfaces the inner message, got: {feedback}"
309 );
310 assert!(
311 Error::source(&feedback).is_some(),
312 "Feedback exposes the FeedbackError as its source"
313 );
314 }
315}