blendtutor_core/
grade.rs

1//! The lesson↔runner grading join: pick a language's runner and turn each lesson
2//! check code-string into a typed pass/fail outcome.
3//!
4//! This is where [`lesson`](crate::lesson) meets [`runner`](crate::runner). It
5//! depends on both only through their public types — a [`Language`] to choose a
6//! runner, the [`Runner`] trait to execute, and a lesson's check code-strings to
7//! grade against (§3.1). Selecting the runner is pure; running checks is
8//! effectful (§2.1, §2.2). This module classifies outcomes; it does NOT build
9//! prompts or call LLMs — that is the provider layer's job (§4.1).
10
11use serde::{Deserialize, Serialize};
12
13use crate::lesson::Language;
14use crate::runner::{ExecutionResult, PythonRunner, RRunner, Runner, RunnerError, Timeout};
15
16/// The verdict for a single lesson check.
17///
18/// A sum type, not a `bool`, so per-check verdicts stay distinct and the grader
19/// never folds them into one aggregate (§1.2). Three real states: the check
20/// passed, the submission ran and violated it, and — kept deliberately separate
21/// from a failure (§3.3) — the submission could not run at all, so no check ever
22/// got a verdict.
23///
24/// Serializable so the `run` command's [`RunReport`](crate::run::RunReport) can
25/// carry the per-check outcomes into its JSON form; the externally-tagged shape
26/// round-trips each variant losslessly.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub enum CheckOutcome {
29    /// The submission satisfied the check.
30    Pass,
31    /// The check ran and the submission violated it.
32    Fail {
33        /// What the interpreter wrote when the check failed — typically the
34        /// failing assertion on stderr.
35        detail: String,
36    },
37    /// The submission could not be executed at all (a syntax error, or the
38    /// interpreter failed to launch), so this check never ran. Distinct from
39    /// [`Fail`](CheckOutcome::Fail): the submission was never even evaluated.
40    NotRun {
41        /// Why the submission could not run — the interpreter's diagnostic, or
42        /// the launch error.
43        reason: String,
44    },
45}
46
47/// A runner chosen for a lesson's language.
48///
49/// A closed enum holding one concrete runner. [`Runner::execute`] returns
50/// `impl Future` (an RPITIT), so the trait is not object-safe and a
51/// runtime-chosen runner cannot be a `Box<dyn Runner>`; an enum is how we carry
52/// that choice instead. Consumers still depend on the [`Runner`] trait, not on
53/// these concrete variants — `RunnerKind` itself impls it, so [`run_checks`]
54/// takes `impl Runner` and never names `RRunner`/`PythonRunner` (§3.4). The
55/// `match` over it is exhaustive: adding a [`Language`] variant forces a new arm
56/// both here and in [`select_runner`], so a language can never be silently
57/// dropped.
58#[derive(Debug, Clone)]
59pub enum RunnerKind {
60    /// The R runner.
61    R(RRunner),
62    /// The Python runner.
63    Python(PythonRunner),
64}
65
66impl Runner for RunnerKind {
67    async fn execute(&self, code: &str, checks: &[String]) -> Result<ExecutionResult, RunnerError> {
68        match self {
69            RunnerKind::R(r) => r.execute(code, checks).await,
70            RunnerKind::Python(p) => p.execute(code, checks).await,
71        }
72    }
73}
74
75/// Select the runner for a lesson's `language`, threading `packages` to the
76/// Python runner (ADR-0011).
77///
78/// Pure: it maps a [`Language`] to a runner with no I/O (§2.1). The `match` is
79/// exhaustive with no wildcard arm, so a new [`Language`] variant fails to
80/// compile here until it is dispatched explicitly — a language can never
81/// silently fall through to the wrong runner. Packages are constructor state
82/// on [`PythonRunner`], not a per-call parameter on the [`Runner`] trait (§3.4);
83/// R ignores them.
84pub fn select_runner(language: &Language, packages: &[String]) -> RunnerKind {
85    match language {
86        Language::R => RunnerKind::R(RRunner::default()),
87        Language::Python => RunnerKind::Python(PythonRunner::new(
88            Timeout(std::time::Duration::from_secs(30)),
89            packages.to_vec(),
90        )),
91    }
92}
93
94/// Run each `check` against `submission` through `runner`, returning one
95/// [`CheckOutcome`] per check, in order.
96///
97/// Effectful (§2.2). The submission is first run **on its own**: if it cannot
98/// execute — a syntax error, or a launch failure — every check is
99/// [`NotRun`](CheckOutcome::NotRun), because no check could have a verdict
100/// (§3.3). This standalone gate is load-bearing: concatenating the submission
101/// with a check and reading the combined exit code cannot tell a broken
102/// submission from a violated check (and some interpreters, R among them, would
103/// even splice an unterminated submission into the check). Once the submission
104/// runs cleanly, each check is the submission followed by the check code-string;
105/// the check passes when that program exits cleanly and fails otherwise.
106/// Outcomes are element-wise — never folded into a single aggregate verdict.
107///
108/// With no checks there is nothing to classify, so the submission is not run at
109/// all — an LLM-only lesson (the common case) never pays for a subprocess here.
110pub async fn run_checks(
111    runner: impl Runner,
112    submission: &str,
113    checks: &[String],
114) -> Vec<CheckOutcome> {
115    // Guard before the gate's subprocess (§1.3.1): with nothing to classify there
116    // is no reason to run the submission at all.
117    if checks.is_empty() {
118        return Vec::new();
119    }
120
121    if let Some(reason) = submission_run_failure(&runner, submission).await {
122        return checks
123            .iter()
124            .map(|_| CheckOutcome::NotRun {
125                reason: reason.clone(),
126            })
127            .collect();
128    }
129
130    let mut outcomes = Vec::with_capacity(checks.len());
131    for check in checks {
132        let program = format!("{submission}\n{check}");
133        let outcome = match runner.execute(&program, &[]).await {
134            Ok(result) if result.exit == Some(0) => CheckOutcome::Pass,
135            Ok(result) => CheckOutcome::Fail {
136                detail: result.stderr,
137            },
138            Err(error) => CheckOutcome::Fail {
139                detail: error.to_string(),
140            },
141        };
142        outcomes.push(outcome);
143    }
144    outcomes
145}
146
147/// Run `submission` on its own and report why it could not run, or `None` if it
148/// ran cleanly.
149///
150/// The gate that makes "errored before checks" distinct from a check verdict
151/// (§3.3, §5.1): a non-zero exit means the submission ran and failed (a syntax
152/// error surfaces here), and an [`Err`] means the interpreter never launched —
153/// both are reasons no check can run. A clean exit is `None`, so the caller
154/// proceeds to the checks.
155async fn submission_run_failure(runner: &impl Runner, submission: &str) -> Option<String> {
156    match runner.execute(submission, &[]).await {
157        Ok(result) if result.exit == Some(0) => None,
158        Ok(result) => Some(result.stderr),
159        Err(error) => Some(error.to_string()),
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    use std::sync::atomic::{AtomicUsize, Ordering};
168
169    /// One scripted interpreter outcome for [`ScriptedRunner`] to replay.
170    enum Reply {
171        /// The program ran and exited with `code`, writing `stderr`.
172        Exited { code: i32, stderr: String },
173        /// The interpreter could not be launched at all.
174        FailedToSpawn,
175    }
176
177    /// A [`Runner`] that replays a fixed script of replies in call order instead
178    /// of spawning an interpreter, so `run_checks`'s classification (exit code →
179    /// outcome, plus the spawn-failure arm a real interpreter never reaches) *and*
180    /// its element-wise ordering are pinned deterministically with no
181    /// `Rscript`/`uv` on `PATH`. It impls the real [`Runner`] trait (so the
182    /// signature can't drift) and returns the real
183    /// [`ExecutionResult`]/[`RunnerError`] through their own constructors,
184    /// honouring the seam contract: a program that *ran* (any exit) is `Ok`; a
185    /// failure to launch is `Err`. The call cursor is an [`AtomicUsize`] — not a
186    /// `Cell` — so `&self` stays `Sync` and the `execute` future stays `Send`.
187    struct ScriptedRunner {
188        script: Vec<Reply>,
189        next: AtomicUsize,
190    }
191
192    impl ScriptedRunner {
193        fn new(script: Vec<Reply>) -> Self {
194            Self {
195                script,
196                next: AtomicUsize::new(0),
197            }
198        }
199    }
200
201    impl Runner for ScriptedRunner {
202        async fn execute(
203            &self,
204            _code: &str,
205            _checks: &[String],
206        ) -> Result<ExecutionResult, RunnerError> {
207            let turn = self.next.fetch_add(1, Ordering::SeqCst);
208            match &self.script[turn] {
209                Reply::Exited { code, stderr } => Ok(ExecutionResult::from_capture(
210                    b"",
211                    stderr.as_bytes(),
212                    Some(*code),
213                    false,
214                )),
215                Reply::FailedToSpawn => Err(RunnerError::new(
216                    "spawn fake",
217                    std::io::Error::new(std::io::ErrorKind::NotFound, "no such interpreter"),
218                )),
219            }
220        }
221    }
222
223    fn check_strings(n: usize) -> Vec<String> {
224        (0..n).map(|i| format!("check_{i}")).collect()
225    }
226
227    #[test]
228    fn select_runner_dispatches_an_r_lesson_to_the_r_runner() {
229        assert!(
230            matches!(select_runner(&Language::R, &[]), RunnerKind::R(_)),
231            "an R lesson must select the R runner"
232        );
233    }
234
235    #[test]
236    fn select_runner_dispatches_a_python_lesson_to_the_python_runner() {
237        assert!(
238            matches!(select_runner(&Language::Python, &[]), RunnerKind::Python(_)),
239            "a Python lesson must select the Python runner"
240        );
241    }
242
243    #[test]
244    fn select_runner_threads_packages_to_python_runner() {
245        // ADR-0011: packages must reach PythonRunner as constructor state. A
246        // regression that drops the `packages` parameter from select_runner
247        // (the "select_runner bottleneck" negative) would leave PythonRunner
248        // with empty packages — caught here by asserting the runner carries
249        // them via the `packages()` getter, not just that the variant matches.
250        let packages = vec!["pandas".to_string(), "numpy".to_string()];
251        match select_runner(&Language::Python, &packages) {
252            RunnerKind::Python(r) => assert_eq!(
253                r.packages(),
254                &["pandas", "numpy"][..],
255                "packages must reach PythonRunner as constructor state"
256            ),
257            other => panic!("a Python lesson must select the Python runner, got {other:?}"),
258        }
259    }
260
261    /// A reply for a submission that ran cleanly on its own — the gate's
262    /// pass-through, so a test's script can focus on the checks that follow it.
263    fn clean() -> Reply {
264        Reply::Exited {
265            code: 0,
266            stderr: String::new(),
267        }
268    }
269
270    #[tokio::test]
271    async fn no_checks_runs_nothing_and_returns_empty() {
272        // The script is empty, so any execute call would panic indexing it. This
273        // passes only if run_checks short-circuits before touching the runner —
274        // the LLM-only path must not spawn the submission.
275        let runner = ScriptedRunner::new(vec![]);
276        let outcomes = run_checks(runner, "submission", &check_strings(0)).await;
277        assert!(
278            outcomes.is_empty(),
279            "no checks yields no outcomes, got {outcomes:?}"
280        );
281    }
282
283    #[tokio::test]
284    async fn checks_are_classified_element_wise_in_order() {
285        // The submission passes the gate, then the first check exits clean → Pass
286        // and the second exits non-zero → Fail carrying its stderr. Replaying
287        // distinct replies in call order means a grader that reversed, folded, or
288        // mis-paired the results cannot reproduce this exact ordered vector —
289        // pinning the per-exit-code classification and the element-wise order off
290        // any live interpreter.
291        let runner = ScriptedRunner::new(vec![
292            clean(),
293            Reply::Exited {
294                code: 0,
295                stderr: String::new(),
296            },
297            Reply::Exited {
298                code: 1,
299                stderr: "assertion failed".to_string(),
300            },
301        ]);
302        let outcomes = run_checks(runner, "submission", &check_strings(2)).await;
303        assert_eq!(
304            outcomes,
305            vec![
306                CheckOutcome::Pass,
307                CheckOutcome::Fail {
308                    detail: "assertion failed".to_string()
309                },
310            ],
311            "a clean exit then a non-zero exit must be [Pass, Fail{{stderr}}] in order"
312        );
313    }
314
315    #[tokio::test]
316    async fn a_submission_that_exits_nonzero_makes_every_check_notrun() {
317        // The submission runs but exits non-zero on its own (e.g. a syntax error):
318        // the gate fails, so every check is NotRun carrying the submission's
319        // stderr — never Fail — and no check is executed at all.
320        let runner = ScriptedRunner::new(vec![Reply::Exited {
321            code: 1,
322            stderr: "could not parse submission".to_string(),
323        }]);
324        let outcomes = run_checks(runner, "broken submission", &check_strings(2)).await;
325        assert_eq!(
326            outcomes,
327            vec![
328                CheckOutcome::NotRun {
329                    reason: "could not parse submission".to_string()
330                },
331                CheckOutcome::NotRun {
332                    reason: "could not parse submission".to_string()
333                },
334            ],
335            "a submission that cannot run makes every check NotRun, not Fail"
336        );
337    }
338
339    #[tokio::test]
340    async fn a_submission_whose_interpreter_cannot_launch_makes_checks_notrun() {
341        // The gate's other failure arm: the interpreter never launched for the
342        // submission, so the checks are NotRun, distinct from a check failure.
343        let runner = ScriptedRunner::new(vec![Reply::FailedToSpawn]);
344        let outcomes = run_checks(runner, "submission", &check_strings(1)).await;
345        assert!(
346            matches!(outcomes.as_slice(), [CheckOutcome::NotRun { .. }]),
347            "a submission launch failure is NotRun, got {outcomes:?}"
348        );
349    }
350
351    #[tokio::test]
352    async fn a_check_whose_interpreter_cannot_launch_is_a_fail() {
353        // After the submission passes the gate, a per-check launch failure is a
354        // Fail — the check could not produce a verdict — NOT a NotRun, which is
355        // reserved for the submission itself failing to run.
356        let runner = ScriptedRunner::new(vec![clean(), Reply::FailedToSpawn]);
357        let outcomes = run_checks(runner, "submission", &check_strings(1)).await;
358        assert!(
359            matches!(outcomes.as_slice(), [CheckOutcome::Fail { .. }]),
360            "a per-check launch failure after a clean submission is a Fail, got {outcomes:?}"
361        );
362    }
363}