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}