openmerit 0.4.1 → 0.4.2
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/README.md +239 -58
- package/data/catalogue.json +6778 -0
- package/dist/catalogue/catalogue.d.ts +41 -0
- package/dist/catalogue/catalogue.js +33 -0
- package/dist/cli/prompt.d.ts +26 -0
- package/dist/cli/prompt.js +114 -0
- package/dist/cli/render.d.ts +12 -0
- package/dist/cli/render.js +104 -0
- package/dist/cli.js +245 -158
- package/dist/connect.d.ts +34 -0
- package/dist/connect.js +158 -0
- package/dist/connectors/langfuse/index.d.ts +37 -110
- package/dist/connectors/langfuse/index.js +95 -246
- package/dist/daemon/daemon.d.ts +81 -0
- package/dist/daemon/daemon.js +408 -0
- package/dist/daemon/registry.d.ts +111 -0
- package/dist/daemon/registry.js +109 -0
- package/dist/eval/proxy.d.ts +54 -0
- package/dist/eval/proxy.js +174 -0
- package/dist/eval/runner.d.ts +29 -0
- package/dist/eval/runner.js +80 -0
- package/dist/harness/claude-bridge.d.ts +21 -0
- package/dist/harness/claude-bridge.js +65 -0
- package/dist/harness/harness.d.ts +60 -0
- package/dist/harness/harness.js +221 -0
- package/dist/index.d.ts +9 -10
- package/dist/index.js +9 -10
- package/dist/local.d.ts +12 -0
- package/dist/local.js +40 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +155 -0
- package/dist/scout/pool.d.ts +17 -0
- package/dist/scout/pool.js +56 -0
- package/dist/scout/scan.d.ts +42 -0
- package/dist/scout/scan.js +169 -0
- package/dist/scout/search.d.ts +25 -98
- package/dist/scout/search.js +56 -156
- package/dist/scout/workflow.d.ts +221 -0
- package/dist/scout/workflow.js +941 -0
- package/dist/setup.d.ts +44 -0
- package/dist/setup.js +211 -0
- package/dist/state/config.d.ts +116 -0
- package/dist/state/config.js +60 -0
- package/dist/state/db.d.ts +159 -0
- package/dist/state/db.js +260 -0
- package/dist/state/paths.d.ts +12 -0
- package/dist/state/paths.js +23 -0
- package/dist/update.d.ts +22 -0
- package/dist/update.js +58 -0
- package/package.json +12 -17
- package/dist/connectors/langfuse/otlp.d.ts +0 -60
- package/dist/connectors/langfuse/otlp.js +0 -40
- package/dist/pi/extension.d.ts +0 -13
- package/dist/pi/extension.js +0 -148
- package/dist/pi/harness.d.ts +0 -20
- package/dist/pi/harness.js +0 -49
- package/dist/pi/instructions.d.ts +0 -2
- package/dist/pi/instructions.js +0 -12
- package/dist/scout/evaluators/module.d.ts +0 -24
- package/dist/scout/evaluators/module.js +0 -19
- package/dist/scout/evaluators/pi.d.ts +0 -53
- package/dist/scout/evaluators/pi.js +0 -117
- package/dist/scout/pipeline.d.ts +0 -111
- package/dist/scout/pipeline.js +0 -157
- package/dist/scout/report.d.ts +0 -3
- package/dist/scout/report.js +0 -42
- package/dist/scout/service.d.ts +0 -148
- package/dist/scout/service.js +0 -143
- package/examples/health-declaration-codes/dataset.jsonl +0 -14
- package/examples/sahra-enrollment/evaluator.mts +0 -152
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
// Scout's workflow as a resumable state machine shared by the CLI and the MCP server.
|
|
2
|
+
// `advance(ctx, runId, answers)` works until it needs a person (questions), finishes, pauses on the
|
|
3
|
+
// budget or fails. Every step is saved in SQLite, so a run continues after a restart and history
|
|
4
|
+
// keeps failed, interrupted and budget-paused runs.
|
|
5
|
+
//
|
|
6
|
+
// scan → map → model → tasks (with corrections) → pool → discover → approve → evaluate
|
|
7
|
+
// → compare → configure → done
|
|
8
|
+
//
|
|
9
|
+
// Evaluate is Scout Model Binary Search (search.ts) on the task's own eval: the current model runs
|
|
10
|
+
// every item; candidates are screened on sampled items the current model passes; five finalists
|
|
11
|
+
// (plus cheaper, stronger challengers) run every item. Each eval run is a unit in SQLite, so a
|
|
12
|
+
// resumed run replays the search from recorded results and never pays twice for a finished unit.
|
|
13
|
+
// Per-item results come from the Langfuse experiment the eval records, read with a Viewer key.
|
|
14
|
+
import { createHash, randomInt } from "node:crypto";
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "node:path";
|
|
17
|
+
import { catalogueIndex } from "../catalogue/catalogue.js";
|
|
18
|
+
import { createLangfuseConnector } from "../connectors/langfuse/index.js";
|
|
19
|
+
import { EvalProxy, retryable } from "../eval/proxy.js";
|
|
20
|
+
import { runEval } from "../eval/runner.js";
|
|
21
|
+
import { DEFAULT_BUDGET_USD, DEFAULT_REPEATS, readRepoConfig, resolveCredential, updateRepoConfig } from "../state/config.js";
|
|
22
|
+
import { allowedOn, baselineEndpoint, blendedPrice, pool } from "./pool.js";
|
|
23
|
+
import { context, listFiles, redact, scanRepo } from "./scan.js";
|
|
24
|
+
import { binarySearch, CandidateUnavailable, CHALLENGERS, challengers, FINALISTS, order, SAMPLE_SIZE } from "./search.js";
|
|
25
|
+
const PHASES = ["scan", "disclose", "map", "model", "corrections", "tasks", "pool", "discover", "approve", "evaluate", "compare", "configure", "done"];
|
|
26
|
+
export const TOP = 3;
|
|
27
|
+
export const TASK_QUESTION = "Select which Agents/Tasks do you want OpenMerit to review?";
|
|
28
|
+
export const CONFIGURE_QUESTION = "Should OpenMerit configure this? Yes / No.";
|
|
29
|
+
/** Provider answers that mean the model cannot run at all: skipped as untested, not failed. */
|
|
30
|
+
const UNAVAILABLE_STATUSES = [400, 402, 404, 413, 422];
|
|
31
|
+
const slug = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "").slice(0, 40) || "task";
|
|
32
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
33
|
+
const yes = (value) => value === true || (typeof value === "string" && /^(y|yes|true|approve|ok)$/i.test(value.trim()));
|
|
34
|
+
const no = (value) => value === false || (typeof value === "string" && /^(n|no|false|decline|cancel)$/i.test(value.trim()));
|
|
35
|
+
const list = (value) => (Array.isArray(value) ? value.map(String) : typeof value === "string" ? value.split(",").map(item => item.trim()).filter(Boolean) : []);
|
|
36
|
+
const strict = (properties) => ({ type: "object", properties, required: Object.keys(properties), additionalProperties: false });
|
|
37
|
+
const usd = (value) => (value === null ? "unknown" : `$${value.toFixed(value < 0.01 ? 5 : 4)}`);
|
|
38
|
+
class NeedsInput {
|
|
39
|
+
questions;
|
|
40
|
+
status;
|
|
41
|
+
notices;
|
|
42
|
+
constructor(questions, status = "needs_input", notices = []) { this.questions = questions; this.status = status; this.notices = notices; }
|
|
43
|
+
}
|
|
44
|
+
/** Ends one task's search without a recommendation; the run goes on with the other tasks. */
|
|
45
|
+
class StopTask extends Error {
|
|
46
|
+
}
|
|
47
|
+
export function newRunId() { return `run-${new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "").replace("T", "-")}-${hash(String(Math.random())).slice(0, 4)}`; }
|
|
48
|
+
/** Advances a run as far as it can go. Answers are keyed by question ID. */
|
|
49
|
+
export async function advance(ctx, runId, answers = {}) {
|
|
50
|
+
const { state } = ctx;
|
|
51
|
+
state.reapInterrupted(ctx.repo.id);
|
|
52
|
+
const run = state.getRun(runId);
|
|
53
|
+
if (!run)
|
|
54
|
+
throw new Error(`No run ${runId} in this repository's history.`);
|
|
55
|
+
if (run.repoId !== ctx.repo.id)
|
|
56
|
+
throw new Error(`Run ${runId} belongs to ${run.repoRoot}, not ${ctx.repo.root}.`);
|
|
57
|
+
if (run.status === "done" || run.status === "cancelled")
|
|
58
|
+
return step(ctx, runId, [], []);
|
|
59
|
+
if (!state.lock(runId))
|
|
60
|
+
return { ...step(ctx, runId, [], ["Another OpenMerit process (CLI or MCP) is working on this run. Wait for it, or check `openmerit history`."]) };
|
|
61
|
+
const data = run.state;
|
|
62
|
+
const notices = [];
|
|
63
|
+
let harnessStart = null;
|
|
64
|
+
let harness = null;
|
|
65
|
+
const getHarness = async () => {
|
|
66
|
+
if (!harness) {
|
|
67
|
+
harness = await ctx.harness();
|
|
68
|
+
harnessStart = { ...harness.usage };
|
|
69
|
+
// `openmerit connect` between steps switches the harness for the rest of the run: history lists each one in order.
|
|
70
|
+
const used = state.getRun(runId).harness.split(" → ");
|
|
71
|
+
if (used.at(-1) !== harness.label) {
|
|
72
|
+
state.updateRun(runId, { harness: [...used, harness.label].join(" → ") });
|
|
73
|
+
state.event(runId, "harness", `Harness switched from ${used.at(-1)} to ${harness.label}.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return harness;
|
|
77
|
+
};
|
|
78
|
+
const save = (phase, status = "running") => {
|
|
79
|
+
if (harness && harnessStart) {
|
|
80
|
+
const used = harness.usage, total = data.harnessUsage ?? { calls: 0, costUsd: 0, unknownCost: 0, inputTokens: 0, outputTokens: 0 };
|
|
81
|
+
data.harnessUsage = { calls: total.calls + used.calls - harnessStart.calls, costUsd: total.costUsd + used.costUsd - harnessStart.costUsd, unknownCost: total.unknownCost + used.unknownCost - harnessStart.unknownCost,
|
|
82
|
+
inputTokens: total.inputTokens + used.inputTokens - harnessStart.inputTokens, outputTokens: total.outputTokens + used.outputTokens - harnessStart.outputTokens };
|
|
83
|
+
harnessStart = { ...used };
|
|
84
|
+
}
|
|
85
|
+
state.updateRun(runId, { state: data, status, ...(phase ? { phase } : {}) });
|
|
86
|
+
};
|
|
87
|
+
let phase = run.phase;
|
|
88
|
+
state.updateRun(runId, { status: "running", error: null });
|
|
89
|
+
try {
|
|
90
|
+
for (;;) {
|
|
91
|
+
if (ctx.signal?.aborted)
|
|
92
|
+
throw new Error("Interrupted");
|
|
93
|
+
const next = await PHASE_HANDLERS[phase]({ ctx, runId, data, answers, notices, getHarness, save });
|
|
94
|
+
if (next === "done") {
|
|
95
|
+
save("done", "done");
|
|
96
|
+
state.event(runId, "done", "Run finished.");
|
|
97
|
+
return step(ctx, runId, [], notices);
|
|
98
|
+
}
|
|
99
|
+
if (next === "cancelled") {
|
|
100
|
+
save(phase, "cancelled");
|
|
101
|
+
state.event(runId, "cancelled", "Run cancelled; nothing further was executed.");
|
|
102
|
+
return step(ctx, runId, [], notices);
|
|
103
|
+
}
|
|
104
|
+
phase = next;
|
|
105
|
+
save(phase);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (error instanceof NeedsInput) {
|
|
110
|
+
save(phase, error.status);
|
|
111
|
+
return step(ctx, runId, error.questions, [...notices, ...error.notices]);
|
|
112
|
+
}
|
|
113
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
114
|
+
data.failedPhase = phase;
|
|
115
|
+
if (ctx.signal?.aborted) {
|
|
116
|
+
save(phase, "interrupted");
|
|
117
|
+
state.markInterrupted(runId);
|
|
118
|
+
return step(ctx, runId, [], notices);
|
|
119
|
+
}
|
|
120
|
+
save(phase, "failed");
|
|
121
|
+
state.updateRun(runId, { error: redact(message) });
|
|
122
|
+
state.event(runId, "failed", redact(message));
|
|
123
|
+
return { ...step(ctx, runId, [], notices), error: redact(message) };
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
state.unlock(runId);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export function step(ctx, runId, questions, notices) {
|
|
130
|
+
const run = ctx.state.getRun(runId);
|
|
131
|
+
const data = run.state;
|
|
132
|
+
return {
|
|
133
|
+
runId, status: run.status, phase: run.phase, questions, notices, spend: ctx.state.spend(runId), budgetUsd: run.budgetUsd, harnessUsage: data.harnessUsage ?? null,
|
|
134
|
+
...(run.error ? { error: run.error } : {}),
|
|
135
|
+
...(run.status === "done" || data.compare ? { result: { model: data.model ?? null, tasks: Object.values(data.compare ?? {}), configuration: data.configure ?? {} } } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const usageById = (data) => new Map((data.scan?.usages ?? []).map(usage => [usage.id, usage]));
|
|
139
|
+
const ref = (usage) => `${usage.file}:${usage.line}`;
|
|
140
|
+
const modelKey = (usage) => usage.catalogueId ?? `?${usage.value}`;
|
|
141
|
+
function tasksForModel(data) {
|
|
142
|
+
const usages = usageById(data);
|
|
143
|
+
return (data.tasks ?? []).filter(task => task.usageIds.some(id => { const usage = usages.get(id); return usage && usage.kind === "model" && modelKey(usage) === data.model; }));
|
|
144
|
+
}
|
|
145
|
+
function repoConfig(ctx) { return readRepoConfig(ctx.repo.root); }
|
|
146
|
+
function endpoints(ctx) {
|
|
147
|
+
const configured = repoConfig(ctx).endpoints ?? [];
|
|
148
|
+
if (!configured.length)
|
|
149
|
+
throw new Error("No candidate endpoints are configured for this repository. Run `openmerit setup`.");
|
|
150
|
+
return configured;
|
|
151
|
+
}
|
|
152
|
+
function baselineRef(ctx, data) {
|
|
153
|
+
const base = baselineEndpoint(endpoints(ctx), ctx.catalogue, data.model);
|
|
154
|
+
return { model: base.model.id, endpoint: base.endpoint.id, endpointModel: base.endpointModel };
|
|
155
|
+
}
|
|
156
|
+
const plannedEvals = (data) => Object.entries(data.evals ?? {}).filter((entry) => !("skipped" in entry[1]));
|
|
157
|
+
const PHASE_HANDLERS = {
|
|
158
|
+
async scan({ ctx, runId, data }) {
|
|
159
|
+
ctx.log("Scanning codebase for models and endpoints…");
|
|
160
|
+
const scan = scanRepo(ctx.repo.root, ctx.catalogue, { exclude: repoConfig(ctx).scan?.exclude ?? [] });
|
|
161
|
+
data.scan = scan;
|
|
162
|
+
if (scan.skipped.length) {
|
|
163
|
+
ctx.log(`Skipped ${scan.skipped.length} data file${scan.skipped.length === 1 ? "" : "s"}: ${scan.skipped.slice(0, 2).join(", ")}${scan.skipped.length > 2 ? ` and ${scan.skipped.length - 2} more` : ""}`);
|
|
164
|
+
ctx.state.event(runId, "scan", `Skipped data files that list many models: ${scan.skipped.join(", ")}.`);
|
|
165
|
+
}
|
|
166
|
+
const models = scan.usages.filter(usage => usage.kind === "model");
|
|
167
|
+
ctx.state.event(runId, "scan", `Scanned ${scan.files} files: ${models.length} model usages (${models.filter(usage => usage.dynamic).length} dynamic), ${scan.usages.length - models.length} endpoint usages.`);
|
|
168
|
+
if (!models.length)
|
|
169
|
+
throw new Error("No model usages found in this repository. Scout looks for model IDs in code and configuration that Git does not ignore.");
|
|
170
|
+
return "map";
|
|
171
|
+
},
|
|
172
|
+
// Scout used to ask here before sending code context to the harness; runs saved at that question continue to the mapping.
|
|
173
|
+
async disclose() { return "map"; },
|
|
174
|
+
async map({ ctx, runId, data, getHarness }) {
|
|
175
|
+
const scan = data.scan;
|
|
176
|
+
ctx.log(`Mapping ${scan.usages.length} usages to tasks…`);
|
|
177
|
+
const harness = await getHarness();
|
|
178
|
+
const listing = scan.usages.slice(0, 120).map(usage => `### ${usage.id} ${usage.kind}${usage.dynamic ? " (dynamic)" : ""}: ${usage.value}${usage.catalogueId ? ` = catalogue ${usage.catalogueId}` : ""} at ${ref(usage)}\n${context(ctx.repo.root, usage)}`).join("\n\n");
|
|
179
|
+
const reply = await harness.json({
|
|
180
|
+
cwd: ctx.repo.root,
|
|
181
|
+
system: "You are OpenMerit Harness running OpenMerit Scout. You map model usages in a codebase to the agents or tasks they serve. Read files only to understand them; never change anything.",
|
|
182
|
+
prompt: `Repository: ${ctx.repo.root}\n\nGroup these model usages into the agents or tasks they serve (one task per distinct job a model does in production, such as \"ticket classifier\" or \"claim summary\"). A model used by two jobs belongs to two tasks. Include every model usage, including dynamic ones, in a task. For each task say whether the call uses tool calls, image input, or structured (JSON) output. Use short task names, and describe each task in one short sentence.\n\n${listing}`,
|
|
183
|
+
schema: strict({ tasks: { type: "array", items: strict({ name: { type: "string" }, description: { type: "string" }, usageIds: { type: "array", items: { type: "string" } }, usesTools: { type: "boolean" }, needsVision: { type: "boolean" }, structuredOutput: { type: "boolean" } }) } }),
|
|
184
|
+
});
|
|
185
|
+
const known = usageById(data);
|
|
186
|
+
const tasks = (reply.tasks ?? []).map(task => ({ ...task, name: String(task.name).trim().slice(0, 80), usageIds: [...new Set(task.usageIds.filter(id => known.has(id)))] })).filter(task => task.name && task.usageIds.length);
|
|
187
|
+
for (const usage of scan.usages.filter(item => item.kind === "model" && !tasks.some(task => task.usageIds.includes(item.id)))) {
|
|
188
|
+
const name = `unmapped: ${usage.file}`;
|
|
189
|
+
const existing = tasks.find(task => task.name === name);
|
|
190
|
+
if (existing)
|
|
191
|
+
existing.usageIds.push(usage.id);
|
|
192
|
+
else
|
|
193
|
+
tasks.push({ name, description: "Model usage the harness did not map to a task", usageIds: [usage.id], usesTools: false, needsVision: false, structuredOutput: false });
|
|
194
|
+
}
|
|
195
|
+
data.tasks = tasks;
|
|
196
|
+
ctx.state.event(runId, "map", `Harness mapped usages to ${tasks.length} tasks: ${tasks.map(task => task.name).join(", ")}.`);
|
|
197
|
+
return "model";
|
|
198
|
+
},
|
|
199
|
+
async model({ ctx, data, answers }) {
|
|
200
|
+
const scan = data.scan;
|
|
201
|
+
const index = catalogueIndex(ctx.catalogue);
|
|
202
|
+
const choices = scan.models;
|
|
203
|
+
const answer = answers.model;
|
|
204
|
+
if (typeof answer === "string" && answer.trim()) {
|
|
205
|
+
const value = answer.trim();
|
|
206
|
+
const match = choices.find(item => item.value === value || item.catalogueId === value || `?${item.value}` === value) ?? (index.find(value) ? choices.find(item => item.catalogueId === index.find(value).id) : undefined);
|
|
207
|
+
if (!match)
|
|
208
|
+
throw new NeedsInput([modelQuestion(data)], "needs_input", [`${value} is not one of the models found in this repository.`]);
|
|
209
|
+
data.model = match.catalogueId ?? `?${match.value}`;
|
|
210
|
+
if (!match.catalogueId)
|
|
211
|
+
throw new Error(`${match.value} is not in the bundled catalogue (version ${ctx.catalogue.version}), so Scout cannot price or place it. Update openmerit, or choose a catalogue model.`);
|
|
212
|
+
return "tasks";
|
|
213
|
+
}
|
|
214
|
+
if (!data.scan.models.some(item => item.catalogueId))
|
|
215
|
+
throw new Error(`None of the models found in this repository is in the bundled catalogue (version ${ctx.catalogue.version}), so Scout cannot price or place them. Update openmerit.`);
|
|
216
|
+
throw new NeedsInput([modelQuestion(data)]);
|
|
217
|
+
},
|
|
218
|
+
// Scout used to ask for mapping corrections here; the task question takes them now.
|
|
219
|
+
async corrections() { return "tasks"; },
|
|
220
|
+
async tasks({ data, answers }) {
|
|
221
|
+
const chosen = list(answers.tasks);
|
|
222
|
+
// <usage id>=<task name> moves a usage to that task, or to a new one, before the selection.
|
|
223
|
+
for (const item of chosen.filter(entry => entry.includes("="))) {
|
|
224
|
+
const [id = "", ...rest] = item.split("=");
|
|
225
|
+
const name = rest.join("=").trim();
|
|
226
|
+
const usage = usageById(data).get(id.trim());
|
|
227
|
+
if (!usage || !name)
|
|
228
|
+
throw new NeedsInput([tasksQuestion(data)], "needs_input", [`Write a correction as <usage id>=<task name>, not "${item}".`]);
|
|
229
|
+
for (const task of data.tasks)
|
|
230
|
+
task.usageIds = task.usageIds.filter(other => other !== usage.id);
|
|
231
|
+
const target = data.tasks.find(task => task.name === name);
|
|
232
|
+
if (target)
|
|
233
|
+
target.usageIds.push(usage.id);
|
|
234
|
+
else
|
|
235
|
+
data.tasks.push({ name, description: "Added by the user", usageIds: [usage.id], usesTools: false, needsVision: false, structuredOutput: false });
|
|
236
|
+
data.tasks = data.tasks.filter(task => task.usageIds.length);
|
|
237
|
+
}
|
|
238
|
+
const names = chosen.filter(entry => !entry.includes("="));
|
|
239
|
+
if (!names.length)
|
|
240
|
+
throw new NeedsInput([tasksQuestion(data)]);
|
|
241
|
+
const available = tasksForModel(data);
|
|
242
|
+
const unknown = names.filter(name => !available.some(task => task.name === name));
|
|
243
|
+
if (unknown.length)
|
|
244
|
+
throw new NeedsInput([tasksQuestion(data)], "needs_input", [`Not a task of ${data.model}: ${unknown.join(", ")}.`]);
|
|
245
|
+
data.selected = names;
|
|
246
|
+
return "pool";
|
|
247
|
+
},
|
|
248
|
+
async pool({ ctx, runId, data }) {
|
|
249
|
+
const configured = endpoints(ctx);
|
|
250
|
+
const baseline = ctx.catalogue.models.find(model => model.id === data.model);
|
|
251
|
+
if (!baselineEndpoint(configured, ctx.catalogue, baseline.id))
|
|
252
|
+
throw new Error(`No configured endpoint can run the current model ${baseline.id}, so it cannot be measured as the baseline. Add an endpoint that serves it with \`openmerit setup\`.`);
|
|
253
|
+
data.pool = {};
|
|
254
|
+
data.unranked = {};
|
|
255
|
+
for (const name of data.selected) {
|
|
256
|
+
const task = data.tasks.find(item => item.name === name);
|
|
257
|
+
const eligible = eligibleCandidates(ctx.catalogue, configured, baseline, task);
|
|
258
|
+
const ranked = order(eligible.filter(item => item.model.eci !== null).map(toEntry));
|
|
259
|
+
data.pool[name] = ranked;
|
|
260
|
+
data.unranked[name] = eligible.length - ranked.length;
|
|
261
|
+
ctx.state.event(runId, "pool", `${name}: ${ranked.length} ECI-ranked candidates cheaper than ${baseline.id} in the allowed pool${data.unranked[name] ? ` (${data.unranked[name]} without an ECI left out)` : ""}.`);
|
|
262
|
+
}
|
|
263
|
+
return "discover";
|
|
264
|
+
},
|
|
265
|
+
async discover({ ctx, runId, data, answers, getHarness, save }) {
|
|
266
|
+
data.evals ??= {};
|
|
267
|
+
const saved = repoConfig(ctx).evals ?? {};
|
|
268
|
+
const missing = [];
|
|
269
|
+
for (const name of data.selected) {
|
|
270
|
+
if (data.evals[name])
|
|
271
|
+
continue;
|
|
272
|
+
const key = `eval_${slug(name)}`;
|
|
273
|
+
const answer = answers[key];
|
|
274
|
+
if (typeof answer === "string" && answer.trim()) {
|
|
275
|
+
data.evals[name] = /^skip$/i.test(answer.trim()) ? { skipped: "No eval: skipped by the user" } : { source: "user", command: answer.trim() };
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (saved[name]) {
|
|
279
|
+
data.evals[name] = { ...saved[name], source: "saved" };
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (data[`asked_${key}`]) {
|
|
283
|
+
missing.push(evalQuestion(name, String(data[`asked_${key}`])));
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
ctx.log(`Finding the eval for ${name}…`);
|
|
287
|
+
const harness = await getHarness();
|
|
288
|
+
const task = data.tasks.find(item => item.name === name);
|
|
289
|
+
const usages = usageById(data);
|
|
290
|
+
const files = listFiles(ctx.repo.root).filter(file => /(eval|bench|test|spec|dataset|fixture|golden|score|grade|langfuse)/i.test(file)).slice(0, 200);
|
|
291
|
+
const pkg = existsSync(join(ctx.repo.root, "package.json")) ? JSON.stringify(JSON.parse(readFileSync(join(ctx.repo.root, "package.json"), "utf8")).scripts ?? {}, null, 1) : "(no package.json)";
|
|
292
|
+
const reply = await harness.json({
|
|
293
|
+
cwd: ctx.repo.root,
|
|
294
|
+
system: "You are OpenMerit Harness running OpenMerit Scout's evaluation step. You find the eval a task already has and say exactly how to run it for another model. Never invent an eval, dataset or labels: if the repository has none for this task, say so.",
|
|
295
|
+
prompt: `Task: ${task.name}: ${task.description}\nUsages:\n${task.usageIds.map(id => usages.get(id)).filter((usage) => !!usage).map(usage => `- ${usage.value} at ${ref(usage)}\n${context(ctx.repo.root, usage, 4)}`).join("\n")}\n\npackage.json scripts: ${pkg}\nFiles that look like evals, tests or datasets:\n${files.join("\n")}\n\n${EVAL_CONTRACT}\n\nIf the eval records its results as a Langfuse experiment (dataset run) with a per-item score, give the Langfuse dataset name and that score's name (and passMin for a numeric score with a pass bound; null when the score is boolean or 1 means pass). OpenMerit reads each item's result back from Langfuse with a read-only key, so the eval must record under the OPENMERIT_EXPERIMENT name and run only the OPENMERIT_ITEMS items when given. Otherwise leave langfuseDataset and passScore empty: the eval must then report its items itself. Only if the existing eval cannot take the model, the items or the experiment name this way may you add helper files, and only under .openmerit/evals/; they must call the existing eval code with the same inputs, prompts, tools and scoring. threshold is the minimum pass rate (0 to 1) the eval itself requires, or null. Set found false and explain in missing when there is no eval for this task.`,
|
|
296
|
+
schema: strict({ found: { type: "boolean" }, command: { type: "string" }, cwd: { type: "string" }, env: { type: "array", items: strict({ name: { type: "string" }, value: { type: "string" } }) }, threshold: { type: ["number", "null"] }, langfuseDataset: { type: "string" }, passScore: { type: "string" }, passMin: { type: ["number", "null"] }, helpers: { type: "array", items: strict({ path: { type: "string" }, content: { type: "string" }, purpose: { type: "string" } }) }, explanation: { type: "string" }, missing: { type: "string" } }),
|
|
297
|
+
});
|
|
298
|
+
if (!reply.found || !reply.command?.trim()) {
|
|
299
|
+
data[`asked_${key}`] = reply.missing || "The harness found no eval for this task.";
|
|
300
|
+
missing.push(evalQuestion(name, String(data[`asked_${key}`])));
|
|
301
|
+
save();
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const helpers = (reply.helpers ?? []).map(helper => ({ ...helper, path: normalize(helper.path).replace(/^\.\//, "") }));
|
|
305
|
+
const outside = helpers.find(helper => !helper.path.startsWith(".openmerit/evals/") || helper.path.includes(".."));
|
|
306
|
+
if (outside)
|
|
307
|
+
throw new Error(`The harness proposed a helper outside .openmerit/evals/ (${outside.path}); refusing it. Retry with \`openmerit scout --resume ${runId}\`.`);
|
|
308
|
+
const cwd = evalCwd(ctx.repo.root, reply.cwd);
|
|
309
|
+
data.evals[name] = {
|
|
310
|
+
source: "harness", command: reply.command.trim(), ...(cwd ? { cwd } : {}),
|
|
311
|
+
env: Object.fromEntries((reply.env ?? []).filter(item => item.name && !/(KEY|TOKEN|SECRET)/.test(item.name)).map(item => [item.name, item.value])),
|
|
312
|
+
...(typeof reply.threshold === "number" ? { threshold: reply.threshold } : {}),
|
|
313
|
+
...(reply.langfuseDataset && reply.passScore ? { langfuse: { dataset: reply.langfuseDataset, score: reply.passScore, ...(typeof reply.passMin === "number" ? { min: reply.passMin } : {}) } } : {}),
|
|
314
|
+
helperFiles: helpers, explanation: reply.explanation,
|
|
315
|
+
};
|
|
316
|
+
ctx.state.event(runId, "discover", `${name}: eval \`${redact(reply.command.trim())}\`${reply.passScore ? `, per-item score ${reply.passScore} read back from Langfuse dataset ${reply.langfuseDataset}` : ", per-item results reported by the eval"}${helpers.length ? `, ${helpers.length} helper file(s)` : ""}.`);
|
|
317
|
+
save();
|
|
318
|
+
}
|
|
319
|
+
if (missing.length)
|
|
320
|
+
throw new NeedsInput(missing, "needs_input", ["Scout does not fabricate quality: each task needs its existing eval, or skip it."]);
|
|
321
|
+
if (!plannedEvals(data).length)
|
|
322
|
+
throw new Error("Every selected task was skipped for lack of an eval, so nothing can be measured.");
|
|
323
|
+
if (plannedEvals(data).some(([, plan]) => plan.langfuse) && !repoConfig(ctx).langfuse)
|
|
324
|
+
throw new Error("This eval records its results in Langfuse, but this repository has no read-only Langfuse connection to read them back. Run `openmerit setup` and connect a Viewer key, then resume.");
|
|
325
|
+
return "approve";
|
|
326
|
+
},
|
|
327
|
+
async approve({ ctx, runId, data, answers }) {
|
|
328
|
+
if (data.evalApproved)
|
|
329
|
+
return "evaluate";
|
|
330
|
+
const answer = answers.approve_evals;
|
|
331
|
+
if (no(answer))
|
|
332
|
+
return "cancelled";
|
|
333
|
+
if (!yes(answer))
|
|
334
|
+
throw new NeedsInput([approvalQuestion(ctx, runId, data)]);
|
|
335
|
+
const config = repoConfig(ctx);
|
|
336
|
+
const evals = { ...config.evals };
|
|
337
|
+
for (const [name, plan] of plannedEvals(data)) {
|
|
338
|
+
for (const helper of plan.helperFiles ?? []) {
|
|
339
|
+
const path = join(ctx.repo.root, helper.path);
|
|
340
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
341
|
+
writeFileSync(path, helper.content);
|
|
342
|
+
}
|
|
343
|
+
const { source: _source, helperFiles, explanation: _explanation, ...spec } = plan;
|
|
344
|
+
evals[name] = { ...spec, ...(helperFiles?.length ? { helpers: helperFiles.map(helper => helper.path) } : {}) };
|
|
345
|
+
}
|
|
346
|
+
updateRepoConfig(ctx.repo.root, { evals });
|
|
347
|
+
data.evalApproved = true;
|
|
348
|
+
data.repeats = Math.max(1, config.repeats ?? DEFAULT_REPEATS);
|
|
349
|
+
ctx.state.decision(runId, "eval_execution", "approved", { tasks: plannedEvals(data).map(([name]) => name), repeats: data.repeats, helpers: plannedEvals(data).flatMap(([, plan]) => (plan.helperFiles ?? []).map(helper => helper.path)) });
|
|
350
|
+
return "evaluate";
|
|
351
|
+
},
|
|
352
|
+
evaluate: evaluatePhase,
|
|
353
|
+
async compare({ ctx, runId, data, answers, save }) {
|
|
354
|
+
data.compare ??= {};
|
|
355
|
+
data.accepted ??= {};
|
|
356
|
+
const config = repoConfig(ctx);
|
|
357
|
+
const pending = [];
|
|
358
|
+
for (const name of data.selected) {
|
|
359
|
+
const plan = data.evals[name];
|
|
360
|
+
if ("skipped" in plan) {
|
|
361
|
+
data.compare[name] = { task: name, eval: { command: "", source: "none", threshold: null, langfuse: null }, baseline: null, candidates: [], top: [], status: "no_eval", note: plan.skipped };
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const comparison = compareTask(ctx, runId, data, name, plan);
|
|
365
|
+
const key = `accept_${slug(name)}`;
|
|
366
|
+
if (comparison.status === "non_discriminating") {
|
|
367
|
+
const answer = answers[key] ?? data.accepted[name];
|
|
368
|
+
if (answer === "accept") {
|
|
369
|
+
data.accepted[name] = "accept";
|
|
370
|
+
Object.assign(comparison, rank(comparison, true));
|
|
371
|
+
}
|
|
372
|
+
else if (answer === "stop")
|
|
373
|
+
data.accepted[name] = "stop";
|
|
374
|
+
else
|
|
375
|
+
pending.push({ id: key, kind: "choice", prompt: `Every model scored the same on the ${name} eval. Rank by cost anyway?`, detail: comparison.note, options: [{ value: "accept", label: "Accept: rank the qualifying models by cost" }, { value: "stop", label: "Stop: no recommendation for this task until the eval is improved" }] });
|
|
376
|
+
}
|
|
377
|
+
const mapping = config.langfuse?.mappings.find(item => item.task === name);
|
|
378
|
+
if (config.langfuse && mapping) {
|
|
379
|
+
try {
|
|
380
|
+
comparison.langfuse = await viewerConnector(ctx).taskEvidence(mapping);
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
comparison.langfuse = { error: error instanceof Error ? redact(error.message) : String(error) };
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
data.compare[name] = comparison;
|
|
387
|
+
}
|
|
388
|
+
save();
|
|
389
|
+
if (pending.length)
|
|
390
|
+
throw new NeedsInput(pending);
|
|
391
|
+
ctx.state.decision(runId, "ranking", "computed", Object.values(data.compare).map(item => ({ task: item.task, status: item.status, top: item.top.map(model => ({ model: model.model, endpoint: model.endpoint, score: model.score, costPerItemUsd: model.costPerItemUsd })) })));
|
|
392
|
+
return "configure";
|
|
393
|
+
},
|
|
394
|
+
async configure({ ctx, runId, data, answers }) {
|
|
395
|
+
data.configure ??= {};
|
|
396
|
+
for (const comparison of Object.values(data.compare ?? {})) {
|
|
397
|
+
const winner = comparison.top[0];
|
|
398
|
+
if (!winner)
|
|
399
|
+
continue;
|
|
400
|
+
const key = slug(comparison.task);
|
|
401
|
+
const current = data.configure[comparison.task] ??= {};
|
|
402
|
+
if (current.decision === "no" || current.applied || (current.decision === "yes" && !current.edits?.length))
|
|
403
|
+
continue;
|
|
404
|
+
if (!current.decision) {
|
|
405
|
+
const answer = answers[`configure_${key}`];
|
|
406
|
+
if (no(answer)) {
|
|
407
|
+
current.decision = "no";
|
|
408
|
+
ctx.state.decision(runId, "configure", "no", { task: comparison.task, model: winner.model });
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (!yes(answer))
|
|
412
|
+
throw new NeedsInput([{ id: `configure_${key}`, kind: "confirm", default: false, prompt: CONFIGURE_QUESTION, detail: `${comparison.task}: ${data.model} → ${winner.model} (${winner.endpoint})` }]);
|
|
413
|
+
current.decision = "yes";
|
|
414
|
+
Object.assign(current, planEdit(ctx, data, comparison, winner));
|
|
415
|
+
if (!current.edits?.length) {
|
|
416
|
+
ctx.state.decision(runId, "configure", "manual", { task: comparison.task, model: winner.model, manual: current.manual });
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const answer = answers[`apply_${key}`];
|
|
421
|
+
if (no(answer)) {
|
|
422
|
+
current.applied = false;
|
|
423
|
+
current.edits = [];
|
|
424
|
+
ctx.state.decision(runId, "configure", "preview_declined", { task: comparison.task });
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (!yes(answer))
|
|
428
|
+
throw new NeedsInput([{ id: `apply_${key}`, kind: "confirm", default: false, prompt: `Apply this change to ${comparison.task}?`, detail: `${diff(current.edits)}${current.manual?.length ? `\n\nAlso, by hand:\n${current.manual.map(item => `- ${item}`).join("\n")}` : ""}` }]);
|
|
429
|
+
applyEdits(ctx.repo.root, current.edits);
|
|
430
|
+
current.applied = true;
|
|
431
|
+
ctx.state.decision(runId, "configure", "applied", { task: comparison.task, model: winner.model, diff: diff(current.edits) });
|
|
432
|
+
ctx.state.event(runId, "configure", `Configured "${comparison.task}": ${current.edits.map(edit => `${edit.file}:${edit.line}`).join(", ")}.`);
|
|
433
|
+
}
|
|
434
|
+
return "done";
|
|
435
|
+
},
|
|
436
|
+
async done() { return "done"; },
|
|
437
|
+
};
|
|
438
|
+
const EVAL_CONTRACT = `OpenMerit runs the eval once per model and item set with these environment variables: OPENMERIT_MODEL (the model ID, as the endpoint names it), OPENAI_BASE_URL and OPENAI_API_KEY (an OpenAI-compatible endpoint, also as OPENMERIT_BASE_URL and OPENMERIT_API_KEY), OPENMERIT_ITEMS (comma-separated dataset item IDs to run; empty means every item), OPENMERIT_EXPERIMENT (a fresh experiment or run name for this run) and OPENMERIT_RESULT (a file path). \`{{model}}\`, \`{{items}}\` and \`{{experiment}}\` in the command or env values are replaced by the same values; map them onto the eval's own variables in env when it reads others (for example CLASSIFIER_MODEL={{model}}). An eval that does not record to Langfuse writes {"items":[{"id": string, "pass": boolean}]} to $OPENMERIT_RESULT or prints it as its last JSON line.`;
|
|
439
|
+
function viewerConnector(ctx) {
|
|
440
|
+
const settings = repoConfig(ctx).langfuse;
|
|
441
|
+
if (!settings)
|
|
442
|
+
throw new Error("This repository has no Langfuse connection. Run `openmerit setup`.");
|
|
443
|
+
const publicKey = resolveCredential(settings.publicKey, ctx.env), secretKey = resolveCredential(settings.secretKey, ctx.env);
|
|
444
|
+
if (!publicKey || !secretKey)
|
|
445
|
+
throw new Error("The Langfuse Viewer key pair is not available. Run `openmerit setup`.");
|
|
446
|
+
return createLangfuseConnector({ baseUrl: settings.baseUrl, publicKey, secretKey }, { ...(ctx.fetch ? { fetch: ctx.fetch } : {}), retries: 3 });
|
|
447
|
+
}
|
|
448
|
+
/** The binary search for every planned task. Replays finished units from SQLite; runs the rest. */
|
|
449
|
+
async function evaluatePhase({ ctx, runId, data, answers, save }) {
|
|
450
|
+
const { state } = ctx;
|
|
451
|
+
const configured = endpoints(ctx);
|
|
452
|
+
const repeats = data.repeats ??= Math.max(1, repoConfig(ctx).repeats ?? DEFAULT_REPEATS);
|
|
453
|
+
const base = baselineRef(ctx, data);
|
|
454
|
+
data.samples ??= {};
|
|
455
|
+
data.datasetItems ??= {};
|
|
456
|
+
data.search ??= {};
|
|
457
|
+
data.stopped ??= {};
|
|
458
|
+
data.retryDeclined ??= [];
|
|
459
|
+
const stale = () => state.units(runId).filter(unit => ["failed", "interrupted", "running"].includes(unit.status) && !data.retryDeclined.includes(unit.id));
|
|
460
|
+
// Budget: a raise answers a pause; a decline leaves the run paused.
|
|
461
|
+
const paused = state.units(runId).filter(unit => unit.status === "budget_paused");
|
|
462
|
+
if (paused.length) {
|
|
463
|
+
const run = state.getRun(runId);
|
|
464
|
+
const action = answers.budget_action, amount = Number(answers.budget_amount), spent = state.spend(runId).totalUsd;
|
|
465
|
+
if (action === "decline") {
|
|
466
|
+
state.budgetEvent(runId, { kind: "decline", fromUsd: run.budgetUsd, spentUsd: spent });
|
|
467
|
+
throw new NeedsInput([], "budget_paused", [`Budget not raised. The run stays paused at $${spent.toFixed(4)} of $${run.budgetUsd.toFixed(2)}; continue later with \`openmerit scout --resume ${runId}\`.`]);
|
|
468
|
+
}
|
|
469
|
+
if ((action === "run" || action === "default") && Number.isFinite(amount) && amount > run.budgetUsd) {
|
|
470
|
+
state.updateRun(runId, { budgetUsd: amount });
|
|
471
|
+
state.budgetEvent(runId, { kind: "raise", fromUsd: run.budgetUsd, toUsd: amount, scope: action, spentUsd: spent });
|
|
472
|
+
if (action === "default")
|
|
473
|
+
updateRepoConfig(ctx.repo.root, { budget: { defaultUsd: amount } });
|
|
474
|
+
for (const unit of paused)
|
|
475
|
+
state.setUnit(unit.id, { status: "pending" });
|
|
476
|
+
state.event(runId, "budget", `Budget raised to $${amount.toFixed(2)} for ${action === "default" ? "this run and as the repository default" : "this run only"}; ${paused.length} paused eval run(s) continue.`);
|
|
477
|
+
}
|
|
478
|
+
else
|
|
479
|
+
throw new NeedsInput(budgetQuestions(ctx, runId), "budget_paused");
|
|
480
|
+
}
|
|
481
|
+
if (answers.retry_units !== undefined) {
|
|
482
|
+
if (yes(answers.retry_units))
|
|
483
|
+
for (const unit of stale())
|
|
484
|
+
state.setUnit(unit.id, { status: "pending" });
|
|
485
|
+
else
|
|
486
|
+
data.retryDeclined.push(...stale().map(unit => unit.id));
|
|
487
|
+
}
|
|
488
|
+
const declined = new Set(data.retryDeclined);
|
|
489
|
+
const proxy = new EvalProxy(state, ctx.fetch);
|
|
490
|
+
const proxyUrl = await proxy.start();
|
|
491
|
+
let viewer = null;
|
|
492
|
+
/** One eval run: replayed from SQLite when finished, otherwise run through the proxy and read back. */
|
|
493
|
+
const runUnit = async (task, plan, kind, entry, repeat, items) => {
|
|
494
|
+
const id = `${runId}|${slug(task)}|${kind}|${entry.endpoint}|${entry.model}|r${repeat}`;
|
|
495
|
+
const unit = state.ensureUnit({ id, runId, task, model: entry.model, endpoint: entry.endpoint, role: kind, repeat });
|
|
496
|
+
const label = `${kind === "baseline" ? "current" : kind} ${entry.model}${kind === "screen" ? "" : ` ${repeat}/${repeats}`}`;
|
|
497
|
+
if (unit.status === "done")
|
|
498
|
+
return unit.result;
|
|
499
|
+
if (unit.status === "unavailable")
|
|
500
|
+
throw new CandidateUnavailable(unit.error ?? "could not run");
|
|
501
|
+
if (unit.status === "budget_paused")
|
|
502
|
+
throw new NeedsInput(budgetQuestions(ctx, runId), "budget_paused");
|
|
503
|
+
if (unit.status !== "pending") {
|
|
504
|
+
if (declined.has(id))
|
|
505
|
+
throw new StopTask(`${label} failed and was not retried: ${(unit.error ?? unit.status).split("\n")[0]}`);
|
|
506
|
+
throw new NeedsInput([retryQuestion(ctx, runId, stale())]);
|
|
507
|
+
}
|
|
508
|
+
const fail = (error) => { state.setUnit(id, { status: "failed", error }); save(); throw new NeedsInput([retryQuestion(ctx, runId, stale())]); };
|
|
509
|
+
const endpoint = configured.find(item => item.id === entry.endpoint);
|
|
510
|
+
const apiKey = endpoint ? resolveCredential(endpoint.apiKey, ctx.env) : null;
|
|
511
|
+
if (!endpoint || !apiKey)
|
|
512
|
+
return fail(`Endpoint ${entry.endpoint} has no usable credential; run \`openmerit setup\`.`);
|
|
513
|
+
// The expected items: the sample for a screen; every dataset item (or the current model's first run's items) otherwise.
|
|
514
|
+
const expected = items ?? (plan.langfuse ? data.datasetItems[task] : kind === "baseline" && repeat === 1 ? null : Object.keys(firstBaseline(task)?.items ?? {}));
|
|
515
|
+
const experiment = `openmerit-${runId}-${slug(task)}-${kind}-${entry.endpointModel.replace(/[^a-zA-Z0-9.]+/g, "-")}-r${repeat}-a${unit.attempts + 1}`;
|
|
516
|
+
ctx.log(`${label}${items ? ` (${items.length} items)` : ""}…`);
|
|
517
|
+
state.setUnit(id, { status: "running", attempt: true });
|
|
518
|
+
const pooled = allowedOn(endpoint, ctx.catalogue);
|
|
519
|
+
// The baseline is the model already in production: it runs even when the candidate pool leaves it out.
|
|
520
|
+
const allowed = kind === "baseline" ? (model) => (model.toLowerCase() === entry.endpointModel.toLowerCase() ? ctx.catalogue.models.find(item => item.id === entry.model) ?? null : pooled(model)) : pooled;
|
|
521
|
+
const { token, tally } = proxy.open({ runId, unitId: id, endpointId: endpoint.id, baseUrl: endpoint.baseUrl, apiKey, model: entry.endpointModel, allowed });
|
|
522
|
+
const started = Date.now();
|
|
523
|
+
let outcome;
|
|
524
|
+
try {
|
|
525
|
+
outcome = await runEval(plan, { root: ctx.repo.root, model: entry.endpointModel, baseUrl: proxyUrl, token, items, experiment, env: ctx.env, ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
526
|
+
}
|
|
527
|
+
finally {
|
|
528
|
+
proxy.close(token);
|
|
529
|
+
}
|
|
530
|
+
if (tally.budgetHit) {
|
|
531
|
+
state.setUnit(id, { status: "budget_paused", error: "Paused: the eval budget was reached." });
|
|
532
|
+
const run = state.getRun(runId), spend = state.spend(runId);
|
|
533
|
+
state.budgetEvent(runId, { kind: "alert", fromUsd: run.budgetUsd, spentUsd: spend.totalUsd });
|
|
534
|
+
state.event(runId, "budget", `Eval budget reached: $${spend.totalUsd.toFixed(4)} of $${run.budgetUsd.toFixed(2)}. Further paid eval requests are paused.`);
|
|
535
|
+
throw new NeedsInput(budgetQuestions(ctx, runId), "budget_paused");
|
|
536
|
+
}
|
|
537
|
+
const refused = tally.poolViolations.length ? `; refused models outside the allowed pool: ${[...new Set(tally.poolViolations)].join(", ")}` : "";
|
|
538
|
+
if (!outcome.ok) {
|
|
539
|
+
// A candidate the endpoint rejects, or whose calls still fail after the proxy's retries, is skipped as untested.
|
|
540
|
+
const rejected = tally.upstreamErrors.find(status => UNAVAILABLE_STATUSES.includes(status) || retryable(status));
|
|
541
|
+
if (kind !== "baseline" && rejected) {
|
|
542
|
+
const reason = rejected === 429 ? `${entry.endpoint} rate-limited ${entry.endpointModel} (HTTP 429)`
|
|
543
|
+
: retryable(rejected) ? `${entry.endpoint} kept failing for ${entry.endpointModel} (HTTP ${rejected})` : `${entry.endpoint} rejected ${entry.endpointModel} (HTTP ${rejected})`;
|
|
544
|
+
state.setUnit(id, { status: "unavailable", error: `${reason}${outcome.log ? `\n${outcome.log}` : ""}` });
|
|
545
|
+
ctx.log(` skipped: ${entry.endpoint} HTTP ${rejected}`);
|
|
546
|
+
save();
|
|
547
|
+
throw new CandidateUnavailable(reason);
|
|
548
|
+
}
|
|
549
|
+
const statuses = [...new Set(tally.upstreamErrors)];
|
|
550
|
+
return fail(`${outcome.error ?? "eval failed"}${statuses.length ? ` (${entry.endpoint} answered HTTP ${statuses.join(", ")})` : ""}${refused}${outcome.log ? `\n${outcome.log}` : ""}`);
|
|
551
|
+
}
|
|
552
|
+
let results;
|
|
553
|
+
let source;
|
|
554
|
+
if (plan.langfuse) {
|
|
555
|
+
viewer ??= viewerConnector(ctx);
|
|
556
|
+
try {
|
|
557
|
+
const read = await viewer.waitForExperiment({ experimentName: experiment, pass: { score: plan.langfuse.score, ...(plan.langfuse.min !== undefined ? { min: plan.langfuse.min } : {}) }, itemIds: expected, since: new Date(started - 300_000).toISOString() }, { expectItems: expected.length, ...(ctx.langfusePoll ?? {}) });
|
|
558
|
+
results = Object.fromEntries(read.items.map(item => [item.itemId, item.passed]));
|
|
559
|
+
source = "langfuse";
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
return fail(`Langfuse read-back: ${error instanceof Error ? error.message : String(error)}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
if (!outcome.items)
|
|
567
|
+
return fail("The eval reported no per-item results: it must write {\"items\":[{\"id\",\"pass\"}]} to $OPENMERIT_RESULT, or record a Langfuse experiment.");
|
|
568
|
+
const reported = new Map(outcome.items.map(item => [item.id, item.pass]));
|
|
569
|
+
const ids = expected ?? [...reported.keys()];
|
|
570
|
+
const absent = ids.filter(item => !reported.has(item));
|
|
571
|
+
if (absent.length)
|
|
572
|
+
return fail(`The eval reported no result for ${absent.length} requested item(s): ${absent.slice(0, 10).join(", ")}.`);
|
|
573
|
+
results = Object.fromEntries(ids.map(item => [item, reported.get(item)]));
|
|
574
|
+
source = "eval";
|
|
575
|
+
}
|
|
576
|
+
const passes = Object.values(results).filter(Boolean).length, total = Object.keys(results).length;
|
|
577
|
+
const result = { items: results, passes, total, source, experiment, requests: tally.requests, durationMs: outcome.durationMs };
|
|
578
|
+
state.setUnit(id, { status: "done", result: result });
|
|
579
|
+
ctx.log(` ${passes}/${total} · ${usd(state.spend(runId).totalUsd)} of $${state.getRun(runId).budgetUsd.toFixed(2)}`);
|
|
580
|
+
save();
|
|
581
|
+
return result;
|
|
582
|
+
};
|
|
583
|
+
const firstBaseline = (task) => state.units(runId).find(unit => unit.task === task && unit.role === "baseline" && unit.repeat === 1 && unit.status === "done")?.result;
|
|
584
|
+
const evaluateTask = async (task, plan) => {
|
|
585
|
+
const candidates = data.pool[task] ?? [];
|
|
586
|
+
const summary = { status: "running", pool: candidates.length, unranked: data.unranked?.[task] ?? 0, sampleIds: [], pick: null, trials: [], skipped: [], finalists: [], challengers: [] };
|
|
587
|
+
data.search[task] = summary;
|
|
588
|
+
if (plan.langfuse && !data.datasetItems[task]) {
|
|
589
|
+
viewer ??= viewerConnector(ctx);
|
|
590
|
+
data.datasetItems[task] = await viewer.datasetItemIds(plan.langfuse.dataset);
|
|
591
|
+
save();
|
|
592
|
+
}
|
|
593
|
+
const baseline = [];
|
|
594
|
+
for (let repeat = 1; repeat <= repeats; repeat++)
|
|
595
|
+
baseline.push(await runUnit(task, plan, "baseline", base, repeat, null));
|
|
596
|
+
// Screens draw from items the current model passes in every run, so a 5/5 screen asks whether a candidate keeps up with it.
|
|
597
|
+
if (!data.samples[task]) {
|
|
598
|
+
const reliable = Object.keys(baseline[0].items).filter(item => baseline.every(run => run.items[item] === true));
|
|
599
|
+
if (reliable.length < SAMPLE_SIZE)
|
|
600
|
+
throw new StopTask(`The current model passes only ${reliable.length} items in every run; Scout screens candidates on ${SAMPLE_SIZE} items it passes. Add labelled items the current model gets right.`);
|
|
601
|
+
const pool = [...reliable], picked = [];
|
|
602
|
+
for (let count = 0; count < SAMPLE_SIZE; count++)
|
|
603
|
+
picked.push(pool.splice(randomInt(pool.length), 1)[0]);
|
|
604
|
+
data.samples[task] = picked;
|
|
605
|
+
save();
|
|
606
|
+
}
|
|
607
|
+
const samples = data.samples[task];
|
|
608
|
+
summary.sampleIds = samples;
|
|
609
|
+
const screen = async (candidate, stage) => {
|
|
610
|
+
const result = await runUnit(task, plan, "screen", candidate, 1, samples);
|
|
611
|
+
const accepted = samples.every(item => result.items[item] === true);
|
|
612
|
+
summary.trials.push({ stage, model: candidate.model, eci: candidate.eci, passes: result.passes, total: result.total, accepted });
|
|
613
|
+
return accepted;
|
|
614
|
+
};
|
|
615
|
+
const runFinal = async (candidate) => {
|
|
616
|
+
try {
|
|
617
|
+
for (let repeat = 1; repeat <= repeats; repeat++)
|
|
618
|
+
await runUnit(task, plan, "final", candidate, repeat, null);
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
if (error instanceof CandidateUnavailable) {
|
|
622
|
+
summary.skipped.push({ model: candidate.model, stage: "final", reason: error.message });
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (error instanceof StopTask)
|
|
626
|
+
return; // A declined failed final is listed as unmeasured.
|
|
627
|
+
throw error;
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
const searched = await binarySearch(candidates, screen, FINALISTS);
|
|
631
|
+
summary.skipped.push(...searched.unavailable.map(item => ({ model: item.candidate.model, stage: item.stage, reason: item.reason })));
|
|
632
|
+
summary.pick = searched.selected?.model ?? null;
|
|
633
|
+
if (searched.shortfall)
|
|
634
|
+
summary.shortfall = searched.shortfall;
|
|
635
|
+
if (!searched.selected) {
|
|
636
|
+
summary.status = "no_passing_model";
|
|
637
|
+
save();
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
summary.finalists = searched.finalists.map(item => item.model);
|
|
641
|
+
for (const candidate of searched.finalists)
|
|
642
|
+
await runFinal(candidate);
|
|
643
|
+
const leader = searched.finalists.map(candidate => ({ candidate, measured: finalMeasure(task, candidate.model) })).filter(item => item.measured)
|
|
644
|
+
.sort((a, b) => b.measured.score - a.measured.score || (a.measured.cost ?? Infinity) - (b.measured.cost ?? Infinity) || a.candidate.eci - b.candidate.eci)[0]?.candidate;
|
|
645
|
+
if (leader) {
|
|
646
|
+
const tried = new Set([...summary.trials.map(item => item.model), ...summary.skipped.map(item => item.model)]);
|
|
647
|
+
for (const candidate of challengers(candidates, leader, tried, CHALLENGERS)) {
|
|
648
|
+
try {
|
|
649
|
+
if (await screen(candidate, "challenger")) {
|
|
650
|
+
summary.challengers.push(candidate.model);
|
|
651
|
+
await runFinal(candidate);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
if (!(error instanceof CandidateUnavailable))
|
|
656
|
+
throw error;
|
|
657
|
+
summary.skipped.push({ model: candidate.model, stage: "challenger", reason: error.message });
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
summary.status = "selected";
|
|
662
|
+
save();
|
|
663
|
+
};
|
|
664
|
+
const finalMeasure = (task, model) => {
|
|
665
|
+
const units = state.units(runId).filter(unit => unit.task === task && unit.role === "final" && unit.model === model);
|
|
666
|
+
if (units.length < repeats || units.some(unit => unit.status !== "done"))
|
|
667
|
+
return null;
|
|
668
|
+
const results = units.map(unit => unit.result);
|
|
669
|
+
const total = results.reduce((sum, item) => sum + item.total, 0);
|
|
670
|
+
return { score: results.reduce((sum, item) => sum + item.passes, 0) / total, cost: unitCost(ctx, runId, units)?.perItem(total) ?? null };
|
|
671
|
+
};
|
|
672
|
+
try {
|
|
673
|
+
for (const [task, plan] of plannedEvals(data)) {
|
|
674
|
+
if (data.stopped[task])
|
|
675
|
+
continue;
|
|
676
|
+
try {
|
|
677
|
+
await evaluateTask(task, plan);
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
if (!(error instanceof StopTask))
|
|
681
|
+
throw error;
|
|
682
|
+
data.stopped[task] = error.message;
|
|
683
|
+
data.search[task].status = "stopped";
|
|
684
|
+
state.event(runId, "stopped", `${task}: ${error.message}`);
|
|
685
|
+
save();
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
finally {
|
|
690
|
+
await proxy.stop();
|
|
691
|
+
}
|
|
692
|
+
return "compare";
|
|
693
|
+
}
|
|
694
|
+
function modelQuestion(data) {
|
|
695
|
+
const usages = usageById(data);
|
|
696
|
+
return {
|
|
697
|
+
id: "model", kind: "choice", prompt: "Which model should Scout review?",
|
|
698
|
+
// Scout can only price and place catalogue models, so only those are offered.
|
|
699
|
+
options: data.scan.models.filter(item => item.catalogueId).map(item => ({
|
|
700
|
+
value: item.catalogueId, label: item.value,
|
|
701
|
+
detail: item.usageIds.map(id => usages.get(id)).filter((usage) => !!usage).map(usage => `${ref(usage)}${usage.dynamic ? ` (default of ${usage.envVar})` : ""}`).join(", "),
|
|
702
|
+
})),
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
function tasksQuestion(data) {
|
|
706
|
+
const usages = usageById(data);
|
|
707
|
+
return {
|
|
708
|
+
id: "tasks", kind: "multi", prompt: TASK_QUESTION, hint: "or <usage>=<task> to fix the mapping",
|
|
709
|
+
options: tasksForModel(data).map(task => {
|
|
710
|
+
const uses = task.usageIds.map(id => usages.get(id)).filter((usage) => !!usage && usage.kind === "model" && modelKey(usage) === data.model);
|
|
711
|
+
const refs = [...uses.slice(0, 2).map(usage => `${usage.id} ${basename(usage.file)}:${usage.line}`), ...(uses.length > 2 ? [`+${uses.length - 2} more`] : [])].join(", ");
|
|
712
|
+
return { value: task.name, label: task.name, detail: `${task.description.split(/(?<=\.)\s/)[0]} (${refs})` };
|
|
713
|
+
}),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
function evalQuestion(task, reason) {
|
|
717
|
+
return { id: `eval_${slug(task)}`, kind: "text", prompt: `No eval found for ${task}. Command to run it, or "skip":`, detail: `${reason}\n${EVAL_CONTRACT}` };
|
|
718
|
+
}
|
|
719
|
+
/** Upper estimate of eval runs: baseline and finals on every item, screens on the sample. */
|
|
720
|
+
export function estimate(poolSize, repeats) {
|
|
721
|
+
const probes = Math.ceil(Math.log2(poolSize + 1));
|
|
722
|
+
const screens = Math.min(probes + FINALISTS - 1 + 2 + CHALLENGERS, poolSize);
|
|
723
|
+
const finals = Math.min(FINALISTS + CHALLENGERS, poolSize);
|
|
724
|
+
return { probes, screens, fullRuns: (1 + finals) * repeats };
|
|
725
|
+
}
|
|
726
|
+
function approvalQuestion(ctx, runId, data) {
|
|
727
|
+
const configured = endpoints(ctx);
|
|
728
|
+
const repeats = Math.max(1, repoConfig(ctx).repeats ?? DEFAULT_REPEATS);
|
|
729
|
+
const base = baselineRef(ctx, data);
|
|
730
|
+
const run = ctx.state.getRun(runId);
|
|
731
|
+
const destinations = new Set();
|
|
732
|
+
const endpointLabel = (id) => { const endpoint = configured.find(item => item.id === id); return endpoint ? `${endpoint.id} (${endpoint.baseUrl})` : id; };
|
|
733
|
+
destinations.add(endpointLabel(base.endpoint));
|
|
734
|
+
const sections = Object.entries(data.evals).map(([name, plan]) => {
|
|
735
|
+
if ("skipped" in plan)
|
|
736
|
+
return `${name}: skipped (${plan.skipped})`;
|
|
737
|
+
const candidates = data.pool[name] ?? [];
|
|
738
|
+
for (const candidate of candidates)
|
|
739
|
+
destinations.add(endpointLabel(candidate.endpoint));
|
|
740
|
+
const runs = estimate(candidates.length, repeats);
|
|
741
|
+
return [
|
|
742
|
+
`${name}`,
|
|
743
|
+
` Command: ${plan.command}${plan.cwd ? ` (in ${plan.cwd})` : ""}`,
|
|
744
|
+
...(plan.env && Object.keys(plan.env).length ? [` Env: ${Object.entries(plan.env).map(([key, value]) => `${key}=${value}`).join(" ")}`] : []),
|
|
745
|
+
` Results: ${plan.langfuse ? `Langfuse dataset ${plan.langfuse.dataset}, score ${plan.langfuse.score}${plan.langfuse.min !== undefined ? ` ≥ ${plan.langfuse.min}` : ""}` : "$OPENMERIT_RESULT"}${plan.threshold !== undefined ? `; pass rate ≥ ${plan.threshold}` : ""}`,
|
|
746
|
+
` Candidates: ${candidates.length} cheaper than ${data.model}${candidates.length ? `, ECI ${candidates[0].eci.toFixed(1)}–${candidates.at(-1).eci.toFixed(1)}` : ""}${data.unranked?.[name] ? ` (${data.unranked[name]} without ECI left out)` : ""}`,
|
|
747
|
+
` Runs: at most ${runs.fullRuns} full and ${runs.screens} ${SAMPLE_SIZE}-item screens`,
|
|
748
|
+
...(plan.helperFiles ?? []).map(helper => ` New helper file ${helper.path} (${helper.purpose}):\n${helper.content.split("\n").map(line => ` + ${line}`).join("\n")}`),
|
|
749
|
+
].join("\n");
|
|
750
|
+
});
|
|
751
|
+
return {
|
|
752
|
+
id: "approve_evals", kind: "confirm", default: false,
|
|
753
|
+
prompt: "Run these evals? They make paid requests.",
|
|
754
|
+
detail: `${sections.join("\n\n")}\n\nSent to: ${[...destinations].join(", ")}. Budget: $${run.budgetUsd.toFixed(2)}; requests past it are refused.`,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
function retryQuestion(ctx, runId, stale) {
|
|
758
|
+
return {
|
|
759
|
+
id: "retry_units", kind: "confirm", default: false,
|
|
760
|
+
// A run cut off by a stopped daemon or process is interrupted, not failed; say which it was.
|
|
761
|
+
prompt: `${stale.length} eval run${stale.length === 1 ? "" : "s"} ${stale.every(unit => unit.status !== "failed") ? (stale.length === 1 ? "was interrupted" : "were interrupted") : stale.some(unit => unit.status !== "failed") ? "failed or were interrupted" : "failed"}. Run ${stale.length === 1 ? "it" : "them"} again?`,
|
|
762
|
+
detail: stale.map(unit => `- ${unit.role} ${unit.model} ${unit.repeat}: ${(unit.error ?? unit.status).split("\n")[0]}, ${usd(ctx.state.spend(runId, unit.id).totalUsd)} spent`).join("\n"),
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
function budgetQuestions(ctx, runId) {
|
|
766
|
+
const run = ctx.state.getRun(runId);
|
|
767
|
+
const spend = ctx.state.spend(runId);
|
|
768
|
+
return [
|
|
769
|
+
{ id: "budget_action", kind: "choice", prompt: `Budget of $${run.budgetUsd.toFixed(2)} reached. Raise it?`,
|
|
770
|
+
detail: `Used $${(spend.settledUsd + spend.inFlightUsd).toFixed(4)}${spend.estimatedUsd > 0 || spend.inFlight || spend.unknown ? " (partly estimated)" : ""}.`,
|
|
771
|
+
options: [{ value: "run", label: "This run only" }, { value: "default", label: "This run and future runs" }, { value: "decline", label: "Keep paused" }] },
|
|
772
|
+
{ id: "budget_amount", kind: "text", prompt: "New budget (USD)", default: String(Math.max(run.budgetUsd * 2, run.budgetUsd + 5)) },
|
|
773
|
+
];
|
|
774
|
+
}
|
|
775
|
+
/** The eval's directory relative to the repository, so the saved config stays portable; null for the root. */
|
|
776
|
+
function evalCwd(root, cwd) {
|
|
777
|
+
if (!cwd || cwd === ".")
|
|
778
|
+
return null;
|
|
779
|
+
const path = relative(root, resolve(root, cwd));
|
|
780
|
+
if (path.startsWith("..") || isAbsolute(path))
|
|
781
|
+
throw new Error(`The harness proposed running the eval outside the repository (${cwd}); refusing it.`);
|
|
782
|
+
return path || null;
|
|
783
|
+
}
|
|
784
|
+
const toEntry = (item) => ({ key: item.model.id, model: item.model.id, endpoint: item.endpoint.id, endpointModel: item.endpointModel, eci: item.model.eci, eciSource: item.model.eciSource, price: blendedPrice(item.model) });
|
|
785
|
+
/**
|
|
786
|
+
* Allowed-pool models a task could switch to: priced, cheaper than the current model, and able to do
|
|
787
|
+
* what the task needs (tools, images, structured output). The binary search ranks them by ECI.
|
|
788
|
+
*/
|
|
789
|
+
export function eligibleCandidates(catalogue, configured, baseline, task) {
|
|
790
|
+
const ceiling = blendedPrice(baseline);
|
|
791
|
+
return pool(configured, catalogue).filter(({ model }) => {
|
|
792
|
+
const price = blendedPrice(model);
|
|
793
|
+
if (model.id === baseline.id || price === null || price <= 0)
|
|
794
|
+
return false;
|
|
795
|
+
if (ceiling !== null && price >= ceiling)
|
|
796
|
+
return false;
|
|
797
|
+
if (task.usesTools && !model.tools)
|
|
798
|
+
return false;
|
|
799
|
+
if (task.needsVision && !model.vision)
|
|
800
|
+
return false;
|
|
801
|
+
if (task.structuredOutput && !model.structuredOutputs)
|
|
802
|
+
return false;
|
|
803
|
+
return true;
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
/** Measured eval cost of some units from the proxy ledger, or null when a request had no price. */
|
|
807
|
+
function unitCost(ctx, runId, units) {
|
|
808
|
+
const usage = units.map(unit => ctx.state.usage(runId, unit.id));
|
|
809
|
+
const requests = usage.reduce((sum, item) => sum + item.requests, 0);
|
|
810
|
+
if (!requests || usage.some(item => item.unknown))
|
|
811
|
+
return null;
|
|
812
|
+
const cost = usage.reduce((sum, item) => sum + item.actualUsd + item.estimatedUsd, 0);
|
|
813
|
+
return { cost, estimate: usage.some(item => item.estimatedUsd > 0), perItem: (total) => (total ? cost / total : null) };
|
|
814
|
+
}
|
|
815
|
+
function measure(ctx, runId, units, repeats, role, entry, eci) {
|
|
816
|
+
const done = units.filter(unit => unit.status === "done");
|
|
817
|
+
const results = done.map(unit => unit.result);
|
|
818
|
+
const passes = results.reduce((sum, item) => sum + item.passes, 0), total = results.reduce((sum, item) => sum + item.total, 0);
|
|
819
|
+
const usage = units.map(unit => ctx.state.usage(runId, unit.id));
|
|
820
|
+
const requests = usage.reduce((sum, item) => sum + item.requests, 0);
|
|
821
|
+
const status = !units.length ? "not_run" : done.length === repeats && units.length === repeats ? "measured" : units.some(unit => unit.status === "unavailable") ? "unavailable"
|
|
822
|
+
: units.some(unit => unit.status === "budget_paused") ? "budget_paused" : units.some(unit => unit.status === "failed" || unit.status === "interrupted") ? "failed" : "incomplete";
|
|
823
|
+
const cost = status === "measured" ? unitCost(ctx, runId, units) : null;
|
|
824
|
+
const failed = units.find(unit => unit.status === "failed" || unit.status === "unavailable");
|
|
825
|
+
return {
|
|
826
|
+
...entry, role, eci, status, passes, total, runs: done.length, score: total ? passes / total : null,
|
|
827
|
+
source: results[0]?.source ?? null,
|
|
828
|
+
costPerItemUsd: cost?.perItem(total) ?? null, costIsEstimate: cost?.estimate ?? false,
|
|
829
|
+
meanLatencyMs: requests ? usage.reduce((sum, item) => sum + item.latencyMs, 0) / requests : null,
|
|
830
|
+
requests, qualified: false,
|
|
831
|
+
note: status === "measured" ? (requests === 0 ? "the eval made no model requests through OpenMerit, so its cost is not measured" : "")
|
|
832
|
+
: status === "unavailable" ? `untested: ${(failed?.error ?? "").split("\n")[0]}` : status === "failed" ? `failed: ${(failed?.error ?? "interrupted").split("\n")[0]}`
|
|
833
|
+
: status === "budget_paused" ? "not measured: the run is paused on its budget" : status === "not_run" ? "not run" : "not every run finished",
|
|
834
|
+
evidence: units.map(unit => unit.id), experiments: results.map(item => item.experiment),
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
function compareTask(ctx, runId, data, name, plan) {
|
|
838
|
+
const units = ctx.state.units(runId).filter(unit => unit.task === name);
|
|
839
|
+
const repeats = data.repeats ?? DEFAULT_REPEATS;
|
|
840
|
+
const base = baselineRef(ctx, data);
|
|
841
|
+
const baseEci = ctx.catalogue.models.find(model => model.id === base.model)?.eci ?? null;
|
|
842
|
+
const baseline = measure(ctx, runId, units.filter(unit => unit.role === "baseline"), repeats, "baseline", base, baseEci);
|
|
843
|
+
const summary = data.search?.[name];
|
|
844
|
+
const byModel = new Map((data.pool?.[name] ?? []).map(entry => [entry.model, entry]));
|
|
845
|
+
const finals = [...(summary?.finalists ?? []), ...(summary?.challengers ?? [])].map(model => byModel.get(model)).filter(Boolean);
|
|
846
|
+
const candidates = finals.map(entry => measure(ctx, runId, units.filter(unit => unit.role === "final" && unit.model === entry.model), repeats, "candidate", entry, entry.eci));
|
|
847
|
+
const comparison = {
|
|
848
|
+
task: name, eval: { command: redact(plan.command), source: plan.source, threshold: plan.threshold ?? null, langfuse: plan.langfuse ? `score ${plan.langfuse.score} on dataset ${plan.langfuse.dataset}` : null },
|
|
849
|
+
baseline, candidates, top: [], status: "incomplete", note: "", ...(summary ? { search: summary } : {}),
|
|
850
|
+
};
|
|
851
|
+
if (baseline.status !== "measured")
|
|
852
|
+
return { ...comparison, status: "baseline_failed", note: `The current model's eval did not complete (${baseline.note}), so no candidate can be compared. Fix the eval and resume the run.` };
|
|
853
|
+
// A stopped search recommends nothing, but the finalists it measured keep their real comparison with the current model.
|
|
854
|
+
if (data.stopped?.[name])
|
|
855
|
+
return { ...comparison, candidates: rank(comparison, false).candidates, status: "stopped", note: `The search stopped, so no model is recommended: ${data.stopped[name]}` };
|
|
856
|
+
if (summary?.status === "no_passing_model")
|
|
857
|
+
return { ...comparison, status: "no_passing_model", note: `No candidate in the pool passed all ${SAMPLE_SIZE} screening items the current model passes.` };
|
|
858
|
+
const measured = candidates.filter(item => item.status === "measured");
|
|
859
|
+
// Finalists pass the screen by construction; a failed screen already shows the eval separates models.
|
|
860
|
+
const screenFailed = summary?.trials.some(trial => !trial.accepted) ?? false;
|
|
861
|
+
if (!screenFailed && measured.length && [baseline, ...measured].every(item => item.score === baseline.score)) {
|
|
862
|
+
return { ...comparison, status: "non_discriminating", note: `The current model, every screened candidate and ${measured.length} finalist(s) all passed ${Math.round(baseline.score * 100)}% of their item runs.` };
|
|
863
|
+
}
|
|
864
|
+
return { ...comparison, ...rank(comparison, false) };
|
|
865
|
+
}
|
|
866
|
+
/** Qualifies finalists at least as good as the current model under the eval's own criteria, then ranks them cheapest first. */
|
|
867
|
+
function rank(comparison, accepted) {
|
|
868
|
+
const baseline = comparison.baseline;
|
|
869
|
+
const threshold = comparison.eval.threshold;
|
|
870
|
+
const candidates = comparison.candidates.map(item => {
|
|
871
|
+
if (item.status !== "measured" || item.score === null)
|
|
872
|
+
return { ...item, qualified: false };
|
|
873
|
+
const asGood = item.score >= baseline.score;
|
|
874
|
+
const meets = threshold === null || item.score >= threshold;
|
|
875
|
+
const why = !asGood ? `passed ${item.passes}/${item.total} vs the current model's ${baseline.passes}/${baseline.total}` : !meets ? `below the eval's required pass rate ${threshold}` : item.costPerItemUsd === null ? "qualifies; cost not measured" : "";
|
|
876
|
+
return { ...item, qualified: asGood && meets, note: item.note || why };
|
|
877
|
+
});
|
|
878
|
+
const top = candidates.filter(item => item.qualified).sort((a, b) => (a.costPerItemUsd ?? Infinity) - (b.costPerItemUsd ?? Infinity) || b.score - a.score || (a.eci ?? 0) - (b.eci ?? 0)).slice(0, TOP);
|
|
879
|
+
const failed = candidates.filter(item => item.status !== "measured").length;
|
|
880
|
+
return {
|
|
881
|
+
candidates, top, status: top.length ? "ranked" : "no_qualified",
|
|
882
|
+
note: `${candidates.filter(item => item.qualified).length} of ${candidates.length} finalists performed at least as well as ${baseline.model}${top.length ? `; the ${top.length} cheapest are shown` : ""}${failed ? `; ${failed} could not be measured` : ""}.${accepted ? " Accepted by the user although the eval did not separate the models." : ""}${top.some(item => baseline.costPerItemUsd !== null && item.costPerItemUsd !== null && item.costPerItemUsd >= baseline.costPerItemUsd) ? " Some qualifying finalists cost no less than the current model on this workload." : ""}`,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
/** The narrow edit for a configure "Yes": the task's literal model IDs, when the app already calls the winner's endpoint. */
|
|
886
|
+
function planEdit(ctx, data, comparison, winner) {
|
|
887
|
+
const usages = usageById(data);
|
|
888
|
+
const task = data.tasks.find(item => item.name === comparison.task);
|
|
889
|
+
const endpoint = endpoints(ctx).find(item => item.id === winner.endpoint);
|
|
890
|
+
const endpointUsages = data.scan.usages.filter(usage => usage.kind === "endpoint");
|
|
891
|
+
// The application calls the provider, not OpenMerit's eval route: match OpenAI and OpenRouter by their public hosts.
|
|
892
|
+
const host = endpoint.kind === "openai" ? "api.openai.com" : endpoint.kind === "openrouter" ? "openrouter.ai" : new URL(endpoint.baseUrl).host;
|
|
893
|
+
const edits = [];
|
|
894
|
+
const manual = [];
|
|
895
|
+
for (const usage of task.usageIds.map(id => usages.get(id)).filter((item) => !!item && item.kind === "model" && modelKey(item) === data.model)) {
|
|
896
|
+
const fileEndpoints = endpointUsages.filter(item => item.file === usage.file);
|
|
897
|
+
const sameEndpoint = (fileEndpoints.length ? fileEndpoints : endpointUsages).some(item => item.value.includes(host)) || (!endpointUsages.length && endpoint.kind === "openai");
|
|
898
|
+
const style = usage.value.includes("/") ? "openrouter" : "openai";
|
|
899
|
+
const target = style === "openrouter" ? winner.model : winner.endpointModel;
|
|
900
|
+
if (usage.dynamic && !usage.catalogueId) {
|
|
901
|
+
manual.push(`${ref(usage)} reads ${usage.envVar ?? usage.value} at run time: set it to ${winner.endpointModel} where that environment is configured (OpenMerit cannot change your shell's environment).`);
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
if (!sameEndpoint || (style === "openrouter") !== (endpoint.kind === "openrouter")) {
|
|
905
|
+
manual.push(`${ref(usage)} calls a different endpoint than ${endpoint.id}: switching needs an endpoint change too. Point it at ${endpoint.baseUrl} and use model ${winner.endpointModel}.`);
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
const raw = readFileSync(join(ctx.repo.root, usage.file), "utf8").split("\n")[usage.line - 1] ?? "";
|
|
909
|
+
const quoted = new RegExp(`(["'\`])${usage.value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\1`);
|
|
910
|
+
if (!quoted.test(raw)) {
|
|
911
|
+
manual.push(`${ref(usage)}: change ${usage.value} to ${target} by hand.`);
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
edits.push({ file: usage.file, line: usage.line, before: redact(raw), after: redact(raw.replace(quoted, `$1${target}$1`)), hash: hash(raw), from: usage.value, to: target });
|
|
915
|
+
if (usage.envVar)
|
|
916
|
+
manual.push(`${ref(usage)}: ${usage.envVar} overrides this default when it is set; update it too if your environment sets it.`);
|
|
917
|
+
}
|
|
918
|
+
return { edits, manual };
|
|
919
|
+
}
|
|
920
|
+
const diff = (edits) => edits.map(edit => `--- a/${edit.file}\n+++ b/${edit.file}\n@@ line ${edit.line} @@\n-${edit.before}\n+${edit.after}`).join("\n");
|
|
921
|
+
/** Applies only previewed lines, and only when each line is still what the preview showed. */
|
|
922
|
+
function applyEdits(root, edits) {
|
|
923
|
+
const byFile = new Map();
|
|
924
|
+
for (const edit of edits)
|
|
925
|
+
byFile.set(edit.file, [...byFile.get(edit.file) ?? [], edit]);
|
|
926
|
+
const writes = [];
|
|
927
|
+
for (const [file, fileEdits] of byFile) {
|
|
928
|
+
const path = join(root, file);
|
|
929
|
+
const lines = readFileSync(path, "utf8").split("\n");
|
|
930
|
+
for (const edit of fileEdits) {
|
|
931
|
+
const raw = lines[edit.line - 1] ?? "";
|
|
932
|
+
if (hash(raw) !== edit.hash)
|
|
933
|
+
throw new Error(`${file}:${edit.line} changed after the preview; nothing was applied. Resume the run to preview again.`);
|
|
934
|
+
lines[edit.line - 1] = raw.replace(new RegExp(`(["'\`])${edit.from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\1`), `$1${edit.to}$1`);
|
|
935
|
+
}
|
|
936
|
+
writes.push([path, lines.join("\n")]);
|
|
937
|
+
}
|
|
938
|
+
for (const [path, text] of writes)
|
|
939
|
+
writeFileSync(path, text);
|
|
940
|
+
}
|
|
941
|
+
export const DEFAULT_BUDGET = DEFAULT_BUDGET_USD;
|