Files
MOPC-Portal/src/server/services/juror-balance.ts
Matt 70f1f64ea3 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>
2026-04-27 14:28:49 +02:00

294 lines
9.0 KiB
TypeScript

/**
* Juror balancing: z-score normalization to correct for per-juror grading harshness.
*
* A juror who grades 1 standard deviation below their peers on shared projects
* shouldn't punish those projects more than a juror who grades at the mean.
* We compute per-juror mean + stddev across their scores in a round, z-normalize
* each score, then rescale back onto the same 1-10 scale using the overall
* round-level mean + stddev so the balanced number is directly comparable to
* the raw average.
*/
export type ScorePoint = {
projectId: string
userId: string
rawScore: number
}
export type BalancedProjectResult = {
projectId: string
rawAverage: number | null
balancedAverage: number | null
count: number
}
export type JurorBalance = {
userId: string
mean: number
stddev: number
count: number
}
export type BalanceContext = {
overallMean: number
overallStddev: number
jurorStats: Map<string, JurorBalance>
}
/**
* Build per-juror and overall grading statistics from a flat list of
* (project, juror, score) points. Returns the stats plus a helper to
* rescale z-scores back onto the raw-score scale.
*/
export function computeBalanceContext(points: ScorePoint[]): BalanceContext {
const jurorScores = new Map<string, number[]>()
for (const p of points) {
const arr = jurorScores.get(p.userId) ?? []
arr.push(p.rawScore)
jurorScores.set(p.userId, arr)
}
const jurorStats = new Map<string, JurorBalance>()
for (const [userId, scores] of jurorScores.entries()) {
const mean = scores.reduce((a, b) => a + b, 0) / scores.length
const variance = scores.length > 1
? scores.reduce((s, v) => s + (v - mean) ** 2, 0) / scores.length
: 0
jurorStats.set(userId, {
userId,
mean,
stddev: Math.sqrt(variance),
count: scores.length,
})
}
const allScores = points.map((p) => p.rawScore)
const overallMean = allScores.length > 0
? allScores.reduce((a, b) => a + b, 0) / allScores.length
: 0
const overallStddev = allScores.length > 1
? Math.sqrt(
allScores.reduce((s, v) => s + (v - overallMean) ** 2, 0) / allScores.length,
)
: 0
return { overallMean, overallStddev, jurorStats }
}
/**
* Aggregate per-project raw + balanced averages from score points.
*/
export function computeBalancedProjectScores(
points: ScorePoint[],
ctx: BalanceContext,
): Map<string, BalancedProjectResult> {
const byProject = new Map<string, ScorePoint[]>()
for (const p of points) {
const arr = byProject.get(p.projectId) ?? []
arr.push(p)
byProject.set(p.projectId, arr)
}
const results = new Map<string, BalancedProjectResult>()
for (const [projectId, projectPoints] of byProject.entries()) {
const rawAverage = projectPoints.reduce((a, b) => a + b.rawScore, 0) / projectPoints.length
let balancedAverage: number | null = null
if (ctx.overallStddev > 0) {
const zValues: number[] = []
for (const pt of projectPoints) {
const stats = ctx.jurorStats.get(pt.userId)
if (stats && stats.stddev > 0) {
zValues.push((pt.rawScore - stats.mean) / stats.stddev)
} else {
zValues.push((pt.rawScore - ctx.overallMean) / ctx.overallStddev)
}
}
const avgZ = zValues.reduce((a, b) => a + b, 0) / zValues.length
balancedAverage = ctx.overallMean + avgZ * ctx.overallStddev
}
results.set(projectId, {
projectId,
rawAverage,
balancedAverage,
count: projectPoints.length,
})
}
return results
}
/**
* Per-round balanced rollup: groups points by roundId, computes a balance
* context per round, then averages the per-round balanced averages for each
* project. Use when surfacing edition-level rankings — never pool z-contexts
* across rounds, because a juror's grading profile differs by round type.
*/
export type RoundScopedScorePoint = ScorePoint & { roundId: string }
export type EditionRollupResult = {
projectId: string
rawAverage: number | null
balancedAverage: number | null
count: number
roundCount: number
}
export function computePerRoundBalanced(
points: RoundScopedScorePoint[],
): Map<string, EditionRollupResult> {
const byRound = new Map<string, ScorePoint[]>()
for (const p of points) {
const arr = byRound.get(p.roundId) ?? []
arr.push({ projectId: p.projectId, userId: p.userId, rawScore: p.rawScore })
byRound.set(p.roundId, arr)
}
const perRoundResults: Array<Map<string, BalancedProjectResult>> = []
for (const roundPoints of byRound.values()) {
const ctx = computeBalanceContext(roundPoints)
perRoundResults.push(computeBalancedProjectScores(roundPoints, ctx))
}
const accumulator = new Map<
string,
{ rawSum: number; rawCount: number; balancedSum: number; balancedCount: number; count: number; roundCount: number }
>()
for (const roundMap of perRoundResults) {
for (const [projectId, result] of roundMap.entries()) {
const acc = accumulator.get(projectId) ?? {
rawSum: 0, rawCount: 0, balancedSum: 0, balancedCount: 0, count: 0, roundCount: 0,
}
if (result.rawAverage != null) {
acc.rawSum += result.rawAverage
acc.rawCount += 1
}
if (result.balancedAverage != null) {
acc.balancedSum += result.balancedAverage
acc.balancedCount += 1
}
acc.count += result.count
acc.roundCount += 1
accumulator.set(projectId, acc)
}
}
const out = new Map<string, EditionRollupResult>()
for (const [projectId, acc] of accumulator.entries()) {
out.set(projectId, {
projectId,
rawAverage: acc.rawCount > 0 ? acc.rawSum / acc.rawCount : null,
balancedAverage: acc.balancedCount > 0 ? acc.balancedSum / acc.balancedCount : null,
count: acc.count,
roundCount: acc.roundCount,
})
}
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
}