Skip to main content

deepstrike_core/orchestration/
tournament.rs

1//! Single-elimination tournament — pairwise comparative judging.
2//!
3//! A pure control state machine: it holds the bracket and emits **abstract actions**; the SDK
4//! runs a fresh-context judge agent per match and feeds back winners. No prompt assembly, no
5//! I/O, no clock.
6//!
7//! Why a tournament instead of absolute scoring: comparative judgment ("which of these
8//! two is better?") is more reliable than asking one agent to score 1000 items, and only
9//! the current round's match-ups ever enter context — the deterministic loop holds the
10//! whole bracket.
11//!
12//! ```text
13//! entrants ──▶ JudgeRound{round 1, matches} ──▶ SDK runs N parallel pairwise judges
14//!                    ▲                                      │
15//!                    └────────── feed_round(winners) ◀──────┘
16//!          (repeat until one survivor) ──▶ Done{winner}
17//! ```
18
19use crate::types::error::{DeepStrikeError, Result};
20
21/// A participant. The SDK maps the id back to the real item being compared.
22pub type EntrantId = String;
23
24/// One pairwise match-up in a round.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Match {
27    /// Index of this match within its round (0-based).
28    pub id: u32,
29    pub left: EntrantId,
30    pub right: EntrantId,
31}
32
33/// What the SDK should do next.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum TournamentAction {
36    /// Run one fresh-context judge per match this round (matches are independent — run
37    /// them in parallel), then call [`Tournament::feed_round`] with the winners.
38    JudgeRound { round: u32, matches: Vec<Match> },
39    /// The bracket is resolved.
40    Done { winner: EntrantId, rounds_used: u32 },
41}
42
43/// Single-elimination bracket control state machine.
44#[derive(Debug)]
45pub struct Tournament {
46    /// Entrants advancing into the next round to be played.
47    survivors: Vec<EntrantId>,
48    /// Matches emitted for the current round, awaiting results.
49    pending: Vec<Match>,
50    /// Entrant that drew a bye this round (odd survivor count) — auto-advances.
51    bye: Option<EntrantId>,
52    /// Number of the most recently emitted round (0 before `start`).
53    round: u32,
54    done: bool,
55}
56
57impl Tournament {
58    /// Build a tournament. Requires at least one entrant.
59    pub fn new(entrants: Vec<EntrantId>) -> Result<Self> {
60        if entrants.is_empty() {
61            return Err(DeepStrikeError::InvalidConfig(
62                "tournament requires at least one entrant".into(),
63            ));
64        }
65        Ok(Self {
66            survivors: entrants,
67            pending: Vec::new(),
68            bye: None,
69            round: 0,
70            done: false,
71        })
72    }
73
74    /// Begin the tournament. A single entrant wins immediately (zero rounds);
75    /// otherwise the first round of match-ups is emitted.
76    pub fn start(&mut self) -> TournamentAction {
77        self.emit_round_or_done()
78    }
79
80    /// Report the winners of the round last emitted by [`TournamentAction::JudgeRound`].
81    /// `winners` must align one-to-one with that round's `matches`, and each winner must
82    /// be one of the two entrants in its match.
83    pub fn feed_round(&mut self, winners: Vec<EntrantId>) -> Result<TournamentAction> {
84        if self.done {
85            return Err(DeepStrikeError::InvalidConfig(
86                "tournament already complete".into(),
87            ));
88        }
89        if winners.len() != self.pending.len() {
90            return Err(DeepStrikeError::InvalidConfig(format!(
91                "expected {} winner(s) for round {}, got {}",
92                self.pending.len(),
93                self.round,
94                winners.len()
95            )));
96        }
97        for (w, m) in winners.iter().zip(&self.pending) {
98            if w != &m.left && w != &m.right {
99                return Err(DeepStrikeError::InvalidConfig(format!(
100                    "winner '{w}' is not a participant in match {}",
101                    m.id
102                )));
103            }
104        }
105
106        let mut next = winners;
107        if let Some(bye) = self.bye.take() {
108            next.push(bye);
109        }
110        self.survivors = next;
111        self.pending.clear();
112        Ok(self.emit_round_or_done())
113    }
114
115    /// Emit the next round of match-ups, or finish if only one survivor remains.
116    fn emit_round_or_done(&mut self) -> TournamentAction {
117        if self.survivors.len() == 1 {
118            self.done = true;
119            return TournamentAction::Done {
120                winner: self.survivors[0].clone(),
121                rounds_used: self.round,
122            };
123        }
124
125        self.round += 1;
126        let mut matches = Vec::with_capacity(self.survivors.len() / 2);
127        let mut i = 0;
128        while i + 1 < self.survivors.len() {
129            matches.push(Match {
130                id: (i / 2) as u32,
131                left: self.survivors[i].clone(),
132                right: self.survivors[i + 1].clone(),
133            });
134            i += 2;
135        }
136        // Odd count → the trailing entrant draws a bye and advances untouched.
137        self.bye = if self.survivors.len() % 2 == 1 {
138            self.survivors.last().cloned()
139        } else {
140            None
141        };
142        self.pending = matches.clone();
143        TournamentAction::JudgeRound {
144            round: self.round,
145            matches,
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn ids(xs: &[&str]) -> Vec<EntrantId> {
155        xs.iter().map(|s| s.to_string()).collect()
156    }
157
158    #[test]
159    fn empty_entrants_is_error() {
160        assert!(Tournament::new(vec![]).is_err());
161    }
162
163    #[test]
164    fn single_entrant_wins_immediately() {
165        let mut t = Tournament::new(ids(&["a"])).unwrap();
166        match t.start() {
167            TournamentAction::Done {
168                winner,
169                rounds_used,
170            } => {
171                assert_eq!(winner, "a");
172                assert_eq!(rounds_used, 0);
173            }
174            _ => panic!("expected immediate Done"),
175        }
176    }
177
178    #[test]
179    fn two_entrants_one_round() {
180        let mut t = Tournament::new(ids(&["a", "b"])).unwrap();
181        match t.start() {
182            TournamentAction::JudgeRound { round, matches } => {
183                assert_eq!(round, 1);
184                assert_eq!(matches.len(), 1);
185                assert_eq!(
186                    matches[0],
187                    Match {
188                        id: 0,
189                        left: "a".into(),
190                        right: "b".into()
191                    }
192                );
193            }
194            _ => panic!("expected JudgeRound"),
195        }
196        match t.feed_round(ids(&["b"])).unwrap() {
197            TournamentAction::Done {
198                winner,
199                rounds_used,
200            } => {
201                assert_eq!(winner, "b");
202                assert_eq!(rounds_used, 1);
203            }
204            _ => panic!("expected Done"),
205        }
206    }
207
208    #[test]
209    fn four_entrants_two_rounds() {
210        let mut t = Tournament::new(ids(&["a", "b", "c", "d"])).unwrap();
211        let r1 = t.start();
212        match r1 {
213            TournamentAction::JudgeRound { round, matches } => {
214                assert_eq!(round, 1);
215                assert_eq!(matches.len(), 2);
216            }
217            _ => panic!(),
218        }
219        // a beats b, d beats c
220        let r2 = t.feed_round(ids(&["a", "d"])).unwrap();
221        match r2 {
222            TournamentAction::JudgeRound { round, matches } => {
223                assert_eq!(round, 2);
224                assert_eq!(matches.len(), 1);
225                assert_eq!(
226                    matches[0],
227                    Match {
228                        id: 0,
229                        left: "a".into(),
230                        right: "d".into()
231                    }
232                );
233            }
234            _ => panic!(),
235        }
236        match t.feed_round(ids(&["d"])).unwrap() {
237            TournamentAction::Done {
238                winner,
239                rounds_used,
240            } => {
241                assert_eq!(winner, "d");
242                assert_eq!(rounds_used, 2);
243            }
244            _ => panic!(),
245        }
246    }
247
248    #[test]
249    fn three_entrants_bye_advances() {
250        let mut t = Tournament::new(ids(&["a", "b", "c"])).unwrap();
251        match t.start() {
252            TournamentAction::JudgeRound { round, matches } => {
253                assert_eq!(round, 1);
254                // only (a,b) plays; c gets a bye
255                assert_eq!(matches.len(), 1);
256                assert_eq!(
257                    matches[0],
258                    Match {
259                        id: 0,
260                        left: "a".into(),
261                        right: "b".into()
262                    }
263                );
264            }
265            _ => panic!(),
266        }
267        // a beats b; survivors = [a, c (bye)]
268        match t.feed_round(ids(&["a"])).unwrap() {
269            TournamentAction::JudgeRound { round, matches } => {
270                assert_eq!(round, 2);
271                assert_eq!(
272                    matches[0],
273                    Match {
274                        id: 0,
275                        left: "a".into(),
276                        right: "c".into()
277                    }
278                );
279            }
280            _ => panic!(),
281        }
282        match t.feed_round(ids(&["c"])).unwrap() {
283            TournamentAction::Done {
284                winner,
285                rounds_used,
286            } => {
287                assert_eq!(winner, "c");
288                assert_eq!(rounds_used, 2);
289            }
290            _ => panic!(),
291        }
292    }
293
294    #[test]
295    fn eight_entrants_three_rounds() {
296        let mut t = Tournament::new(ids(&["1", "2", "3", "4", "5", "6", "7", "8"])).unwrap();
297        let mut action = t.start();
298        let mut last_round = 0;
299        loop {
300            match action {
301                TournamentAction::JudgeRound { round, matches } => {
302                    last_round = round;
303                    // winners = the left entrant of each match (deterministic)
304                    let winners: Vec<EntrantId> = matches.iter().map(|m| m.left.clone()).collect();
305                    action = t.feed_round(winners).unwrap();
306                }
307                TournamentAction::Done {
308                    winner,
309                    rounds_used,
310                } => {
311                    assert_eq!(winner, "1");
312                    assert_eq!(rounds_used, 3);
313                    assert_eq!(last_round, 3);
314                    break;
315                }
316            }
317        }
318    }
319
320    #[test]
321    fn wrong_winner_count_is_error() {
322        let mut t = Tournament::new(ids(&["a", "b", "c", "d"])).unwrap();
323        t.start();
324        // round 1 has 2 matches; feeding 1 winner is invalid
325        assert!(t.feed_round(ids(&["a"])).is_err());
326    }
327
328    #[test]
329    fn winner_not_in_match_is_error() {
330        let mut t = Tournament::new(ids(&["a", "b"])).unwrap();
331        t.start();
332        assert!(t.feed_round(ids(&["zzz"])).is_err());
333    }
334
335    #[test]
336    fn feed_after_done_is_error() {
337        let mut t = Tournament::new(ids(&["a", "b"])).unwrap();
338        t.start();
339        t.feed_round(ids(&["a"])).unwrap();
340        assert!(t.feed_round(ids(&["a"])).is_err());
341    }
342}