Skip to main content

magi/
verdict.rs

1//! Structured answers extracted from free-form agent output.
2//!
3//! Agents are asked to end with a fenced `json` block. They mostly do, and
4//! sometimes they narrate afterwards, emit two blocks, or wrap the object in
5//! prose. [`extract_json`] therefore scans for *every* balanced top-level
6//! object in the text and returns the last one that deserializes into the type
7//! the caller wants, rather than trusting a single regex to find the right one.
8use std::collections::BTreeMap;
9
10use anyhow::{Result, bail};
11use serde::{Deserialize, Serialize};
12
13/// A judge's independent ranking of the candidates.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Ranking {
16    /// Labels, best first. Must be a permutation of the presented labels.
17    pub ranking: Vec<String>,
18    /// Per-label justification.
19    #[serde(default)]
20    pub reasons: BTreeMap<String, String>,
21    /// Self-reported confidence, 1-5.
22    #[serde(default)]
23    pub confidence: Option<u8>,
24    /// Per-label strengths worth porting onto the winner, written for the
25    /// candidates the judge did not rank first. Optional: a judge that
26    /// names none simply leaves the graft phase nothing to do.
27    #[serde(default, deserialize_with = "lenient_strengths")]
28    pub strengths: BTreeMap<String, Vec<String>>,
29}
30
31/// Read `strengths` without ever failing: this field is optional, so a value of
32/// the wrong shape (`null`, a string, a map of plain strings) must cost the
33/// judge nothing but the strengths. Entries that are not a string array keep
34/// their string members only; anything else is dropped.
35fn lenient_strengths<'de, D>(d: D) -> Result<BTreeMap<String, Vec<String>>, D::Error>
36where
37    D: serde::Deserializer<'de>,
38{
39    let value = serde_json::Value::deserialize(d)?;
40    let mut out = BTreeMap::new();
41    if let serde_json::Value::Object(map) = value {
42        for (label, v) in map {
43            match v {
44                serde_json::Value::Array(items) => {
45                    let list: Vec<String> = items
46                        .into_iter()
47                        .filter_map(|i| i.as_str().map(str::to_owned))
48                        .collect();
49                    if !list.is_empty() {
50                        out.insert(label, list);
51                    }
52                }
53                serde_json::Value::String(s) if !s.trim().is_empty() => {
54                    out.insert(label, vec![s]);
55                }
56                _ => {}
57            }
58        }
59    }
60    Ok(out)
61}
62
63impl Ranking {
64    /// The judge's first choice.
65    pub fn top(&self) -> Option<&str> {
66        self.ranking.first().map(String::as_str)
67    }
68
69    /// Reject a ranking that is not a permutation of `labels`, so a malformed
70    /// verdict is retried instead of silently skewing the tally.
71    pub fn validate(&self, labels: &[char]) -> Result<()> {
72        let mut got: Vec<char> = self
73            .ranking
74            .iter()
75            .filter_map(|s| s.trim().chars().next())
76            .map(|c| c.to_ascii_uppercase())
77            .collect();
78        got.sort_unstable();
79        got.dedup();
80        let mut want: Vec<char> = labels.to_vec();
81        want.sort_unstable();
82        if got != want {
83            bail!(
84                "ranking {:?} is not a permutation of the candidate labels {:?}",
85                self.ranking,
86                labels
87            );
88        }
89        Ok(())
90    }
91
92    /// Normalise labels to single uppercase characters.
93    pub fn normalized(&self) -> Vec<char> {
94        self.ranking
95            .iter()
96            .filter_map(|s| s.trim().chars().next())
97            .map(|c| c.to_ascii_uppercase())
98            .collect()
99    }
100}
101
102/// A judge's final vote, collected privately.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct FinalVote {
105    /// The winning label, in this judge's view.
106    pub vote: String,
107    /// Why.
108    #[serde(default)]
109    pub reason: String,
110}
111
112impl FinalVote {
113    /// The voted label as a single uppercase char.
114    pub fn label(&self) -> Option<char> {
115        self.vote
116            .trim()
117            .chars()
118            .next()
119            .map(|c| c.to_ascii_uppercase())
120    }
121}
122
123/// How bad a review finding is.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
125#[serde(rename_all = "lowercase")]
126pub enum Severity {
127    /// Cosmetic.
128    Nit,
129    /// Should fix, does not block.
130    Minor,
131    /// Should fix before merge.
132    Major,
133    /// Must fix before merge.
134    Blocker,
135}
136
137impl Severity {
138    /// Does this finding hold the merge?
139    pub fn blocks(self) -> bool {
140        matches!(self, Self::Major | Self::Blocker)
141    }
142}
143
144impl std::fmt::Display for Severity {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        f.write_str(match self {
147            Self::Nit => "nit",
148            Self::Minor => "minor",
149            Self::Major => "major",
150            Self::Blocker => "blocker",
151        })
152    }
153}
154
155/// One review finding.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct Finding {
158    /// Assigned by magi after parsing, e.g. `R1-1-2`. Never trusted from the
159    /// agent, because the fixer's adoption report is keyed by it.
160    #[serde(default)]
161    pub id: String,
162    /// How bad.
163    pub severity: Severity,
164    /// File it concerns.
165    #[serde(default)]
166    pub file: Option<String>,
167    /// Line it concerns.
168    #[serde(default)]
169    pub line: Option<u32>,
170    /// One-line summary.
171    pub title: String,
172    /// The argument.
173    #[serde(default)]
174    pub detail: String,
175}
176
177/// A reviewer seat's verdict on the patch, cast alongside its findings.
178///
179/// Three values, not a boolean, because "fine to proceed" and "fine, but
180/// look at these" are different signals for the operator watching the run —
181/// collapsing them would hide exactly the middle case a lens-based reviewer
182/// is most likely to land on. Ord follows declaration order (least to most
183/// cautious) so a round's [`ReviewVote::worst`] is a plain `max`, the same
184/// trick [`Severity`] uses for `blocks`.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
186#[serde(rename_all = "snake_case")]
187pub enum ReviewVote {
188    /// No reservations.
189    Approve,
190    /// Fine to proceed, but the findings below are worth fixing.
191    ApproveWithFindings,
192    /// Do not proceed as-is.
193    Reject,
194}
195
196impl ReviewVote {
197    /// Operator-facing label, used in events and the report — never
198    /// `Debug`, so a rename of a variant does not silently reword history.
199    pub fn label(self) -> &'static str {
200        match self {
201            Self::Approve => "approve",
202            Self::ApproveWithFindings => "approve with findings",
203            Self::Reject => "reject",
204        }
205    }
206
207    /// The most cautious of a set of votes, or `None` if none were cast.
208    ///
209    /// A round's recorded verdict must never read softer than its most
210    /// cautious seat — the same reason a single blocking finding, not an
211    /// average of severities, decides whether a round is clean.
212    pub fn worst(votes: impl IntoIterator<Item = Self>) -> Option<Self> {
213        votes.into_iter().max()
214    }
215}
216
217/// A reviewer's report.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct Review {
220    /// Findings, worst first is conventional but not required.
221    #[serde(default)]
222    pub findings: Vec<Finding>,
223    /// Reviewer's overall verdict prose.
224    #[serde(default)]
225    pub summary: String,
226    /// This seat's vote. Required, not defaulted: a reviewer that skips it
227    /// is retried the same as one that skipped `severity` on a finding,
228    /// rather than silently counted as an `approve`.
229    pub vote: ReviewVote,
230}
231
232/// A reviewer seat's revote after a split round, cast once it has read every
233/// seat's findings and votes (still numbered, never named — see
234/// [`crate::prompt::review_reconsider`]).
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct ReviewRevote {
237    /// The seat's revote.
238    pub vote: ReviewVote,
239    /// Why, one or two sentences.
240    #[serde(default)]
241    pub reason: String,
242}
243
244/// The fixer's response to a round of findings.
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct FixReport {
247    /// Finding ids that were acted on.
248    #[serde(default)]
249    pub addressed: Vec<String>,
250    /// Finding ids that were deliberately not acted on, with the reason.
251    #[serde(default)]
252    pub rejected: Vec<Rejection>,
253    /// What changed.
254    #[serde(default)]
255    pub notes: String,
256}
257
258/// A finding the fixer declined, and why.
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct Rejection {
261    /// Finding id.
262    pub id: String,
263    /// Argument for not acting.
264    #[serde(default)]
265    pub why: String,
266}
267
268/// A judge's position during deliberation.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct Position {
271    /// Where the judge currently stands.
272    #[serde(default)]
273    pub tentative: Option<String>,
274}
275
276/// One advisor's independent design proposal, gathered by
277/// `graph::Runner::advise` before a run's implementer seats begin.
278///
279/// No patch and no plan of tool calls - a design proposal is cheap exactly
280/// because it is a few paragraphs an agent can write without touching the
281/// repository, which is what makes running several of them, on every run,
282/// affordable in a way several full implementations were not.
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct Proposal {
285    /// What to do and how, in the advisor's own words.
286    pub approach: String,
287    /// The one tradeoff this design turns on.
288    pub key_tradeoff: String,
289    /// What could go wrong.
290    #[serde(default)]
291    pub risks: Vec<String>,
292    /// Files or modules the design touches.
293    #[serde(default)]
294    pub touches: Vec<String>,
295    /// Why this earns its complexity over the obvious first draft - the
296    /// question that keeps a proposal from being a restatement of the task.
297    pub why_not_naive: String,
298}
299
300impl Proposal {
301    /// Reject a proposal with an empty field that matters, so a malformed or
302    /// lazy answer is retried instead of silently thinning the panel down to
303    /// prose nobody can act on.
304    pub fn validate(&self) -> Result<()> {
305        for (field, value) in [
306            ("approach", &self.approach),
307            ("key_tradeoff", &self.key_tradeoff),
308            ("why_not_naive", &self.why_not_naive),
309        ] {
310            if value.trim().is_empty() {
311                bail!("`{field}` is empty");
312            }
313        }
314        Ok(())
315    }
316}
317
318/// Extract the last balanced JSON object in `text` that parses as `T`.
319///
320/// Handles fenced blocks, trailing prose, and multiple objects. Strings and
321/// escapes are tracked so a `}` inside a string literal does not close an
322/// object early.
323pub fn extract_json<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
324    let bytes = text.as_bytes();
325    let mut spans: Vec<(usize, usize)> = Vec::new();
326    let mut i = 0usize;
327    while i < bytes.len() {
328        if bytes[i] != b'{' {
329            i += 1;
330            continue;
331        }
332        let mut depth = 0usize;
333        let mut in_str = false;
334        let mut escaped = false;
335        let mut j = i;
336        while j < bytes.len() {
337            let c = bytes[j];
338            if in_str {
339                if escaped {
340                    escaped = false;
341                } else if c == b'\\' {
342                    escaped = true;
343                } else if c == b'"' {
344                    in_str = false;
345                }
346            } else {
347                match c {
348                    b'"' => in_str = true,
349                    b'{' => depth += 1,
350                    b'}' => {
351                        depth -= 1;
352                        if depth == 0 {
353                            spans.push((i, j + 1));
354                            break;
355                        }
356                    }
357                    _ => {}
358                }
359            }
360            j += 1;
361        }
362        // Skip past this object's opening brace either way; a truncated object
363        // must not make the scan quadratic on long transcripts.
364        i = if depth == 0 && j < bytes.len() {
365            j + 1
366        } else {
367            i + 1
368        };
369    }
370
371    let mut last_err = None;
372    for (start, end) in spans.iter().rev() {
373        match serde_json::from_str::<T>(&text[*start..*end]) {
374            Ok(v) => return Ok(v),
375            Err(e) => last_err = Some(e),
376        }
377    }
378    match last_err {
379        Some(e) => bail!("no JSON object in the reply matched the expected shape: {e}"),
380        None => bail!("the reply contained no JSON object"),
381    }
382}
383
384/// Pull the text after a `## <heading>` marker, to the end or the next heading.
385///
386/// Used for the prose sections agents are asked to emit alongside their JSON.
387pub fn section(text: &str, heading: &str) -> Option<String> {
388    let want = heading.to_ascii_lowercase();
389    let mut out: Option<String> = None;
390    for line in text.lines() {
391        let trimmed = line.trim();
392        if let Some(rest) = trimmed.strip_prefix("##") {
393            let name = rest.trim_start_matches('#').trim().to_ascii_lowercase();
394            if name == want {
395                out = Some(String::new());
396                continue;
397            }
398            if out.is_some() {
399                break;
400            }
401            continue;
402        }
403        if let Some(buf) = out.as_mut() {
404            buf.push_str(line);
405            buf.push('\n');
406        }
407    }
408    out.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
409}
410
411/// The marker `prompt::implement`'s reply format documents for an
412/// implementer that investigated and concluded, with evidence, that no code
413/// change belongs in this worktree — see that prompt's "Reply format"
414/// section for the exact wording asked for.
415pub const NO_CHANGE_NEEDED_MARKER: &str = "NO CHANGE NEEDED:";
416
417/// Pull the evidence out of an implementer's `## SUMMARY` section when it
418/// opens with [`NO_CHANGE_NEEDED_MARKER`].
419///
420/// Returns `None` for an ordinary bullet-list summary, and also for a
421/// marker with nothing after it — an unsupported claim must read exactly
422/// like a candidate that never made one, not like a verified one with an
423/// empty reason. This only recognises the *declaration*; whether it is
424/// trusted is [`crate::graph`]'s adoption guard's call, made from evidence
425/// this function cannot see (the CLI's own exit status, whether the tree is
426/// really empty, whether a command went unconfirmed).
427pub fn verified_noop(summary: &str) -> Option<String> {
428    let evidence = summary.trim_start().strip_prefix(NO_CHANGE_NEEDED_MARKER)?;
429    let evidence = evidence.trim();
430    (!evidence.is_empty()).then(|| evidence.to_owned())
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn severity_has_explicit_operator_labels() {
439        for (severity, label) in [
440            (Severity::Nit, "nit"),
441            (Severity::Minor, "minor"),
442            (Severity::Major, "major"),
443            (Severity::Blocker, "blocker"),
444        ] {
445            assert_eq!(severity.to_string(), label);
446        }
447    }
448
449    #[test]
450    fn malformed_strengths_never_cost_the_ranking() {
451        for bad in [
452            "null",
453            r#"{"B":"one point"}"#,
454            r#""x""#,
455            r#"[1]"#,
456            r#"{"B":[1,"ok"]}"#,
457        ] {
458            let text = format!(r#"{{"ranking":["A","B"],"strengths":{bad}}}"#);
459            let r: Ranking = extract_json(&text).unwrap();
460            assert_eq!(r.ranking, ["A", "B"]);
461        }
462    }
463
464    #[test]
465    fn fenced_block_is_found() {
466        let text = "Here is my verdict.\n\n```json\n{\"ranking\":[\"B\",\"A\"]}\n```\n";
467        let r: Ranking = extract_json(text).unwrap();
468        assert_eq!(r.top(), Some("B"));
469    }
470
471    #[test]
472    fn last_matching_object_wins_over_an_earlier_example() {
473        let text = concat!(
474            "The format is {\"ranking\":[\"X\"]} for illustration.\n",
475            "```json\n{\"ranking\":[\"C\",\"A\",\"B\"],\"confidence\":4}\n```\n",
476            "Happy to elaborate.\n"
477        );
478        let r: Ranking = extract_json(text).unwrap();
479        assert_eq!(r.normalized(), ['C', 'A', 'B']);
480        assert_eq!(r.confidence, Some(4));
481    }
482
483    #[test]
484    fn braces_inside_strings_do_not_close_the_object() {
485        let text = r#"{"ranking":["A"],"reasons":{"A":"uses format!(\"{}\", x) safely}"}}"#;
486        let r: Ranking = extract_json(text).unwrap();
487        assert_eq!(r.top(), Some("A"));
488        assert!(r.reasons["A"].contains("format!"));
489    }
490
491    #[test]
492    fn objects_of_the_wrong_shape_are_skipped() {
493        let text = concat!(
494            "```json\n{\"ranking\":[\"A\",\"B\"]}\n```\n",
495            "and some telemetry: {\"tokens\":123}\n"
496        );
497        let r: Ranking = extract_json(text).unwrap();
498        assert_eq!(r.normalized(), ['A', 'B']);
499    }
500
501    #[test]
502    fn no_json_is_an_error_not_a_default() {
503        let err = extract_json::<Ranking>("I decline to produce JSON.").unwrap_err();
504        assert!(err.to_string().contains("no JSON object"));
505    }
506
507    #[test]
508    fn truncated_object_does_not_hang() {
509        let err = extract_json::<Ranking>("{\"ranking\": [\"A\"").unwrap_err();
510        assert!(err.to_string().contains("no JSON object"));
511    }
512
513    #[test]
514    fn ranking_validation_rejects_a_non_permutation() {
515        let r = Ranking {
516            ranking: vec!["A".to_owned(), "A".to_owned()],
517            reasons: BTreeMap::new(),
518            confidence: None,
519            strengths: BTreeMap::new(),
520        };
521        assert!(r.validate(&['A', 'B', 'C']).is_err());
522
523        let r = Ranking {
524            ranking: vec!["c".to_owned(), "B".to_owned(), "A".to_owned()],
525            reasons: BTreeMap::new(),
526            confidence: None,
527            strengths: BTreeMap::new(),
528        };
529        r.validate(&['A', 'B', 'C']).expect("case is normalised");
530        assert_eq!(r.normalized(), ['C', 'B', 'A']);
531    }
532
533    #[test]
534    fn final_vote_label_is_normalised() {
535        let v: FinalVote = extract_json(r#"{"vote":" b ","reason":"tests"}"#).unwrap();
536        assert_eq!(v.label(), Some('B'));
537    }
538
539    #[test]
540    fn severity_blocking_is_major_and_up() {
541        assert!(Severity::Blocker.blocks());
542        assert!(Severity::Major.blocks());
543        assert!(!Severity::Minor.blocks());
544        assert!(!Severity::Nit.blocks());
545        assert!(Severity::Blocker > Severity::Nit);
546    }
547
548    #[test]
549    fn review_parses_with_optional_fields_missing() {
550        let r: Review = extract_json(
551            r#"{"vote":"reject","findings":[{"severity":"blocker","title":"panics on empty input"}]}"#,
552        )
553        .unwrap();
554        assert_eq!(r.findings.len(), 1);
555        assert!(r.findings[0].file.is_none());
556        assert_eq!(r.findings[0].id, "");
557        assert_eq!(r.vote, ReviewVote::Reject);
558    }
559
560    #[test]
561    fn review_without_a_vote_is_rejected_rather_than_defaulted() {
562        let err = extract_json::<Review>(r#"{"findings":[]}"#).unwrap_err();
563        assert!(err.to_string().contains("no JSON object"), "{err}");
564    }
565
566    #[test]
567    fn review_vote_worst_is_the_most_cautious() {
568        assert_eq!(
569            ReviewVote::worst([ReviewVote::Approve, ReviewVote::Reject, ReviewVote::Approve]),
570            Some(ReviewVote::Reject)
571        );
572        assert_eq!(
573            ReviewVote::worst([ReviewVote::Approve, ReviewVote::ApproveWithFindings]),
574            Some(ReviewVote::ApproveWithFindings)
575        );
576        assert_eq!(ReviewVote::worst(Vec::<ReviewVote>::new()), None);
577    }
578
579    #[test]
580    fn review_revote_parses_the_reconsideration_shape() {
581        let r: ReviewRevote =
582            extract_json(r#"{"vote":"approve","reason":"the other findings do not hold"}"#)
583                .unwrap();
584        assert_eq!(r.vote, ReviewVote::Approve);
585        assert_eq!(r.reason, "the other findings do not hold");
586    }
587
588    #[test]
589    fn fix_report_parses_rejections() {
590        let f: FixReport = extract_json(
591            r#"{"addressed":["R1-1-1"],"rejected":[{"id":"R1-2-1","why":"not reachable"}]}"#,
592        )
593        .unwrap();
594        assert_eq!(f.addressed, ["R1-1-1"]);
595        assert_eq!(f.rejected[0].id, "R1-2-1");
596    }
597
598    #[test]
599    fn proposal_parses_with_optional_fields_missing() {
600        let p: Proposal = extract_json(
601            r#"{"approach":"do X","key_tradeoff":"simpler now, slower later","why_not_naive":"the naive version corrupts state under a retry"}"#,
602        )
603        .unwrap();
604        assert!(p.risks.is_empty());
605        assert!(p.touches.is_empty());
606        p.validate()
607            .expect("a proposal with only required fields is valid");
608    }
609
610    #[test]
611    fn proposal_validation_rejects_an_empty_required_field() {
612        let p = Proposal {
613            approach: String::new(),
614            key_tradeoff: "t".to_owned(),
615            risks: Vec::new(),
616            touches: Vec::new(),
617            why_not_naive: "w".to_owned(),
618        };
619        let err = p.validate().unwrap_err();
620        assert!(err.to_string().contains("approach"));
621    }
622
623    #[test]
624    fn sections_are_sliced_by_heading() {
625        let text = "## SUMMARY\nchanged the retry loop.\nadded a test.\n\n## NOTES\nignore me\n";
626        assert_eq!(
627            section(text, "summary").unwrap(),
628            "changed the retry loop.\nadded a test."
629        );
630        assert_eq!(section(text, "notes").unwrap(), "ignore me");
631        assert!(section(text, "missing").is_none());
632    }
633
634    #[test]
635    fn no_change_needed_marker_yields_its_evidence() {
636        let summary = "NO CHANGE NEEDED: already fixed by b32cfc4, which is on main \
637                        (git merge-base --is-ancestor confirms it).";
638        assert_eq!(
639            verified_noop(summary).unwrap(),
640            "already fixed by b32cfc4, which is on main (git merge-base --is-ancestor \
641             confirms it)."
642        );
643    }
644
645    #[test]
646    fn no_change_needed_marker_with_no_evidence_is_not_verified() {
647        assert!(verified_noop("NO CHANGE NEEDED:").is_none());
648        assert!(verified_noop("NO CHANGE NEEDED:   \n  ").is_none());
649    }
650
651    #[test]
652    fn an_ordinary_summary_is_not_verified() {
653        assert!(verified_noop("- changed the retry loop.\n- added a test.").is_none());
654        // The marker only counts leading the section, not mentioned in passing.
655        assert!(verified_noop("I considered NO CHANGE NEEDED: but wrote a fix instead.").is_none());
656    }
657}