feat: factor balanced pass rate into composite rankings

The dashboard now computes its own composite ranking score on the
client, blending (balanced-or-raw) average score with (balanced-or-raw)
advance pass rate via the existing scoreWeight / passRateWeight
sliders. Both inputs are toggled independently:

- 'Balance juror grading style (score)' — existing useBalancedRanking
- 'Balance juror approval rate (advance vote)' — new useBalancedPassRate

Both default to true and persist per-round. The pass rate is balanced
the same way scores are: each juror's personal yes-rate gives them a
Bernoulli stddev, each vote is z-normalized against that, and the
project's mean z is rescaled to the round's overall yes rate. A 'yes'
from a juror who rarely says yes counts more than a 'yes' from a
lenient juror.

List rows now show two chips — score (Bal/Raw X.XX) and pass rate
(Bal Yes% / Yes% N%) — so admins can see what's driving the order.
The threshold cutoff and live re-sort effect both use the same
composite formula.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-27 14:28:49 +02:00
parent aed5e078b3
commit 70f1f64ea3
4 changed files with 268 additions and 36 deletions

View File

@@ -12,7 +12,14 @@ import {
} from '../services/ai-ranking'
import { logAudit } from '../utils/audit'
import type { EvaluationConfig } from '@/types/competition-configs'
import { computeBalanceContext, computeBalancedProjectScores, type ScorePoint } from '../services/juror-balance'
import {
computeBalanceContext,
computeBalancedProjectScores,
computePassRateContext,
computeBalancedPassRates,
type ScorePoint,
type VotePoint,
} from '../services/juror-balance'
// ─── Local Types ───────────────────────────────────────────────────────────────
@@ -492,6 +499,7 @@ export const rankingRouter = router({
}>> = {}
const balancePoints: ScorePoint[] = []
const votePoints: VotePoint[] = []
for (const a of assignments) {
if (!a.evaluation) continue
@@ -523,19 +531,45 @@ export const rankingRouter = router({
rawScore: a.evaluation.globalScore,
})
}
if (decision !== null) {
votePoints.push({
projectId: a.projectId,
userId: a.userId,
vote: decision,
})
}
}
const balanceCtx = computeBalanceContext(balancePoints)
const balancedByProject = computeBalancedProjectScores(balancePoints, balanceCtx)
// Per-project balanced average on the 1-10 scale, comparable to raw avgs.
const balanced: Record<string, { rawAverage: number | null; balancedAverage: number | null }> = {}
const passRateCtx = computePassRateContext(votePoints)
const balancedPassRateByProject = computeBalancedPassRates(votePoints, passRateCtx)
// Per-project: balanced score (1-10) + balanced pass rate (0-1).
const balanced: Record<string, {
rawAverage: number | null
balancedAverage: number | null
rawPassRate: number | null
balancedPassRate: number | null
}> = {}
for (const [projectId, result] of balancedByProject.entries()) {
balanced[projectId] = {
rawAverage: result.rawAverage,
balancedAverage: result.balancedAverage,
rawPassRate: null,
balancedPassRate: null,
}
}
for (const [projectId, result] of balancedPassRateByProject.entries()) {
const existing = balanced[projectId] ?? {
rawAverage: null, balancedAverage: null, rawPassRate: null, balancedPassRate: null,
}
existing.rawPassRate = result.rawPassRate
existing.balancedPassRate = result.balancedPassRate
balanced[projectId] = existing
}
// Per-juror grading stats so the side panel can render each juror's
// personal baseline and rescaled contribution.
@@ -544,12 +578,20 @@ export const rankingRouter = router({
jurorStats[userId] = { mean: s.mean, stddev: s.stddev, count: s.count }
}
const jurorYesRates: Record<string, { yesRate: number; stddev: number; count: number }> = {}
for (const [userId, s] of passRateCtx.jurorYesRates.entries()) {
jurorYesRates[userId] = { yesRate: s.yesRate, stddev: s.stddev, count: s.count }
}
return {
byProject,
balanced,
jurorStats,
overallMean: balanceCtx.overallMean,
overallStddev: balanceCtx.overallStddev,
jurorYesRates,
overallYesRate: passRateCtx.overallYesRate,
overallYesStddev: passRateCtx.overallStddev,
}
}),
})