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}