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:
Matt
2026-04-27 14:28:49 +02:00
parent aed5e078b3
commit 70f1f64ea3
4 changed files with 268 additions and 36 deletions

View File

@@ -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}
@@ -1065,15 +1133,26 @@ 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>
{/* Stats summary: combined Avg card with Raw + Balanced side-by-side */}
{projectDetail.stats && (() => {