1use std::collections::BTreeSet;
10
11use crate::blind;
12use crate::run::RunState;
13
14pub const MIN_LIMIT: usize = 40;
16pub const MAX_LIMIT: usize = 400;
18pub const RATIO: usize = 4;
20const MAX_ITEMS: usize = 8;
22const MAX_CHARS: usize = 400;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Skip {
27 Off,
29 SoloCandidate,
31 NoWinner,
33 NoQuorum,
35 NoStrengths,
37 AlreadyDone,
39}
40
41impl Skip {
42 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#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Plan {
58 pub winner: char,
60 pub strengths: Vec<(char, String, Vec<String>)>,
62}
63
64pub 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
81pub 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 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 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
141pub fn limit(winner_lines: usize) -> usize {
146 winner_lines.div_ceil(RATIO).clamp(MIN_LIMIT, MAX_LIMIT)
147}
148
149pub fn within_budget(lines: Option<usize>, limit: usize) -> bool {
152 lines.is_some_and(|l| l <= limit)
153}
154
155pub 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 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 let mut s = ready();
276 s.judgements = vec![judge(&[("A", &["x"]), ("Z", &["y"])])];
277 assert_eq!(plan(&s), Err(Skip::NoStrengths));
278
279 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}