profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM
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&nbsp;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&nbsp;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