Files
MOPC-Portal/docs/superpowers/specs/2026-04-27-juror-balance-toggle-and-round-scoping-design.md
Matt 64e7be2418 docs: add design spec for juror-balance toggle and round-scoping fixes
Captures the per-round toggle, side-panel deeper display, "How scores
are calculated" explainer dialog, and the cross-round contamination
fixes for getProjectDetail and getProjectRankings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:50:32 +02:00

219 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Juror-Balanced Scoring Toggle + Round-Scoping Fixes
**Status:** design
**Date:** 2026-04-27
**Author:** Matt + Claude
## Goal
Two related changes to the ranking system:
1. **Add a per-round toggle** that controls whether the ranking dashboard ranks projects by the juror-balanced (z-normalized) score or by the raw average. The toggle persists in `Round.configJson` and is shared across all viewers. Admins flip it from the side panel of the admin ranking dashboard; observers see the effect (which score is "active") but don't get the toggle UI themselves, matching today's role gates on the dashboard.
2. **Fix cross-round contamination** in two analytics procedures (`getProjectDetail`, `getProjectRankings`) and several UI surfaces that consume them. Per-juror balance contexts must be computed within a single round; aggregate stats (avg score, evaluator count, pass rate) must be scoped to the round being viewed.
A side panel "deeper display" replaces the small `⇢ X.X` annotation on the list view: the list view stays clean, and clicking into a project surfaces the raw + balanced numbers, the toggle, an explainer, and per-juror balance contributions.
## Background
Juror-balanced scoring (`src/server/services/juror-balance.ts`) corrects for per-juror grading harshness using z-normalization. Each juror's scores are normalized against their own mean + stddev across the round, then rescaled onto the round's overall mean + stddev so balanced numbers are comparable to raw averages.
The math is correct, but two scoping problems exist:
**Problem 1 — `getProjectDetail` is round-blind.** The query at `src/server/routers/analytics.ts:1417-1422` pulls every SUBMITTED evaluation for a project across every round it ever participated in, then computes Avg Score / Evaluators / Pass Rate from that pool. Meanwhile the per-juror list rendered in the admin sheet at `src/components/admin/round/ranking-dashboard.tsx:1034-1036` filters to the current round. Result: stats card disagrees with the visible per-juror list.
**Problem 2 — `getProjectRankings` (programId/edition mode) pools z-context across rounds.** At `src/server/routers/analytics.ts:212-218`, when invoked with `programId` (instead of `roundId`), evaluations from every round in the edition are fed into a single `computeBalanceContext`. A juror's mean/stddev is then computed across mixed contexts (e.g. quick intake screening + deep evaluation), producing meaningless personal calibration.
Other call sites (`ranking.ts`, `ai-juror-calibration.ts`) already filter by round and are unaffected.
## Surfaces affected
| # | Surface | Procedure | Issue |
|---|---|---|---|
| 1 | Admin ranking dashboard side sheet | `analytics.getProjectDetail` | Stats card pulls cross-round evals |
| 2 | Observer full project detail page | `analytics.getProjectDetail` | Same; observer-side |
| 3 | Observer reports preview dialog | `analytics.getProjectDetail` | Same; observer-side |
| 4 | Admin reports overview tab rankings | `analytics.getProjectRankings` | Edition mode uses cross-round z-context |
| 5 | Admin reports detail tab rankings | `analytics.getProjectRankings` | Same |
| 6 | Admin reports overview "Balanced Avg" tile | derives from #4 | Inherits the bad numbers |
| 7 | Result lock controls | `analytics.getProjectRankings` (roundId only) | OK — already round-scoped |
| 8 | Admin ranking dashboard list | `ranking.getRoundRanking` | OK — already filters by roundId |
| 9 | AI juror calibration service | self-contained | OK — already filters by roundId |
## Design
### 1. Round-scoping fixes
#### `analytics.getProjectDetail`
- Add an optional `roundId` to the input schema.
- When `roundId` is provided, filter `submittedEvaluations` (the query at line 1417) by `assignment: { roundId }`. The stats block computed from those evaluations becomes round-scoped automatically.
- When `roundId` is not provided, return `stats: null` and a new field `statsByRound: Array<{ roundId, roundName, stats }>` so callers can render per-round breakdowns instead of one misleading aggregate. (The current dialogs always know which round they want — they just weren't passing it.)
- Pass `roundId` from the three callers (#1, #2, #3 above).
#### `analytics.getProjectRankings`
When called in edition mode (`programId` only), z-normalization must run **per round**, not across the pool:
1. Group `points: ScorePoint[]` by `roundId` (we'll need to include `roundId` in each point — currently `evalWhere` returns flat evaluations; add `assignment.round.id` to the select).
2. For each round, call `computeBalanceContext(pointsForRound)` and `computeBalancedProjectScores(pointsForRound, ctx)`.
3. Aggregate per-project: a project's edition-level `balancedScore` is the unweighted mean of its per-round balanced averages. Its `averageScore` (raw) is the unweighted mean of its per-round raw averages.
4. `evaluationCount` becomes the total across rounds (unchanged in spirit).
In `roundId` mode, behavior is unchanged.
#### Default round resolution (observer full project page, #2)
The observer page at `/observer/projects/[projectId]` doesn't know which round to focus on. Resolution logic:
```
Among rounds where ProjectRoundState exists for this project:
1. If exactly one round.status = ROUND_ACTIVE, use it.
2. Else use the most recent round with status = ROUND_CLOSED
(ordered by sortOrder desc, or exitedAt desc as tiebreak).
3. Else if only ROUND_DRAFT rounds exist, fall back to none (stats: null).
```
A small round selector chip near the stats card lets the user switch contexts; the URL updates with `?round=<id>`.
### 2. Per-round balanced-scoring toggle
#### Storage
Add `useBalancedRanking: boolean` to `Round.configJson` (default `true` — preserve current behavior). No schema migration needed since `configJson` is already a flexible JSON column.
#### tRPC procedure
Extend `ranking.updateConfig` (or add `setUseBalancedRanking`) — admin/observer-procedure level. The page is admin-only today, so observer access for this toggle would be a deliberate widening. **Decision: keep it `adminProcedure`** (PROGRAM_ADMIN + SUPER_ADMIN). The user said "anyone who can view should be able to toggle," and the page is gated to admins.
#### UI integration
- Toggle lives at the top of the side sheet (not the list view) — labeled "Use balanced scoring for ranking" with a help icon that opens the explainer.
- When toggled, the dashboard re-sorts immediately (the list-view sort at `ranking-dashboard.tsx:417,879` reads from `evalScores.balanced[id]?.balancedAverage`; we'll wrap that in `useBalancedRanking ? balanced : raw`).
- The list row's compact `⇢ X.X` annotation is **removed**. Visual delta lives in the side panel only.
### 3. Side panel deeper display
The existing side sheet (`ranking-dashboard.tsx:970-1090`) gains:
#### Stats area (replaces the current 3-card grid)
```
┌──────────────────────────────────────────────────────────────┐
│ Avg Score │
│ Raw: 8.3 Balanced: 8.0 ← used for ranking │
│ │
│ Evaluators: 3 Pass Rate: 67% │
│ │
│ ⓘ How is this calculated? (collapsible) │
└──────────────────────────────────────────────────────────────┘
```
- "Raw" and "Balanced" sit side-by-side. The active one (per the round's toggle) gets a subtle "← used for ranking" tag and bolder weight.
- Both numbers always show one decimal (`.toFixed(1)`).
- Below the numbers, a clickable affordance: **"How scores are calculated"** (small button or link with an info icon). Clicking opens an explainer dialog (see "Score explainer dialog" below).
#### Per-juror rows (extends current `Juror Evaluations` block)
Each row currently shows `Name · Yes/No badge · Score: 9.0`. New layout when balanced is on:
```
Rachid Benchaouir Yes Score: 9.0 (typical 7.2 → contributes 8.5)
```
The trailing chip is muted text. When balanced is off, the chip is hidden. Tooltip on the chip explains the calculation.
#### Per-round toggle row at top
```
[Use balanced scoring for ranking] [toggle] ⓘ
```
Single horizontal row, just below the project header. Persists on flip. The ⓘ icon opens the same "How scores are calculated" dialog.
#### Score explainer dialog ("How scores are calculated")
A reusable dialog component (`<ScoreExplainerDialog />`) opens from the affordance in the side panel and from a matching affordance on the observer surfaces (#2, #3) so both audiences see the same explanation. Content is plain-language, not academic, and walks through one concrete worked example.
Structure:
1. **What it does (1 paragraph)** — "Different jurors have different grading styles. Some grade harshly, some leniently. Balanced scoring corrects for that so a project isn't punished for drawing harsh jurors or rewarded for drawing lenient ones."
2. **How it works, step by step** — five short numbered points:
1. For each juror, calculate their personal average and spread across all the projects they scored in this round.
2. Convert each individual score into "how many standard deviations above or below this juror's typical" — a 6 from a juror who averages 5 reads the same as a 9 from a juror who averages 8.
3. Average those normalized values across the project's jurors.
4. Rescale back onto the same 110 scale using the round's overall average and spread.
5. The result is directly comparable to the raw average — same scale, but corrected for grading style.
3. **Worked example** — a concrete table using fabricated jurors, e.g.:
| Juror | Their typical avg | Their score for "Project X" | What that means |
|---|---|---|---|
| Juror A (lenient) | 8.2 | 9.0 | Just slightly above their typical (+0.4σ) |
| Juror B (harsh) | 5.8 | 7.5 | Well above their typical (+1.5σ) |
| Juror C (typical) | 7.0 | 8.0 | Slightly above their typical (+0.7σ) |
"Raw average: (9.0 + 7.5 + 8.0) / 3 = **8.2**
Balanced average rescales each juror's enthusiasm to the round's overall scale and lands at **8.4** — Juror B's strong endorsement (well above their harsh baseline) carries more weight than the raw 7.5 suggests."
4. **When it kicks in / when it doesn't** — short paragraph:
- Needs ≥ 2 evaluations from the round to compute a juror's spread; otherwise that juror falls back to the round-wide average.
- Needs at least one juror with non-zero spread for the round; if everyone gave identical scores, balanced equals raw.
- Computed within a single round only — a juror's grading style in an intake screening round doesn't affect their balance in a deeper evaluation round.
5. **Why "Raw" is still shown** — "We always show both numbers so admins can sanity-check. The toggle at the top of the panel decides which one is used for ranking."
The dialog is a `shadcn/ui` `Dialog`, max-width ~`md`, scrollable. No live data — content is static text + the static example table. Lives in `src/components/shared/score-explainer-dialog.tsx` so it can be imported by admin and observer surfaces alike.
### 4. Decimal display audit
Standardize on **one decimal** for all balanced/raw score surfaces:
- `admin/reports/page.tsx:368` currently shows `toFixed(2)` — change to `toFixed(1)`.
- All other sites already use `.toFixed(1)` or compute integers.
## Data flow summary
```
Round.configJson.useBalancedRanking ──→ ranking-dashboard reads on mount
──→ list sort uses raw or balanced based on flag
──→ side panel shows both, marks the active one
getProjectDetail({ id, roundId }) ──→ filtered submittedEvaluations
──→ round-scoped stats
──→ optionally: per-round balance context computed
inline for the side panel deeper display
getProjectRankings({ programId }) ──→ group by roundId
──→ per-round balance context
──→ aggregate per-project means across rounds
```
## Out of scope
- Migrating historical `ResultLock` snapshots that captured the old (potentially miscomputed) edition-level rankings. Past locks were round-scoped, so they're already correct; only the read-time edition rollup was broken.
- Exposing the toggle to OBSERVER role. Today it's admin-only, matching page access.
- AI calibration service changes — already round-scoped.
- Changing the underlying juror-balance math. The algorithm is correct; only the inputs needed scoping.
## Risks
- **Edition rollup semantic change.** Anyone currently looking at "all rounds" balanced rankings sees different numbers after the fix. This is the right outcome but should be communicated to the team. The numbers shown today are not trustworthy.
- **Toggle default.** Defaulting `useBalancedRanking = true` preserves today's behavior. Existing rounds without the field set use the default.
- **Side-panel re-renders.** The toggle live-updates the list sort; ensure `useQuery` invalidations are wired so a flip in the panel triggers a re-fetch / re-sort without a full page reload.
## Open items
None blocking. Implementation plan can proceed.
## Acceptance criteria
1. With 3 round-scoped evaluations of 9, 8, 8, the side panel stats card shows **Avg 8.3** (not 8.0) and **Evaluators 3** (not 5).
2. Flipping the per-round toggle re-sorts the list view; the choice persists across page reloads and is shared across users.
3. The list view shows no per-row balanced delta annotation.
4. The side panel always shows both Raw and Balanced; the active one is marked.
5. Edition-level rankings (`programId` mode) compute one balance context per round and aggregate, never pooling across rounds.
6. Observer project detail page defaults to the currently-active or most-recently-closed round the project participated in.
7. All score displays use one decimal.
8. A "How scores are calculated" affordance is present in the admin side panel, the observer full project page, and the observer reports preview dialog. Clicking it opens an explainer dialog with the algorithm summary, a step-by-step plain-language walkthrough, and a worked example.