Judge Profiles

Shows every persisted judge profile as a roster — side-vote bias, average speaker points, delivery-speed tolerance, theory receptiveness, and the paradigm each judge is most often tagged with — ordered by rounds judged (most experienced first).

  • Route: /judges
  • Nav: the Tools page's Prep & Practice group; the Reason Editor's Workspace menu (t judge in Ctrl/Cmd-Shift-Space's command palette)
  • Package: debate-speech-writer

What it shows

For every judge with a saved JudgeProfile:

ColumnMeaning
JudgejudgeId
RoundsTotal rounds judged, across every tournament tracked
Side recordAff/Neg win record, flagged "notable bias" once it clears the judge-profile.ts threshold
Avg speaker ptsOverall average speaker points awarded
Speed tolerancelow / medium / high, or "unknown" if no round tracked pace
Theory receptivenesslow / medium / high, or "unknown" if no round raised a theory argument
ParadigmThe judge's most-tagged paradigm, if any round was tagged with one, with a "N% confidence" badge showing its share of paradigm-tagged rounds

The confidence badge is mostCommonParadigmConfidence (judge-profile.ts): the most-tagged paradigm's tag count divided by the judge's total number of paradigm-tagged rounds (untagged rounds don't count toward the denominator). A judge tagged flow in 2 of 3 tagged rounds shows "67% confidence"; a judge tagged the same paradigm every time shows "100% confidence." The same figure is folded into buildJudgeTendencySummary's "Most-tagged paradigm" line (used by the Pre-Round Intelligence Panel) and into scout-to-strategy.ts's buildJudgeAdaptationNotes, so a low-confidence tag reads as a tentative signal everywhere it's surfaced, not just here.

Logging a judged round

The panel's Log a judged round form is the in-app way to create or update a profile. One ballot at a time is entered — judge id, tournament, date, division, winning side, each side's speaker points, optionally the pace (wpm) the judge followed and the paradigm they were tagged with, and whether a theory argument was raised and won — and saved as a JudgeRoundRecord. The judge's JudgeProfile is then re-derived from their full logged history, so every column in the roster stays a derived value: there is no way to edit an aggregate directly.

"Theory argument won" is only meaningful when "Theory argument raised" is on; turning the latter off clears and disables the former, so a round can't be logged as won-but-never-raised.

Correcting a logged round

The Logged rounds table below the roster lists every ballot logged so far, filtered to one judge by typing into Filter by judge ID (a case-insensitive substring match on the judge id, so a long history doesn't bury the judge you just logged).

Each row has up to three actions:

  • Edit loads the ballot back into the form above, which switches to "Edit logged round" with Save changes / Cancel buttons. Saving rewrites that round in place (keeping its id and its position in the history) and re-aggregates the judge from the updated history. Changing the Judge ID while editing reassigns the ballot to a different judge and re-aggregates both — dropping the previous judge's derived profile if that was their last round.
  • Delete removes the ballot and re-aggregates the affected judge from whatever rounds remain, deleting the derived profile entirely once their last round is gone (rather than leaving a zero-round one). Deleting the round currently being edited also cancels the edit.
  • Undo last edit appears only on a round that has at least one edit still undoable, and steps it back to the version it held immediately before its most recent edit — re-aggregating the affected judge (or judges, if that edit reassigned the round) the same way an Edit/Save would. Clicking it repeatedly walks further back through up to the last 10 corrections, one edit at a time; deleting the round discards its undo history along with it.
  • Redo appears only on a round that has at least one undone edit still redoable — i.e. right after clicking Undo — and steps it forward again to the version Undo just replaced, the same way Undo itself re-aggregates. Clicking it repeatedly walks forward through however many times Undo was just clicked in a row. Making a fresh Edit/Save after an Undo discards the redo history (the same way any undo/redo stack invalidates "the future" once a new edit branches off from it), and deleting the round discards it too.

Data flow

state/judgeRoundRecords.ts (localStorage: judgeRoundRecords)
  → recordJudgeRound(entry)                — appends one JudgeRoundRecord to the
                                              persisted ballot history, then …
  → updateJudgeRoundRecord(entry)          — replaces one ballot by id, in place, saving
                                              its pre-edit version to a small per-round undo
                                              history first, then re-aggregates its judge
                                              (and the previous judge too, when the ballot
                                              is reassigned)
  → undoLastJudgeRoundRecordEdit(id)       — restores a ballot to the version held before
                                              its most recent edit, popping that version off
                                              the id's undo history, then re-aggregates the
                                              same way updateJudgeRoundRecord would
  → hasJudgeRoundRecordEditHistory(id)     — whether a ballot has at least one edit still
                                              undoable
  → listJudgeRoundRecordEditHistory(id)    — a ballot's prior versions, most-recent-edit-first
  → redoLastJudgeRoundRecordEdit(id)       — re-applies the version replaced by the most
                                              recent undo, popping that version off the id's
                                              redo history and pushing the version it replaces
                                              back onto the undo history, then re-aggregates
                                              the same way undoLastJudgeRoundRecordEdit would
  → hasJudgeRoundRecordRedoHistory(id)     — whether a ballot has at least one undone edit
                                              still redoable
  → listJudgeRoundRecordRedoHistory(id)    — a ballot's undone versions, most-recently-undone-first
  → deleteJudgeRoundRecord(id)             — removes one ballot and its undo/redo history,
                                              then re-aggregates (deleting the profile if
                                              none remain)
  → rebuildJudgeProfileFromRecords(judgeId) — … re-runs judge-profile.ts's existing
                                              buildJudgeProfile over that judge's
                                              full history and persists the result
                                              through saveJudgeProfile (or deletes
                                              the derived profile when no rounds
                                              remain, rather than leaving a
                                              zero-round one)

state/judgeProfiles.ts (localStorage: judgeProfiles)
  → buildJudgeProfilesRoster()             — lists every persisted JudgeProfile,
                                              ordered by rounds judged descending
                                              (ties broken alphabetically)
  → panels/JudgeProfilesPanel.tsx          — renders the log/edit form, the roster
                                              table, and the logged-rounds list
  → apps/debate-ai.com/app/judges/page.tsx — mounts the panel as a route

The two stores are deliberately separate: judgeRoundRecords is the raw ballot history (a judge decides many rounds, so each entry carries its own id, mirroring debate-data-sync's tournamentResults.ts convention), while judgeProfiles holds only the aggregate a caller looks up by judgeId. Editing (updateJudgeRoundRecord) and deleting (deleteJudgeRoundRecord) a logged round re-aggregate the affected judge the same way. A third store, judgeRoundRecordEditHistory (keyed by round id, capped at the 10 most recent prior versions per round), holds what each round looked like before each edit, so a correction can be undone via undoLastJudgeRoundRecordEdit instead of being permanent. A fourth store, judgeRoundRecordRedoHistory (same per-round cap), holds whatever version undoLastJudgeRoundRecordEdit most recently replaced, so redoLastJudgeRoundRecordEdit can step forward through it again; a fresh edit or a delete discards it, the same way it discards the undo history.

Every profile field already existed and was Vitest-covered by judge/judge-profile.ts's buildJudgeProfile; this feature closes follow-up (b), "a judge-profile card/panel UI," named under the "⚖️ Judge Profiles" bullet in TODO.md, adding one small ordering helper (buildJudgeProfilesRoster) to state/judgeProfiles.ts rather than introducing new aggregation logic. Vitest-covered in packages/debate-speech-writer/test/judgeProfiles.test.ts; state/judgeRoundRecords.ts in packages/debate-speech-writer/test/judgeRoundRecords.test.ts.

The "Filter by judge ID" input on the logged-rounds list now backs onto a <datalist> of listJudgeIds() — every distinct judge id with at least one logged round, sorted alphabetically — so typing offers the ids actually on record instead of a blank guess. When the typed filter matches nothing, findNearestJudgeId(query) (a small case-insensitive Levenshtein edit-distance search over the same id list, local to state/judgeRoundRecords.ts) suggests the closest known id as a clickable "Did you mean <id>?" prompt that refills the filter. Both are Vitest-covered in judgeRoundRecords.test.ts.

Comparing judges on a panel

The Compare judges section below the roster lets a debater prep for a panel round (two or more judges deciding together) instead of only ever reading one judge's profile at a time. Checking two or more judges in the roster's leftmost checkbox column reveals a panel-level read, built by judge/judge-panel-comparison.ts#buildJudgePanelComparison from the checked judges' already-persisted profiles:

SectionMeaning
Side leansEvery checked judge with a notable side bias (the same hasNotableSideBias threshold as the roster), and which side they lean
Recommended paceThe slowest tracked average pace among the checked judges, so prep targets the panel's least speed-tolerant vote rather than its fastest — with the judge that pace came from named alongside it
Theorysafe (every tracked judge is medium/high receptiveness), risky (every tracked judge is low), mixed (the panel is split), or unknown (no checked judge tracked theory receptiveness) — a risky/mixed verdict also lists which judges are averse
ParadigmsEach checked judge's most-tagged paradigm, flagged "conflicting" once two or more disagree (an untagged judge, or judges who all agree, don't count as a conflict)

"Clear selection" empties the checked set. The section itself only appears once at least two judge profiles exist to compare; with zero or one judge checked it shows a prompt instead of a comparison.

This is read-only — it doesn't persist anything of its own, doesn't feed back into research-task-routing.ts/progress-unlocks.ts, and (like the roster it draws from) only ever compares judges that already have a saved profile logged through this same panel.

Bulk import (CSV)

Logging one ballot at a time doesn't scale to a coach or scout's existing spreadsheet of judged rounds, so a Bulk import (CSV) section sits below the log form: paste a CSV (a header row naming the columns, in any order, then one row per round) and every well-formed row is persisted in one pass.

Required columns: judgeId, tournamentName, date, division, winningSide (aff/neg), affSpeakerPoints, and negSpeakerPoints. Optional: paceWpm, theoryArgumentRaised/theoryArgumentWon (true/false, or yes/no, 1/0 — a blank cell defaults to false, and "won" is forced false whenever "raised" is false, mirroring the log form's own rule that a theory argument can't be won without being raised), and paradigmId (one of the built-in paradigm ids — flow, lay, policymaker, critic, educator, truth-tester — matched case-insensitively, or left blank for "not tagged").

A row that fails to parse — a missing required field, an unrecognized winningSide/boolean/paradigmId, or a non-numeric points/pace value — is skipped and reported by row number rather than aborting the whole import, the same convention Opponent Team Profiles' bulk CSV import uses. Every imported row gets its own id and is appended to the full ballot history (never replacing an existing one), and each affected judge's profile is re-aggregated once per import, not once per row.

Implemented by judge/judge-round-record-csv-import.ts#parseJudgeRoundRecordsCsv (a pure, dependency-free parser) composed with state/judgeRoundRecords.ts#bulkImportJudgeRoundRecords, which does the persistence and re-aggregation. Vitest-covered in test/judge-round-record-csv-import.test.ts (parsing) and test/judgeRoundRecords.test.ts (the bulk-import store wrapper).

Cross-tab live update

Until now, JudgeProfilesPanel only read its persisted roster and logged- round history on mount, or right after its own log/edit/undo/redo/delete/ bulk-import actions — a teammate's second open tab (or a second browser window on the same machine) logging a ballot for the same judge showed a stale roster until it re-rendered for some unrelated reason. The browser's storage event fires only in other same-origin tabs/windows, never the one that made the write, so it's exactly the missing cross-tab signal every other closed panel in this repo already uses (see shared-flow-sync.md's "Cross-tab live update" section).

A new pure helper, state/live-update.ts's isJudgeProfilesLiveUpdateStorageEvent, checks whether the event's key is one of this panel's four backing stores (judgeProfiles, the aggregated roster; judgeRoundRecords, the logged-ballot history; and judgeRoundRecordEditHistory/judgeRoundRecordRedoHistory, which decide whether a round shows an Undo/Redo action) or null (a localStorage.clear()). JudgeProfilesPanel subscribes to window's storage event and calls its existing refresh() closure when the predicate matches, re-deriving the roster and logged-round list the same way its own actions already do — the in-progress "Log a judged round" form draft is left untouched, only the persisted roster/history re-reads.

Vitest-covered in test/live-update.test.ts (every backing-store key, the null-key clear-all case, and unrelated/substring-matching keys staying ignored).

Known gaps

  • No real ballot data source yet (follow-up (a) — no Round/ballot schema in this repo captures speaker points, pace, or theory outcomes today); every round is entered by hand through this panel's form or its Bulk import (CSV) section, or supplied by a caller of recordJudgeRound/bulkImportJudgeRoundRecords/saveJudgeProfile directly. This is the same gap the Opponent Team Profiles panel has, and — like that panel's own CSV import — the bulk import above doesn't need the still-blocked live Tabroom scrape (see TODO.md's "Confirmed blocker" section): it's a manual upload path for ballots already in a spreadsheet, not a live fetch.
  • Undo has no matching "redo" Closed: a Redo action now steps forward again to whatever version Undo just replaced (see "Correcting a logged round" above). Undo (and now redo) history is still capped at the 10 most recent edits per round, so a round corrected more than 10 times can't be undone all the way back to its first-ever logged version.
  • Profiles are per-browser localStorage Closed for a signed-in user: judgeProfiles and the judgeRoundRecords they aggregate now sync to the account through saved_tool_records — see Tool Data Sync. They are still per-user rather than a shared team resource, and there are still no identity/permission checks on who may log a round for a judge beyond "it's your own account's data".