@crowandraven/evalkeeper 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +6 -2
- package/bin/evalkeeper.mjs +9 -0
- package/dist/attention.d.ts +49 -0
- package/dist/attention.js +48 -0
- package/dist/attention.js.map +1 -0
- package/dist/board.d.ts +55 -0
- package/dist/board.js +2 -0
- package/dist/board.js.map +1 -0
- package/dist/cli/addPages.d.ts +79 -0
- package/dist/cli/addPages.js +181 -0
- package/dist/cli/addPages.js.map +1 -0
- package/dist/cli/main.d.ts +18 -0
- package/dist/cli/main.js +115 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/common.d.ts +72 -0
- package/dist/common.js +276 -0
- package/dist/common.js.map +1 -0
- package/dist/compare.d.ts +85 -0
- package/dist/compare.js +185 -0
- package/dist/compare.js.map +1 -0
- package/dist/decisions.d.ts +84 -0
- package/dist/decisions.js +146 -0
- package/dist/decisions.js.map +1 -0
- package/dist/feedback.d.ts +114 -0
- package/dist/feedback.js +116 -0
- package/dist/feedback.js.map +1 -0
- package/dist/format.d.ts +6 -0
- package/dist/format.js +22 -0
- package/dist/format.js.map +1 -0
- package/dist/glossary.d.ts +46 -0
- package/dist/glossary.js +45 -0
- package/dist/glossary.js.map +1 -0
- package/dist/health.d.ts +40 -0
- package/dist/health.js +101 -0
- package/dist/health.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/labels.d.ts +8 -0
- package/dist/labels.js +15 -0
- package/dist/labels.js.map +1 -0
- package/dist/legs.d.ts +23 -0
- package/dist/legs.js +22 -0
- package/dist/legs.js.map +1 -0
- package/dist/memoryStore.d.ts +21 -0
- package/dist/memoryStore.js +228 -0
- package/dist/memoryStore.js.map +1 -0
- package/dist/models.d.ts +50 -0
- package/dist/models.js +39 -0
- package/dist/models.js.map +1 -0
- package/dist/prune.d.ts +48 -0
- package/dist/prune.js +77 -0
- package/dist/prune.js.map +1 -0
- package/dist/quickSet.d.ts +26 -0
- package/dist/quickSet.js +44 -0
- package/dist/quickSet.js.map +1 -0
- package/dist/runner.d.ts +193 -0
- package/dist/runner.js +706 -0
- package/dist/runner.js.map +1 -0
- package/dist/settings.d.ts +157 -0
- package/dist/settings.js +183 -0
- package/dist/settings.js.map +1 -0
- package/dist/stableJson.d.ts +11 -0
- package/dist/stableJson.js +15 -0
- package/dist/stableJson.js.map +1 -0
- package/dist/stats.d.ts +98 -0
- package/dist/stats.js +119 -0
- package/dist/stats.js.map +1 -0
- package/dist/store.d.ts +146 -0
- package/dist/store.js +3 -0
- package/dist/store.js.map +1 -0
- package/dist/suggest.d.ts +75 -0
- package/dist/suggest.js +150 -0
- package/dist/suggest.js.map +1 -0
- package/dist/summary.d.ts +51 -0
- package/dist/summary.js +61 -0
- package/dist/summary.js.map +1 -0
- package/dist/testing.d.ts +26 -0
- package/dist/testing.js +341 -0
- package/dist/testing.js.map +1 -0
- package/dist/timeout.d.ts +2 -0
- package/dist/timeout.js +15 -0
- package/dist/timeout.js.map +1 -0
- package/dist/types.d.ts +219 -0
- package/dist/types.js +46 -0
- package/dist/types.js.map +1 -0
- package/dist/upkeepRules.d.ts +28 -0
- package/dist/upkeepRules.js +40 -0
- package/dist/upkeepRules.js.map +1 -0
- package/dist/util.d.ts +8 -0
- package/dist/util.js +18 -0
- package/dist/util.js.map +1 -0
- package/dist/weekly.d.ts +80 -0
- package/dist/weekly.js +111 -0
- package/dist/weekly.js.map +1 -0
- package/package.json +81 -4
- package/src/attention.ts +88 -0
- package/src/board.ts +56 -0
- package/src/cli/addPages.ts +256 -0
- package/src/cli/main.ts +125 -0
- package/src/common.ts +338 -0
- package/src/compare.ts +261 -0
- package/src/decisions.ts +230 -0
- package/src/feedback.ts +225 -0
- package/src/format.ts +19 -0
- package/src/glossary.ts +75 -0
- package/src/health.ts +147 -0
- package/src/index.ts +33 -0
- package/src/labels.ts +18 -0
- package/src/legs.ts +38 -0
- package/src/memoryStore.ts +268 -0
- package/src/models.ts +82 -0
- package/src/prune.ts +95 -0
- package/src/quickSet.ts +55 -0
- package/src/runner.ts +971 -0
- package/src/settings.ts +331 -0
- package/src/stableJson.ts +24 -0
- package/src/stats.ts +215 -0
- package/src/store.ts +167 -0
- package/src/suggest.ts +245 -0
- package/src/summary.ts +109 -0
- package/src/testing.ts +392 -0
- package/src/timeout.ts +14 -0
- package/src/types.ts +252 -0
- package/src/upkeepRules.ts +46 -0
- package/src/util.ts +17 -0
- package/src/weekly.ts +167 -0
- package/starter/index.ts +86 -0
- package/templates/app/api/cron/evals-weekly/route.ts +24 -0
- package/templates/app/evals/[suite]/page.tsx +449 -0
- package/templates/app/evals/[suite]/runs/[runId]/page.tsx +95 -0
- package/templates/app/evals/feedback/page.tsx +219 -0
- package/templates/app/evals/page.tsx +313 -0
- package/templates/components/evalkeeper/AddCase.tsx +62 -0
- package/templates/components/evalkeeper/AnswerKey.tsx +360 -0
- package/templates/components/evalkeeper/CaseMenu.tsx +122 -0
- package/templates/components/evalkeeper/Fields.tsx +110 -0
- package/templates/components/evalkeeper/RefreshWhile.tsx +25 -0
- package/templates/components/evalkeeper/SettingsHistory.tsx +191 -0
- package/templates/components/evalkeeper/Submit.tsx +58 -0
- package/templates/components/evalkeeper/Thumbs.tsx +117 -0
- package/templates/components/evalkeeper/Tip.tsx +98 -0
- package/templates/components/evalkeeper/WeeklyCheck.tsx +124 -0
- package/templates/components/evalkeeper/evalkeeper.css +71 -0
- package/templates/components/evalkeeper/ui.tsx +103 -0
- package/templates/lib/evalkeeper/actions.ts +263 -0
- package/templates/lib/evalkeeper/cronAuth.ts +18 -0
- package/templates/lib/evalkeeper/paths.ts +11 -0
- package/templates/lib/evalkeeper/time.ts +4 -0
- package/templates/lib/evalkeeper/weekly.ts +51 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { CheckResult } from "./types.js";
|
|
2
|
+
import type { SuiteMetrics } from "./stats.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where Evalkeeper keeps cases, answers, tests, and outputs. The runner
|
|
6
|
+
* talks only to this interface: `@crowandraven/evalkeeper-prisma` implements it on your
|
|
7
|
+
* database, and `memoryStore()` keeps everything in memory for tests and
|
|
8
|
+
* demos. Every method is a plain read or write; the rules live in the
|
|
9
|
+
* runner.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** ALL: every case in use. SMOKE: the quick set. DEV and TEST: by split. */
|
|
13
|
+
export type EvalSet = "ALL" | "SMOKE" | "DEV" | "TEST";
|
|
14
|
+
/** Every set of cases a test can run on. */
|
|
15
|
+
export const EVAL_SETS: EvalSet[] = ["ALL", "SMOKE", "DEV", "TEST"];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where an answer stands: pending review, confirmed, provisional, or rejected.
|
|
19
|
+
*/
|
|
20
|
+
export type LabelStatus = "PENDING" | "CONFIRMED" | "PROVISIONAL" | "REJECTED";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* An answer to a case. EXPECTED is what a good answer looks like; a case's
|
|
24
|
+
* newest CONFIRMED one is what tests grade against. PENDING answers (from a
|
|
25
|
+
* model, or not yet reviewed) are never graded against; PROVISIONAL ones are
|
|
26
|
+
* put to use before review and say so.
|
|
27
|
+
*/
|
|
28
|
+
export type LabelRecord = {
|
|
29
|
+
id: string;
|
|
30
|
+
kind: "EXPECTED";
|
|
31
|
+
status: LabelStatus;
|
|
32
|
+
value: unknown;
|
|
33
|
+
/** Who gave it: "OWNER", "MODEL", a reviewer's role. */
|
|
34
|
+
raterRole: string;
|
|
35
|
+
raterId: string | null;
|
|
36
|
+
note: string | null;
|
|
37
|
+
createdAt: Date;
|
|
38
|
+
updatedAt: Date;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** A stored case: a frozen input, its tags and set, and its answers. */
|
|
42
|
+
export type CaseRecord = {
|
|
43
|
+
id: string;
|
|
44
|
+
suite: string;
|
|
45
|
+
/** The suite's input, frozen. */
|
|
46
|
+
input: unknown;
|
|
47
|
+
tags: string[];
|
|
48
|
+
split: "DEV" | "TEST";
|
|
49
|
+
status: "DRAFT" | "ACTIVE" | "RETIRED";
|
|
50
|
+
/** Where it came from: "OWNER", "MODEL_DRAFT", "FEEDBACK", … */
|
|
51
|
+
source: string;
|
|
52
|
+
/** What it was made from (an id in your app), to avoid adding it twice. */
|
|
53
|
+
sourceRef: string | null;
|
|
54
|
+
note: string | null;
|
|
55
|
+
createdAt: Date;
|
|
56
|
+
updatedAt: Date;
|
|
57
|
+
labels: LabelRecord[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Where a test stands. */
|
|
61
|
+
export type RunStatus = "RUNNING" | "SUCCEEDED" | "FAILED";
|
|
62
|
+
|
|
63
|
+
/** What the runner stores on a finished test. */
|
|
64
|
+
export type RunMetrics = SuiteMetrics & {
|
|
65
|
+
judgeCostUsd: number;
|
|
66
|
+
failureReason: string | null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A stored test: one suite on one model, with what it ran with and its metrics.
|
|
71
|
+
*/
|
|
72
|
+
export type RunRecord = {
|
|
73
|
+
id: string;
|
|
74
|
+
suite: string;
|
|
75
|
+
model: string;
|
|
76
|
+
/** Hash of the instructions it ran with (see `instructionsHash`). */
|
|
77
|
+
promptVersion: string;
|
|
78
|
+
/** The reasoning setting tested; null: the model's own default. */
|
|
79
|
+
reasoningEffort: string | null;
|
|
80
|
+
set: EvalSet;
|
|
81
|
+
repeats: number;
|
|
82
|
+
/** Changes when the cases or their answers do, so tests compare. */
|
|
83
|
+
datasetVersion: string | null;
|
|
84
|
+
/** The suite's grader version when last graded. */
|
|
85
|
+
graderVersion: string;
|
|
86
|
+
status: RunStatus;
|
|
87
|
+
metrics: RunMetrics | null;
|
|
88
|
+
error: string | null;
|
|
89
|
+
estimatedCostUsd: number | null;
|
|
90
|
+
/** Who started it; null for a scheduled check. */
|
|
91
|
+
startedBy: string | null;
|
|
92
|
+
startedAt: Date;
|
|
93
|
+
finishedAt: Date | null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** One stored answer in a test, with its grades, cost, and time. */
|
|
97
|
+
export type OutputRecord = {
|
|
98
|
+
id: string;
|
|
99
|
+
runId: string;
|
|
100
|
+
caseId: string;
|
|
101
|
+
repeat: number;
|
|
102
|
+
/** The request's hash, so an identical one is reused, not paid twice. */
|
|
103
|
+
requestHash: string;
|
|
104
|
+
output: unknown;
|
|
105
|
+
/** Why the answer failed; null when it came back. */
|
|
106
|
+
error: string | null;
|
|
107
|
+
scores: CheckResult[] | null;
|
|
108
|
+
costUsd: number;
|
|
109
|
+
ms: number;
|
|
110
|
+
/** Copied from an identical earlier request: cost nothing this time. */
|
|
111
|
+
reused: boolean;
|
|
112
|
+
createdAt: Date;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** A test to record; the store fills in the id, times, and status. */
|
|
116
|
+
export type NewRun = Omit<
|
|
117
|
+
RunRecord,
|
|
118
|
+
"id" | "startedAt" | "finishedAt" | "status" | "metrics" | "error"
|
|
119
|
+
>;
|
|
120
|
+
/** An answer to record. */
|
|
121
|
+
export type NewOutput = Omit<OutputRecord, "id" | "createdAt">;
|
|
122
|
+
/** An answer to add to a case. */
|
|
123
|
+
export type NewLabel = Omit<LabelRecord, "id" | "createdAt" | "updatedAt">;
|
|
124
|
+
/** A case to create, with its first answers. */
|
|
125
|
+
export type NewCase = Omit<
|
|
126
|
+
CaseRecord,
|
|
127
|
+
"id" | "createdAt" | "updatedAt" | "labels"
|
|
128
|
+
> & {
|
|
129
|
+
labels?: NewLabel[];
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Where Evalkeeper keeps cases, answers, tests, and outputs. Implement it for another
|
|
134
|
+
* database, and run describeStoreContract against it.
|
|
135
|
+
*/
|
|
136
|
+
export interface EvalStore {
|
|
137
|
+
/** A suite's cases that aren't retired, with their answers, oldest first. */
|
|
138
|
+
listCases(suite: string): Promise<CaseRecord[]>;
|
|
139
|
+
createCase(data: NewCase): Promise<CaseRecord>;
|
|
140
|
+
/** Adds an answer to a case (answers are never overwritten). */
|
|
141
|
+
addLabel(caseId: string, label: NewLabel): Promise<LabelRecord>;
|
|
142
|
+
/** Changes an answer's status, e.g. a person confirming a draft. */
|
|
143
|
+
setLabelStatus(labelId: string, status: LabelStatus): Promise<void>;
|
|
144
|
+
/** Changes a case's status, e.g. retiring it. */
|
|
145
|
+
setCaseStatus(caseId: string, status: CaseRecord["status"]): Promise<void>;
|
|
146
|
+
getRun(id: string): Promise<RunRecord | null>;
|
|
147
|
+
/** Newest first. */
|
|
148
|
+
listRuns(
|
|
149
|
+
suite: string,
|
|
150
|
+
opts?: { status?: RunStatus; limit?: number },
|
|
151
|
+
): Promise<RunRecord[]>;
|
|
152
|
+
createRun(data: NewRun): Promise<RunRecord>;
|
|
153
|
+
updateRun(
|
|
154
|
+
id: string,
|
|
155
|
+
patch: Partial<Omit<RunRecord, "id">>,
|
|
156
|
+
/** Only if the run still has this status (a guard against races). */
|
|
157
|
+
onlyIf?: { status: RunStatus },
|
|
158
|
+
): Promise<void>;
|
|
159
|
+
listOutputs(runId: string): Promise<OutputRecord[]>;
|
|
160
|
+
/** The newest original (not reused, no error) output with this hash. */
|
|
161
|
+
findReusableOutput(
|
|
162
|
+
requestHash: string,
|
|
163
|
+
since: Date,
|
|
164
|
+
): Promise<OutputRecord | null>;
|
|
165
|
+
createOutput(data: NewOutput): Promise<OutputRecord>;
|
|
166
|
+
setOutputScores(id: string, scores: CheckResult[]): Promise<void>;
|
|
167
|
+
}
|
package/src/suggest.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { retiresLabel, type ModelCatalog } from "./models.js";
|
|
2
|
+
import type { Rate } from "./stats.js";
|
|
3
|
+
import type { CheckDef, SuitePriority } from "./types.js";
|
|
4
|
+
import type { Verdict } from "./compare.js";
|
|
5
|
+
import { at } from "./util.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Which model to make live and which the fallback, from a suite's current
|
|
9
|
+
* tests, by fixed rules so the reason is always the real one. Switching
|
|
10
|
+
* live is conservative: the candidate must score at least as high as live
|
|
11
|
+
* and make no more serious mistakes, and then needs a real reason: clearly
|
|
12
|
+
* more accurate, at least two fewer serious mistakes, or, by the suite's
|
|
13
|
+
* priority, at least 30% faster (speed) or cheaper (cost). One exception,
|
|
14
|
+
* for suites that favor cost or speed: a model that scores a little lower
|
|
15
|
+
* (within 10 points, and no clear difference) with no more serious
|
|
16
|
+
* mistakes qualifies when it costs or takes at most half as much. A suite's
|
|
17
|
+
* time limit rules out slower models, live included. Otherwise live stays, and the best-scoring other
|
|
18
|
+
* model is named as one to watch. The fallback is the most accurate model
|
|
19
|
+
* from another provider; it needn't beat live, it only runs in an outage.
|
|
20
|
+
* Models the provider is retiring are never suggested, and a retiring live
|
|
21
|
+
* model is replaced by the best-scoring one that isn't. Pure.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** One model's row, as the suggestion reads it. */
|
|
25
|
+
export type SuggestRow = {
|
|
26
|
+
key: string;
|
|
27
|
+
model: string;
|
|
28
|
+
label: string;
|
|
29
|
+
variant: boolean;
|
|
30
|
+
live: boolean;
|
|
31
|
+
fallback: boolean;
|
|
32
|
+
vsLive: Verdict | null;
|
|
33
|
+
/** May only be live (e.g. a model that can't stand in for a text model). */
|
|
34
|
+
liveOnly?: boolean;
|
|
35
|
+
/** May only be the fallback. */
|
|
36
|
+
fallbackOnly?: boolean;
|
|
37
|
+
test: {
|
|
38
|
+
right: number;
|
|
39
|
+
total: number;
|
|
40
|
+
byCheck: Record<string, Rate>;
|
|
41
|
+
avgMs: number | null;
|
|
42
|
+
costPerAnswer: number | null;
|
|
43
|
+
stale: boolean;
|
|
44
|
+
/** Answers that failed; a test with any is incomplete. */
|
|
45
|
+
failedAnswers?: number;
|
|
46
|
+
} | null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Which model to make live and which the fallback, with the reasons in plain sentences.
|
|
51
|
+
*/
|
|
52
|
+
export type Suggestion<R extends SuggestRow = SuggestRow> = {
|
|
53
|
+
live: R;
|
|
54
|
+
fallback: R | null;
|
|
55
|
+
/** Live stays as it is. */
|
|
56
|
+
keepLive: boolean;
|
|
57
|
+
/** Nothing to change at all: live stays and the fallback is already set. */
|
|
58
|
+
same: boolean;
|
|
59
|
+
/** Why, in plain sentences. */
|
|
60
|
+
reasons: string[];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How many serious checks a model's test failed; Infinity without a test.
|
|
65
|
+
*/
|
|
66
|
+
export function seriousMistakes(row: SuggestRow, checks: CheckDef[]): number {
|
|
67
|
+
const test = row.test;
|
|
68
|
+
if (!test) return Infinity;
|
|
69
|
+
return checks
|
|
70
|
+
.filter((c) => c.severity === "serious")
|
|
71
|
+
.reduce((t, c) => {
|
|
72
|
+
const r = test.byCheck[c.key];
|
|
73
|
+
return t + (r ? r.n - r.passed : 0);
|
|
74
|
+
}, 0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const mistakes = (n: number) => `${n} serious mistake${n === 1 ? "" : "s"}`;
|
|
78
|
+
const rate = (r: SuggestRow) => (r.test ? r.test.right / r.test.total : 0);
|
|
79
|
+
const pct = (x: number) => `${Math.round(x * 100)}%`;
|
|
80
|
+
const cost = (r: SuggestRow) => r.test?.costPerAnswer ?? Infinity;
|
|
81
|
+
const ms = (r: SuggestRow) => r.test?.avgMs ?? Infinity;
|
|
82
|
+
/** A cost or speed trade-off may give up at most this much accuracy. */
|
|
83
|
+
const MAX_TRADE_DROP = 0.1;
|
|
84
|
+
/** ...and must cost or take at most this share of live's. */
|
|
85
|
+
const HALF = 0.5;
|
|
86
|
+
/** At least 30% lower. */
|
|
87
|
+
const clearlyLess = (a: number, b: number) =>
|
|
88
|
+
Number.isFinite(a) && Number.isFinite(b) && a <= b * 0.7;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Suggests the live and fallback models from a suite's board, by fixed rules, so the
|
|
92
|
+
* reason given is always the real one. Null with nothing current to go on.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```ts
|
|
96
|
+
* const board = await runner.board("ticket_triage");
|
|
97
|
+
* const s = suggest(board.rows, suite.checks, suite.priority, MODELS);
|
|
98
|
+
* if (s && !s.same) console.log(s.live.label, s.fallback?.label, s.reasons);
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export function suggest<R extends SuggestRow>(
|
|
102
|
+
rows: R[],
|
|
103
|
+
checks: CheckDef[],
|
|
104
|
+
priority: SuitePriority = { favor: "quality" },
|
|
105
|
+
/** Your model catalog: providers (for the fallback) and retirements. */
|
|
106
|
+
models: ModelCatalog = {},
|
|
107
|
+
): Suggestion<R> | null {
|
|
108
|
+
const provider = (model: string) => models[model]?.provider ?? model;
|
|
109
|
+
const retiring = (r: SuggestRow) => retiresLabel(models, r.model);
|
|
110
|
+
const limitMs = priority.maxSeconds ? priority.maxSeconds * 1000 : Infinity;
|
|
111
|
+
const tooSlow = (r: SuggestRow) => ms(r) !== Infinity && ms(r) > limitMs;
|
|
112
|
+
const tested = rows.filter(
|
|
113
|
+
(r) =>
|
|
114
|
+
r.test &&
|
|
115
|
+
!r.test.stale &&
|
|
116
|
+
!r.test.failedAnswers &&
|
|
117
|
+
!retiring(r) &&
|
|
118
|
+
!tooSlow(r),
|
|
119
|
+
);
|
|
120
|
+
if (!tested.length) return null;
|
|
121
|
+
const serious = (r: R) => seriousMistakes(r, checks);
|
|
122
|
+
const live = tested.find((r) => r.live) ?? null;
|
|
123
|
+
const reasons: string[] = [];
|
|
124
|
+
const liveRow = rows.find((r) => r.live);
|
|
125
|
+
const liveRetires = liveRow ? retiring(liveRow) : null;
|
|
126
|
+
const liveSlow =
|
|
127
|
+
liveRow?.test && !liveRow.test.stale && tooSlow(liveRow)
|
|
128
|
+
? `${liveRow.label} takes ${(ms(liveRow) / 1000).toFixed(1)}s, over this suite's ${priority.maxSeconds}s limit.`
|
|
129
|
+
: null;
|
|
130
|
+
|
|
131
|
+
// Live: switch only for a real, safe improvement.
|
|
132
|
+
let next: R;
|
|
133
|
+
const liveable = tested.filter((r) => !r.fallbackOnly);
|
|
134
|
+
if (!live) {
|
|
135
|
+
if (!liveable.length) return null;
|
|
136
|
+
next = at(
|
|
137
|
+
[...liveable].sort(
|
|
138
|
+
(a, b) =>
|
|
139
|
+
rate(b) - rate(a) || serious(a) - serious(b) || cost(a) - cost(b),
|
|
140
|
+
),
|
|
141
|
+
0,
|
|
142
|
+
);
|
|
143
|
+
reasons.push(
|
|
144
|
+
liveRetires || liveSlow
|
|
145
|
+
? `${liveRetires && liveRow ? `${liveRow.label} retires in ${liveRetires}.` : liveSlow} ${next.label} scores highest of the rest (${pct(rate(next))}) with ${mistakes(serious(next))}.`
|
|
146
|
+
: `${next.label} scores highest (${pct(rate(next))}) with ${mistakes(serious(next))}. The live model has no up-to-date test to compare with.`,
|
|
147
|
+
);
|
|
148
|
+
} else {
|
|
149
|
+
const why = (r: R): string | null => {
|
|
150
|
+
if (rate(r) < rate(live)) return trade(r);
|
|
151
|
+
if (r.vsLive === "BETTER")
|
|
152
|
+
return `${r.label} is clearly more accurate than ${live.label}: ${pct(rate(r))} against ${pct(rate(live))}.`;
|
|
153
|
+
if (serious(r) <= serious(live) - 2)
|
|
154
|
+
return `${r.label} makes fewer serious mistakes than ${live.label} (${serious(r)} against ${serious(live)}), and scores at least as well (${pct(rate(r))} against ${pct(rate(live))}).`;
|
|
155
|
+
if (priority.favor === "cost" && clearlyLess(cost(r), cost(live)))
|
|
156
|
+
return `${r.label} scores at least as well as ${live.label} (${pct(rate(r))} against ${pct(rate(live))}) at ${Math.round((1 - cost(r) / cost(live)) * 100)}% less per answer.`;
|
|
157
|
+
if (priority.favor === "speed" && clearlyLess(ms(r), ms(live)))
|
|
158
|
+
return `${r.label} scores at least as well as ${live.label} (${pct(rate(r))} against ${pct(rate(live))}) and answers faster (${(ms(r) / 1000).toFixed(1)}s against ${(ms(live) / 1000).toFixed(1)}s).`;
|
|
159
|
+
return null;
|
|
160
|
+
};
|
|
161
|
+
// Cost and speed suites: a little less accurate, no clear difference,
|
|
162
|
+
// and at most half the cost (or time).
|
|
163
|
+
const trade = (r: R): string | null => {
|
|
164
|
+
if (
|
|
165
|
+
priority.favor === "quality" ||
|
|
166
|
+
r.vsLive !== "SAME" ||
|
|
167
|
+
rate(live) - rate(r) > MAX_TRADE_DROP ||
|
|
168
|
+
serious(r) > serious(live)
|
|
169
|
+
)
|
|
170
|
+
return null;
|
|
171
|
+
const close = `${r.label} is about as accurate as ${live.label} (${pct(rate(r))} against ${pct(rate(live))}: closer than these cases can tell apart), with no more serious mistakes`;
|
|
172
|
+
if (priority.favor === "cost" && cost(r) <= cost(live) * HALF)
|
|
173
|
+
return `${close}, at ${Math.round((1 - cost(r) / cost(live)) * 100)}% less per answer. This suite favors cost.`;
|
|
174
|
+
if (priority.favor === "speed" && ms(r) <= ms(live) * HALF)
|
|
175
|
+
return `${close}, and answers in ${(ms(r) / 1000).toFixed(1)}s against ${(ms(live) / 1000).toFixed(1)}s. This suite favors speed.`;
|
|
176
|
+
return null;
|
|
177
|
+
};
|
|
178
|
+
const candidates = liveable
|
|
179
|
+
.filter(
|
|
180
|
+
(r) =>
|
|
181
|
+
!r.live &&
|
|
182
|
+
r.vsLive !== "WORSE" &&
|
|
183
|
+
serious(r) <= serious(live) &&
|
|
184
|
+
why(r),
|
|
185
|
+
)
|
|
186
|
+
.sort(
|
|
187
|
+
(a, b) =>
|
|
188
|
+
Number(b.vsLive === "BETTER") - Number(a.vsLive === "BETTER") ||
|
|
189
|
+
// A trade-off only when nothing scores as well.
|
|
190
|
+
Number(rate(b) >= rate(live)) - Number(rate(a) >= rate(live)) ||
|
|
191
|
+
rate(b) - rate(a) ||
|
|
192
|
+
serious(a) - serious(b) ||
|
|
193
|
+
(priority.favor === "speed"
|
|
194
|
+
? ms(a) - ms(b) || cost(a) - cost(b)
|
|
195
|
+
: cost(a) - cost(b) || ms(a) - ms(b)),
|
|
196
|
+
);
|
|
197
|
+
if (candidates.length) {
|
|
198
|
+
next = at(candidates, 0);
|
|
199
|
+
const reason = why(next);
|
|
200
|
+
if (reason) reasons.push(reason);
|
|
201
|
+
} else {
|
|
202
|
+
next = live;
|
|
203
|
+
reasons.push(
|
|
204
|
+
`Keep ${live.label}: no other model is safely better on these cases.`,
|
|
205
|
+
);
|
|
206
|
+
// The one to watch: scored higher, but not by enough to tell.
|
|
207
|
+
const watch = tested
|
|
208
|
+
.filter((r) => !r.live && rate(r) > rate(live))
|
|
209
|
+
.sort((a, b) => rate(b) - rate(a))[0];
|
|
210
|
+
if (watch)
|
|
211
|
+
reasons.push(
|
|
212
|
+
`${watch.label} scored highest (${pct(rate(watch))} against ${pct(rate(live))}), but with this many cases the gap can't be told from noise${serious(watch) > serious(live) ? `, and it makes more serious mistakes (${serious(watch)} against ${serious(live)})` : ""}. Add cases, then test again.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Fallback: the most accurate from another provider, at the operation's
|
|
218
|
+
// setting (the setting is shared, so setting rows don't qualify).
|
|
219
|
+
const fallback =
|
|
220
|
+
tested
|
|
221
|
+
.filter(
|
|
222
|
+
(r) =>
|
|
223
|
+
!r.variant &&
|
|
224
|
+
!r.liveOnly &&
|
|
225
|
+
r.key !== next.key &&
|
|
226
|
+
provider(r.model) !== provider(next.model),
|
|
227
|
+
)
|
|
228
|
+
.sort(
|
|
229
|
+
(a, b) =>
|
|
230
|
+
rate(b) - rate(a) || serious(a) - serious(b) || cost(a) - cost(b),
|
|
231
|
+
)[0] ?? null;
|
|
232
|
+
if (fallback)
|
|
233
|
+
reasons.push(
|
|
234
|
+
`Fallback: ${fallback.label} is the most accurate from another provider (${pct(rate(fallback))}, ${mistakes(serious(fallback))}).`,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const keepLive = next.live;
|
|
238
|
+
return {
|
|
239
|
+
live: next,
|
|
240
|
+
fallback,
|
|
241
|
+
keepLive,
|
|
242
|
+
same: keepLive && (!fallback || fallback.fallback),
|
|
243
|
+
reasons,
|
|
244
|
+
};
|
|
245
|
+
}
|
package/src/summary.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { SuiteMetrics } from "./stats.js";
|
|
2
|
+
import type { CheckDef } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A test result in plain words, for people who don't want the statistics:
|
|
6
|
+
* how many answers were fully right, how far to trust that, and the most
|
|
7
|
+
* common problems. Pure.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** A check that failed, and how often. */
|
|
11
|
+
export type Problem = {
|
|
12
|
+
key: string;
|
|
13
|
+
label: string;
|
|
14
|
+
failed: number;
|
|
15
|
+
of: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A test in plain words: answers right, how far to trust that, and the common problems.
|
|
20
|
+
*/
|
|
21
|
+
export type Summary = {
|
|
22
|
+
right: number;
|
|
23
|
+
total: number;
|
|
24
|
+
/** One sentence on how much the number can be trusted. */
|
|
25
|
+
trust: string;
|
|
26
|
+
/** Checks that failed at least once, most failures first. */
|
|
27
|
+
problems: Problem[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Below this many answers the number is mostly noise. */
|
|
31
|
+
export const TRUST_MIN = 10;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A test's result in plain words, for people who don't want the statistics.
|
|
35
|
+
*/
|
|
36
|
+
export function summarize(m: SuiteMetrics, checks: CheckDef[]): Summary {
|
|
37
|
+
const right = m.allPass.passed;
|
|
38
|
+
const total = m.allPass.n;
|
|
39
|
+
const spread =
|
|
40
|
+
m.allPass.low !== null && m.allPass.high !== null
|
|
41
|
+
? Math.round(((m.allPass.high - m.allPass.low) / 2) * 100)
|
|
42
|
+
: null;
|
|
43
|
+
const trust =
|
|
44
|
+
total < TRUST_MIN
|
|
45
|
+
? `Only ${total} ${total === 1 ? "answer" : "answers"}: too few to trust yet. Confirm more cases, then test again.`
|
|
46
|
+
: spread !== null && spread > 10
|
|
47
|
+
? `A rough number: with ${total} answers it could be about ${spread} points either way.`
|
|
48
|
+
: `A solid number: ${total} answers.`;
|
|
49
|
+
const problems = checks
|
|
50
|
+
.map((c) => {
|
|
51
|
+
const r = m.byCheck[c.key];
|
|
52
|
+
return r && r.n
|
|
53
|
+
? { key: c.key, label: c.label, failed: r.n - r.passed, of: r.n }
|
|
54
|
+
: null;
|
|
55
|
+
})
|
|
56
|
+
.filter((p): p is Problem => p !== null && p.failed > 0)
|
|
57
|
+
.sort((a, b) => b.failed - a.failed);
|
|
58
|
+
return { right, total, trust, problems };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* How far a score on this many cases can be trusted, and how far off it could be.
|
|
63
|
+
*/
|
|
64
|
+
export type Confidence = {
|
|
65
|
+
level: "too-few" | "rough" | "good" | "strong";
|
|
66
|
+
label: string;
|
|
67
|
+
/** About how many points a score could be off, either way (95%). */
|
|
68
|
+
margin: number;
|
|
69
|
+
/** What the count means for choosing a model, in one sentence. */
|
|
70
|
+
sentence: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* How far to trust scores on this many cases. The margin is the 95% range
|
|
75
|
+
* of a pass rate (at the given rate, or 50%, the widest), in points.
|
|
76
|
+
*/
|
|
77
|
+
export function confidenceFor(cases: number, rate = 0.5): Confidence {
|
|
78
|
+
const p = Math.min(Math.max(rate, 0.05), 0.95);
|
|
79
|
+
const margin = cases
|
|
80
|
+
? Math.round(1.96 * Math.sqrt((p * (1 - p)) / cases) * 100)
|
|
81
|
+
: 100;
|
|
82
|
+
const level =
|
|
83
|
+
cases < 20
|
|
84
|
+
? "too-few"
|
|
85
|
+
: cases < 50
|
|
86
|
+
? "rough"
|
|
87
|
+
: cases < 100
|
|
88
|
+
? "good"
|
|
89
|
+
: "strong";
|
|
90
|
+
const label = {
|
|
91
|
+
"too-few": "too few",
|
|
92
|
+
rough: "rough",
|
|
93
|
+
good: "good",
|
|
94
|
+
strong: "strong",
|
|
95
|
+
}[level];
|
|
96
|
+
const sentence =
|
|
97
|
+
level === "too-few"
|
|
98
|
+
? `With ${cases} cases a score could be off by about ${margin} points: only big problems show. Aim for 50.`
|
|
99
|
+
: `With ${cases} cases a score could be off by about ${margin} points either way, so models closer than that can't be told apart.${level === "rough" ? " Aim for 50." : ""}`;
|
|
100
|
+
return { level, label, margin, sentence };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The answer key size to aim for (see confidenceFor). */
|
|
104
|
+
export const TARGET_CASES = 50;
|
|
105
|
+
|
|
106
|
+
/** How many drafts get the key to the target: at least 10, at most 30 a batch. */
|
|
107
|
+
export function draftsToTarget(casesNotRetired: number): number {
|
|
108
|
+
return Math.min(30, Math.max(10, TARGET_CASES - casesNotRetired));
|
|
109
|
+
}
|