Skip to main content

speccoder_runtime/
evidence.rs

1//! A task's evidence as rows: its verdict and what
2//! established it, read from the signed statements on its commit
3//! and the report — never re-derived, so what an interface shows is what a
4//! reviewer can verify with `attest verify`. Every interface renders these
5//! rows; none computes its own.
6
7use serde_json::Value;
8use speccoder_core::{RunReport, TaskStatus};
9
10pub use speccoder_core::evidence::Row;
11
12/// The banner: the verdict's mark and its words, which never blur.
13#[must_use]
14pub fn banner(state: &str) -> (&'static str, &'static str) {
15    match state {
16        "verified" => ("verified", "VERIFIED"),
17        "regression_only" => (
18            "regression_only",
19            "REGRESSION ONLY — nothing task-specific was shown",
20        ),
21        "attested" => ("attested", "ATTESTED — a person's word, not evidence"),
22        "ticked_without_evidence" => (
23            "ticked_without_evidence",
24            "TICKED WITHOUT SPECCODER EVIDENCE",
25        ),
26        "evidence_without_tick" => (
27            "evidence_without_tick",
28            "COMMITTED — its local tick is missing",
29        ),
30        "skipped" => ("skipped", "SKIPPED"),
31        "ready" => ("ready", "READY"),
32        _ => ("blocked", "BLOCKED"),
33    }
34}
35
36/// The statements of a commit's note, decoded.
37#[must_use]
38pub fn statements(repo: &std::path::Path, commit: &str) -> Vec<Value> {
39    let Some(note) = crate::attest::notes::read(repo, commit) else {
40        return Vec::new();
41    };
42    crate::attest::dsse::decode(&note)
43        .0
44        .iter()
45        .filter_map(crate::attest::dsse::Envelope::statement)
46        .collect()
47}
48
49fn phase<'a>(statements: &'a [Value], name: &str) -> Option<&'a Value> {
50    statements.iter().find_map(|s| {
51        let p = &s["predicate"];
52        (p["configuration"][0]["name"] == format!("speccoder:phase/{name}")).then_some(p)
53    })
54}
55
56fn names(v: &Value) -> usize {
57    v.as_array().map_or(0, Vec::len)
58}
59
60/// The rows for `task`, from its commit's `statements` and, in a hosted
61/// run, the run's own report.
62#[must_use]
63pub fn rows(task: &TaskStatus, statements: &[Value], run: Option<&RunReport>) -> Vec<Row> {
64    let mut out = Vec::new();
65    let row = |label: &str, mark: &str, text: String| Row {
66        label: label.to_owned(),
67        mark: mark.to_owned(),
68        text,
69        items: Vec::new(),
70    };
71    let tv = statements
72        .iter()
73        .find(|s| s["predicateType"] == "https://speccoder.dev/task-verification/v0")
74        .map(|s| &s["predicate"]);
75    // Why a regression-only tick is one: the signed reason, else the trailer's.
76    let why = tv
77        .and_then(|tv| {
78            serde_json::from_value::<speccoder_core::regression::RegressionReason>(
79                tv["regression"].clone(),
80            )
81            .ok()
82        })
83        .map(|r| speccoder_core::regression::wording(&r))
84        .or_else(|| task.regression.as_ref().map(|r| r.text.clone()))
85        .filter(|_| task.state == "regression_only");
86    // Acceptance: red, then green.
87    match (phase(statements, "red"), phase(statements, "green")) {
88        (Some(red), Some(green)) => out.push(row(
89            "Acceptance",
90            "verified",
91            format!(
92                "red → green: {} failing before, {} passing after (spec-derived, frozen)",
93                names(&red["failedTests"]),
94                names(&green["passedTests"])
95            ),
96        )),
97        (None, Some(green)) => out.push(row(
98            "Acceptance",
99            "regression_only",
100            why.clone().map_or_else(
101                || {
102                    format!(
103                        "none red first — the checks alone ({} passing)",
104                        names(&green["passedTests"])
105                    )
106                },
107                |w| format!("none red first — {w}"),
108            ),
109        )),
110        _ => out.push(match why.clone() {
111            Some(w) => row(
112                "Acceptance",
113                "regression_only",
114                format!("none red first — {w}"),
115            ),
116            None => row("Acceptance", "ready", "not run".to_owned()),
117        }),
118    }
119    // Collection: whether the test files the attempt wrote ever ran.
120    let collection = tv
121        .and_then(|tv| {
122            serde_json::from_value::<speccoder_core::collection::Collection>(
123                tv["collection"].clone(),
124            )
125            .ok()
126        })
127        .or_else(|| run.and_then(|r| r.collection.clone()));
128    let recorded = tv.is_some_and(|tv| tv["revision"].as_u64().is_some_and(|r| r >= 3));
129    if let Some(r) = collection_row(collection.as_ref(), recorded || run.is_some()) {
130        out.push(r);
131    }
132    out.push(match phase(statements, "held_out") {
133        Some(h) if h["result"] == "PASSED" => row(
134            "Held-out",
135            "verified",
136            format!(
137                "{} passed (judge only, never shown to the implementer)",
138                names(&h["passedTests"])
139            ),
140        ),
141        Some(h) => row(
142            "Held-out",
143            "failed",
144            format!("{} failed", names(&h["failedTests"])),
145        ),
146        None => row("Held-out", "ready", "not run".to_owned()),
147    });
148    out.push(match phase(statements, "baseline") {
149        Some(b) => {
150            let warned = names(&b["warnedTests"]);
151            row(
152                "Checks",
153                if warned == 0 {
154                    "verified"
155                } else {
156                    "regression_only"
157                },
158                if warned == 0 {
159                    "baseline green, nothing regressed".to_owned()
160                } else {
161                    format!(
162                        "{warned} failing before the attempt (not blamed on it), nothing regressed"
163                    )
164                },
165            )
166        }
167        None => row("Checks", "ready", "no record".to_owned()),
168    });
169    if let Some(tv) = tv {
170        let findings = names(&tv["integrity"]["findings"]);
171        out.push(row(
172            "Diff audit",
173            if findings == 0 { "verified" } else { "failed" },
174            if findings == 0 {
175                "no tests weakened, no skips, no config changes".to_owned()
176            } else {
177                format!("{findings} finding(s)")
178            },
179        ));
180        let monitor = tv["monitor"]["state"]
181            .as_str()
182            .or_else(|| tv["monitor"].as_str())
183            .unwrap_or("off");
184        out.push(row(
185            "Gaming monitor",
186            match monitor {
187                "clean" => "verified",
188                "suspected" => "needs_approval",
189                _ => "ready",
190            },
191            monitor.to_owned(),
192        ));
193        let mutation = &tv["mutation"];
194        out.push(if mutation.is_null() {
195            row("Mutation", "ready", "not run".to_owned())
196        } else {
197            let caught = mutation["caught"].as_u64().unwrap_or(0);
198            let missed = mutation["missed"].as_u64().unwrap_or(0);
199            row(
200                "Mutation",
201                if missed == 0 {
202                    "verified"
203                } else {
204                    "regression_only"
205                },
206                format!("{caught} caught, {missed} survived"),
207            )
208        });
209        let decisions: Vec<String> = tv["decisions"]
210            .as_array()
211            .into_iter()
212            .flatten()
213            .map(|d| {
214                format!(
215                    "{} {} — {}{}",
216                    d["verb"].as_str().unwrap_or("decided"),
217                    d["grant"]
218                        .as_str()
219                        .or_else(|| d["tool"].as_str())
220                        .unwrap_or_default(),
221                    d["by"].as_str().unwrap_or("?"),
222                    d["via"]
223                        .as_str()
224                        .map(|v| format!(" via {v}"))
225                        .unwrap_or_default()
226                )
227            })
228            .collect();
229        out.push(row(
230            "Approvals",
231            if decisions.is_empty() {
232                "ready"
233            } else {
234                "verified"
235            },
236            if decisions.is_empty() {
237                "none needed".to_owned()
238            } else {
239                decisions.join("; ")
240            },
241        ));
242        // What ran without asking: the sandbox confined it.
243        let contained: Vec<String> = tv["contained"]
244            .as_array()
245            .into_iter()
246            .flatten()
247            .map(|c| {
248                let argv: Vec<&str> = c["argv"]
249                    .as_array()
250                    .into_iter()
251                    .flatten()
252                    .filter_map(Value::as_str)
253                    .collect();
254                format!("`{}`", argv.join(" "))
255            })
256            .collect();
257        if !contained.is_empty() {
258            out.push(Row {
259                items: contained
260                    .iter()
261                    .map(|c| c.trim_matches('`').to_owned())
262                    .collect(),
263                ..row(
264                    "Ran without asking",
265                    "ready",
266                    format!(
267                        "{} command{} the sandbox confined: {}",
268                        contained.len(),
269                        if contained.len() == 1 { "" } else { "s" },
270                        contained.join("; ")
271                    ),
272                )
273            });
274        }
275        let instructions: Vec<&str> = tv["instructions"]
276            .as_array()
277            .into_iter()
278            .flatten()
279            .filter_map(|i| i["path"].as_str())
280            .collect();
281        out.push(row(
282            "Instructions",
283            "ready",
284            if instructions.is_empty() {
285                "none".to_owned()
286            } else {
287                instructions.join(", ")
288            },
289        ));
290        if let Some(by) = tv["decider"]["by"].as_str() {
291            out.push(row(
292                "Decided by",
293                "attested",
294                format!(
295                    "{by} — {}",
296                    tv["decider"]["reason"].as_str().unwrap_or_default()
297                ),
298            ));
299        }
300    }
301    // Acceptance strength: how well the set tells right from wrong.
302    if let Some(st) = tv
303        .and_then(|tv| tv.get("strength"))
304        .filter(|v| !v.is_null())
305    {
306        let share = st["combined"]["share"].as_f64();
307        let rejected = st["permitted"]
308            .as_array()
309            .into_iter()
310            .flatten()
311            .any(|p| p["outcome"] == "rejected");
312        out.push(row(
313            "Strength",
314            match share {
315                _ if rejected => "needs_approval",
316                Some(x) if x >= 0.999 => "verified",
317                Some(_) => "regression_only",
318                None => "ready",
319            },
320            task.strength.clone().unwrap_or_else(|| {
321                share.map_or_else(|| "not measured".to_owned(), |x| format!("{x:.2}"))
322            }),
323        ));
324    } else if let Some(st) = &task.strength {
325        out.push(row("Strength", "ready", st.clone()));
326    }
327    out.push(match task.attestation.as_deref() {
328        Some(a) if a.starts_with("signed") => row("Attestation", "verified", a.to_owned()),
329        Some(a) => row("Attestation", "regression_only", a.to_owned()),
330        None => row(
331            "Attestation",
332            "ready",
333            "none (not ticked by speccoder)".to_owned(),
334        ),
335    });
336    if let Some(a) = &task.last_attempt {
337        out.push(row(
338            "Last attempt",
339            a.outcome.as_str(),
340            format!(
341                "{} — {} ({})",
342                a.outcome.replace('_', " "),
343                a.reason,
344                a.reference
345            ),
346        ));
347    }
348    if let Some(r) = run {
349        for e in &r.earlier {
350            out.push(row(
351                "Earlier attempt",
352                "failed",
353                format!("{} — run {}", e.attempt_status.replace('_', " "), e.run_id),
354            ));
355        }
356        // The test author's notes: a model's words, in the task's detail only.
357        for note in r
358            .acceptance
359            .as_ref()
360            .and_then(|a| a["notes"].as_array())
361            .into_iter()
362            .flatten()
363            .filter_map(|n| n.as_str())
364            .filter(|n| !n.trim().is_empty())
365        {
366            out.push(row(
367                "Test author",
368                "ready",
369                format!("the test author's note (a model's words): \"{note}\""),
370            ));
371        }
372        if let Some(claim) = r.model_report.as_ref().and_then(|m| m["summary"].as_str()) {
373            out.push(row(
374                "Model says",
375                "ready",
376                format!("\"{claim}\" (a claim, not evidence)"),
377            ));
378        }
379    }
380    out
381}
382
383/// The *Collection* row: "2 test files collected (5 tests)", "1 never ran:
384/// `x` — the rule", "no test files changed"; `None` when nothing was recorded
385/// (a tick from before collection was judged).
386#[must_use]
387pub fn collection_row(
388    c: Option<&speccoder_core::collection::Collection>,
389    recorded: bool,
390) -> Option<Row> {
391    use speccoder_core::collection::FileState;
392    let row = |mark: &str, text: String| Row {
393        label: "Collection".to_owned(),
394        mark: mark.to_owned(),
395        text,
396        items: Vec::new(),
397    };
398    let Some(c) = c.filter(|c| !c.files.is_empty()) else {
399        return recorded.then(|| row("ready", "no test files changed".to_owned()));
400    };
401    let never: Vec<&speccoder_core::collection::FileCollection> = c.not_collected().collect();
402    let unknown = c.unknown().count();
403    let ran: Vec<_> = c
404        .files
405        .iter()
406        .filter(|f| f.state == FileState::Collected)
407        .collect();
408    let mut parts = Vec::new();
409    if !ran.is_empty() {
410        let tests: usize = ran.iter().map(|f| f.tests).sum();
411        parts.push(format!(
412            "{} test file{} collected ({tests} test{})",
413            ran.len(),
414            if ran.len() == 1 { "" } else { "s" },
415            if tests == 1 { "" } else { "s" }
416        ));
417    }
418    if let Some(first) = never.first() {
419        parts.push(format!(
420            "{} never ran: `{}`{}",
421            never.len(),
422            first.path,
423            first
424                .rule
425                .as_ref()
426                .map(|r| format!(" — {}", r.text))
427                .unwrap_or_default()
428        ));
429    }
430    if unknown > 0 {
431        parts.push(format!("{unknown} could not be told"));
432    }
433    let mark = if !never.is_empty() {
434        "failed"
435    } else if unknown > 0 {
436        "needs_approval"
437    } else {
438        "verified"
439    };
440    Some(row(mark, parts.join(" · ")))
441}
442
443/// The model's account of `run`, turn by turn, read from the project's
444/// journal: its reasoning and its words — a claim, shown beside the
445/// evidence, never as it.
446///
447/// # Errors
448///
449/// No project or state directory, or the journal cannot be read.
450pub fn account_of(
451    path: &std::path::Path,
452    run: &str,
453) -> Result<Vec<crate::substrate::AccountTurn>, String> {
454    let root = crate::format::at(path)
455        .detect(path)
456        .ok_or_else(|| "not a spec project".to_owned())?;
457    let state = crate::worktree::state_dir().ok_or_else(|| "no state directory".to_owned())?;
458    let plane = crate::worktree::project_state_dir(&state, &root);
459    crate::pipeline::tokio_runtime()?
460        .block_on(crate::substrate::account(&plane, run))
461        .map_err(|e| e.0)
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use serde_json::json;
468
469    fn task(state: &str) -> TaskStatus {
470        TaskStatus {
471            lane: speccoder_core::lane::Lane::default(),
472            id: "T002".to_owned(),
473            title: "t".to_owned(),
474            phase: "P".to_owned(),
475            state: state.to_owned(),
476            drift: None,
477            blocked_by: Vec::new(),
478            commit: Some("abc".to_owned()),
479            run_id: None,
480            last_attempt: None,
481            judged: None,
482            set_aside: None,
483            bug: None,
484            regression: None,
485            note: None,
486            attestation: Some("signed ssh SHA256:x".to_owned()),
487            strength: None,
488        }
489    }
490
491    fn phase_stmt(name: &str, result: &str, passed: &[&str], failed: &[&str]) -> Value {
492        json!({ "predicateType": "https://in-toto.io/attestation/test-result/v0.1", "predicate": {
493            "result": result, "configuration": [{ "name": format!("speccoder:phase/{name}") }],
494            "passedTests": passed, "failedTests": failed, "warnedTests": [] } })
495    }
496
497    #[test]
498    fn rows_come_from_the_statements() {
499        let statements = vec![
500            phase_stmt("baseline", "PASSED", &["a"], &[]),
501            phase_stmt("red", "FAILED", &[], &["accept::login"]),
502            phase_stmt("green", "PASSED", &["accept::login", "a"], &[]),
503            json!({ "predicateType": "https://speccoder.dev/task-verification/v0", "predicate": {
504                "integrity": { "findings": [] }, "monitor": { "state": "clean" },
505                "mutation": { "caught": 26, "missed": 3 },
506                "decisions": [{ "verb": "approve", "grant": "run `cargo add x`", "by": "Ada", "via": "dashboard" }],
507                "contained": [{ "argv": ["python3", "-c", "print(1)"] }],
508                "instructions": [{ "path": "AGENTS.md" }] } }),
509        ];
510        let rows = rows(&task("verified"), &statements, None);
511        let text = |label: &str| {
512            rows.iter()
513                .find(|r| r.label == label)
514                .map(|r| r.text.clone())
515                .unwrap_or_default()
516        };
517        assert!(
518            text("Acceptance").contains("1 failing before, 2 passing after"),
519            "{rows:?}"
520        );
521        assert_eq!(text("Held-out"), "not run");
522        assert_eq!(text("Mutation"), "26 caught, 3 survived");
523        assert!(text("Approvals").contains("Ada via dashboard"));
524        assert_eq!(
525            text("Ran without asking"),
526            "1 command the sandbox confined: `python3 -c print(1)`"
527        );
528        assert_eq!(text("Instructions"), "AGENTS.md");
529        assert!(text("Attestation").starts_with("signed"));
530    }
531
532    #[test]
533    fn a_regression_only_tick_says_why_and_its_collection_is_a_row() {
534        let statements = vec![
535            phase_stmt("green", "PASSED", &["a"], &[]),
536            json!({ "predicateType": "https://speccoder.dev/task-verification/v0", "predicate": {
537                "revision": 3,
538                "regression": { "reason": "already_passing" },
539                "collection": { "files": [
540                    { "path": "tests/greet.rs", "state": "collected", "decided_by": "junit", "tests": 2, "test_ids": ["greet::greet::a", "greet::greet::b"] },
541                    { "path": "tests/unit/x.rs", "state": "not_collected", "decided_by": "static", "tests": 0, "test_ids": [],
542                      "rule": { "toolchain": "rust", "text": "Cargo builds `tests/*.rs` and `tests/<dir>/main.rs` (and the modules they declare)", "source": "Cargo.toml (default)" } }
543                ] } } }),
544        ];
545        let rows = rows(&task("regression_only"), &statements, None);
546        let row = |label: &str| rows.iter().find(|r| r.label == label).cloned().unwrap();
547        assert_eq!(
548            row("Acceptance").text,
549            "none red first — already passing before the attempt"
550        );
551        let c = row("Collection");
552        assert_eq!(c.mark, "failed");
553        assert_eq!(
554            c.text,
555            "1 test file collected (2 tests) · 1 never ran: `tests/unit/x.rs` — Cargo builds `tests/*.rs` and `tests/<dir>/main.rs` (and the modules they declare)"
556        );
557        // Revision 3 with nothing changed; revision 2 records nothing.
558        assert_eq!(
559            collection_row(None, true).map(|r| r.text),
560            Some("no test files changed".to_owned())
561        );
562        assert_eq!(collection_row(None, false), None);
563        // A legacy tick: the trailer said nothing — unrecorded, never a guess.
564        let mut legacy = task("regression_only");
565        legacy.regression = Some(speccoder_core::regression::Regression::of(
566            speccoder_core::regression::RegressionReason::Unrecorded,
567        ));
568        let rows = super::rows(&legacy, &[phase_stmt("green", "PASSED", &["a"], &[])], None);
569        assert!(
570            rows[0]
571                .text
572                .ends_with("why was not recorded (ticked by an earlier speccoder)")
573        );
574    }
575
576    #[test]
577    fn the_test_author_s_note_is_a_model_s_words_in_the_detail() {
578        let mut run = crate::pipeline::outcome::bare_report("001-x", "T002", "/w", "completed");
579        run.acceptance = Some(json!({ "notes": ["the spec has no example for an empty name"] }));
580        let rows = rows(&task("regression_only"), &[], Some(&run));
581        let note = rows
582            .iter()
583            .find(|r| r.label == "Test author")
584            .unwrap_or_else(|| panic!("{rows:?}"));
585        assert_eq!(
586            note.text,
587            "the test author's note (a model's words): \"the spec has no example for an empty name\""
588        );
589    }
590
591    #[test]
592    fn banners_never_blur() {
593        assert_eq!(banner("verified").1, "VERIFIED");
594        assert!(
595            banner("regression_only")
596                .1
597                .contains("nothing task-specific")
598        );
599        assert!(banner("attested").1.contains("not evidence"));
600        assert_ne!(banner("attested").0, banner("verified").0);
601    }
602}