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,
}
}),
})

View File

@@ -186,3 +186,108 @@ export function computePerRoundBalanced(
}
return out
}
/**
* Juror balancing for binary advance votes (yes/no).
*
* A "yes" from a juror who rarely says yes carries more weight than a "yes"
* from a juror who routinely advances projects. We z-normalize each vote
* against the juror's personal yes-rate distribution, then rescale the
* project-level mean back onto the round's overall yes-rate scale so the
* balanced number is directly comparable to the raw pass rate.
*/
export type VotePoint = {
projectId: string
userId: string
vote: boolean
}
export type JurorYesRate = {
userId: string
yesRate: number
stddev: number
count: number
}
export type BalancedPassRateResult = {
projectId: string
rawPassRate: number | null
balancedPassRate: number | null
count: number
}
export type PassRateContext = {
overallYesRate: number
overallStddev: number
jurorYesRates: Map<string, JurorYesRate>
}
export function computePassRateContext(votes: VotePoint[]): PassRateContext {
const byJuror = new Map<string, boolean[]>()
for (const v of votes) {
const arr = byJuror.get(v.userId) ?? []
arr.push(v.vote)
byJuror.set(v.userId, arr)
}
const jurorYesRates = new Map<string, JurorYesRate>()
for (const [userId, jurorVotes] of byJuror.entries()) {
const yesCount = jurorVotes.filter(Boolean).length
const yesRate = yesCount / jurorVotes.length
// Bernoulli stddev: sqrt(p * (1 - p))
const stddev = Math.sqrt(yesRate * (1 - yesRate))
jurorYesRates.set(userId, { userId, yesRate, stddev, count: jurorVotes.length })
}
const totalYes = votes.filter((v) => v.vote).length
const overallYesRate = votes.length > 0 ? totalYes / votes.length : 0
const overallStddev = Math.sqrt(overallYesRate * (1 - overallYesRate))
return { overallYesRate, overallStddev, jurorYesRates }
}
export function computeBalancedPassRates(
votes: VotePoint[],
ctx: PassRateContext,
): Map<string, BalancedPassRateResult> {
const byProject = new Map<string, VotePoint[]>()
for (const v of votes) {
const arr = byProject.get(v.projectId) ?? []
arr.push(v)
byProject.set(v.projectId, arr)
}
const results = new Map<string, BalancedPassRateResult>()
for (const [projectId, projectVotes] of byProject.entries()) {
const yesCount = projectVotes.filter((v) => v.vote).length
const rawPassRate = yesCount / projectVotes.length
let balancedPassRate: number | null = null
if (ctx.overallStddev > 0) {
const zValues: number[] = []
for (const v of projectVotes) {
const stats = ctx.jurorYesRates.get(v.userId)
const voteVal = v.vote ? 1 : 0
if (stats && stats.stddev > 0) {
zValues.push((voteVal - stats.yesRate) / stats.stddev)
} else {
zValues.push((voteVal - ctx.overallYesRate) / ctx.overallStddev)
}
}
const avgZ = zValues.reduce((a, b) => a + b, 0) / zValues.length
// Rescale and clamp to [0, 1] — z-rescaling can otherwise produce values
// slightly outside that range when the round's yes rate is near 0 or 1.
const rescaled = ctx.overallYesRate + avgZ * ctx.overallStddev
balancedPassRate = Math.max(0, Math.min(1, rescaled))
}
results.set(projectId, {
projectId,
rawPassRate,
balancedPassRate,
count: projectVotes.length,
})
}
return results
}