2026-04-24 16:19:00 +02:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}
|
2026-04-27 13:14:30 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}
|
2026-04-27 14:28:49 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}
|