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 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 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 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
124pub fn limit(winner_lines: usize) -> usize {
129 winner_lines.div_ceil(RATIO).clamp(MIN_LIMIT, MAX_LIMIT)
130}
131
132pub fn within_budget(lines: Option<usize>, limit: usize) -> bool {
135 lines.is_some_and(|l| l <= limit)
136}
137
138pub 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 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 let mut s = ready();
259 s.judgements = vec![judge(&[("A", &["x"]), ("Z", &["y"])])];
260 assert_eq!(plan(&s), Err(Skip::NoStrengths));
261
262 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}