1use std::collections::BTreeSet;
9
10use crate::blind;
11use crate::run::RunState;
12
13const MAX_ITEMS: usize = 8;
15const MAX_CHARS: usize = 400;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum Skip {
20 Off,
22 SoloCandidate,
24 NoWinner,
26 NoQuorum,
28 NoStrengths,
30 AlreadyDone,
32}
33
34impl Skip {
35 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#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Plan {
51 pub winner: char,
53 pub strengths: Vec<(char, String, Vec<String>)>,
55}
56
57pub 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
74pub 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 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 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
134pub 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 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 let mut s = ready();
255 s.judgements = vec![judge(&[("A", &["x"]), ("Z", &["y"])])];
256 assert_eq!(plan(&s), Err(Skip::NoStrengths));
257
258 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}