Skip to main content

magi/
graft.rs

1//! The graft phase's decisions: whether it runs and what it is told.
2//!
3//! Between the tally and the review loop a fixer round ports the strengths
4//! the judges attributed to the losing candidates onto the winner
5//! (`graph::Runner::graft`). Everything that can be decided without I/O lives
6//! here so the skip conditions are unit-tested directly.
7
8use std::collections::BTreeSet;
9
10use crate::blind;
11use crate::run::RunState;
12
13/// Strengths kept per candidate, and characters kept per strength.
14const MAX_ITEMS: usize = 8;
15const MAX_CHARS: usize = 400;
16
17/// Why the graft phase did nothing. The wording is the run event.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum Skip {
20    /// `[graph] graft_runner_up = false`.
21    Off,
22    /// Fewer than two candidates ever existed.
23    SoloCandidate,
24    /// The tally named nobody.
25    NoWinner,
26    /// The verdict rests on too few judges.
27    NoQuorum,
28    /// No judge attributed a strength to a losing candidate.
29    NoStrengths,
30    /// Finished earlier, or the run is already past the point it applies.
31    AlreadyDone,
32}
33
34impl Skip {
35    /// The reason as shown in the run's events.
36    pub fn reason(&self) -> &'static str {
37        match self {
38            Skip::Off => "[graph] graft_runner_up is off",
39            Skip::SoloCandidate => "only one candidate",
40            Skip::NoWinner => "no winner",
41            Skip::NoQuorum => "the tally did not meet quorum",
42            Skip::NoStrengths => "the judges recorded no strengths for the losing candidates",
43            Skip::AlreadyDone => "already done",
44        }
45    }
46}
47
48/// What a graft round is asked to port, by label only.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Plan {
51    /// The winning label.
52    pub winner: char,
53    /// `(label, branch, strengths)` for each losing candidate with any.
54    pub strengths: Vec<(char, String, Vec<String>)>,
55}
56
57/// The strengths of a plan with the ids the prompt shows (`A1`, `A2`, ...).
58pub fn requested(plan: &Plan) -> Vec<crate::run::GraftStrength> {
59    plan.strengths
60        .iter()
61        .flat_map(|&(label, _, ref items)| {
62            items
63                .iter()
64                .enumerate()
65                .map(move |(i, text)| crate::run::GraftStrength {
66                    id: format!("{label}{}", i + 1),
67                    source: label,
68                    text: text.clone(),
69                })
70        })
71        .collect()
72}
73
74/// Decide whether the graft runs and with what input. Pure.
75pub fn plan(state: &RunState) -> Result<Plan, Skip> {
76    if !state.config.graph.graft_runner_up {
77        return Err(Skip::Off);
78    }
79    if state.candidates.len() < 2 {
80        return Err(Skip::SoloCandidate);
81    }
82    let Some(tally) = state.tally.as_ref() else {
83        return Err(Skip::NoWinner);
84    };
85    if !tally.met_quorum {
86        return Err(Skip::NoQuorum);
87    }
88    // Reviews already exist, or a record settled the question: a resumed run
89    // never gets a graft inserted after the fact.
90    if !state.reviews.is_empty() || state.graft.as_ref().is_some_and(|g| g.outcome.settled()) {
91        return Err(Skip::AlreadyDone);
92    }
93    let winner = tally.winner;
94    let mut strengths = Vec::new();
95    for c in &state.candidates {
96        // A folded loser has lost its branch: nothing to port from.
97        if c.label == winner || c.folded {
98            continue;
99        }
100        let mut seen = BTreeSet::new();
101        let mut items = Vec::new();
102        for j in state.judgements.iter().filter(|j| j.failed.is_none()) {
103            let Some(list) = j
104                .strengths
105                .iter()
106                .find(|(k, _)| {
107                    k.trim().chars().next().map(|ch| ch.to_ascii_uppercase()) == Some(c.label)
108                })
109                .map(|(_, v)| v)
110            else {
111                continue;
112            };
113            for raw in list {
114                let clean: String = blind::sanitize_prose(raw, &state.config.blind)
115                    .chars()
116                    .take(MAX_CHARS)
117                    .collect();
118                if !clean.trim().is_empty() && seen.insert(clean.to_lowercase()) {
119                    items.push(clean);
120                }
121            }
122        }
123        items.truncate(MAX_ITEMS);
124        if !items.is_empty() {
125            strengths.push((c.label, c.branch.clone(), items));
126        }
127    }
128    if strengths.is_empty() {
129        return Err(Skip::NoStrengths);
130    }
131    Ok(Plan { winner, strengths })
132}
133
134/// The reason a record in a non-final state is rolled back on resume.
135pub const INTERRUPTED: &str = "interrupted";
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::config::Config;
141    use crate::run::{Candidate, GraftOutcome, GraftRecord, Judgement, Tally};
142    use std::collections::BTreeMap;
143    use std::path::PathBuf;
144
145    fn cand(label: char) -> Candidate {
146        Candidate {
147            index: 0,
148            label,
149            agent: "a".to_owned(),
150            branch: format!("magi/r/{label}"),
151            worktree: PathBuf::from("/w"),
152            summary: String::new(),
153            stat: String::new(),
154            files: 1,
155            commits: 1,
156            empty: false,
157            failed: None,
158            verified_noop: None,
159            duration_ms: 0,
160            folded: false,
161        }
162    }
163
164    fn judge(strengths: &[(&str, &[&str])]) -> Judgement {
165        Judgement {
166            judge: 1,
167            seat: "judge-1".to_owned(),
168            agent: "a".to_owned(),
169            ranking: vec!['A', 'B'],
170            reasons: BTreeMap::new(),
171            confidence: None,
172            strengths: strengths
173                .iter()
174                .map(|(k, v)| ((*k).to_owned(), v.iter().map(|s| (*s).to_owned()).collect()))
175                .collect(),
176            order: vec![0, 1],
177            failed: None,
178            duration_ms: 0,
179        }
180    }
181
182    fn tally(winner: char, met_quorum: bool) -> Tally {
183        Tally {
184            first_choice: BTreeMap::new(),
185            borda: BTreeMap::new(),
186            winner,
187            rankings: 1,
188            unanimous_initial: true,
189            deliberated: false,
190            changed_votes: 0,
191            unanimous_final: true,
192            tie_break: None,
193            judges: 1,
194            present: 1,
195            quorum: 1,
196            met_quorum,
197            uncontested: None,
198        }
199    }
200
201    /// Two candidates, A won, one judge praised B's error handling.
202    fn ready() -> RunState {
203        let mut s = RunState::new(
204            PathBuf::from("/repo"),
205            "main".to_owned(),
206            "abc1234def".to_owned(),
207            "task".to_owned(),
208            Config::default(),
209        );
210        s.candidates = vec![cand('A'), cand('B')];
211        s.judgements = vec![judge(&[("B", &["handles the empty input"])])];
212        s.tally = Some(tally('A', true));
213        s
214    }
215
216    #[test]
217    fn a_ready_run_plans_the_losers_strengths_by_label() {
218        let p = plan(&ready()).unwrap();
219        assert_eq!(p.winner, 'A');
220        assert_eq!(
221            p.strengths,
222            vec![(
223                'B',
224                "magi/r/B".to_owned(),
225                vec!["handles the empty input".to_owned()]
226            )]
227        );
228    }
229
230    #[test]
231    fn each_skip_condition_is_named() {
232        let mut s = ready();
233        s.config.graph.graft_runner_up = false;
234        assert_eq!(plan(&s), Err(Skip::Off));
235
236        let mut s = ready();
237        s.candidates.truncate(1);
238        assert_eq!(plan(&s), Err(Skip::SoloCandidate));
239
240        let mut s = ready();
241        s.tally = None;
242        assert_eq!(plan(&s), Err(Skip::NoWinner));
243
244        let mut s = ready();
245        s.tally = Some(tally('A', false));
246        assert_eq!(plan(&s), Err(Skip::NoQuorum));
247
248        let mut s = ready();
249        s.judgements = vec![judge(&[])];
250        assert_eq!(plan(&s), Err(Skip::NoStrengths));
251
252        // Strengths for the winner itself, or for a label that does not
253        // exist, are not input.
254        let mut s = ready();
255        s.judgements = vec![judge(&[("A", &["x"]), ("Z", &["y"])])];
256        assert_eq!(plan(&s), Err(Skip::NoStrengths));
257
258        // A failed judge's strengths do not count.
259        let mut s = ready();
260        s.judgements[0].failed = Some("timeout".to_owned());
261        assert_eq!(plan(&s), Err(Skip::NoStrengths));
262
263        let mut s = ready();
264        s.graft = Some(GraftRecord {
265            pre: "x".to_owned(),
266            post: None,
267            lines: None,
268            limit: None,
269            agent: None,
270            outcome: GraftOutcome::RolledBack("no change".to_owned()),
271            requested: Vec::new(),
272            adoption: None,
273            adoption_error: None,
274        });
275        assert_eq!(plan(&s), Err(Skip::AlreadyDone));
276    }
277
278    #[test]
279    fn strengths_are_deduplicated_across_judges() {
280        let mut s = ready();
281        s.judgements = vec![
282            judge(&[("b", &["Same point", "another"])]),
283            judge(&[("B", &["same point"])]),
284        ];
285        let p = plan(&s).unwrap();
286        assert_eq!(p.strengths[0].2, vec!["Same point", "another"]);
287    }
288}