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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user