tournament
when N candidates are too many for one judge, run pairwise brackets with byes; winners advance until one remains.
when a single judge would have to compare too many candidates at once, run a bracket instead. each match is a pairwise comparison; winners advance; the pool halves each round until one remains. this is the pipeline shape scaled to large N.
the scenario
generate-and-filter puts every draft in one judge prompt. that works for a handful. past that, a single judgment over the whole set gets unreliable and the prompt gets long. a tournament keeps every comparison to two candidates: judge A against B, advance the winner, repeat. the number of matches is linear in N; the number of rounds is logarithmic.
the annotated recipe
import { orcr } from "@orchestratr/sdk";
async function tournament(candidates: string[]): Promise<string> {
return orcr.scope("tournament", async () => {
let pool = candidates;
for (let round = 1; pool.length > 1; round++) {
const next: string[] = [];
for (let i = 0; i < pool.length; i += 2) {
if (i + 1 >= pool.length) { next.push(pool[i]); continue; } // bye
const verdict = await orcr.ask({
agent: "claude", path: `round_${round}/match_${i / 2}`,
prompt: `Which is better, A or B? Reply exactly A or B.\n` +
`--- A ---\n${pool[i]}\n--- B ---\n${pool[i + 1]}`,
});
next.push(verdict.trim().startsWith("B") ? pool[i + 1] : pool[i]);
}
pool = next;
}
return pool[0];
});
}what each piece is doing:
- the outer
forruns one round per halving; it continues whilepool.length > 1. - the inner
forsteps through the pool in pairs (i += 2). each pair is one match. - byes are the odd-one-out rule. when
i + 1 >= pool.length, there is no partner, sopool[i]advances unopposed withnext.push(pool[i]); continue;. an odd pool never drops a candidate. - path composition encodes the bracket.
round_${round}/match_${i / 2}gives every match a unique, readable path:tournament/round_1/match_0,tournament/round_1/match_1,tournament/round_2/match_0. the round and match indices are path segments, so nothing collides across rounds. - the judge replies
AorB;verdict.trim().startsWith("B") ? pool[i + 1] : pool[i]reads the winner. anything that is notB(including a malformed answer) advancesA. pool = nextreduces the pool for the next round. when one candidate remains,pool[0]is the winner.
matches here run sequentially
this recipe awaits each match before the next. that keeps the code simple and the pool reduction obvious. to speed up a wide round, wrap the inner loop's matches in Promise.all so a whole round runs in parallel; the path scheme already keeps their paths distinct.
primitives it exercises
orcr.scopeto root the whole bracket undertournament.- sequential
orcr.ask, one per match. - path composition to encode round and match indices as segments.
failure and cleanup
- the tie-break is deterministic.
startsWith("B") ? B : Ameans A wins unless the judge explicitly says B. a blank, malformed, or injected answer never stalls the bracket; it just advances A. every match resolves to exactly one winner. - singleton scope. the
tournamentroot is unique among live agents, so a second concurrent bracket fails fast withstate_conflict(path_in_use). when you need concurrent brackets, parameterize the scope with a job id, for exampleorcr.scope("tournament_" + jobId, ...). see identity and paths. - every match is
gc: "immediate"(viaask), so panes close as each verdict is captured. nothing to reap.
run it
orcr scaffold tournament-workflow && cd tournament-workflow
# paste the recipe into workflow.ts, then call tournament([...]) from run()
npx tsx workflow.tsthe recipe exports tournament(candidates); the CI fixture calls it with four strings and prints the winner.
next: loop-until-done takes any of these flows durable, handing remaining work to a scheduled loop.
generate-and-filter
fan the same prompt across several providers and models, judge the drafts once, and keep the winner with a safe fallback.
loop-until-done + durable handoff
work a queue interactively, then hand the rest to a self-terminating loop and exit; the loop does one increment per run and removes itself when the queue is empty.