Tabbycat Adjcore Toolkit

The fold, and the simulator

A public page for everyone who is not on the adjudication core: the points stack folding into the break, the break itself, the break-round bracket — and a simulator where anybody can play out the rest of the tournament themselves.

That sample is a real tournament — a 110-team intervarsity, with its real teams, real break and real speaker tab. Not because we were careless with somebody's data: because everything this page can show was already public on that tournament's own Tabbycat, so the honest demo is the real thing. If that claim is wrong the whole tool is wrong, and you can check it — open the What's shown tab on the sample and it lists, switch by switch, what the tab has released and what it has not.

The other two tools' samples are invented, because those two publish things no tab makes public.

The problem it solves

Between the last prelim and the break announcement, a few hundred people all want the same thing: to work out who is breaking. They do it on paper, badly, in a hallway, and then argue about it. And once the break rounds start, nobody outside the room can follow which room feeds which — so the bracket exists only in the heads of the people who drew it.

Both of those are just a rendering problem. The tab already knows everything required; it is simply not shown that way. This is that page.

It shows only what your tab has already made public. Not “the parts we judged safe” — literally the switches you have already set in Tabbycat. If results are not public, they are not on the page. If a round is silent, it stays silent. If the draw is not released, the rooms are empty. Turning something on in Tabbycat turns it on here at the next refresh, and nowhere else. See how that is enforced.

The fold

Every team stacked by points, with the break line cut across it. This is the page people actually want between the last prelim and the announcement.
Every team stacked by points, with the break line cut across it. This is the page people actually want between the last prelim and the announcement.
  1. 1
    What your tab is currently saying

    The round in play, whether its draw is out, whether its rankings are public. Read from the tab, not asserted by us.

  2. 2
    Break categories come from the tab

    Their names, their sizes and how many there are. Nothing here knows what your categories are called.

  3. 3
    Scrub back through the tournament

    How the fold looked after any earlier round. Useful for the argument about who would have broken if.

  4. 4
    The team on the line

    Named, with the points total that got them there — which is the first thing anyone asks.

  5. 5
    One row per points total

    Grouped by bracket rather than listed 1 to 48, because that is how a break is actually read.

A sub-category's break line is not the top N of its own stack. This caught us out live. If two teams eligible for a sub-category break the main category instead, they are marked as such and sit above the sub-category's line — so cutting after the first N positions strands the last teams that genuinely broke. The line is drawn after the last team that actually broke, and the page says so: “8 teams, with 2 above the line out of this break”. The demo tournament reproduces the condition on purpose, so the fix stays tested.

The break rounds

The bracket, drawn so you can see which room feeds which. Two things about it are worth stating plainly, because getting either wrong makes a correct bracket look broken:

Which means everything past the first break round is genuinely knowable in shape and genuinely unknowable in occupants — so the shape is drawn and the rooms stay empty until the round before has been debated.

The judges

Names, and what each of them actually did: how many rooms, how many as chair, as panellist, as trainee. Click any judge and you get their round-by-round — the position they held, the teams in the room, and who sat alongside them.

Every judge, and what they did. All of it from the tab's own results pages.
Every judge, and what they did. All of it from the tab's own results pages.
  1. 1
    What each of them actually did

    Rooms, chairs, panels and trainee sits — counted from the results, not typed in anywhere.

  2. 2
    Who broke

    Shown only where your tab has published the adjudicator break.

  3. 3
    Click any judge

    Their round by round: the position they held, the teams in the room, and who sat alongside them.

This appears as soon as your results are public, even if your draw is switched off. Those are two different settings in Tabbycat: the draw page shows who is about to judge whom, and the results pages show who did — with the chair and any trainee marked. Most tournaments switch the draw off when they finish and leave results up forever, so who judged stays public.

Room names are the exception and follow the draw setting, because your tab's results pages do not carry a room column. On the sample you will see a dash where the room would be — that tournament's draw is off.

The speaker tab

Once a tournament releases its speaker tab, the fold shows it — and only then. The tab appears in the navigation when Tabbycat's own speaker_tab_released switch is on, and disappears again if it is turned off.

The speaker tab, on a tournament that has released it. Every number comes from the tab's own speaker tab; nothing is recomputed.
The speaker tab, on a tournament that has released it. Every number comes from the tab's own speaker tab; nothing is recomputed.
  1. 1
    It says how far the tab goes

    Tabbycat lets a tournament publish only the top N. Where that is set, the list stops there and the page says so rather than looking truncated by accident.

  2. 2
    Anonymous speakers keep their scores

    A speaker the tab marks anonymous is shown without a name. The ranks stay continuous and nothing is invented.

  3. 3
    Iron-person speeches are counted out

    Tabbycat excludes them from the average, so this does too — and marks them, so a short speech count has a visible reason.

  4. 4
    Round by round, per speaker

    Each speech, in round order, with an asterisk on the ones that do not count toward the average.

Three of Tabbycat's rules here are obeyed, not reimplemented, because each is a decision the tournament made and not ours to second-guess: the anonymous flag on a speaker, the tab limit, and the exclusion of iron-person speeches from an average.

And three things are not published even when the speaker tab is: reply speeches, the adjudicator tab, and any ballot or margin. The first two have their own release switches in Tabbycat, and releasing the speaker tab does not release them — conflating those would publish something a tournament deliberately held back.

What this cost, and what replaced it

The gate's sharpest check used to be no float anywhere in the payload. Speaks and feedback averages are floats in Tabbycat while points and counts are integers, so any float at all was evidence that something score-shaped had got in — whatever it was named, and wherever it came from. That is a much stronger check than trying to enumerate the fields you do not want.

Publishing a released speaker tab means floats are now legitimate. So the check was narrowed rather than deleted: no float outside speaker_scores[].total|avg|stdev, speaker_scores[].by_round[][].score and standings[].speaks. Everywhere else the tell still works.

Two more checks were added alongside it, because a switch that is off has to be provable: when speaker_tab_released is off there must be no speaker_scores key at all — not an empty one, absent — and when it is on, no row marked anonymous may carry a name, and no rank may exceed the published limit. The demo tournaments cover both states, and the one whose tab is closed exists specifically so the off-path is tested rather than assumed.

The pull is careful in one more way. A Tabbycat speaker record carries an email, a phone number, a barcode and a url_key — which is that person's private ballot URL. So the pull takes the name, the team and the anonymous flag, and leaves the rest in the response object it never stores.

The simulator

Run your own break. Pick who goes through and the next round re-forms — following the same fixed tree the real rounds do.
Run your own break. Pick who goes through and the next round re-forms — following the same fixed tree the real rounds do.
  1. 1
    The first round is folded off the break

    Room 1 takes seeds 1, 8, 9 and 16 here. The rule is derived from the break size and how many teams are in a debate, so it reads correctly for a two-team break too.

  2. 2
    This round is already decided

    Where a result is public, the simulator locks it to what actually happened rather than letting you re-run history.

  3. 3
    This one is yours to pick

    Per room, not per round — ballots land one room at a time, so a half-decided round shows its results and stays pickable elsewhere.

  4. 4
    Room 1 meets room 4, not room 2

    Four quarterfinal rooms, so room i meets room P+1−i. The lines are drawn so no connector crosses another.

  5. 5
    Honest about what it cannot know yet

    “Waiting on OSF rooms 1 and 2” rather than a blank box or a guess.

  6. 6
    Chalk, Surprise me, Share, Clear

    Share produces a link that encodes your picks. Nothing is sent anywhere; it is all in the address.

The simulator is a separate tab from the real bracket on purpose. One is your adjudication core's answer and the other is a spectator's guess, and those should never be a click apart from looking like the same thing.

Picks never leave the browser Technical

Picks live in localStorage and in a #sim=<slug>.<digits> fragment. A URL fragment is never sent to a server, so a shared bracket is shared peer-to-peer through whatever you pasted the link into. The share code is fixed width — one character per seat, x for unpicked — because a variable-width code let a half-finished round shift every later digit by one, and a shared link decoded into somebody else's bracket.

The page makes no network requests at all after it loads, and the test suite asserts that: it checks the inlined script contains no fetch, WebSocket or XMLHttpRequest. It keeps itself current by navigating — a full reload once the page is five minutes old, and only when nothing is open, the tab is visible, and nobody has typed for two minutes — which is why the “makes no requests” claim and the content security policy both still hold.

How it decides what to show

It reads your tab's own settings, every time it builds. Not a copy of them made once — the live values.

So the page follows your tab. Switch something off in Tabbycat and it comes off the page at the next refresh — and if something is on the page you did not expect to be public, the fix is in Tabbycat, not here.

You do not have to take that on trust. Open What's shown on any page this builds and it lists, setting by setting, what your tab has released and what it has not.

And it refuses to publish if a check fails. Not a warning — it stops. So a mistake in the page cannot quietly reach the internet.

The two mechanisms, and why one would not do

The rules above stop the wrong values. They cannot stop a wrong field — one extra key added in a hurry, carrying something private. So every key that may appear in the published data is declared in fold/gate.py, and the build walks what it is about to publish and raises on anything undeclared. A new field cannot leak by accident; somebody has to declare it, which means somebody has to think about it.

That allowlist also enforces shape, which it did not originally: a field declared as holding a scalar is checked to be one. A declaration of "a list of names" once waved through a list of whole participant records — emails and private URL keys included — and reported the payload clean.

fold/tests/test_gate.py is the deploy gate, and its sharpest check is about floats: speaks are floats in Tabbycat while points and counts are integers, so a float outside the four declared speaker fields is the tell that something score-shaped has got in, whatever it is named. The others: per-round points only for rounds whose rankings are public; every points value inside this format's scale, read from teams-per-debate rather than assumed to be 3/2/1/0; no room name outside the draw-public rounds and no panel outside the results-public ones; silent rounds silent; the script makes no network call; the only outside references are the font host and your own tab.

Two failures, and the guards that came out of them

Both of these happened live, and both are now guarded, which is more useful to you than a claim that nothing goes wrong.

An empty break is a real state, so it must never be inferred from a failure. A transient error on the break endpoint was being swallowed and returning “no break”, so the site published “the break has not been announced” over a break that had been out for a day. No error, no log line — the page just forgot who broke.

Now: the endpoint is retried and then raises; and separately the build compares against the previous pull and refuses to publish a category that had ranked teams and now has none. The retry alone cannot catch it, because a successful response with an empty body looks exactly like “not announced yet”.

“Results are public” is not the same as “the result is in”. A round was un-silenced before a single ballot was confirmed. The page read the flag, concluded the round was decided, locked the simulator, and left every later round waiting forever.

Now the ballots decide, not the flag — and per room, because ballots land one room at a time. A decided room shows its result while its neighbours stay pickable.

On the sample, “Round by round” is empty, and that is the tool working. That tournament has finished and has since switched its public draw off, so there are no rooms or panels to show. The fold does not cache what a tab used to allow.

Publishing it

The whole site is one static HTML file plus a headers file. That is a deliberate constraint rather than an aesthetic one: a break announcement is the one moment a tournament page gets hammered, and a single file on a static host does not fall over.

Cloudflare Pages is the default because it is unmetered on bandwidth. Netlify and surge are also supported — one word in tournament.json.

Use a host that does not charge for bandwidth. Cloudflare Pages is the default here and is free for this. A break announcement is the one moment your page gets hammered, and a metered host can cut you off exactly then.

Why that warning is there

A free tier ran out of bandwidth credits mid-tournament and refused every deploy for eighteen hours, freezing the live page on a stale build right through a break-round draw going out. Worse, that provider's own quota endpoint reported plenty of credit remaining while refusing every deploy — so a quota reading is not evidence. Post a deploy and read the error.


Next: consolidated judge feedback →