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/// Decide whether the graft runs and with what input. Pure.
65pub fn plan(state: &RunState) -> Result<Plan, Skip> {
66    if !state.config.graph.graft_runner_up {
67        return Err(Skip::Off);
68    }
69    if state.candidates.len() < 2 {
70        return Err(Skip::SoloCandidate);
71    }
72    let Some(tally) = state.tally.as_ref() else {
73        return Err(Skip::NoWinner);
74    };
75    if !tally.met_quorum {
76        return Err(Skip::NoQuorum);
77    }
78    // Reviews already exist, or a record settled the question: a resumed run
79    // never gets a graft inserted after the fact.
80    if !state.reviews.is_empty() || state.graft.as_ref().is_some_and(|g| g.outcome.settled()) {
81        return Err(Skip::AlreadyDone);
82    }
83    let winner = tally.winner;
84    let mut strengths = Vec::new();
85    for c in &state.candidates {
86        // A folded loser has lost its branch: nothing to port from.
87        if c.label == winner || c.folded {
88            continue;
89        }
90        let mut seen = BTreeSet::new();
91        let mut items = Vec::new();
92        for j in state.judgements.iter().filter(|j| j.failed.is_none()) {
93            let Some(list) = j
94                .strengths
95                .iter()
96                .find(|(k, _)| {
97                    k.trim().chars().next().map(|ch| ch.to_ascii_uppercase()) == Some(c.label)
98                })
99                .map(|(_, v)| v)
100            else {
101                continue;
102            };
103            for raw in list {
104                let clean: String = blind::sanitize_prose(raw, &state.config.blind)
105                    .chars()
106                    .take(MAX_CHARS)
107                    .collect();
108                if !clean.trim().is_empty() && seen.insert(clean.to_lowercase()) {
109                    items.push(clean);
110                }
111            }
112        }
113        items.truncate(MAX_ITEMS);
114        if !items.is_empty() {
115            strengths.push((c.label, c.branch.clone(), items));
116        }
117    }
118    if strengths.is_empty() {
119        return Err(Skip::NoStrengths);
120    }
121    Ok(Plan { winner, strengths })
122}
123
124/// The most changed lines a graft may add to a winner whose own diff against
125/// the base is `winner_lines`: a quarter of it, never under [`MIN_LIMIT`] and
126/// never over [`MAX_LIMIT`]. A quarter keeps the graft a supplement; half
127/// would let it turn the winner into something else.
128pub fn limit(winner_lines: usize) -> usize {
129    winner_lines.div_ceil(RATIO).clamp(MIN_LIMIT, MAX_LIMIT)
130}
131
132/// Is a graft that changed `lines` (`None`: not measurable, or binary) within
133/// `limit`? An unmeasurable change is rejected.
134pub fn within_budget(lines: Option<usize>, limit: usize) -> bool {
135    lines.is_some_and(|l| l <= limit)
136}
137
138/// The reason a record in a non-final state is rolled back on resume.
139pub const INTERRUPTED: &str = "interrupted";
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::config::Config;
145    use crate::run::{Candidate, GraftOutcome, GraftRecord, Judgement, Tally};
146    use std::collections::BTreeMap;
147    use std::path::PathBuf;
148
149    fn cand(label: char) -> Candidate {
150        Candidate {
151            index: 0,
152            label,
153            agent: "a".to_owned(),
154            branch: format!("magi/r/{label}"),
155            worktree: PathBuf::from("/w"),
156            summary: String::new(),
157            stat: String::new(),
158            files: 1,
159            commits: 1,
160            empty: false,
161            failed: None,
162            verified_noop: None,
163            duration_ms: 0,
164            folded: false,
165        }
166    }
167
168    fn judge(strengths: &[(&str, &[&str])]) -> Judgement {
169        Judgement {
170            judge: 1,
171            seat: "judge-1".to_owned(),
172            agent: "a".to_owned(),
173            ranking: vec!['A', 'B'],
174            reasons: BTreeMap::new(),
175            confidence: None,
176            strengths: strengths
177                .iter()
178                .map(|(k, v)| ((*k).to_owned(), v.iter().map(|s| (*s).to_owned()).collect()))
179                .collect(),
180            order: vec![0, 1],
181            failed: None,
182            duration_ms: 0,
183        }
184    }
185
186    fn tally(winner: char, met_quorum: bool) -> Tally {
187        Tally {
188            first_choice: BTreeMap::new(),
189            borda: BTreeMap::new(),
190            winner,
191            rankings: 1,
192            unanimous_initial: true,
193            deliberated: false,
194            changed_votes: 0,
195            unanimous_final: true,
196            tie_break: None,
197            judges: 1,
198            present: 1,
199            quorum: 1,
200            met_quorum,
201            uncontested: None,
202        }
203    }
204
205    /// Two candidates, A won, one judge praised B's error handling.
206    fn ready() -> RunState {
207        let mut s = RunState::new(
208            PathBuf::from("/repo"),
209            "main".to_owned(),
210            "abc1234def".to_owned(),
211            "task".to_owned(),
212            Config::default(),
213        );
214        s.candidates = vec![cand('A'), cand('B')];
215        s.judgements = vec![judge(&[("B", &["handles the empty input"])])];
216        s.tally = Some(tally('A', true));
217        s
218    }
219
220    #[test]
221    fn a_ready_run_plans_the_losers_strengths_by_label() {
222        let p = plan(&ready()).unwrap();
223        assert_eq!(p.winner, 'A');
224        assert_eq!(
225            p.strengths,
226            vec![(
227                'B',
228                "magi/r/B".to_owned(),
229                vec!["handles the empty input".to_owned()]
230            )]
231        );
232    }
233
234    #[test]
235    fn each_skip_condition_is_named() {
236        let mut s = ready();
237        s.config.graph.graft_runner_up = false;
238        assert_eq!(plan(&s), Err(Skip::Off));
239
240        let mut s = ready();
241        s.candidates.truncate(1);
242        assert_eq!(plan(&s), Err(Skip::SoloCandidate));
243
244        let mut s = ready();
245        s.tally = None;
246        assert_eq!(plan(&s), Err(Skip::NoWinner));
247
248        let mut s = ready();
249        s.tally = Some(tally('A', false));
250        assert_eq!(plan(&s), Err(Skip::NoQuorum));
251
252        let mut s = ready();
253        s.judgements = vec![judge(&[])];
254        assert_eq!(plan(&s), Err(Skip::NoStrengths));
255
256        // Strengths for the winner itself, or for a label that does not
257        // exist, are not input.
258        let mut s = ready();
259        s.judgements = vec![judge(&[("A", &["x"]), ("Z", &["y"])])];
260        assert_eq!(plan(&s), Err(Skip::NoStrengths));
261
262        // A failed judge's strengths do not count.
263        let mut s = ready();
264        s.judgements[0].failed = Some("timeout".to_owned());
265        assert_eq!(plan(&s), Err(Skip::NoStrengths));
266
267        let mut s = ready();
268        s.graft = Some(GraftRecord {
269            pre: "x".to_owned(),
270            post: None,
271            lines: None,
272            limit: None,
273            agent: None,
274            outcome: GraftOutcome::RolledBack("no change".to_owned()),
275        });
276        assert_eq!(plan(&s), Err(Skip::AlreadyDone));
277    }
278
279    #[test]
280    fn strengths_are_deduplicated_across_judges() {
281        let mut s = ready();
282        s.judgements = vec![
283            judge(&[("b", &["Same point", "another"])]),
284            judge(&[("B", &["same point"])]),
285        ];
286        let p = plan(&s).unwrap();
287        assert_eq!(p.strengths[0].2, vec!["Same point", "another"]);
288    }
289
290    #[test]
291    fn the_limit_is_a_quarter_clamped() {
292        assert_eq!(limit(0), 40);
293        assert_eq!(limit(160), 40);
294        assert_eq!(limit(161), 41);
295        assert_eq!(limit(1000), 250);
296        assert_eq!(limit(1600), 400);
297        assert_eq!(limit(1_000_000), 400);
298    }
299
300    #[test]
301    fn the_budget_is_inclusive_and_rejects_the_unmeasurable() {
302        assert!(within_budget(Some(40), 40));
303        assert!(!within_budget(Some(41), 40));
304        assert!(within_budget(Some(0), 40));
305        assert!(!within_budget(None, 400));
306    }
307}