Skip to main content

magi/
graft.rs

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