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:
@@ -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 && (() => {
|
||||
|
||||
Reference in New Issue
Block a user