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