/** * 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 } /** * 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() for (const p of points) { const arr = jurorScores.get(p.userId) ?? [] arr.push(p.rawScore) jurorScores.set(p.userId, arr) } const jurorStats = new Map() 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 { const byProject = new Map() for (const p of points) { const arr = byProject.get(p.projectId) ?? [] arr.push(p) byProject.set(p.projectId, arr) } const results = new Map() 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 { const byRound = new Map() 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> = [] 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() 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 } export function computePassRateContext(votes: VotePoint[]): PassRateContext { const byJuror = new Map() for (const v of votes) { const arr = byJuror.get(v.userId) ?? [] arr.push(v.vote) byJuror.set(v.userId, arr) } const jurorYesRates = new Map() 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 { const byProject = new Map() for (const v of votes) { const arr = byProject.get(v.projectId) ?? [] arr.push(v) byProject.set(v.projectId, arr) } const results = new Map() 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 }