openmerit 0.3.11 → 0.4.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/README.md +74 -22
- package/data/eci-estimates.csv +179 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +180 -0
- package/dist/connectors/langfuse/client.d.ts +42 -0
- package/dist/connectors/langfuse/client.js +141 -0
- package/dist/connectors/langfuse/index.d.ts +163 -0
- package/dist/connectors/langfuse/index.js +378 -0
- package/dist/connectors/langfuse/otlp.d.ts +60 -0
- package/dist/connectors/langfuse/otlp.js +40 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/pi/extension.d.ts +13 -0
- package/dist/pi/extension.js +148 -0
- package/dist/pi/harness.d.ts +20 -0
- package/dist/pi/harness.js +49 -0
- package/dist/pi/instructions.d.ts +2 -0
- package/dist/pi/instructions.js +12 -0
- package/dist/scout/candidates.d.ts +62 -0
- package/dist/scout/candidates.js +90 -0
- package/dist/scout/eci.d.ts +42 -0
- package/dist/scout/eci.js +84 -0
- package/dist/scout/evaluators/module.d.ts +24 -0
- package/dist/scout/evaluators/module.js +19 -0
- package/dist/scout/evaluators/pi.d.ts +53 -0
- package/dist/scout/evaluators/pi.js +117 -0
- package/dist/scout/pipeline.d.ts +111 -0
- package/dist/scout/pipeline.js +157 -0
- package/dist/scout/report.d.ts +3 -0
- package/dist/scout/report.js +42 -0
- package/dist/scout/search.d.ts +110 -0
- package/dist/scout/search.js +205 -0
- package/dist/scout/service.d.ts +148 -0
- package/dist/scout/service.js +143 -0
- package/examples/health-declaration-codes/dataset.jsonl +14 -0
- package/examples/sahra-enrollment/evaluator.mts +152 -0
- package/package.json +34 -78
- package/CHANGELOG.md +0 -255
- package/dist/pi/scout-extension.mjs +0 -287
- package/dist/pi/src/index.d.ts +0 -6
- package/dist/pi/src/index.js +0 -56
- package/dist/terminal/scout-update.json +0 -1
- package/dist/terminal/src/browser.d.mts +0 -9
- package/dist/terminal/src/browser.mjs +0 -28
- package/dist/terminal/src/cli.mjs +0 -28
- package/dist/terminal/src/connections.js +0 -151
- package/dist/terminal/src/install.d.mts +0 -2
- package/dist/terminal/src/install.mjs +0 -524
- package/dist/terminal/src/scout-claude.js +0 -25
- package/dist/terminal/src/scout-credentials.js +0 -41
- package/dist/terminal/src/scout-instructions.js +0 -213
- package/dist/terminal/src/scout-pi.js +0 -84
- package/dist/terminal/src/scout.js +0 -229
- package/dist/terminal/src/telemetry.d.mts +0 -30
- package/dist/terminal/src/telemetry.mjs +0 -138
- package/dist/terminal/src/updates.js +0 -397
- package/docs/agent-evaluations.md +0 -15
- package/docs/commands.md +0 -9
- package/docs/getting-started.md +0 -37
- package/docs/index.md +0 -25
- package/docs/inference-terminal.md +0 -25
- package/docs/pi-extension.md +0 -58
- package/docs/roadmap.md +0 -16
- package/docs/scout-close-loop.md +0 -371
- package/docs/scout-data-policy.md +0 -87
- package/docs/scout.md +0 -237
- package/docs/security.md +0 -107
- package/docs/testing.md +0 -114
- package/docs/troubleshooting.md +0 -50
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { randomInt } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by an evaluator when this one candidate cannot run, such as a provider rejecting the model
|
|
4
|
+
* (400/404) or the balance not covering its request (402). The candidate is skipped and listed as
|
|
5
|
+
* untested; the search goes on without it. Any other throw stops the search.
|
|
6
|
+
*/
|
|
7
|
+
export class CandidateUnavailable extends Error {
|
|
8
|
+
name = "CandidateUnavailable";
|
|
9
|
+
}
|
|
10
|
+
/** By name, so an evaluator module can throw its own `CandidateUnavailable` without importing OpenMerit. */
|
|
11
|
+
const unavailableError = (error) => error instanceof Error && error.name === "CandidateUnavailable";
|
|
12
|
+
export const SAMPLE_SIZE = 5;
|
|
13
|
+
export const FINALISTS = 5;
|
|
14
|
+
export async function scoutSearch(input) {
|
|
15
|
+
const sampleSize = input.sampleSize ?? SAMPLE_SIZE;
|
|
16
|
+
const finalistCount = input.finalists ?? FINALISTS;
|
|
17
|
+
if (input.dataset.length < sampleSize)
|
|
18
|
+
throw new Error(`Scout needs an eval dataset with at least ${sampleSize} labelled items; it has ${input.dataset.length}`);
|
|
19
|
+
if (new Set(input.dataset.map(item => item.id)).size !== input.dataset.length)
|
|
20
|
+
throw new Error("Dataset item IDs must be unique");
|
|
21
|
+
if (!input.candidates.length)
|
|
22
|
+
throw new Error("No ECI-ranked candidate models are runnable with the connected providers");
|
|
23
|
+
if (new Set(input.candidates.map(item => item.model)).size !== input.candidates.length)
|
|
24
|
+
throw new Error("Candidate models must be unique");
|
|
25
|
+
const ordered = [...input.candidates].sort((a, b) => a.eci - b.eci || a.model.localeCompare(b.model));
|
|
26
|
+
const pool = [...input.dataset];
|
|
27
|
+
const samples = [];
|
|
28
|
+
const pick = input.pickIndex ?? randomInt;
|
|
29
|
+
for (let count = 0; count < sampleSize; count++) {
|
|
30
|
+
const index = pick(pool.length);
|
|
31
|
+
if (!Number.isInteger(index) || index < 0 || index >= pool.length)
|
|
32
|
+
throw new Error("Invalid random sample index");
|
|
33
|
+
samples.push(pool.splice(index, 1)[0]);
|
|
34
|
+
}
|
|
35
|
+
const repeats = input.repeats ?? 3;
|
|
36
|
+
if (!Number.isInteger(repeats) || repeats < 1)
|
|
37
|
+
throw new Error("repeats must be a positive integer");
|
|
38
|
+
const log = input.log ?? (() => { });
|
|
39
|
+
const trials = [];
|
|
40
|
+
const finals = [];
|
|
41
|
+
const unavailable = [];
|
|
42
|
+
let selected = null;
|
|
43
|
+
const result = (status, error) => ({
|
|
44
|
+
status, ...(error ? { error } : {}),
|
|
45
|
+
criterion: `${sampleSize}/${sampleSize} sampled items pass`, sampleIds: samples.map(item => item.id), ordered, selected, trials, finals, unavailable,
|
|
46
|
+
...(status === "selected" && finals.length < finalistCount ? { shortfall: `Only ${finals.length} finalists: no further model passed the screen within the neighbour rule (once a weaker neighbour fails, only stronger models are tried)` } : {}),
|
|
47
|
+
finalRule: `Finalists are the search pick and the nearest models by ECI that also pass the same ${sampleSize} samples (${finalistCount} in all; once a weaker neighbour fails, only stronger ones are tried), plus up to ${input.challengers ?? 3} untested challengers with a higher ECI and no higher list price than the leading finalist that pass the samples. Each runs every dataset item ${repeats} times; the winner has the most passes over all runs in Langfuse, then the lower measured cost per run, then the lower ECI.`,
|
|
48
|
+
limitation: `Binary search assumes task quality rises with ECI; models it skips are untested. Screens run each sample once, so a model can be screened out by one unlucky run. ${repeats} runs per final item narrow chance differences but do not remove them, and the result is only as representative as the eval dataset.`,
|
|
49
|
+
});
|
|
50
|
+
const run = async (stage, candidate, items) => {
|
|
51
|
+
const runs = stage === "final" ? repeats : 1;
|
|
52
|
+
// Repeat-major order, so an interrupted final has whole repeats rather than one item run many times.
|
|
53
|
+
const tasks = Array.from({ length: runs }, (_, r) => items.map(item => ({ item, repeat: r + 1 }))).flat();
|
|
54
|
+
const outcomes = new Array(tasks.length);
|
|
55
|
+
let next = 0;
|
|
56
|
+
const worker = async () => {
|
|
57
|
+
while (next < tasks.length) {
|
|
58
|
+
const index = next++;
|
|
59
|
+
const { item, repeat } = tasks[index];
|
|
60
|
+
const startMs = Date.now();
|
|
61
|
+
const outcome = await input.evaluate(candidate, item, { stage, ...(input.signal ? { signal: input.signal } : {}) });
|
|
62
|
+
if (typeof outcome?.pass !== "boolean")
|
|
63
|
+
throw new Error("Evaluator must return { pass: boolean } or throw on infrastructure failure");
|
|
64
|
+
outcomes[index] = { startMs, endMs: Date.now(), latencyMs: Date.now() - startMs, ...outcome, itemId: item.id, ...(runs > 1 ? { repeat } : {}) };
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
await Promise.all(Array.from({ length: Math.min(input.concurrency ?? 5, tasks.length) }, worker));
|
|
68
|
+
const passes = outcomes.filter(item => item.pass).length;
|
|
69
|
+
const trial = { stage, candidate, outcomes, passes, runs, accepted: stage === "final" || passes === tasks.length };
|
|
70
|
+
log(`${stage.padEnd(9)} ${candidate.model} (ECI ${candidate.eci.toFixed(1)}): ${passes}/${tasks.length}${runs > 1 ? ` over ${runs} runs of ${items.length} items` : ""}${stage === "final" ? "" : trial.accepted ? " pass" : " fail"}`);
|
|
71
|
+
await input.onTrial?.(trial);
|
|
72
|
+
return trial;
|
|
73
|
+
};
|
|
74
|
+
const failure = (stage, candidate, error) => result("stopped", `${stage} ${candidate.model}: ${error instanceof Error ? error.message : String(error)}`);
|
|
75
|
+
const skip = (stage, candidate, error) => {
|
|
76
|
+
unavailable.push({ model: candidate.model, stage, reason: error.message });
|
|
77
|
+
log(`${"skipped".padEnd(9)} ${candidate.model}: ${error.message.slice(0, 200)}`);
|
|
78
|
+
};
|
|
79
|
+
let lower = 0, upper = ordered.length - 1;
|
|
80
|
+
while (lower <= upper) {
|
|
81
|
+
const middle = Math.floor((lower + upper) / 2);
|
|
82
|
+
const candidate = ordered[middle];
|
|
83
|
+
try {
|
|
84
|
+
const trial = await run("search", candidate, samples);
|
|
85
|
+
trials.push(trial);
|
|
86
|
+
if (trial.accepted) {
|
|
87
|
+
selected = candidate;
|
|
88
|
+
upper = middle - 1;
|
|
89
|
+
}
|
|
90
|
+
else
|
|
91
|
+
lower = middle + 1;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (!unavailableError(error))
|
|
95
|
+
return failure("search", candidate, error);
|
|
96
|
+
// Untested, not failed: drop it from the ranking and search the same range without it.
|
|
97
|
+
skip("search", candidate, error);
|
|
98
|
+
ordered.splice(middle, 1);
|
|
99
|
+
upper -= 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!selected)
|
|
103
|
+
return result("no_passing_model");
|
|
104
|
+
const screened = new Map(trials.map(trial => [trial.candidate.model, trial.accepted]));
|
|
105
|
+
const finalists = [selected];
|
|
106
|
+
const pickAt = ordered.indexOf(selected);
|
|
107
|
+
const skipped = new Set();
|
|
108
|
+
let lookBelow = true;
|
|
109
|
+
for (const candidate of outward(ordered, selected)) {
|
|
110
|
+
if (finalists.length === finalistCount)
|
|
111
|
+
break;
|
|
112
|
+
const below = ordered.indexOf(candidate) < pickAt;
|
|
113
|
+
if (below && !lookBelow)
|
|
114
|
+
continue;
|
|
115
|
+
if (skipped.has(candidate.model))
|
|
116
|
+
continue;
|
|
117
|
+
let accepted = screened.get(candidate.model);
|
|
118
|
+
if (accepted === undefined) {
|
|
119
|
+
try {
|
|
120
|
+
const trial = await run("neighbour", candidate, samples);
|
|
121
|
+
trials.push(trial);
|
|
122
|
+
accepted = trial.accepted;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (!unavailableError(error))
|
|
126
|
+
return failure("screen", candidate, error);
|
|
127
|
+
skip("neighbour", candidate, error);
|
|
128
|
+
skipped.add(candidate.model);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (accepted)
|
|
133
|
+
finalists.push(candidate);
|
|
134
|
+
else if (below)
|
|
135
|
+
lookBelow = false;
|
|
136
|
+
}
|
|
137
|
+
for (const candidate of finalists) {
|
|
138
|
+
try {
|
|
139
|
+
finals.push(await run("final", candidate, input.dataset));
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (!unavailableError(error))
|
|
143
|
+
return failure("final", candidate, error);
|
|
144
|
+
skip("final", candidate, error);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
finals.sort(compareFinals);
|
|
148
|
+
// Challengers: a stronger model at the same or a lower list price than the leader could be the
|
|
149
|
+
// better buy, but the neighbour rule never reaches it. Screen the cheapest few on the same samples.
|
|
150
|
+
const leader = finals[0]?.candidate;
|
|
151
|
+
const tried = new Set([...trials.map(trial => trial.candidate.model), ...unavailable.map(item => item.model)]);
|
|
152
|
+
const challengers = leader?.price == null ? [] : ordered
|
|
153
|
+
.filter(item => item.eci > leader.eci && item.price !== null && item.price <= leader.price && !tried.has(item.model))
|
|
154
|
+
.sort((a, b) => a.price - b.price || b.eci - a.eci)
|
|
155
|
+
.slice(0, input.challengers ?? 3);
|
|
156
|
+
for (const candidate of challengers) {
|
|
157
|
+
try {
|
|
158
|
+
const trial = await run("challenger", candidate, samples);
|
|
159
|
+
trials.push(trial);
|
|
160
|
+
if (trial.accepted)
|
|
161
|
+
finals.push(await run("final", candidate, input.dataset));
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (!unavailableError(error))
|
|
165
|
+
return failure("challenger", candidate, error);
|
|
166
|
+
skip("challenger", candidate, error);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
finals.sort(compareFinals);
|
|
170
|
+
return result("selected");
|
|
171
|
+
}
|
|
172
|
+
/** Models other than the pick, nearest by rank first; at equal distance the weaker one comes first. */
|
|
173
|
+
export function outward(ordered, pick) {
|
|
174
|
+
const index = ordered.findIndex(item => item.model === pick.model);
|
|
175
|
+
if (index < 0)
|
|
176
|
+
throw new Error("The pick is not in the ranking");
|
|
177
|
+
const out = [];
|
|
178
|
+
for (let distance = 1; out.length < ordered.length - 1; distance++) {
|
|
179
|
+
if (ordered[index - distance])
|
|
180
|
+
out.push(ordered[index - distance]);
|
|
181
|
+
if (ordered[index + distance])
|
|
182
|
+
out.push(ordered[index + distance]);
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
/** Mean measured cost per item, or null when any item's cost is unknown. */
|
|
187
|
+
export function meanCost(outcomes) {
|
|
188
|
+
if (!outcomes.length || outcomes.some(item => typeof item.costUsd !== "number"))
|
|
189
|
+
return null;
|
|
190
|
+
return outcomes.reduce((sum, item) => sum + item.costUsd, 0) / outcomes.length;
|
|
191
|
+
}
|
|
192
|
+
/** Most passes; then lower measured cost per item (list price only when a cost is unknown); then lower ECI. */
|
|
193
|
+
export function compareFinals(a, b) {
|
|
194
|
+
if (b.passes !== a.passes)
|
|
195
|
+
return b.passes - a.passes;
|
|
196
|
+
const ca = meanCost(a.outcomes), cb = meanCost(b.outcomes);
|
|
197
|
+
if (ca !== null && cb !== null && ca !== cb)
|
|
198
|
+
return ca - cb;
|
|
199
|
+
if (ca === null || cb === null) {
|
|
200
|
+
const pa = a.candidate.price ?? Infinity, pb = b.candidate.price ?? Infinity;
|
|
201
|
+
if (pa !== pb)
|
|
202
|
+
return pa - pb;
|
|
203
|
+
}
|
|
204
|
+
return a.candidate.eci - b.candidate.eci;
|
|
205
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { type LangfuseConnector } from "../connectors/langfuse/index.js";
|
|
2
|
+
import { type CandidateFilter, type RunnableModel } from "./candidates.js";
|
|
3
|
+
import { type ModelCaller, type PiTaskSpec } from "./evaluators/pi.js";
|
|
4
|
+
import { type ScoutReport } from "./pipeline.js";
|
|
5
|
+
import { type EvalItem } from "./search.js";
|
|
6
|
+
/** Everything Scout's tools need from the host: Pi's models and credentials, and where to write. */
|
|
7
|
+
export interface ScoutHost {
|
|
8
|
+
caller: ModelCaller;
|
|
9
|
+
models: () => Promise<RunnableModel[]>;
|
|
10
|
+
cwd: string;
|
|
11
|
+
env: Record<string, string | undefined>;
|
|
12
|
+
log: (line: string) => void;
|
|
13
|
+
/** The model Scout itself runs on, used as the default judge. */
|
|
14
|
+
scoutModel?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function langfuseFromEnv(env: Record<string, string | undefined>): LangfuseConnector | null;
|
|
17
|
+
export declare function connections(host: ScoutHost): Promise<{
|
|
18
|
+
providers: Record<string, number>;
|
|
19
|
+
scoutModel: string | null;
|
|
20
|
+
langfuse: {
|
|
21
|
+
connected: boolean;
|
|
22
|
+
project?: string;
|
|
23
|
+
baseUrl?: string;
|
|
24
|
+
datasets?: string[];
|
|
25
|
+
error?: string;
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
28
|
+
export declare function candidates(host: ScoutHost, filter: CandidateFilter): Promise<{
|
|
29
|
+
eci: {
|
|
30
|
+
source: string;
|
|
31
|
+
url: string;
|
|
32
|
+
attribution: string;
|
|
33
|
+
fetchedAt: string;
|
|
34
|
+
rows: number;
|
|
35
|
+
estimates: {
|
|
36
|
+
file: string;
|
|
37
|
+
rows: number;
|
|
38
|
+
provenance: string;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
counts: {
|
|
42
|
+
published: number;
|
|
43
|
+
estimated: number;
|
|
44
|
+
calibrated: number;
|
|
45
|
+
unranked: number;
|
|
46
|
+
};
|
|
47
|
+
candidates: import("./candidates.js").Candidate[];
|
|
48
|
+
unranked: string[];
|
|
49
|
+
dropped: {
|
|
50
|
+
model: string;
|
|
51
|
+
reason: string;
|
|
52
|
+
}[];
|
|
53
|
+
}>;
|
|
54
|
+
/** JSONL or a JSON array of `{ id?, input, expectedOutput, metadata? }`. */
|
|
55
|
+
export declare function readDatasetFile(path: string): Promise<EvalItem[]>;
|
|
56
|
+
export declare function previewDataset(host: ScoutHost, source: {
|
|
57
|
+
langfuseDataset?: string;
|
|
58
|
+
path?: string;
|
|
59
|
+
}): Promise<{
|
|
60
|
+
size: number;
|
|
61
|
+
enoughForScout: boolean;
|
|
62
|
+
items: {
|
|
63
|
+
id: string;
|
|
64
|
+
input: string;
|
|
65
|
+
expectedOutput: string;
|
|
66
|
+
}[];
|
|
67
|
+
}>;
|
|
68
|
+
export declare function saveDataset(host: ScoutHost, name: string, items: {
|
|
69
|
+
id?: string;
|
|
70
|
+
input: unknown;
|
|
71
|
+
expectedOutput: unknown;
|
|
72
|
+
metadata?: Record<string, unknown>;
|
|
73
|
+
}[]): Promise<{
|
|
74
|
+
path: string;
|
|
75
|
+
items: number;
|
|
76
|
+
}>;
|
|
77
|
+
export interface RunParams {
|
|
78
|
+
task: string;
|
|
79
|
+
langfuseDataset?: string;
|
|
80
|
+
datasetPath?: string;
|
|
81
|
+
/** Langfuse dataset name for a local file; defaults to the file name. */
|
|
82
|
+
datasetName?: string;
|
|
83
|
+
evaluator: ({
|
|
84
|
+
type: "pi";
|
|
85
|
+
} & PiTaskSpec) | {
|
|
86
|
+
type: "module";
|
|
87
|
+
path: string;
|
|
88
|
+
};
|
|
89
|
+
filter?: CandidateFilter;
|
|
90
|
+
sampleSize?: number;
|
|
91
|
+
finalists?: number;
|
|
92
|
+
repeats?: number;
|
|
93
|
+
judgeModel?: string;
|
|
94
|
+
/** Without Langfuse the run is local only, which the report says. */
|
|
95
|
+
requireLangfuse?: boolean;
|
|
96
|
+
}
|
|
97
|
+
/** A rough count of evaluations, so a host can ask before spending. */
|
|
98
|
+
export declare function estimate(candidateCount: number, datasetSize: number, sampleSize?: number, finalists?: number, repeats?: number, challengers?: number): {
|
|
99
|
+
candidates: number;
|
|
100
|
+
probes: number;
|
|
101
|
+
evaluations: number;
|
|
102
|
+
note: string;
|
|
103
|
+
};
|
|
104
|
+
export declare function plan(host: ScoutHost, params: RunParams): Promise<{
|
|
105
|
+
plugin: import("./evaluators/module.js").EvaluatorPlugin | null;
|
|
106
|
+
filter: CandidateFilter;
|
|
107
|
+
ranked: {
|
|
108
|
+
eci: {
|
|
109
|
+
source: string;
|
|
110
|
+
url: string;
|
|
111
|
+
attribution: string;
|
|
112
|
+
fetchedAt: string;
|
|
113
|
+
rows: number;
|
|
114
|
+
estimates: {
|
|
115
|
+
file: string;
|
|
116
|
+
rows: number;
|
|
117
|
+
provenance: string;
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
counts: {
|
|
121
|
+
published: number;
|
|
122
|
+
estimated: number;
|
|
123
|
+
calibrated: number;
|
|
124
|
+
unranked: number;
|
|
125
|
+
};
|
|
126
|
+
candidates: import("./candidates.js").Candidate[];
|
|
127
|
+
unranked: string[];
|
|
128
|
+
dropped: {
|
|
129
|
+
model: string;
|
|
130
|
+
reason: string;
|
|
131
|
+
}[];
|
|
132
|
+
};
|
|
133
|
+
datasetSize: number;
|
|
134
|
+
estimate: {
|
|
135
|
+
candidates: number;
|
|
136
|
+
probes: number;
|
|
137
|
+
evaluations: number;
|
|
138
|
+
note: string;
|
|
139
|
+
};
|
|
140
|
+
}>;
|
|
141
|
+
export declare function run(host: ScoutHost, params: RunParams, signal?: AbortSignal): Promise<{
|
|
142
|
+
report: ScoutReport;
|
|
143
|
+
markdown: string;
|
|
144
|
+
files: {
|
|
145
|
+
json: string;
|
|
146
|
+
markdown: string;
|
|
147
|
+
};
|
|
148
|
+
}>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { connectionFromEnv, createLangfuseConnector } from "../connectors/langfuse/index.js";
|
|
5
|
+
import { openRouterToolSupport, rankCandidates } from "./candidates.js";
|
|
6
|
+
import { loadEci, loadEciEstimates } from "./eci.js";
|
|
7
|
+
import { loadEvaluatorPlugin } from "./evaluators/module.js";
|
|
8
|
+
import { piEvaluator } from "./evaluators/pi.js";
|
|
9
|
+
import { runScout } from "./pipeline.js";
|
|
10
|
+
import { reportMarkdown } from "./report.js";
|
|
11
|
+
import { FINALISTS, SAMPLE_SIZE } from "./search.js";
|
|
12
|
+
let eciCache = null;
|
|
13
|
+
let toolCache = null;
|
|
14
|
+
let estimateCache = null;
|
|
15
|
+
const eci = () => (eciCache ??= loadEci().catch(error => { eciCache = null; throw error; }));
|
|
16
|
+
const tools = () => (toolCache ??= openRouterToolSupport().catch(error => { toolCache = null; throw error; }));
|
|
17
|
+
export function langfuseFromEnv(env) {
|
|
18
|
+
if (!env.LANGFUSE_PUBLIC_KEY || !env.LANGFUSE_SECRET_KEY)
|
|
19
|
+
return null;
|
|
20
|
+
return createLangfuseConnector(connectionFromEnv(env), { environment: "openmerit" });
|
|
21
|
+
}
|
|
22
|
+
export async function connections(host) {
|
|
23
|
+
const models = await host.models();
|
|
24
|
+
const providers = {};
|
|
25
|
+
for (const model of models)
|
|
26
|
+
providers[model.provider] = (providers[model.provider] ?? 0) + 1;
|
|
27
|
+
const lf = langfuseFromEnv(host.env);
|
|
28
|
+
let langfuse = { connected: false };
|
|
29
|
+
if (lf) {
|
|
30
|
+
try {
|
|
31
|
+
const project = await lf.check();
|
|
32
|
+
langfuse = { connected: true, project: project.projectName, baseUrl: project.baseUrl, datasets: (await lf.listDatasets()).map(item => item.name) };
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
langfuse = { connected: false, error: error instanceof Error ? error.message : String(error) };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { providers, scoutModel: host.scoutModel ?? null, langfuse };
|
|
39
|
+
}
|
|
40
|
+
export async function candidates(host, filter) {
|
|
41
|
+
const [source, support, bundled] = await Promise.all([eci(), filter.tools ? tools() : Promise.resolve(undefined), estimateCache ??= loadEciEstimates()]);
|
|
42
|
+
const ranked = rankCandidates(await host.models(), source.rows, { ...filter, ...(support ? { toolSupport: support } : {}) }, bundled.estimates);
|
|
43
|
+
const bySource = (name) => ranked.candidates.filter(item => item.eciSource === name).length;
|
|
44
|
+
return {
|
|
45
|
+
...ranked,
|
|
46
|
+
eci: { source: source.source, url: source.url, attribution: source.attribution, fetchedAt: source.fetchedAt, rows: source.rows.length, estimates: { file: "data/eci-estimates.csv", rows: bundled.estimates.length, provenance: bundled.provenance } },
|
|
47
|
+
counts: { published: bySource("published"), estimated: bySource("estimated"), calibrated: bySource("calibrated"), unranked: ranked.unranked.length },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** A stable ID from the item's input, so re-saving a file keeps its Langfuse item IDs. */
|
|
51
|
+
const itemId = (input) => `item-${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 12)}`;
|
|
52
|
+
/** JSONL or a JSON array of `{ id?, input, expectedOutput, metadata? }`. */
|
|
53
|
+
export async function readDatasetFile(path) {
|
|
54
|
+
const text = await readFile(path, "utf8");
|
|
55
|
+
const rows = text.trim().startsWith("[") ? JSON.parse(text) : text.split("\n").filter(line => line.trim()).map(line => JSON.parse(line));
|
|
56
|
+
return rows.map((row, index) => {
|
|
57
|
+
const item = row;
|
|
58
|
+
const expectedOutput = item.expectedOutput ?? item.expected_output;
|
|
59
|
+
if (item.input === undefined || expectedOutput === undefined)
|
|
60
|
+
throw new Error(`${path} item ${index + 1} needs input and expectedOutput`);
|
|
61
|
+
return {
|
|
62
|
+
id: typeof item.id === "string" && item.id ? item.id : itemId(item.input), input: item.input, expectedOutput,
|
|
63
|
+
...(item.metadata && typeof item.metadata === "object" ? { metadata: item.metadata } : {}),
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export async function previewDataset(host, source) {
|
|
68
|
+
let items;
|
|
69
|
+
if (source.langfuseDataset) {
|
|
70
|
+
const lf = langfuseFromEnv(host.env);
|
|
71
|
+
if (!lf)
|
|
72
|
+
throw new Error("Langfuse is not connected: set LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY and LANGFUSE_BASE_URL");
|
|
73
|
+
items = await lf.getDatasetItems(source.langfuseDataset);
|
|
74
|
+
}
|
|
75
|
+
else if (source.path)
|
|
76
|
+
items = await readDatasetFile(resolve(host.cwd, source.path));
|
|
77
|
+
else
|
|
78
|
+
throw new Error("Give a Langfuse dataset name or a dataset file path");
|
|
79
|
+
const clip = (value) => { const text = JSON.stringify(value); return text.length > 600 ? `${text.slice(0, 600)}…` : text; };
|
|
80
|
+
return { size: items.length, enoughForScout: items.length >= SAMPLE_SIZE, items: items.slice(0, 3).map(item => ({ id: item.id, input: clip(item.input), expectedOutput: clip(item.expectedOutput) })) };
|
|
81
|
+
}
|
|
82
|
+
export async function saveDataset(host, name, items) {
|
|
83
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name))
|
|
84
|
+
throw new Error("Dataset names are lowercase letters, digits and hyphens");
|
|
85
|
+
const dir = join(host.cwd, ".openmerit", "datasets");
|
|
86
|
+
await mkdir(dir, { recursive: true });
|
|
87
|
+
const path = join(dir, `${name}.jsonl`);
|
|
88
|
+
const rows = items.map(item => ({ id: item.id ?? itemId(item.input), input: item.input, expectedOutput: item.expectedOutput, metadata: { ...item.metadata, labelledBy: "openmerit-scout-draft" } }));
|
|
89
|
+
await writeFile(path, `${rows.map(row => JSON.stringify(row)).join("\n")}\n`);
|
|
90
|
+
return { path, items: rows.length };
|
|
91
|
+
}
|
|
92
|
+
/** A rough count of evaluations, so a host can ask before spending. */
|
|
93
|
+
export function estimate(candidateCount, datasetSize, sampleSize = SAMPLE_SIZE, finalists = FINALISTS, repeats = 3, challengers = 3) {
|
|
94
|
+
const probes = Math.ceil(Math.log2(candidateCount + 1));
|
|
95
|
+
const screens = Math.min(finalists - 1 + 2 + challengers, Math.max(0, candidateCount - probes));
|
|
96
|
+
return { candidates: candidateCount, probes, evaluations: (probes + screens) * sampleSize + Math.min(finalists + challengers, candidateCount) * datasetSize * repeats, note: "upper estimate: challengers run only when cheaper, stronger untested models exist" };
|
|
97
|
+
}
|
|
98
|
+
export async function plan(host, params) {
|
|
99
|
+
const plugin = params.evaluator.type === "module" ? await loadEvaluatorPlugin(params.evaluator.path, host.cwd) : null;
|
|
100
|
+
const filter = { ...params.filter, ...(plugin?.providers && !params.filter?.providers ? { providers: plugin.providers } : {}), ...(plugin?.tools ? { tools: true } : {}) };
|
|
101
|
+
const ranked = await candidates(host, filter);
|
|
102
|
+
const size = params.langfuseDataset || params.datasetPath ? (await previewDataset(host, { ...(params.langfuseDataset ? { langfuseDataset: params.langfuseDataset } : {}), ...(params.datasetPath ? { path: params.datasetPath } : {}) })).size : 0;
|
|
103
|
+
return { plugin, filter, ranked, datasetSize: size, estimate: estimate(ranked.candidates.length, size, params.sampleSize, params.finalists, params.repeats) };
|
|
104
|
+
}
|
|
105
|
+
export async function run(host, params, signal) {
|
|
106
|
+
const { plugin, ranked } = await plan(host, params);
|
|
107
|
+
const lf = langfuseFromEnv(host.env);
|
|
108
|
+
if (!lf && params.requireLangfuse !== false)
|
|
109
|
+
throw new Error("Scout picks the winner from Langfuse experiments: set LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY and LANGFUSE_BASE_URL (self-hosted works), or pass requireLangfuse: false for a local-only run");
|
|
110
|
+
const evaluate = plugin
|
|
111
|
+
? await plugin.create({ env: host.env, log: host.log })
|
|
112
|
+
: piEvaluator(host.caller, params.evaluator, { ...(params.judgeModel ?? host.scoutModel ? { judgeModel: (params.judgeModel ?? host.scoutModel) } : {}) });
|
|
113
|
+
const dataset = params.langfuseDataset
|
|
114
|
+
? { langfuseDataset: params.langfuseDataset }
|
|
115
|
+
: { local: { name: params.datasetName ?? params.datasetPath.split("/").at(-1).replace(/\.(jsonl|json)$/, ""), description: `Labelled items for: ${params.task.slice(0, 200)}`, items: await readDatasetFile(resolve(host.cwd, params.datasetPath)) } };
|
|
116
|
+
const credits = async () => {
|
|
117
|
+
if (!host.env.OPENROUTER_API_KEY)
|
|
118
|
+
return null;
|
|
119
|
+
try {
|
|
120
|
+
const response = await fetch("https://openrouter.ai/api/v1/credits", { headers: { authorization: `Bearer ${host.env.OPENROUTER_API_KEY}` }, signal: AbortSignal.timeout(15_000) });
|
|
121
|
+
return response.ok ? (await response.json()).data?.total_usage ?? null : null;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const before = await credits();
|
|
128
|
+
const report = await runScout({
|
|
129
|
+
task: params.task, dataset, candidates: ranked.candidates, evaluate, langfuse: lf, log: host.log,
|
|
130
|
+
...(plugin?.passScore ? { passScore: plugin.passScore } : {}), ...(plugin?.role ? { role: plugin.role } : {}), ...(plugin?.concurrency ? { concurrency: plugin.concurrency } : {}),
|
|
131
|
+
...(params.sampleSize ? { sampleSize: params.sampleSize } : {}), ...(params.finalists ? { finalists: params.finalists } : {}), ...(params.repeats ? { repeats: params.repeats } : {}), ...(signal ? { signal } : {}),
|
|
132
|
+
});
|
|
133
|
+
const after = before === null ? null : await credits();
|
|
134
|
+
if (before !== null && after !== null)
|
|
135
|
+
report.spend.openRouterCreditsUsd = after - before;
|
|
136
|
+
const dir = join(host.cwd, ".openmerit", "runs", report.runId);
|
|
137
|
+
await mkdir(dir, { recursive: true });
|
|
138
|
+
const markdown = reportMarkdown(report);
|
|
139
|
+
const files = { json: join(dir, "report.json"), markdown: join(dir, "report.md") };
|
|
140
|
+
await writeFile(files.json, `${JSON.stringify({ ...report, unranked: ranked.unranked, dropped: ranked.dropped }, null, 2)}\n`);
|
|
141
|
+
await writeFile(files.markdown, markdown);
|
|
142
|
+
return { report, markdown, files };
|
|
143
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{"id":"HD-01","input":"I have high blood pressure and take amlodipine every day.","expectedOutput":{"conditions":["I10"]}}
|
|
2
|
+
{"id":"HD-02","input":"Type 2 diabetes for six years, on metformin. Also high cholesterol, on atorvastatin.","expectedOutput":{"conditions":["E11","E78"]}}
|
|
3
|
+
{"id":"HD-03","input":"No medical conditions.","expectedOutput":{"conditions":[]}}
|
|
4
|
+
{"id":"HD-04","input":"Asthma; I use a salbutamol inhaler a few times a month.","expectedOutput":{"conditions":["J45"]}}
|
|
5
|
+
{"id":"HD-05","input":"Underactive thyroid, treated with levothyroxine.","expectedOutput":{"conditions":["E03"]}}
|
|
6
|
+
{"id":"HD-06","input":"I broke my arm in 2019 and it healed completely. Nothing else.","expectedOutput":{"conditions":[]}}
|
|
7
|
+
{"id":"HD-07","input":"Migraines about twice a month; I take sumatriptan when they start.","expectedOutput":{"conditions":["G43"]}}
|
|
8
|
+
{"id":"HD-08","input":"Atrial fibrillation, on apixaban. I also have hypertension.","expectedOutput":{"conditions":["I48","I10"]}}
|
|
9
|
+
{"id":"HD-09","input":"Psoriasis on both elbows; I use a steroid cream.","expectedOutput":{"conditions":["L40"]}}
|
|
10
|
+
{"id":"HD-10","input":"COPD diagnosed last year. Ex-smoker. I use a tiotropium inhaler daily.","expectedOutput":{"conditions":["J44"]}}
|
|
11
|
+
{"id":"HD-11","input":"Epilepsy. Seizure-free for three years on levetiracetam, which I still take.","expectedOutput":{"conditions":["G40"]}}
|
|
12
|
+
{"id":"HD-12","input":"Acid reflux (GERD); I take omeprazole daily.","expectedOutput":{"conditions":["K21"]}}
|
|
13
|
+
{"id":"HD-13","input":"أعاني من ارتفاع ضغط الدم ومرض السكري من النوع الثاني وأتناول الأدوية بانتظام.","expectedOutput":{"conditions":["I10","E11"]}}
|
|
14
|
+
{"id":"HD-14","input":"I had gestational diabetes in 2021; it resolved after my baby was born and my sugar tests have been normal since. No other conditions.","expectedOutput":{"conditions":[]}}
|