Skip to main content

speccoder_runtime/pipeline/
judge.rs

1//! The judge's steps within one attempt: which writes a person approved,
2//! what the integrity audit finds, the verdict from the checks, and whether
3//! a failed attempt is worth retrying.
4
5use std::collections::BTreeSet;
6use std::path::Path;
7
8use serde_json::Value;
9use speccoder_core::RunReport;
10use speccoder_core::verify::{CheckStatus, Verdict, VerifyReport, compare_except, passes, verdict};
11
12use crate::tools::policy::{Object, Policy};
13use crate::{integrity, sandbox, verify, worktree};
14
15use super::{PreparedAcceptance, run_acceptance};
16
17/// Paths a person granted writing, for the task or inline in this attempt.
18/// A frozen test so approved is not a violation; the audit still flags
19/// removed tests and skip markers in it — approval permits editing a test,
20/// not weakening it.
21pub(crate) fn approved_writes(granted: &[Object], tool_calls: &Value) -> BTreeSet<String> {
22    granted
23        .iter()
24        .filter_map(|g| g.path().map(str::to_owned))
25        .chain(
26            tool_calls
27                .as_array()
28                .into_iter()
29                .flatten()
30                .filter(|c| c["approved_by"] == "person")
31                .filter_map(|c| {
32                    serde_json::from_value::<Object>(c["object"].clone())
33                        .ok()?
34                        .path()
35                        .map(str::to_owned)
36                }),
37        )
38        .collect()
39}
40
41/// What the integrity audit of one attempt reads.
42pub(crate) struct Audited<'a> {
43    pub(crate) wt_root: &'a Path,
44    pub(crate) policy: &'a Policy,
45    /// Judge inputs as the attempt found them.
46    pub(crate) judged_before: &'a integrity::JudgeSnapshot,
47    pub(crate) raw_changes: &'a [worktree::Change],
48    pub(crate) patch: &'a str,
49    pub(crate) frozen: &'a [String],
50    pub(crate) frozen_additions: bool,
51    pub(crate) approved_writes: &'a BTreeSet<String>,
52    /// Judge drift a command caused, reported by the tool (it ended the attempt).
53    pub(crate) command_drift: &'a Value,
54}
55
56/// Every integrity finding of an attempt, each `integrity: …`: the diff
57/// audit, judge inputs the diff cannot show (git-ignored files, whoever
58/// changed them — any executor), and drift a command caused.
59pub(crate) fn attempt_findings(a: &Audited<'_>) -> Vec<String> {
60    let unseen = a
61        .judged_before
62        .drift(
63            &integrity::JudgeSnapshot::take(a.wt_root, a.policy),
64            a.policy,
65            a.approved_writes,
66        )
67        .into_iter()
68        .filter(|d| !a.raw_changes.iter().any(|c| c.path == d.path))
69        .map(|d| format!("integrity: {} input {}: {}", d.class, d.change, d.path));
70    let frozen: BTreeSet<String> = a
71        .frozen
72        .iter()
73        .filter(|p| !a.approved_writes.contains(*p))
74        .cloned()
75        .collect();
76    // A planning task's documents are its work, not a change to the spec.
77    let audited: Vec<worktree::Change> = a
78        .raw_changes
79        .iter()
80        .filter(|c| !a.policy.is_planning_write(&c.path))
81        .cloned()
82        .collect();
83    let mut findings: Vec<String> = integrity::audit_in(
84        a.policy.protected(),
85        &audited,
86        a.patch,
87        &frozen,
88        a.frozen_additions,
89        a.approved_writes,
90    )
91    .into_iter()
92    .map(|f| format!("integrity: {f}"))
93    .chain(unseen)
94    .collect();
95    for d in a.command_drift.as_array().into_iter().flatten() {
96        let finding = format!(
97            "integrity: judge input changed by a command: {} ({})",
98            d["path"].as_str().unwrap_or_default(),
99            d["change"].as_str().unwrap_or_default()
100        );
101        if !findings.contains(&finding) {
102            findings.push(finding);
103        }
104    }
105    findings
106}
107
108/// `(path, added|modified|deleted)` for the report.
109pub(crate) fn changes_of(raw: Vec<worktree::Change>) -> Vec<(String, String)> {
110    raw.into_iter()
111        .map(|c| {
112            let kind = match c.kind {
113                worktree::ChangeKind::Added => "added",
114                worktree::ChangeKind::Modified => "modified",
115                worktree::ChangeKind::Deleted => "deleted",
116            };
117            (c.path, kind.to_owned())
118        })
119        .collect()
120}
121
122/// The verdict: the fast checks against the baseline, and the acceptance
123/// tests green — task evidence only when they were red first. Any integrity
124/// finding fails it, whatever the checks said.
125/// Tests of the story's `oracle` files failing in a check are the
126/// oracle's to judge, never the check's.
127pub(crate) fn judge_checks(
128    project: &Path,
129    runner: &sandbox::Runner,
130    (baseline, oracle): (&VerifyReport, &[String]),
131    acceptance: Option<&PreparedAcceptance>,
132    findings: &[String],
133    report: &mut RunReport,
134) -> Result<(), String> {
135    let mut current = verify::run_checks(project, Some(verify::config::Tier::Fast), runner)?;
136    let mut task_checks: Vec<&str> = Vec::new();
137    let mut uncollected_at_green = false;
138    if let Some(a) = acceptance.filter(|a| a.argv.is_some()) {
139        let argv = a.argv.as_deref().unwrap_or_default();
140        let all: Vec<&[String]> = std::iter::once(argv)
141            .chain(a.more_argv.iter().map(Vec::as_slice))
142            .collect();
143        let counted = std::cell::Cell::new(None);
144        let green = verify::rerun_once(run_all_acceptance(project, &all, runner, &counted), || {
145            run_all_acceptance(project, &all, runner, &counted)
146        });
147        // Green is evidence only when it collected what red did:
148        // red's count, else what the files declare, and at least one; a
149        // green run whose count cannot be read is no evidence.
150        let red_tests = a.report["red_tests"]
151            .as_u64()
152            .and_then(|n| usize::try_from(n).ok());
153        let required = red_tests
154            .unwrap_or_else(|| declared(project, &a.report))
155            .max(1);
156        if let Some(r) = report.acceptance.as_mut() {
157            r["green_tests"] = serde_json::json!(counted.get());
158            r["green_needs"] = serde_json::json!(required);
159        }
160        if a.red
161            && green.status == CheckStatus::Passed
162            && counted.get().is_none_or(|seen| seen < required)
163        {
164            uncollected_at_green = true;
165        }
166        if let Some(r) = report.acceptance.as_mut() {
167            r["green"] = serde_json::json!(green.status == CheckStatus::Passed);
168            if green.flaky {
169                r["green_flaky"] = serde_json::json!(true);
170                r["note"] = serde_json::json!("acceptance passed only on re-run");
171            }
172        }
173        if a.red {
174            task_checks.push("acceptance");
175        }
176        current.checks.push(green);
177    }
178    let cmp = compare_except(baseline, &current, oracle);
179    current.passed = passes(&current.checks, Some(&cmp));
180    let mut judged = verdict(&task_checks, &current.checks, Some(&cmp));
181    if uncollected_at_green {
182        let mut reasons = vec![GREEN_UNCOLLECTED.to_owned()];
183        if let Verdict::Failed { reasons: more } = judged {
184            reasons.extend(more);
185        }
186        judged = Verdict::Failed { reasons };
187    }
188    report.verdict = Some(if findings.is_empty() {
189        judged
190    } else {
191        let mut reasons = findings.to_vec();
192        if let Verdict::Failed { reasons: more } = judged {
193            reasons.extend(more);
194        }
195        Verdict::Failed { reasons }
196    });
197    current.comparison = Some(cmp);
198    report.verification = Some(current);
199    Ok(())
200}
201
202/// The tests the acceptance files declare by their toolchains' markers.
203fn declared(project: &Path, acceptance: &serde_json::Value) -> usize {
204    acceptance["files"]
205        .as_array()
206        .into_iter()
207        .flatten()
208        .filter_map(|f| f.as_str())
209        .filter_map(|f| {
210            let t = speccoder_core::collection::toolchain_of(f)?;
211            let text = std::fs::read_to_string(project.join(f)).ok()?;
212            Some(speccoder_core::collection::declared_tests(t, &text))
213        })
214        .sum()
215}
216
217/// The verdict reason when green collected fewer tests than red did.
218pub(crate) const GREEN_UNCOLLECTED: &str = "acceptance tests not collected at green";
219
220/// Every command of an acceptance set, as one result: passed only when
221/// each passed (the first failure's output is kept). `counted`: the tests
222/// the first command collected.
223fn run_all_acceptance(
224    project: &Path,
225    all: &[&[String]],
226    runner: &sandbox::Runner,
227    counted: &std::cell::Cell<Option<usize>>,
228) -> speccoder_core::verify::CheckResult {
229    let mut first = None;
230    for (i, argv) in all.iter().enumerate() {
231        let (r, summary) = super::acceptance::acceptance_run(project, argv, runner);
232        if i == 0 {
233            counted.set(speccoder_core::verify::collected_count(
234                &r,
235                summary.as_ref(),
236            ));
237        }
238        if r.status != CheckStatus::Passed {
239            return r;
240        }
241        first.get_or_insert(r);
242    }
243    first.unwrap_or_else(|| run_acceptance(project, &[], runner))
244}
245
246/// Why a failed, completed attempt is not retried, if it is not.
247pub(crate) fn retry_stop(
248    findings: &[String],
249    verification: &VerifyReport,
250    last_fingerprint: Option<&str>,
251    fingerprint: &str,
252    attempt_no: u32,
253    max_attempts: u32,
254) -> Option<String> {
255    if !findings.is_empty() {
256        Some("integrity findings are not retried".to_owned())
257    } else if let Some(infra) = speccoder_core::retry::infrastructure(verification) {
258        Some(format!("not retried: {infra}"))
259    } else if last_fingerprint == Some(fingerprint) {
260        Some(format!("same failure twice: {fingerprint}"))
261    } else if attempt_no >= max_attempts {
262        Some(format!("{attempt_no} of {max_attempts} attempts used"))
263    } else {
264        None
265    }
266}
267
268/// The gaming monitor's view of a passing attempt, and whether it suspects
269/// gaming. A monitor that fails blocks nothing: it can only add doubt.
270pub(crate) fn monitor_attempt(
271    rt: &tokio::runtime::Runtime,
272    plane: &crate::substrate::Plane,
273    feature: &str,
274    task: &str,
275    input: agentplane::core::Tainted<Value>,
276    secret: Option<&str>,
277) -> (Value, bool) {
278    match rt.block_on(plane.monitor(feature, task, input)) {
279        Ok(o) => {
280            let out = o
281                .output
282                .as_ref()
283                .map(|v| v.peek().clone())
284                .unwrap_or(Value::Null);
285            let suspected = out["answer"]["suspected"] == true;
286            (
287                serde_json::json!({
288                    "suspected": suspected,
289                    "findings": out["answer"]["findings"],
290                    "usage": out["usage"],
291                    "run_id": o.run_id.to_string(),
292                }),
293                suspected,
294            )
295        }
296        Err(e) => (
297            serde_json::json!({ "error": super::redact(&e.0, secret) }),
298            false,
299        ),
300    }
301}