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