Skip to main content

speccoder_core/
retry.rs

1//! Retries: the verifier's failure
2//! digest for the next attempt, and the fingerprint that stops retries
3//! which cannot help.
4//!
5//! Everything here comes from verifier output — never from the model's
6//! transcript or its own account of what went wrong.
7
8use serde::{Deserialize, Serialize};
9
10use crate::verify::{CheckStatus, VerifyReport};
11
12/// Lines of output kept per failing check.
13const EXCERPT_LINES: usize = 40;
14/// Bytes of output kept per failing check.
15const EXCERPT_BYTES: usize = 4000;
16/// Words that mark the first error line of a check's output.
17const MARKERS: [&str; 5] = ["error", "panicked", "assert", "failed", "fail"];
18
19/// One check that did not pass.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
21pub struct FailedCheck {
22    /// Name from `speccoder.toml` (or `acceptance`).
23    pub name: String,
24    /// The command, as run.
25    pub argv: Vec<String>,
26    /// What happened.
27    pub status: CheckStatus,
28    /// Failing test IDs, when the check reports tests.
29    pub failing_tests: Vec<String>,
30    /// Output from the first error line on, capped.
31    pub excerpt: String,
32    /// What the sandbox denied it.
33    #[serde(default, skip_serializing_if = "crate::verify::Denials::is_empty")]
34    pub sandbox: crate::verify::Denials,
35}
36
37/// What the next attempt is told about the previous one.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
39pub struct Digest {
40    /// The attempt this digest describes (1-based).
41    pub attempt: u32,
42    /// The verdict's reasons.
43    pub reasons: Vec<String>,
44    /// Checks that did not pass.
45    pub checks: Vec<FailedCheck>,
46    /// Paths the previous attempt changed; its changes were discarded.
47    pub discarded: Vec<String>,
48    /// Test files it wrote that never ran: one line each — the path, the
49    /// runner's rule and the ways out.
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub uncollected: Vec<String>,
52}
53
54/// The retry reason for failed held-out tests: the scenario IDs and the
55/// spec's own words — which the implementer has already seen — and nothing
56/// from the held-out run itself (no path, test name, literal or output).
57#[must_use]
58pub fn held_out_reason(failed: &[crate::spec::ScenarioRef]) -> String {
59    let mut failed: Vec<&crate::spec::ScenarioRef> = failed.iter().collect();
60    failed.sort_by(|a, b| a.id.cmp(&b.id));
61    failed.dedup_by(|a, b| a.id == b.id);
62    let parts: Vec<String> = failed
63        .iter()
64        .map(|s| {
65            format!(
66                "{} (\"{}\")",
67                s.id,
68                s.text.split_whitespace().collect::<Vec<_>>().join(" ")
69            )
70        })
71        .collect();
72    format!("held-out acceptance failed: {}", parts.join(", "))
73}
74
75/// Why a failed attempt must not be retried, if it must not: a check that
76/// could not run at all is an infrastructure problem, not the model's.
77#[must_use]
78pub fn infrastructure(report: &VerifyReport) -> Option<String> {
79    report
80        .checks
81        .iter()
82        .find(|c| c.status == CheckStatus::Error)
83        .map(|c| {
84            format!(
85                "check {} could not run{}",
86                c.name,
87                c.note
88                    .as_deref()
89                    .map(|n| format!(": {n}"))
90                    .unwrap_or_default()
91            )
92        })
93}
94
95/// The digest of a failed attempt.
96#[must_use]
97pub fn digest(
98    attempt: u32,
99    reasons: &[String],
100    report: &VerifyReport,
101    discarded: &[String],
102) -> Digest {
103    let checks = report
104        .checks
105        .iter()
106        .filter(|c| !matches!(c.status, CheckStatus::Passed | CheckStatus::Skipped))
107        .map(|c| FailedCheck {
108            name: c.name.clone(),
109            argv: c.argv.clone(),
110            status: c.status,
111            failing_tests: c
112                .tests
113                .iter()
114                .flatten()
115                .filter(|t| t.outcome.is_failing())
116                .map(|t| t.id.clone())
117                .collect(),
118            excerpt: excerpt(&output(&c.stderr_tail, &c.stdout_tail)),
119            sandbox: c.sandbox.clone(),
120        })
121        .collect();
122    Digest {
123        attempt,
124        reasons: reasons.to_vec(),
125        checks,
126        discarded: discarded.to_vec(),
127        uncollected: Vec::new(),
128    }
129}
130
131/// What a failed check says first, for a person: its failing tests by
132/// name, else the first error line of its output (rules of `=` or `_`
133/// skipped), cut at 160 characters. `None`: it said nothing.
134#[must_use]
135pub fn first_cause(c: &crate::verify::CheckResult) -> Option<String> {
136    let failing: Vec<&str> = c
137        .tests
138        .iter()
139        .flatten()
140        .filter(|t| t.outcome.is_failing())
141        .map(|t| t.id.as_str())
142        .collect();
143    let said = match failing.as_slice() {
144        [] => {
145            let text = output(&c.stderr_tail, &c.stdout_tail);
146            let lines = || {
147                text.lines()
148                    .map(str::trim)
149                    .filter(|l| !l.is_empty() && !l.starts_with("===") && !l.starts_with("___"))
150            };
151            lines()
152                .find(|l| is_error_line(l))
153                .or_else(|| lines().next())?
154                .to_owned()
155        }
156        [one] => format!("{one} fails"),
157        [first, rest @ ..] => format!("{first} and {} more fail", rest.len()),
158    };
159    let cut: String = said.chars().take(160).collect();
160    Some(if cut.len() < said.len() {
161        format!("{cut}…")
162    } else {
163        cut
164    })
165}
166
167/// The failure's identity: failing checks by name, each with its first
168/// error line, numbers normalised. Equal fingerprints mean the same failure.
169#[must_use]
170pub fn fingerprint(digest: &Digest) -> String {
171    let mut parts: Vec<String> = digest
172        .checks
173        .iter()
174        .map(|c| format!("{}: {}", c.name, normalise(first_error(&c.excerpt))))
175        .collect();
176    parts.sort();
177    let joined = if parts.is_empty() {
178        let mut reasons: Vec<String> = digest.reasons.iter().map(|r| normalise(r)).collect();
179        reasons.sort();
180        reasons.join(" | ")
181    } else {
182        parts.join(" | ")
183    };
184    // The same file never running twice is the same failure.
185    let mut paths: Vec<&str> = digest
186        .uncollected
187        .iter()
188        .filter_map(|l| l.split('`').nth(1))
189        .collect();
190    paths.sort_unstable();
191    paths.dedup();
192    if paths.is_empty() {
193        joined
194    } else {
195        format!("{joined} | uncollected: {}", paths.join(", "))
196    }
197}
198
199fn output(stderr: &str, stdout: &str) -> String {
200    match (stderr.trim().is_empty(), stdout.trim().is_empty()) {
201        (false, false) => format!("{stderr}\n{stdout}"),
202        (false, true) => stderr.to_owned(),
203        _ => stdout.to_owned(),
204    }
205}
206
207fn is_error_line(line: &str) -> bool {
208    let lower = line.to_lowercase();
209    MARKERS.iter().any(|m| lower.contains(m))
210}
211
212fn first_error(text: &str) -> &str {
213    text.lines()
214        .find(|l| is_error_line(l))
215        .or_else(|| text.lines().find(|l| !l.trim().is_empty()))
216        .unwrap_or_default()
217}
218
219/// From the first error line on (or the last lines, if there is none),
220/// at most [`EXCERPT_LINES`] lines and [`EXCERPT_BYTES`] bytes.
221fn excerpt(text: &str) -> String {
222    let lines: Vec<&str> = text.lines().collect();
223    let start = lines
224        .iter()
225        .position(|l| is_error_line(l))
226        .unwrap_or_else(|| lines.len().saturating_sub(EXCERPT_LINES));
227    let mut out = lines
228        .iter()
229        .skip(start)
230        .take(EXCERPT_LINES)
231        .copied()
232        .collect::<Vec<_>>()
233        .join("\n");
234    if out.len() > EXCERPT_BYTES {
235        let mut end = EXCERPT_BYTES;
236        while !out.is_char_boundary(end) {
237            end -= 1;
238        }
239        out.truncate(end);
240        out.push_str("\n…");
241    }
242    out
243}
244
245/// Digits become `#`, whitespace runs one space; at most 200 characters.
246fn normalise(line: &str) -> String {
247    let mapped: String = line
248        .chars()
249        .map(|c| if c.is_ascii_digit() { '#' } else { c })
250        .collect();
251    mapped
252        .split_whitespace()
253        .collect::<Vec<_>>()
254        .join(" ")
255        .chars()
256        .take(200)
257        .collect()
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn a_held_out_failure_names_the_requirement_only() {
266        use crate::spec::ScenarioRef;
267        let failed = [
268            ScenarioRef {
269                id: "US1-2".to_owned(),
270                text: "**Given** a name,\n **When** greeted".to_owned(),
271            },
272            ScenarioRef {
273                id: "US1-1".to_owned(),
274                text: "x".to_owned(),
275            },
276        ];
277        let reason = held_out_reason(&failed);
278        assert_eq!(
279            reason,
280            "held-out acceptance failed: US1-1 (\"x\"), US1-2 (\"**Given** a name, **When** greeted\")"
281        );
282        let report = VerifyReport {
283            root: String::new(),
284            tier: "fast".to_owned(),
285            checks: Vec::new(),
286            passed: true,
287            comparison: None,
288        };
289        let d = digest(2, std::slice::from_ref(&reason), &report, &[]);
290        assert!(d.checks.is_empty(), "no check entry for the held-out run");
291        assert_eq!(fingerprint(&d), normalise(&reason));
292    }
293    use crate::verify::{CheckResult, TestOutcome, TestResult};
294
295    fn check(name: &str, status: CheckStatus, stderr: &str) -> CheckResult {
296        CheckResult {
297            name: name.to_owned(),
298            argv: vec!["cargo".to_owned(), "test".to_owned()],
299            status,
300            exit_code: Some(101),
301            duration_ms: 10,
302            junit: false,
303            tests: None,
304            stdout_tail: String::new(),
305            stderr_tail: stderr.to_owned(),
306            truncated: false,
307            note: None,
308            flaky: false,
309            sandbox: Default::default(),
310        }
311    }
312
313    /// The next attempt learns what the sandbox refused; a result without
314    /// denials serialises as before, and an older report still reads.
315    #[test]
316    fn denials_reach_the_digest_and_old_reports_still_read() {
317        let plain = check("test", CheckStatus::Failed, "error: x");
318        let json = serde_json::to_value(&plain).unwrap();
319        assert!(json.get("sandbox").is_none(), "{json}");
320        let back: CheckResult = serde_json::from_value(json).unwrap();
321        assert_eq!(back, plain);
322        let mut denied = plain.clone();
323        denied.sandbox = crate::verify::Denials {
324            denied: vec!["git: file-write-data /w/.git/config — read-only".into()],
325            more: 2,
326            note: None,
327        };
328        let d = digest(1, &[], &report(vec![denied.clone()]), &[]);
329        assert_eq!(d.checks[0].sandbox, denied.sandbox);
330        let json = serde_json::to_value(&d.checks[0]).unwrap();
331        assert_eq!(json["sandbox"]["more"], 2);
332        let plain_digest = digest(1, &[], &report(vec![plain]), &[]);
333        assert!(
334            serde_json::to_value(&plain_digest.checks[0])
335                .unwrap()
336                .get("sandbox")
337                .is_none()
338        );
339    }
340
341    fn report(checks: Vec<CheckResult>) -> VerifyReport {
342        VerifyReport {
343            root: ".".to_owned(),
344            tier: "fast".to_owned(),
345            checks,
346            comparison: None,
347            passed: false,
348        }
349    }
350
351    #[test]
352    fn the_digest_keeps_failing_checks_from_the_first_error() {
353        let mut failing = check(
354            "test",
355            CheckStatus::Failed,
356            "   Compiling x v0.1.0\nerror[E0425]: cannot find function `add`\n --> src/lib.rs:3:5\n",
357        );
358        failing.tests = Some(vec![
359            TestResult {
360                id: "adds".to_owned(),
361                outcome: TestOutcome::Failed,
362            },
363            TestResult {
364                id: "ok".to_owned(),
365                outcome: TestOutcome::Passed,
366            },
367        ]);
368        let r = report(vec![check("fmt", CheckStatus::Passed, ""), failing]);
369        let d = digest(
370            1,
371            &["check test failed".to_owned()],
372            &r,
373            &["src/lib.rs".to_owned()],
374        );
375        assert_eq!(d.checks.len(), 1);
376        assert_eq!(d.checks[0].failing_tests, ["adds"]);
377        assert!(
378            d.checks[0].excerpt.starts_with("error[E0425]"),
379            "{}",
380            d.checks[0].excerpt
381        );
382        assert!(!d.checks[0].excerpt.contains("Compiling"));
383        assert_eq!(d.discarded, ["src/lib.rs"]);
384    }
385
386    #[test]
387    fn fingerprints_ignore_numbers_and_order() {
388        let a = report(vec![
389            check("b", CheckStatus::Failed, "error at line 12"),
390            check("a", CheckStatus::Failed, "assertion failed: 4 == 5"),
391        ]);
392        let b = report(vec![
393            check("a", CheckStatus::Failed, "assertion failed: 3 == 5"),
394            check("b", CheckStatus::Failed, "error at line 40"),
395        ]);
396        let c = report(vec![check(
397            "a",
398            CheckStatus::Failed,
399            "error: something else",
400        )]);
401        let fp = |r: &VerifyReport| fingerprint(&digest(1, &[], r, &[]));
402        assert_eq!(fp(&a), fp(&b));
403        assert_ne!(fp(&a), fp(&c));
404        // Without failing checks (e.g. a regression), the reasons decide.
405        let none = report(vec![]);
406        let x = fingerprint(&digest(1, &["regressed: t1".to_owned()], &none, &[]));
407        assert_eq!(x, "regressed: t#");
408    }
409
410    #[test]
411    fn a_check_that_could_not_run_is_infrastructure() {
412        let mut broken = check("lint", CheckStatus::Error, "");
413        broken.note = Some("command not found".to_owned());
414        assert_eq!(
415            infrastructure(&report(vec![broken])).as_deref(),
416            Some("check lint could not run: command not found")
417        );
418        assert!(infrastructure(&report(vec![check("t", CheckStatus::Failed, "x")])).is_none());
419    }
420
421    #[test]
422    fn long_output_is_capped_on_a_char_boundary() {
423        let long = format!("error: {}", "é".repeat(5000));
424        let e = excerpt(&long);
425        assert!(e.len() <= EXCERPT_BYTES + "\n…".len());
426        assert!(e.ends_with('…'));
427    }
428}