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    pub fn is_done(&self) -> bool {
150        self.done
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn ids(xs: &[&str]) -> Vec<EntrantId> {
159        xs.iter().map(|s| s.to_string()).collect()
160    }
161
162    #[test]
163    fn empty_entrants_is_error() {
164        assert!(Tournament::new(vec![]).is_err());
165    }
166
167    #[test]
168    fn single_entrant_wins_immediately() {
169        let mut t = Tournament::new(ids(&["a"])).unwrap();
170        match t.start() {
171            TournamentAction::Done {
172                winner,
173                rounds_used,
174            } => {
175                assert_eq!(winner, "a");
176                assert_eq!(rounds_used, 0);
177            }
178            _ => panic!("expected immediate Done"),
179        }
180        assert!(t.is_done());
181    }
182
183    #[test]
184    fn two_entrants_one_round() {
185        let mut t = Tournament::new(ids(&["a", "b"])).unwrap();
186        match t.start() {
187            TournamentAction::JudgeRound { round, matches } => {
188                assert_eq!(round, 1);
189                assert_eq!(matches.len(), 1);
190                assert_eq!(
191                    matches[0],
192                    Match {
193                        id: 0,
194                        left: "a".into(),
195                        right: "b".into()
196                    }
197                );
198            }
199            _ => panic!("expected JudgeRound"),
200        }
201        match t.feed_round(ids(&["b"])).unwrap() {
202            TournamentAction::Done {
203                winner,
204                rounds_used,
205            } => {
206                assert_eq!(winner, "b");
207                assert_eq!(rounds_used, 1);
208            }
209            _ => panic!("expected Done"),
210        }
211    }
212
213    #[test]
214    fn four_entrants_two_rounds() {
215        let mut t = Tournament::new(ids(&["a", "b", "c", "d"])).unwrap();
216        let r1 = t.start();
217        match r1 {
218            TournamentAction::JudgeRound { round, matches } => {
219                assert_eq!(round, 1);
220                assert_eq!(matches.len(), 2);
221            }
222            _ => panic!(),
223        }
224        // a beats b, d beats c
225        let r2 = t.feed_round(ids(&["a", "d"])).unwrap();
226        match r2 {
227            TournamentAction::JudgeRound { round, matches } => {
228                assert_eq!(round, 2);
229                assert_eq!(matches.len(), 1);
230                assert_eq!(
231                    matches[0],
232                    Match {
233                        id: 0,
234                        left: "a".into(),
235                        right: "d".into()
236                    }
237                );
238            }
239            _ => panic!(),
240        }
241        match t.feed_round(ids(&["d"])).unwrap() {
242            TournamentAction::Done {
243                winner,
244                rounds_used,
245            } => {
246                assert_eq!(winner, "d");
247                assert_eq!(rounds_used, 2);
248            }
249            _ => panic!(),
250        }
251    }
252
253    #[test]
254    fn three_entrants_bye_advances() {
255        let mut t = Tournament::new(ids(&["a", "b", "c"])).unwrap();
256        match t.start() {
257            TournamentAction::JudgeRound { round, matches } => {
258                assert_eq!(round, 1);
259                // only (a,b) plays; c gets a bye
260                assert_eq!(matches.len(), 1);
261                assert_eq!(
262                    matches[0],
263                    Match {
264                        id: 0,
265                        left: "a".into(),
266                        right: "b".into()
267                    }
268                );
269            }
270            _ => panic!(),
271        }
272        // a beats b; survivors = [a, c (bye)]
273        match t.feed_round(ids(&["a"])).unwrap() {
274            TournamentAction::JudgeRound { round, matches } => {
275                assert_eq!(round, 2);
276                assert_eq!(
277                    matches[0],
278                    Match {
279                        id: 0,
280                        left: "a".into(),
281                        right: "c".into()
282                    }
283                );
284            }
285            _ => panic!(),
286        }
287        match t.feed_round(ids(&["c"])).unwrap() {
288            TournamentAction::Done {
289                winner,
290                rounds_used,
291            } => {
292                assert_eq!(winner, "c");
293                assert_eq!(rounds_used, 2);
294            }
295            _ => panic!(),
296        }
297    }
298
299    #[test]
300    fn eight_entrants_three_rounds() {
301        let mut t = Tournament::new(ids(&["1", "2", "3", "4", "5", "6", "7", "8"])).unwrap();
302        let mut action = t.start();
303        let mut last_round = 0;
304        loop {
305            match action {
306                TournamentAction::JudgeRound { round, matches } => {
307                    last_round = round;
308                    // winners = the left entrant of each match (deterministic)
309                    let winners: Vec<EntrantId> = matches.iter().map(|m| m.left.clone()).collect();
310                    action = t.feed_round(winners).unwrap();
311                }
312                TournamentAction::Done {
313                    winner,
314                    rounds_used,
315                } => {
316                    assert_eq!(winner, "1");
317                    assert_eq!(rounds_used, 3);
318                    assert_eq!(last_round, 3);
319                    break;
320                }
321            }
322        }
323    }
324
325    #[test]
326    fn wrong_winner_count_is_error() {
327        let mut t = Tournament::new(ids(&["a", "b", "c", "d"])).unwrap();
328        t.start();
329        // round 1 has 2 matches; feeding 1 winner is invalid
330        assert!(t.feed_round(ids(&["a"])).is_err());
331    }
332
333    #[test]
334    fn winner_not_in_match_is_error() {
335        let mut t = Tournament::new(ids(&["a", "b"])).unwrap();
336        t.start();
337        assert!(t.feed_round(ids(&["zzz"])).is_err());
338    }
339
340    #[test]
341    fn feed_after_done_is_error() {
342        let mut t = Tournament::new(ids(&["a", "b"])).unwrap();
343        t.start();
344        t.feed_round(ids(&["a"])).unwrap();
345        assert!(t.feed_round(ids(&["a"])).is_err());
346    }
347}