Compare commits
2 Commits
aed5e078b3
...
e0103fa956
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0103fa956 | ||
|
|
70f1f64ea3 |
@@ -86,7 +86,10 @@ type SortableProjectRowProps = {
|
||||
jurorScores: JurorScore[] | undefined
|
||||
rawAverage: number | null
|
||||
balancedAverage: number | null
|
||||
rawPassRate: number | null
|
||||
balancedPassRate: number | null
|
||||
useBalanced: boolean
|
||||
useBalancedPassRate: boolean
|
||||
onSelect: () => void
|
||||
isSelected: boolean
|
||||
originalRank: number | undefined // from snapshotOrder — always in sync with localOrder
|
||||
@@ -102,7 +105,10 @@ function SortableProjectRow({
|
||||
jurorScores,
|
||||
rawAverage,
|
||||
balancedAverage,
|
||||
rawPassRate,
|
||||
balancedPassRate,
|
||||
useBalanced,
|
||||
useBalancedPassRate,
|
||||
onSelect,
|
||||
isSelected,
|
||||
originalRank,
|
||||
@@ -212,7 +218,7 @@ function SortableProjectRow({
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-baseline gap-1 rounded-md border bg-muted/50 px-2 py-0.5 text-xs tabular-nums"
|
||||
title={`${label === 'Bal' ? 'Juror-balanced average' : 'Raw juror average'} (used for ranking)`}
|
||||
title={`${label === 'Bal' ? 'Juror-balanced average' : 'Raw juror average'} (factored into rank)`}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||
<span className="font-semibold">{active.toFixed(2)}</span>
|
||||
@@ -220,6 +226,22 @@ function SortableProjectRow({
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Active pass rate chip */}
|
||||
{(() => {
|
||||
const active = useBalancedPassRate && balancedPassRate != null ? balancedPassRate : rawPassRate
|
||||
if (active == null) return null
|
||||
const label = useBalancedPassRate && balancedPassRate != null ? 'Bal Yes%' : 'Yes%'
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-baseline gap-1 rounded-md border bg-muted/50 px-2 py-0.5 text-xs tabular-nums"
|
||||
title={`${useBalancedPassRate && balancedPassRate != null ? 'Harshness-corrected approval rate' : 'Raw approval rate'} (factored into rank)`}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||
<span className="font-semibold">{Math.round(active * 100)}%</span>
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Advance decision indicator */}
|
||||
<div className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
@@ -270,6 +292,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
const [localScoreWeight, setLocalScoreWeight] = useState(5)
|
||||
const [localPassRateWeight, setLocalPassRateWeight] = useState(5)
|
||||
const [useBalanced, setUseBalanced] = useState(true)
|
||||
const [useBalancedPassRate, setUseBalancedPassRate] = useState(true)
|
||||
const weightsInitialized = useRef(false)
|
||||
|
||||
// ─── Sensors ──────────────────────────────────────────────────────────────
|
||||
@@ -409,20 +432,30 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
const dedupedStartup = dedup(startup)
|
||||
const dedupedConcept = dedup(concept)
|
||||
|
||||
// Sort by balanced (juror-corrected) score descending when the toggle is
|
||||
// on, otherwise by raw. compositeScore is the final tiebreaker. The
|
||||
// threshold cutoff line uses the same metric so the cutoff lands in the
|
||||
// right spot regardless of which score type is used.
|
||||
const scoreFor = (projectId: string, raw: number | null | undefined) => {
|
||||
const balanced = evalScores.balanced[projectId]?.balancedAverage
|
||||
if (useBalanced && balanced != null) return balanced
|
||||
return raw ?? 0
|
||||
// Composite ranking score combining (balanced-or-raw) average with the
|
||||
// (balanced-or-raw) advance pass rate via the round's scoreWeight /
|
||||
// passRateWeight sliders. Same formula used by the live re-sort effect
|
||||
// and the threshold cutoff so all three stay in lock-step.
|
||||
const compositeFor = (projectId: string, rawScoreFallback: number | null | undefined): number => {
|
||||
const b = evalScores.balanced[projectId]
|
||||
const score = useBalanced && b?.balancedAverage != null ? b.balancedAverage : (rawScoreFallback ?? null)
|
||||
const scoreUnit = score != null ? Math.max(0, Math.min(1, (score - 1) / 9)) : 0
|
||||
const passRate =
|
||||
useBalancedPassRate && b?.balancedPassRate != null ? b.balancedPassRate
|
||||
: b?.rawPassRate != null ? b.rawPassRate
|
||||
: null
|
||||
const passUnit = passRate ?? 0
|
||||
const sW = localScoreWeight
|
||||
const pW = localPassRateWeight
|
||||
const totalW = sW + pW
|
||||
if (totalW <= 0) return scoreUnit
|
||||
return (sW * scoreUnit + pW * passUnit) / totalW
|
||||
}
|
||||
dedupedStartup.sort((a, b) =>
|
||||
scoreFor(b.projectId, b.avgGlobalScore) - scoreFor(a.projectId, a.avgGlobalScore)
|
||||
compositeFor(b.projectId, b.avgGlobalScore) - compositeFor(a.projectId, a.avgGlobalScore)
|
||||
|| b.compositeScore - a.compositeScore)
|
||||
dedupedConcept.sort((a, b) =>
|
||||
scoreFor(b.projectId, b.avgGlobalScore) - scoreFor(a.projectId, a.avgGlobalScore)
|
||||
compositeFor(b.projectId, b.avgGlobalScore) - compositeFor(a.projectId, a.avgGlobalScore)
|
||||
|| b.compositeScore - a.compositeScore)
|
||||
|
||||
// Track original order for override detection (same effect = always in sync)
|
||||
@@ -492,22 +525,32 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
return true
|
||||
})
|
||||
}
|
||||
const scoreFor = (projectId: string, raw: number | null | undefined) => {
|
||||
const balanced = evalScores.balanced[projectId]?.balancedAverage
|
||||
if (useBalanced && balanced != null) return balanced
|
||||
return raw ?? 0
|
||||
const compositeFor = (projectId: string, rawScoreFallback: number | null | undefined): number => {
|
||||
const b = evalScores.balanced[projectId]
|
||||
const score = useBalanced && b?.balancedAverage != null ? b.balancedAverage : (rawScoreFallback ?? null)
|
||||
const scoreUnit = score != null ? Math.max(0, Math.min(1, (score - 1) / 9)) : 0
|
||||
const passRate =
|
||||
useBalancedPassRate && b?.balancedPassRate != null ? b.balancedPassRate
|
||||
: b?.rawPassRate != null ? b.rawPassRate
|
||||
: null
|
||||
const passUnit = passRate ?? 0
|
||||
const sW = localScoreWeight
|
||||
const pW = localPassRateWeight
|
||||
const totalW = sW + pW
|
||||
if (totalW <= 0) return scoreUnit
|
||||
return (sW * scoreUnit + pW * passUnit) / totalW
|
||||
}
|
||||
const sortedStartup = dedup(startup).sort((a, b) =>
|
||||
scoreFor(b.projectId, b.avgGlobalScore) - scoreFor(a.projectId, a.avgGlobalScore)
|
||||
compositeFor(b.projectId, b.avgGlobalScore) - compositeFor(a.projectId, a.avgGlobalScore)
|
||||
|| b.compositeScore - a.compositeScore)
|
||||
const sortedConcept = dedup(concept).sort((a, b) =>
|
||||
scoreFor(b.projectId, b.avgGlobalScore) - scoreFor(a.projectId, a.avgGlobalScore)
|
||||
compositeFor(b.projectId, b.avgGlobalScore) - compositeFor(a.projectId, a.avgGlobalScore)
|
||||
|| b.compositeScore - a.compositeScore)
|
||||
setLocalOrder({
|
||||
STARTUP: sortedStartup.map((r) => r.projectId),
|
||||
BUSINESS_CONCEPT: sortedConcept.map((r) => r.projectId),
|
||||
})
|
||||
}, [useBalanced, evalScores, snapshot])
|
||||
}, [useBalanced, useBalancedPassRate, evalScores, snapshot, localScoreWeight, localPassRateWeight])
|
||||
|
||||
// ─── numericCriteria from eval form ─────────────────────────────────────
|
||||
const numericCriteria = useMemo(() => {
|
||||
@@ -523,6 +566,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
if (!roundData?.configJson) return
|
||||
const cfg = roundData.configJson as Record<string, unknown>
|
||||
setUseBalanced((cfg.useBalancedRanking as boolean | undefined) ?? true)
|
||||
setUseBalancedPassRate((cfg.useBalancedPassRate as boolean | undefined) ?? true)
|
||||
if (weightsInitialized.current) return
|
||||
const saved = (cfg.criteriaWeights ?? {}) as Record<string, number>
|
||||
setLocalWeights(saved)
|
||||
@@ -543,6 +587,16 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
})
|
||||
}
|
||||
|
||||
const persistUseBalancedPassRate = (next: boolean) => {
|
||||
setUseBalancedPassRate(next)
|
||||
if (!roundData?.configJson) return
|
||||
const cfg = roundData.configJson as Record<string, unknown>
|
||||
updateRoundMutation.mutate({
|
||||
id: roundId,
|
||||
configJson: { ...cfg, useBalancedPassRate: next },
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Save weights + criteria text to round config ─────────────────────────
|
||||
const saveRankingConfig = () => {
|
||||
if (!roundData?.configJson) return
|
||||
@@ -930,15 +984,26 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
: (evalConfig?.conceptAdvanceCount ?? 0))
|
||||
const threshold = evalConfig?.advanceScoreThreshold ?? 0
|
||||
|
||||
// Effective ranking score respects the per-round
|
||||
// useBalancedRanking toggle. Both the sort and the threshold
|
||||
// check read from the same helper so the cutoff lands in the
|
||||
// right spot.
|
||||
// Effective ranking score for the threshold cutoff. Mirrors
|
||||
// the composite formula used by the sort: weighted blend of
|
||||
// (balanced-or-raw) avg score and (balanced-or-raw) pass rate.
|
||||
// For the visible 1-10 threshold we render the score component
|
||||
// back on the 1-10 scale.
|
||||
const effectiveScore = (id: string) => {
|
||||
const e = rankingMap.get(id)
|
||||
const balanced = evalScores?.balanced[id]?.balancedAverage
|
||||
if (useBalanced && balanced != null) return balanced
|
||||
return e?.avgGlobalScore ?? 0
|
||||
const b = evalScores?.balanced[id]
|
||||
const score = useBalanced && b?.balancedAverage != null ? b.balancedAverage : (e?.avgGlobalScore ?? null)
|
||||
const scoreUnit = score != null ? Math.max(0, Math.min(1, (score - 1) / 9)) : 0
|
||||
const passRate =
|
||||
useBalancedPassRate && b?.balancedPassRate != null ? b.balancedPassRate
|
||||
: b?.rawPassRate != null ? b.rawPassRate
|
||||
: null
|
||||
const passUnit = passRate ?? 0
|
||||
const sW = localScoreWeight
|
||||
const pW = localPassRateWeight
|
||||
const totalW = sW + pW
|
||||
const composite = totalW <= 0 ? scoreUnit : (sW * scoreUnit + pW * passUnit) / totalW
|
||||
return composite * 9 + 1
|
||||
}
|
||||
|
||||
let cutoffIndex = -1
|
||||
@@ -1000,7 +1065,10 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
jurorScores={evalScores?.byProject[projectId]}
|
||||
rawAverage={evalScores?.balanced[projectId]?.rawAverage ?? null}
|
||||
balancedAverage={evalScores?.balanced[projectId]?.balancedAverage ?? null}
|
||||
rawPassRate={evalScores?.balanced[projectId]?.rawPassRate ?? null}
|
||||
balancedPassRate={evalScores?.balanced[projectId]?.balancedPassRate ?? null}
|
||||
useBalanced={useBalanced}
|
||||
useBalancedPassRate={useBalancedPassRate}
|
||||
onSelect={() => setSelectedProjectId(projectId)}
|
||||
isSelected={selectedProjectId === projectId}
|
||||
originalRank={hasReorders ? snapshotOrder[projectId] : undefined}
|
||||
@@ -1044,6 +1112,18 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
<SheetDescription>
|
||||
{selectedProjectId ? `ID: …${selectedProjectId.slice(-8)}` : ''}
|
||||
</SheetDescription>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
{projectDetail?.project.country && (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<CountryDisplay country={projectDetail.project.country} />
|
||||
</Badge>
|
||||
)}
|
||||
{projectDetail?.project.teamName && (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
{projectDetail.project.teamName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{selectedProjectId && (
|
||||
<a
|
||||
href={`/admin/projects/${selectedProjectId}`}
|
||||
@@ -1065,16 +1145,41 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
</div>
|
||||
) : projectDetail ? (
|
||||
<div className="mt-6 space-y-6">
|
||||
{/* Balanced-ranking toggle (per-round; persists across viewers) */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Use balanced scoring for ranking</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Corrects for per-juror grading style. Off uses raw averages.
|
||||
</span>
|
||||
{/* Balanced-ranking toggles (per-round; persist across viewers) */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Balance juror grading style (score)</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Corrects for harshness on average scores. Off uses raw averages.
|
||||
</span>
|
||||
</div>
|
||||
<Switch checked={useBalanced} onCheckedChange={persistUseBalanced} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Balance juror approval rate (advance vote)</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Weights yes/no votes by how often each juror says yes. Off uses raw pass rate.
|
||||
</span>
|
||||
</div>
|
||||
<Switch checked={useBalancedPassRate} onCheckedChange={persistUseBalancedPassRate} />
|
||||
</div>
|
||||
<Switch checked={useBalanced} onCheckedChange={persistUseBalanced} />
|
||||
</div>
|
||||
|
||||
{/* Project description (collapsible) */}
|
||||
{projectDetail.project.description && (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border p-3 text-left hover:bg-muted/50 transition-colors">
|
||||
<span className="text-sm font-medium">Description</span>
|
||||
<ChevronDown className="h-4 w-4 transition-transform data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1 rounded-lg border bg-muted/30 p-3 text-sm whitespace-pre-wrap">
|
||||
{projectDetail.project.description}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Stats summary: combined Avg card with Raw + Balanced side-by-side */}
|
||||
{projectDetail.stats && (() => {
|
||||
const raw = selectedProjectId
|
||||
@@ -1187,10 +1292,55 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && a.evaluation?.feedbackText && (
|
||||
<p className="mt-2 text-sm text-muted-foreground whitespace-pre-wrap border-t pt-2">
|
||||
{a.evaluation.feedbackText}
|
||||
</p>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 space-y-2 border-t pt-2">
|
||||
{/* Per-criterion scores */}
|
||||
{(() => {
|
||||
const scores = a.evaluation?.criterionScoresJson as Record<string, unknown> | null
|
||||
if (!scores || !evalForm?.criteriaJson) return null
|
||||
const criteria = evalForm.criteriaJson as Array<{
|
||||
id: string
|
||||
label: string
|
||||
type?: string
|
||||
trueLabel?: string
|
||||
falseLabel?: string
|
||||
scale?: number | string
|
||||
}>
|
||||
const rendered = criteria
|
||||
.map((c) => {
|
||||
const v = scores[c.id]
|
||||
if (v == null || v === '') return null
|
||||
let display: string
|
||||
if (typeof v === 'boolean') {
|
||||
display = v ? (c.trueLabel ?? 'Yes') : (c.falseLabel ?? 'No')
|
||||
} else if (typeof v === 'number') {
|
||||
display = String(v)
|
||||
} else {
|
||||
display = String(v)
|
||||
}
|
||||
return { label: c.label, display, type: c.type ?? 'numeric' }
|
||||
})
|
||||
.filter((x): x is { label: string; display: string; type: string } => x != null)
|
||||
if (rendered.length === 0) return null
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{rendered.map((c, i) => (
|
||||
<div key={i} className="flex items-start justify-between gap-3 text-xs">
|
||||
<span className="text-muted-foreground flex-1">{c.label}</span>
|
||||
<span className="font-medium tabular-nums">{c.display}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Feedback text */}
|
||||
{a.evaluation?.feedbackText && (
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{a.evaluation.feedbackText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -147,6 +147,12 @@ export const EvaluationConfigSchema = z.object({
|
||||
// from the dashboard side panel.
|
||||
useBalancedRanking: z.boolean().default(true),
|
||||
|
||||
// Whether the project pass rate (yes/no advance vote) is harshness-corrected
|
||||
// before being fed into the composite ranking formula. When true, a "yes" from
|
||||
// a juror who rarely says yes weighs more than a "yes" from a lenient juror.
|
||||
// Toggled separately from useBalancedRanking; both default to true.
|
||||
useBalancedPassRate: z.boolean().default(true),
|
||||
|
||||
// Ranking (Phase 1)
|
||||
rankingEnabled: z.boolean().default(false),
|
||||
rankingCriteria: z.string().optional(),
|
||||
|
||||
Reference in New Issue
Block a user