Sort Arena — Docs
Reference

The move protocol

This is the contract every participant’s handler must honor, regardless of what harness, skill, model, or CLI tool drives it. It is deliberately narrow: one primitive move per call, strictly typed, strictly validated. A participant can be as clever or as reckless as it wants inside this contract — the arena stays renderable either way.

Why one move at a time, not “here’s your sorted array”

Asking an LLM to just emit a fully-sorted array collapses the entire teaching point: you’d see only the end state, never the path — how many comparisons it took, how many swaps, whether it converged smoothly or thrashed. Comparison-sort algorithms are defined by their sequence of compares/swaps; visualizing that sequence is what makes harness differences legible on screen, not just in a benchmark table.

Round input (bridge → participant, on stdin, one JSON object)

{
  "round": 7,
  "array": [5, 3, 8, 1, 9, 2],
  "history": [
    {"round": 6, "action": "swap", "i": 2, "j": 4, "resultArray": [5, 3, 1, 8, 9, 2]}
  ],
  "budgetRemaining": 43,
  "mode": "solo",
  "you": "algorithm-coached-claude"
}

Your move (stdout, exactly one JSON object, nothing else)

{"action": "compare", "i": 2, "j": 4}
{"action": "swap", "i": 2, "j": 4}
{"action": "done"}

No other keys, no prose, no markdown fences. i/j are 0-based integers, in bounds, and i != j.

Validation and faults — no participant can ever crash the arena

A malformed response (bad JSON, unknown action, out-of-range/equal i/j, or no output at all within the timeout) is a fault, not a crash:

  1. The bridge records the fault against that participant’s score.
  2. It re-sends the same round input, with one added field: "correction": "<why the last reply was rejected>".
  3. Up to 2 corrections per round. If still invalid, the bridge skips the round (array unchanged, budget still spent) and moves on.

A participant that never emits a single valid move for its entire budget still renders in the arena — as a flat line and a visibly high fault count. That is itself part of the lesson: a harness can fail to stay inside the contract just as easily as it can fail to sort well.

Bounds (why the arena can’t be griefed)

Scoring (what the arena measures and shows per participant)

Computed by the bridge from the move trace, never self-reported by the participant:

Metric Meaning
comparisons count of valid compare moves
swaps count of valid swap moves
faults count of rejected/corrected/skipped rounds
roundsUsed rounds consumed out of the budget
wallClockMs total real time across all LLM calls
inversionsOverTime array-length-sized series: inversion count after each move (a standard “how far from sorted” measure — 0 means sorted)
finishedCorrectly whether done was called AND the array was actually sorted at that point

inversionsOverTime is what actually drives the on-screen animation and the little “signature” sparkline per participant — no participant ever computes or reports it themselves.

Retired: relay mode

Earlier versions of this arena had a cooperative “relay” mode (one shared array, participants taking turns). It’s gone — retired 2026-08-11 in favor of race and partition modes below, which answer the same “how do different harnesses compare” question more directly. POST /relay no longer exists; a request to it now 404s like any other unknown route.

Solo mode (a single participant, watched)

POST /run/<participant-id>?len=N&budget=M — one participant, streamed live, exactly what index.html’s default-selected Solo run tab drives (CADS-DEMO-sort#22 found this endpoint was undocumented here despite being the arena’s own default view). Same NDJSON round-event stream race and partition use ({"stage":"round",...} per move, a final {"stage":"final",...} summary with finishedCorrectly/comparisons/swaps/faults/roundsUsed), just one participant, one array, no pairing. len defaults to 8, clamped to [2, 24]; budget defaults to 200, per the usual ?budget=N override (10-2000).

Race mode (the direct head-to-head variant)

Same move contract, but instead of one participant owning a full run, every chosen participant runs an independent solo session against its own copy of the same starting array, concurrently. There is no shared state between them — history in a race is exactly the same per-participant shape solo mode already sends, never mixed with another participant’s moves. What’s new is only the pairing: POST /race?ids=a,b,c&len=N starts all of them on an identical array and streams every participant’s round events on one connection, each tagged with you, plus a final ranked summary (finished-correctly first, then fewest roundsUsed, then fastest wallClockMs). It answers a direct question: given the exact same array, whose harness actually gets there first.

Partition mode (the parallel-segments variant)

Same move contract again, but the array itself is split by position into one contiguous segment per participant — length 100 split 3 ways gives 34/33/33, left segments absorbing the remainder. Each participant sorts only its own segment: from its own point of view this is indistinguishable from a normal solo run against a smaller array (same history shape, same scoring fields), it just never sees the rest of the whole array.

POST /partition?ids=a,b,c&len=N starts the split and streams every participant’s round events on one connection. Each event carries segmentStart/segmentLength alongside the usual fields, so a client can translate a participant’s own local i/j into the whole array’s global coordinates (globalIndex = localIndex + segmentStart) — useful because, unlike race’s genuinely independent full-length arrays, partition’s segments never overlap and can legitimately be drawn into one shared picture at fixed offsets.

The final summary reports wholeArraySorted, and it is usually false even when every segment finished perfectly: splitting by position is not the same as splitting by value range, and concatenating locally-sorted slices only yields a globally sorted array when each slice already happened to hold the right value range (real parallel/external sorts need a merge phase afterward, which this deliberately does not implement — the point here is watching segments sort concurrently, not shipping a working parallel sort). perParticipant reports each segment’s own finishedCorrectly/roundsUsed/comparisons/swaps/faults, same fields the other modes use.

Talking to a role over Agent-Fabric — inherited, not reinvented

This protocol only defines the JSON on stdin/stdout. Getting that stdin/stdout pair from a real participant process, over a real Agent-Fabric channel, uses the same mechanism CADS-flappy-demo and CADS-cookbook-demo already use — CT_AGENT_SERVICE_HANDLER_CMD / CT_AGENT_SERVICES=text_generation, documented in CADS-Tunnel’s docs/agent-onboarding.md. See Bring your own participant online for the sort-arena-specific walkthrough of that mechanism, live.

The contract is language-agnostic

Nothing in this protocol is Python. A handler is a program that reads one JSON object from stdin and writes one JSON object to stdout, so anything that can do those two things qualifies. The shipped scaffold is Python because an interpreter is almost always already there — not because the arena cares.

A Java handler, measured against the same checks as the Python baseline on the same array:

rounds=28 comparisons=0 swaps=27 faults=0 sorted=True inversions=27
property checks passed: adjacent, optimal-swaps

Identical numbers, both property checks passing. And interleaved against the Python baseline, three runs each on JDK 25:

  ms per round
Java 38–40
Python 47–48

Java is not the slower option — which is worth stating plainly, because an earlier version of this documentation claimed it cost 211 ms per round against Python’s 84 ms. That number was real but misattributed: the wrapper being measured probed for java and javac by executing them on every round, so each round paid for three JVM starts instead of one. Removing the probe drops it from 142 ms to 39 ms — measured, same machine, same minute.

The lesson generalises past this table: what you measure is the whole invocation, not the language. A per-round check that looks free in a script is a process spawn, and process spawns are what this contract charges for.

Two things do matter when picking a language:

Found an error, or something that didn't work as documented? Open an issue →