critique-matrix.tsx
9,272 bytes
| 1 | 'use client'; |
|---|---|
| 2 | |
| 3 | import { Eye, EyeOff } from 'lucide-react'; |
| 4 | import { useMemo, useState } from 'react'; |
| 5 | import { Button } from '@/components/ui/button'; |
| 6 | import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; |
| 7 | import { |
| 8 | buildPredictionCells, |
| 9 | buildScoreMatrix, |
| 10 | classifyPrediction, |
| 11 | predictionCalibration, |
| 12 | type PredictionCell, |
| 13 | type PredictionReading, |
| 14 | } from '@/core/scoring'; |
| 15 | import type { CritiqueRecord, Participant, PeerReview } from '@/core/types'; |
| 16 | import { participantColor, participantTag, scoreColor } from '@/lib/model-visuals'; |
| 17 | |
| 18 | /** |
| 19 | * N×N critique grid: rows are reviewers, columns are the answers scored. |
| 20 | * Anonymized by default (identities hidden as the models saw them); a toggle |
| 21 | * de-anonymizes to real model names. Hovering a cell reveals the full critique. |
| 22 | * Cells where the reviewer disagreed with the council are outlined: violet when |
| 23 | * it predicted the council's view accurately (informed contrarian), amber when |
| 24 | * it thought everyone would agree with it (miscalibrated). |
| 25 | */ |
| 26 | export function CritiqueMatrix({ |
| 27 | participants, |
| 28 | critiques, |
| 29 | }: { |
| 30 | participants: Participant[]; |
| 31 | critiques: CritiqueRecord[]; |
| 32 | }) { |
| 33 | const [deanon, setDeanon] = useState(false); |
| 34 | const matrix = useMemo(() => buildScoreMatrix(participants, critiques), [participants, critiques]); |
| 35 | const byId = useMemo(() => new Map(participants.map((p, i) => [p.id, { p, i }])), [participants]); |
| 36 | const predictions = useMemo(() => { |
| 37 | const cells = buildPredictionCells(participants, critiques); |
| 38 | const byPair = new Map<string, PredictionCell>(); |
| 39 | for (const c of cells) byPair.set(`${c.reviewerId}:${c.targetId}`, c); |
| 40 | return { byPair, calibration: predictionCalibration(participants, cells), any: cells.length > 0 }; |
| 41 | }, [participants, critiques]); |
| 42 | |
| 43 | const reviewLookup = useMemo(() => { |
| 44 | const map = new Map<string, PeerReview>(); |
| 45 | for (const c of critiques) { |
| 46 | for (const r of c.reviews) map.set(`${c.reviewerParticipantId}:${r.targetParticipantId}`, r); |
| 47 | } |
| 48 | return map; |
| 49 | }, [critiques]); |
| 50 | |
| 51 | const nameFor = (id: string) => { |
| 52 | const entry = byId.get(id); |
| 53 | if (!entry) return id; |
| 54 | return deanon ? entry.p.displayName : `Model ${participantTag(entry.i)}`; |
| 55 | }; |
| 56 | |
| 57 | if (critiques.length === 0) { |
| 58 | return <p className="text-sm text-muted-foreground">No critiques recorded for this round.</p>; |
| 59 | } |
| 60 | |
| 61 | return ( |
| 62 | <div className="space-y-3"> |
| 63 | <div className="flex items-center justify-between"> |
| 64 | <p className="text-xs text-muted-foreground"> |
| 65 | Rows critique columns · scores 1-10 · hover a cell for the full review |
| 66 | {predictions.any && ( |
| 67 | <> |
| 68 | {' '} |
| 69 | · <span className="text-violet-500">outlined violet</span> = informed contrarian ·{' '} |
| 70 | <span className="text-amber-500">outlined amber</span> = miscalibrated |
| 71 | </> |
| 72 | )} |
| 73 | </p> |
| 74 | <Button variant="outline" size="sm" className="gap-1.5" onClick={() => setDeanon((v) => !v)}> |
| 75 | {deanon ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />} |
| 76 | {deanon ? 'Anonymize' : 'De-anonymize'} |
| 77 | </Button> |
| 78 | </div> |
| 79 | |
| 80 | <div className="scrollbar-thin overflow-x-auto"> |
| 81 | <table className="w-full min-w-max border-separate border-spacing-1 text-sm"> |
| 82 | <thead> |
| 83 | <tr> |
| 84 | <th className="p-1 text-left text-xs font-medium text-muted-foreground">reviewer ↓ / target →</th> |
| 85 | {matrix.order.map((id) => ( |
| 86 | <th key={id} className="p-1"> |
| 87 | <ColHeader index={byId.get(id)?.i ?? 0} name={nameFor(id)} /> |
| 88 | </th> |
| 89 | ))} |
| 90 | <th className="p-1 text-xs font-medium text-muted-foreground">avg recv</th> |
| 91 | {predictions.any && ( |
| 92 | <th |
| 93 | className="p-1 text-xs font-medium text-muted-foreground" |
| 94 | title="Mean error of this reviewer's council-average predictions in this round; lower reads the council better" |
| 95 | > |
| 96 | council read |
| 97 | </th> |
| 98 | )} |
| 99 | </tr> |
| 100 | </thead> |
| 101 | <tbody> |
| 102 | {matrix.order.map((reviewerId) => ( |
| 103 | <tr key={reviewerId}> |
| 104 | <td className="whitespace-nowrap p-1"> |
| 105 | <ColHeader index={byId.get(reviewerId)?.i ?? 0} name={nameFor(reviewerId)} /> |
| 106 | </td> |
| 107 | {matrix.order.map((targetId) => { |
| 108 | const score = matrix.cells[reviewerId]?.[targetId] ?? null; |
| 109 | const review = reviewLookup.get(`${reviewerId}:${targetId}`); |
| 110 | const prediction = predictions.byPair.get(`${reviewerId}:${targetId}`); |
| 111 | const reading = prediction ? classifyPrediction(prediction) : null; |
| 112 | return ( |
| 113 | <td key={targetId} className="p-0.5 text-center"> |
| 114 | {score === null ? ( |
| 115 | <div className="flex h-11 w-14 items-center justify-center rounded-md bg-muted/40 text-muted-foreground"> |
| 116 | {reviewerId === targetId ? '-' : '·'} |
| 117 | </div> |
| 118 | ) : ( |
| 119 | <Tooltip> |
| 120 | <TooltipTrigger asChild> |
| 121 | <div |
| 122 | className={`flex h-11 w-14 cursor-help items-center justify-center rounded-md font-semibold text-white transition-transform hover:scale-105 ${readingOutline(reading)}`} |
| 123 | style={{ backgroundColor: scoreColor(score) }} |
| 124 | > |
| 125 | {score} |
| 126 | </div> |
| 127 | </TooltipTrigger> |
| 128 | {review && ( |
| 129 | <TooltipContent side="top" className="max-w-sm space-y-1.5 text-left"> |
| 130 | <div className="text-xs font-medium"> |
| 131 | {nameFor(reviewerId)} → {nameFor(targetId)} · seen as "{review.label}" |
| 132 | </div> |
| 133 | {review.strengths.length > 0 && ( |
| 134 | <div className="text-[11px]"> |
| 135 | <span className="text-emerald-400">Strengths:</span> {review.strengths.join('; ')} |
| 136 | </div> |
| 137 | )} |
| 138 | {review.weaknesses.length > 0 && ( |
| 139 | <div className="text-[11px]"> |
| 140 | <span className="text-amber-400">Weaknesses:</span> {review.weaknesses.join('; ')} |
| 141 | </div> |
| 142 | )} |
| 143 | <div className="text-[11px] text-muted-foreground">{review.justification}</div> |
| 144 | {prediction && ( |
| 145 | <div className="text-[11px]"> |
| 146 | <span className="text-sky-400">Predicted council avg:</span>{' '} |
| 147 | {prediction.predicted.toFixed(1)} |
| 148 | {prediction.actualPeerMean !== null && |
| 149 | ` · actual ${prediction.actualPeerMean.toFixed(1)}`} |
| 150 | {reading === 'informed_contrarian' && ' · informed contrarian'} |
| 151 | {reading === 'miscalibrated' && ' · miscalibrated'} |
| 152 | </div> |
| 153 | )} |
| 154 | </TooltipContent> |
| 155 | )} |
| 156 | </Tooltip> |
| 157 | )} |
| 158 | </td> |
| 159 | ); |
| 160 | })} |
| 161 | <td className="p-1 text-center text-xs font-medium"> |
| 162 | {fmtAvg(matrix.averagesReceived[reviewerId])} |
| 163 | </td> |
| 164 | {predictions.any && ( |
| 165 | <td className="p-1 text-center text-xs text-muted-foreground"> |
| 166 | {fmtCalibration(predictions.calibration[reviewerId])} |
| 167 | </td> |
| 168 | )} |
| 169 | </tr> |
| 170 | ))} |
| 171 | </tbody> |
| 172 | </table> |
| 173 | </div> |
| 174 | </div> |
| 175 | ); |
| 176 | } |
| 177 | |
| 178 | function ColHeader({ index, name }: { index: number; name: string }) { |
| 179 | return ( |
| 180 | <div className="flex items-center gap-1.5"> |
| 181 | <span |
| 182 | className="flex h-5 w-5 items-center justify-center rounded text-[10px] font-bold text-white" |
| 183 | style={{ backgroundColor: participantColor(index) }} |
| 184 | > |
| 185 | {participantTag(index)} |
| 186 | </span> |
| 187 | <span className="max-w-[120px] truncate text-xs">{name}</span> |
| 188 | </div> |
| 189 | ); |
| 190 | } |
| 191 | |
| 192 | function fmtAvg(v: number | null | undefined): string { |
| 193 | return typeof v === 'number' ? v.toFixed(1) : '-'; |
| 194 | } |
| 195 | |
| 196 | /** Mean absolute error of this reviewer's council predictions; lower reads better. */ |
| 197 | function fmtCalibration(v: number | null | undefined): string { |
| 198 | return typeof v === 'number' ? `±${v.toFixed(1)}` : '-'; |
| 199 | } |
| 200 | |
| 201 | function readingOutline(reading: PredictionReading | null): string { |
| 202 | switch (reading) { |
| 203 | case 'informed_contrarian': |
| 204 | return 'ring-2 ring-violet-500'; |
| 205 | case 'miscalibrated': |
| 206 | return 'ring-2 ring-amber-500'; |
| 207 | default: |
| 208 | return ''; |
| 209 | } |
| 210 | } |
| 211 | |