openmerit 0.3.10 → 0.4.0

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.
Files changed (69) hide show
  1. package/README.md +74 -22
  2. package/data/eci-estimates.csv +179 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +180 -0
  5. package/dist/connectors/langfuse/client.d.ts +42 -0
  6. package/dist/connectors/langfuse/client.js +141 -0
  7. package/dist/connectors/langfuse/index.d.ts +163 -0
  8. package/dist/connectors/langfuse/index.js +378 -0
  9. package/dist/connectors/langfuse/otlp.d.ts +60 -0
  10. package/dist/connectors/langfuse/otlp.js +40 -0
  11. package/dist/index.d.ts +11 -0
  12. package/dist/index.js +11 -0
  13. package/dist/pi/extension.d.ts +13 -0
  14. package/dist/pi/extension.js +148 -0
  15. package/dist/pi/harness.d.ts +20 -0
  16. package/dist/pi/harness.js +49 -0
  17. package/dist/pi/instructions.d.ts +2 -0
  18. package/dist/pi/instructions.js +12 -0
  19. package/dist/scout/candidates.d.ts +62 -0
  20. package/dist/scout/candidates.js +90 -0
  21. package/dist/scout/eci.d.ts +42 -0
  22. package/dist/scout/eci.js +84 -0
  23. package/dist/scout/evaluators/module.d.ts +24 -0
  24. package/dist/scout/evaluators/module.js +19 -0
  25. package/dist/scout/evaluators/pi.d.ts +53 -0
  26. package/dist/scout/evaluators/pi.js +117 -0
  27. package/dist/scout/pipeline.d.ts +111 -0
  28. package/dist/scout/pipeline.js +157 -0
  29. package/dist/scout/report.d.ts +3 -0
  30. package/dist/scout/report.js +42 -0
  31. package/dist/scout/search.d.ts +110 -0
  32. package/dist/scout/search.js +205 -0
  33. package/dist/scout/service.d.ts +148 -0
  34. package/dist/scout/service.js +143 -0
  35. package/examples/health-declaration-codes/dataset.jsonl +14 -0
  36. package/examples/sahra-enrollment/evaluator.ts +151 -0
  37. package/package.json +34 -78
  38. package/CHANGELOG.md +0 -250
  39. package/dist/pi/scout-extension.mjs +0 -287
  40. package/dist/pi/src/index.d.ts +0 -6
  41. package/dist/pi/src/index.js +0 -56
  42. package/dist/terminal/scout-update.json +0 -1
  43. package/dist/terminal/src/browser.d.mts +0 -9
  44. package/dist/terminal/src/browser.mjs +0 -28
  45. package/dist/terminal/src/cli.mjs +0 -28
  46. package/dist/terminal/src/connections.js +0 -151
  47. package/dist/terminal/src/install.d.mts +0 -2
  48. package/dist/terminal/src/install.mjs +0 -524
  49. package/dist/terminal/src/scout-claude.js +0 -25
  50. package/dist/terminal/src/scout-credentials.js +0 -41
  51. package/dist/terminal/src/scout-instructions.js +0 -213
  52. package/dist/terminal/src/scout-pi.js +0 -84
  53. package/dist/terminal/src/scout.js +0 -229
  54. package/dist/terminal/src/telemetry.d.mts +0 -30
  55. package/dist/terminal/src/telemetry.mjs +0 -138
  56. package/dist/terminal/src/updates.js +0 -397
  57. package/docs/agent-evaluations.md +0 -15
  58. package/docs/commands.md +0 -9
  59. package/docs/getting-started.md +0 -37
  60. package/docs/index.md +0 -25
  61. package/docs/inference-terminal.md +0 -25
  62. package/docs/pi-extension.md +0 -58
  63. package/docs/roadmap.md +0 -16
  64. package/docs/scout-close-loop.md +0 -359
  65. package/docs/scout-data-policy.md +0 -87
  66. package/docs/scout.md +0 -190
  67. package/docs/security.md +0 -107
  68. package/docs/testing.md +0 -114
  69. package/docs/troubleshooting.md +0 -50
@@ -0,0 +1,117 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { CandidateUnavailable } from "../search.js";
3
+ const text = (message) => message.content.filter(part => part.type === "text").map(part => part.text).join("").trim();
4
+ const asText = (value) => (typeof value === "string" ? value : JSON.stringify(value, null, 2));
5
+ const normalize = (value) => value.toLowerCase().replace(/\s+/g, " ").replace(/[.!\s]+$/, "").trim();
6
+ /** The first JSON object or array in a reply, with code fences removed. */
7
+ export function extractJson(reply) {
8
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(reply)?.[1] ?? reply;
9
+ const start = fenced.search(/[[{]/);
10
+ if (start < 0)
11
+ return undefined;
12
+ for (let end = fenced.length; end > start; end--) {
13
+ try {
14
+ return JSON.parse(fenced.slice(start, end));
15
+ }
16
+ catch { /* shorten */ }
17
+ }
18
+ return undefined;
19
+ }
20
+ /** Every expected field matches: objects by key, arrays as sets, strings case-insensitively. */
21
+ export function jsonMatches(expected, actual) {
22
+ if (expected === null || typeof expected !== "object") {
23
+ if (typeof expected === "string" && typeof actual === "string")
24
+ return normalize(expected) === normalize(actual);
25
+ return expected === actual;
26
+ }
27
+ if (Array.isArray(expected)) {
28
+ return Array.isArray(actual) && expected.length === actual.length && expected.every(item => actual.some(other => jsonMatches(item, other)));
29
+ }
30
+ if (!actual || typeof actual !== "object" || Array.isArray(actual))
31
+ return false;
32
+ return Object.entries(expected).every(([key, value]) => jsonMatches(value, actual[key]));
33
+ }
34
+ const field = (expected, name) => asText(name && expected && typeof expected === "object" ? expected[name] : expected);
35
+ /** Messages with a provider error are infrastructure failures, not quality verdicts. */
36
+ function ensureOk(message, what) {
37
+ if (message.stopReason === "error" || message.stopReason === "aborted")
38
+ throw new Error(`${what}: ${message.errorMessage ?? message.stopReason}`);
39
+ }
40
+ const status = (message) => Number(/^(\d{3})\b/.exec(message.errorMessage ?? "")?.[1] ?? 0);
41
+ /**
42
+ * One call with retries for transient provider errors (429, 5xx, network), waiting 5, 20 and 60 s.
43
+ * A model the provider rejects (400, 404) or the balance cannot cover (402) is unavailable, not failed.
44
+ */
45
+ async function call(caller, model, context, options, what, unavailable, waits) {
46
+ for (const wait of [...waits, -1]) {
47
+ const reply = await caller.complete(model, context, options);
48
+ if (reply.stopReason !== "error")
49
+ return reply;
50
+ const code = status(reply);
51
+ if (unavailable && [400, 402, 404, 413, 422].includes(code))
52
+ throw new CandidateUnavailable(`${what}: ${(reply.errorMessage ?? "").slice(0, 240)}`);
53
+ if (wait < 0 || (code !== 0 && code !== 429 && code < 500))
54
+ return reply;
55
+ await delay(wait, undefined, options.signal ? { signal: options.signal } : undefined);
56
+ }
57
+ throw new Error("unreachable");
58
+ }
59
+ function resolve(caller, ref) {
60
+ const slash = ref.indexOf("/");
61
+ const model = slash > 0 ? caller.getModel(ref.slice(0, slash), ref.slice(slash + 1)) : undefined;
62
+ if (!model)
63
+ throw new Error(`Model ${ref} is not available with the connected providers`);
64
+ return model;
65
+ }
66
+ /**
67
+ * Runs each item as one model call through Pi, so any provider Pi connects (API key or
68
+ * subscription) can be a candidate, then grades the reply. Tool-using agents need an
69
+ * evaluator module that runs the real harness instead.
70
+ */
71
+ export function piEvaluator(caller, spec, options = {}) {
72
+ const waits = options.retryDelaysMs ?? [5_000, 20_000, 60_000];
73
+ return async (candidate, item, context) => {
74
+ const model = resolve(caller, candidate.model);
75
+ const input = asText(item.input);
76
+ const user = spec.inputTemplate ? spec.inputTemplate.replaceAll("{{input}}", input) : input;
77
+ const reply = await call(caller, model, { systemPrompt: spec.systemPrompt, messages: [{ role: "user", content: user, timestamp: Date.now() }] }, { ...(context.signal ? { signal: context.signal } : {}), maxTokens: spec.maxTokens ?? 4096 }, candidate.model, true, waits);
78
+ ensureOk(reply, candidate.model);
79
+ const output = text(reply);
80
+ const usage = { input: reply.usage.input, output: reply.usage.output, total: reply.usage.totalTokens };
81
+ const graded = await grade(spec.grader, item, output, caller, options.judgeModel, waits, context.signal);
82
+ return { pass: graded.pass, comment: graded.comment, output, costUsd: reply.usage.cost.total, usage, ...(reply.durationMs ? { latencyMs: reply.durationMs } : {}) };
83
+ };
84
+ }
85
+ async function grade(grader, item, output, caller, judgeModel, waits, signal) {
86
+ switch (grader.type) {
87
+ case "exact": {
88
+ const expected = field(item.expectedOutput, grader.field);
89
+ return { pass: normalize(output) === normalize(expected), comment: `expected "${expected.slice(0, 80)}"` };
90
+ }
91
+ case "contains": {
92
+ const expected = field(item.expectedOutput, grader.field);
93
+ return { pass: normalize(output).includes(normalize(expected)), comment: `expected to contain "${expected.slice(0, 80)}"` };
94
+ }
95
+ case "json": {
96
+ const parsed = extractJson(output);
97
+ if (parsed === undefined)
98
+ return { pass: false, comment: "reply has no JSON" };
99
+ const pass = jsonMatches(item.expectedOutput, parsed);
100
+ return { pass, comment: pass ? "every expected field matches" : `expected ${JSON.stringify(item.expectedOutput).slice(0, 160)}` };
101
+ }
102
+ case "judge": {
103
+ const ref = grader.model ?? judgeModel;
104
+ if (!ref)
105
+ throw new Error("The judge grader needs a judge model (--judge-model or the Scout model)");
106
+ const verdict = await call(caller, resolve(caller, ref), {
107
+ systemPrompt: "You grade one model output against a labelled expected output. Reply with JSON only: {\"pass\": true|false, \"reason\": \"<one sentence>\"}. Pass only if the output satisfies the rubric and agrees with the expected output on every point the rubric names.",
108
+ messages: [{ role: "user", content: `Rubric:\n${grader.rubric}\n\nInput:\n${asText(item.input)}\n\nExpected output:\n${asText(item.expectedOutput)}\n\nModel output:\n${output}`, timestamp: Date.now() }],
109
+ }, { ...(signal ? { signal } : {}), maxTokens: 1024 }, `judge ${ref}`, false, waits);
110
+ ensureOk(verdict, `judge ${ref}`);
111
+ const parsed = extractJson(text(verdict));
112
+ if (typeof parsed?.pass !== "boolean")
113
+ throw new Error(`Judge ${ref} returned no verdict`);
114
+ return { pass: parsed.pass, comment: `judge: ${typeof parsed.reason === "string" ? parsed.reason : ""}` };
115
+ }
116
+ }
117
+ }
@@ -0,0 +1,111 @@
1
+ import type { LangfuseConnector } from "../connectors/langfuse/index.ts";
2
+ import type { Candidate } from "./candidates.ts";
3
+ import { type EvalItem, type Evaluate, type SearchResult } from "./search.ts";
4
+ export interface DatasetSource {
5
+ /** An existing Langfuse dataset, such as the customer's `sahra-enrollments`. */
6
+ langfuseDataset?: string;
7
+ /** Local labelled items; pushed to Langfuse as `name` before evaluation so every run is an experiment on it. */
8
+ local?: {
9
+ name: string;
10
+ description?: string;
11
+ items: EvalItem[];
12
+ };
13
+ }
14
+ export interface ScoutRunOptions {
15
+ task: string;
16
+ dataset: DatasetSource;
17
+ candidates: Candidate[];
18
+ evaluate: Evaluate;
19
+ /** Score name each recorded item carries; the pass rule reads it back from Langfuse. */
20
+ passScore?: string;
21
+ /** Experiment metadata key for the candidate: `model_<role>`. */
22
+ role?: string;
23
+ langfuse: LangfuseConnector | null;
24
+ sampleSize?: number;
25
+ finalists?: number;
26
+ /** See SearchOptions.challengers. */
27
+ challengers?: number;
28
+ /** See SearchOptions.repeats. */
29
+ repeats?: number;
30
+ concurrency?: number;
31
+ log?: (line: string) => void;
32
+ signal?: AbortSignal;
33
+ /** Injected for tests. */
34
+ pickIndex?: (remaining: number) => number;
35
+ langfuseTimeoutMs?: number;
36
+ }
37
+ export interface FinalVerdict {
38
+ model: string;
39
+ eci: number;
40
+ eciSource: string;
41
+ listPrice: number | null;
42
+ /** Passes as Langfuse reports them; local passes when Langfuse is not connected. */
43
+ passes: number;
44
+ /** Item runs: dataset items × repeats. */
45
+ total: number;
46
+ /** Runs of each item. */
47
+ runs: number;
48
+ /** Passes per item over the repeats, e.g. { "APP-2005": 2 } when it passed 2 of 3 runs. */
49
+ passesByItem: Record<string, number>;
50
+ /** Mean measured cost per item run in USD: Langfuse's recorded generation cost when available. */
51
+ costPerItemUsd: number | null;
52
+ localPasses: number;
53
+ verifiedInLangfuse: boolean;
54
+ missingScores: string[];
55
+ /** One Langfuse experiment per repeat (Langfuse allows one result per dataset item per experiment). */
56
+ experimentIds: string[];
57
+ experimentId: string | null;
58
+ experimentUrl: string | null;
59
+ /** Items that failed at least one run. */
60
+ failedItems: string[];
61
+ }
62
+ export interface ScoutReport {
63
+ scout: "OpenMerit Scout";
64
+ runId: string;
65
+ task: string;
66
+ createdAt: string;
67
+ dataset: {
68
+ name: string;
69
+ source: "langfuse" | "local";
70
+ size: number;
71
+ datasetId: string | null;
72
+ url: string | null;
73
+ };
74
+ candidates: number;
75
+ search: Omit<SearchResult, "ordered" | "trials" | "finals" | "unavailable"> & {
76
+ unavailable: SearchResult["unavailable"];
77
+ trials: {
78
+ stage: string;
79
+ model: string;
80
+ eci: number;
81
+ eciSource: string;
82
+ passes: number;
83
+ total: number;
84
+ accepted: boolean;
85
+ costUsd: number | null;
86
+ experimentUrl: string | null;
87
+ }[];
88
+ };
89
+ finals: FinalVerdict[];
90
+ recommendation: FinalVerdict | null;
91
+ runnerUp: FinalVerdict | null;
92
+ why: string;
93
+ spend: {
94
+ measuredUsd: number;
95
+ unknownCostItems: number;
96
+ evaluations: number;
97
+ /** OpenRouter account credit used between the start and the end of the run (account-wide: concurrent use counts too). */
98
+ openRouterCreditsUsd?: number;
99
+ };
100
+ langfuse: {
101
+ baseUrl: string;
102
+ project: string | null;
103
+ experiments: number;
104
+ } | null;
105
+ limitations: string[];
106
+ }
107
+ /**
108
+ * Scout end to end: dataset → binary search over ECI-ranked candidates → finals on every item,
109
+ * each probe and final recorded as a Langfuse experiment → the winner from Langfuse's results.
110
+ */
111
+ export declare function runScout(options: ScoutRunOptions): Promise<ScoutReport>;
@@ -0,0 +1,157 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { compareFinals, meanCost, scoutSearch } from "./search.js";
3
+ const slug = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
4
+ /**
5
+ * Scout end to end: dataset → binary search over ECI-ranked candidates → finals on every item,
6
+ * each probe and final recorded as a Langfuse experiment → the winner from Langfuse's results.
7
+ */
8
+ export async function runScout(options) {
9
+ const log = options.log ?? (() => { });
10
+ const runId = `scout-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 4)}`;
11
+ const passScore = options.passScore ?? "correct";
12
+ const role = options.role ?? "candidate";
13
+ const lf = options.langfuse;
14
+ let items;
15
+ let datasetName;
16
+ let datasetId = null;
17
+ if (options.dataset.langfuseDataset) {
18
+ if (!lf)
19
+ throw new Error("A Langfuse dataset needs a Langfuse connection (LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_BASE_URL)");
20
+ datasetName = options.dataset.langfuseDataset;
21
+ items = (await lf.getDatasetItems(datasetName)).map(item => ({ id: item.id, input: item.input, expectedOutput: item.expectedOutput, metadata: item.metadata }));
22
+ datasetId = (await lf.getDataset(datasetName)).id;
23
+ }
24
+ else if (options.dataset.local) {
25
+ datasetName = options.dataset.local.name;
26
+ items = options.dataset.local.items;
27
+ if (lf) {
28
+ const pushed = await lf.ensureDataset({
29
+ name: datasetName, ...(options.dataset.local.description ? { description: options.dataset.local.description } : {}),
30
+ metadata: { source: "openmerit-scout" },
31
+ items: items.map(item => ({ id: item.id, input: item.input, expectedOutput: item.expectedOutput, ...(item.metadata ? { metadata: item.metadata } : {}) })),
32
+ });
33
+ datasetId = pushed.datasetId;
34
+ }
35
+ }
36
+ else
37
+ throw new Error("Scout needs a labelled dataset: a Langfuse dataset name or local items");
38
+ log(`Dataset ${datasetName}: ${items.length} items${datasetId ? ` (Langfuse ${datasetId})` : " (local only)"}`);
39
+ log(`${options.candidates.length} ECI-ranked candidates · run ${runId}`);
40
+ const urls = new Map();
41
+ const project = lf ? await lf.check() : null;
42
+ const onTrial = async (trial) => {
43
+ if (!lf || !datasetId)
44
+ return;
45
+ const stage = trial.stage === "final" ? "final" : "screen";
46
+ const recordings = [];
47
+ for (let repeat = 1; repeat <= trial.runs; repeat++) {
48
+ const suffix = trial.runs > 1 ? `-r${repeat}` : "";
49
+ const experimentId = `${runId}-${stage}${suffix}-${slug(trial.candidate.model)}`;
50
+ const recorded = await lf.recordExperimentItems({
51
+ datasetId, experimentId, experimentName: `scout ${stage}${trial.runs > 1 ? ` run ${repeat}/${trial.runs}` : ""} ${trial.candidate.model} ${runId}`, traceName: "openmerit-scout-eval",
52
+ metadata: { [`model_${role}`]: trial.candidate.model, scout_run: runId, stage, ...(trial.runs > 1 ? { repeat, repeats: trial.runs } : {}), eci: trial.candidate.eci, eci_source: trial.candidate.eciSource, task: options.task.slice(0, 200) },
53
+ items: trial.outcomes.filter(outcome => (outcome.repeat ?? 1) === repeat).map(outcome => {
54
+ const item = items.find(entry => entry.id === outcome.itemId);
55
+ return {
56
+ itemId: outcome.itemId, model: trial.candidate.model, input: item.input, output: outcome.output ?? null,
57
+ startMs: outcome.startMs ?? Date.now(), endMs: outcome.endMs ?? Date.now(),
58
+ ...(outcome.usage ? { usage: outcome.usage } : {}), ...(typeof outcome.costUsd === "number" ? { costUsd: outcome.costUsd } : {}),
59
+ metadata: { stage: trial.stage, scout_run: runId, eci: trial.candidate.eci, ...(trial.runs > 1 ? { repeat } : {}) },
60
+ scores: [{ name: passScore, value: outcome.pass, dataType: "BOOLEAN", ...(outcome.comment ? { comment: outcome.comment } : {}) }],
61
+ };
62
+ }),
63
+ });
64
+ recordings.push({ experimentId, url: recorded.url });
65
+ }
66
+ urls.set(trial, recordings);
67
+ };
68
+ const search = await scoutSearch({
69
+ dataset: items, candidates: options.candidates, evaluate: options.evaluate, onTrial, log,
70
+ ...(options.sampleSize ? { sampleSize: options.sampleSize } : {}), ...(options.finalists ? { finalists: options.finalists } : {}),
71
+ ...(options.challengers !== undefined ? { challengers: options.challengers } : {}),
72
+ ...(options.repeats !== undefined ? { repeats: options.repeats } : {}),
73
+ ...(options.concurrency ? { concurrency: options.concurrency } : {}), ...(options.pickIndex ? { pickIndex: options.pickIndex } : {}),
74
+ ...(options.signal ? { signal: options.signal } : {}),
75
+ });
76
+ // The decision: read every final back from Langfuse, so the recommendation rests on the recorded experiment.
77
+ const rule = { score: passScore, equals: true };
78
+ const finals = [];
79
+ for (const trial of search.finals) {
80
+ const recorded = urls.get(trial) ?? [];
81
+ const readbacks = [];
82
+ if (lf && recorded.length) {
83
+ log(`Reading ${trial.candidate.model} back from Langfuse (${recorded.length} experiments)…`);
84
+ for (const { experimentId } of recorded) {
85
+ readbacks.push(await lf.waitForExperiment({ experimentId, pass: rule, includeCost: true, since: new Date(Date.now() - 86_400_000) }, { expectItems: items.length, timeoutMs: options.langfuseTimeoutMs ?? 300_000 }));
86
+ }
87
+ }
88
+ const verified = readbacks.length > 0 && readbacks.length === recorded.length;
89
+ const results = verified
90
+ ? readbacks.flatMap(readback => readback.items.map(item => ({ itemId: item.itemId, pass: item.passed })))
91
+ : trial.outcomes.map(outcome => ({ itemId: outcome.itemId, pass: outcome.pass }));
92
+ const passesByItem = {};
93
+ for (const item of items)
94
+ passesByItem[item.id] = 0;
95
+ for (const result of results)
96
+ if (result.pass)
97
+ passesByItem[result.itemId] = (passesByItem[result.itemId] ?? 0) + 1;
98
+ const langfuseCost = verified && readbacks.every(readback => readback.costUsd != null) ? readbacks.reduce((sum, readback) => sum + readback.costUsd, 0) : null;
99
+ finals.push({
100
+ model: trial.candidate.model, eci: trial.candidate.eci, eciSource: trial.candidate.eciSource, listPrice: trial.candidate.price,
101
+ passes: results.filter(result => result.pass).length, total: results.length, runs: trial.runs, passesByItem,
102
+ costPerItemUsd: langfuseCost !== null && results.length ? langfuseCost / results.length : meanCost(trial.outcomes),
103
+ localPasses: trial.passes, verifiedInLangfuse: verified, missingScores: readbacks.flatMap(readback => readback.missingScores),
104
+ experimentIds: recorded.map(item => item.experimentId), experimentId: recorded[0]?.experimentId ?? null, experimentUrl: recorded[0]?.url ?? null,
105
+ failedItems: Object.entries(passesByItem).filter(([, passes]) => passes < trial.runs).map(([id]) => id),
106
+ });
107
+ }
108
+ const asTrial = (verdict) => {
109
+ const trial = search.finals.find(item => item.candidate.model === verdict.model);
110
+ return { ...trial, passes: verdict.passes, outcomes: trial.outcomes.map(outcome => ({ ...outcome, costUsd: verdict.costPerItemUsd })) };
111
+ };
112
+ finals.sort((a, b) => compareFinals(asTrial(a), asTrial(b)));
113
+ // A stopped run has no recommendation: its finals are incomplete and listed only as evidence.
114
+ const [recommendation = null, runnerUp = null] = search.status === "selected" ? finals : [];
115
+ const all = [...search.trials, ...search.finals].flatMap(trial => trial.outcomes);
116
+ const limitations = [
117
+ search.limitation,
118
+ "ECI is Epoch's published capability index. Models without a published score use ScoutBM's estimated or calibrated ECI from the bundled CSV, which carries 2–4 points of error; models with neither are not candidates (see `unranked`).",
119
+ ...(lf ? [] : ["Langfuse is not connected: results were not recorded or verified in Langfuse."]),
120
+ ...(search.shortfall ? [search.shortfall] : []),
121
+ ...(search.status === "stopped" && finals.length ? [`The run stopped, so the ${finals.length} finals shown are partial and no model is recommended.`] : []),
122
+ ...(search.unavailable.length ? [`${search.unavailable.length} candidates could not run (provider rejected the model or the balance did not cover it) and are untested: ${search.unavailable.map(item => item.model).join(", ")}.`] : []),
123
+ ...(finals.some(item => item.localPasses !== item.passes) ? ["Langfuse and local pass counts differ for at least one finalist; Langfuse's count was used."] : []),
124
+ ];
125
+ return {
126
+ scout: "OpenMerit Scout", runId, task: options.task, createdAt: new Date().toISOString(),
127
+ dataset: { name: datasetName, source: options.dataset.langfuseDataset ? "langfuse" : "local", size: items.length, datasetId, url: project && datasetId ? `${lf.baseUrl}/project/${project.projectId}/datasets/${datasetId}` : null },
128
+ candidates: options.candidates.length,
129
+ search: {
130
+ status: search.status, ...(search.error ? { error: search.error } : {}), criterion: search.criterion, sampleIds: search.sampleIds, selected: search.selected, unavailable: search.unavailable,
131
+ finalRule: search.finalRule, limitation: search.limitation, ...(search.shortfall ? { shortfall: search.shortfall } : {}),
132
+ trials: search.trials.map(trial => ({
133
+ stage: trial.stage, model: trial.candidate.model, eci: trial.candidate.eci, eciSource: trial.candidate.eciSource, passes: trial.passes, total: trial.outcomes.length,
134
+ accepted: trial.accepted, costUsd: meanCost(trial.outcomes) === null ? null : meanCost(trial.outcomes) * trial.outcomes.length, experimentUrl: urls.get(trial)?.[0]?.url ?? null,
135
+ })),
136
+ },
137
+ finals, recommendation, runnerUp,
138
+ why: explain(search, recommendation, runnerUp),
139
+ spend: { measuredUsd: all.reduce((sum, item) => sum + (item.costUsd ?? 0), 0), unknownCostItems: all.filter(item => typeof item.costUsd !== "number").length, evaluations: all.length },
140
+ langfuse: lf && project ? { baseUrl: lf.baseUrl, project: project.projectName, experiments: [...urls.values()].reduce((sum, list) => sum + list.length, 0) } : null,
141
+ limitations,
142
+ };
143
+ }
144
+ function explain(search, best, next) {
145
+ if (search.status === "stopped")
146
+ return `The search stopped without a verdict: ${search.error}`;
147
+ if (search.status === "no_passing_model")
148
+ return `No candidate passed all ${search.sampleIds.length} sampled items, so Scout recommends none. Improve the prompt or harness, or check the labels.`;
149
+ if (!best)
150
+ return "No finalist was evaluated.";
151
+ const cost = (value) => (value === null ? "unknown cost" : `$${value.toFixed(5)}/item`);
152
+ const head = `${best.model} passed ${best.passes}/${best.total} item runs (${best.runs} per item) in Langfuse at ${cost(best.costPerItemUsd)} (ECI ${best.eci.toFixed(1)}).`;
153
+ if (!next)
154
+ return head;
155
+ const tie = next.passes === best.passes;
156
+ return `${head} ${tie ? "Tied on passes with" : "Ahead of"} ${next.model} (${next.passes}/${next.total}, ${cost(next.costPerItemUsd)})${tie ? `; ${best.costPerItemUsd !== null && next.costPerItemUsd !== null && best.costPerItemUsd !== next.costPerItemUsd ? "lower measured cost" : "lower ECI"} breaks the tie` : ""}.`;
157
+ }
@@ -0,0 +1,3 @@
1
+ import type { ScoutReport } from "./pipeline.ts";
2
+ /** The decision record as Markdown: no keys, no raw item contents. */
3
+ export declare function reportMarkdown(report: ScoutReport): string;
@@ -0,0 +1,42 @@
1
+ const usd = (value, digits = 5) => (value === null ? "unknown" : `$${value.toFixed(digits)}`);
2
+ const link = (label, url) => (url ? `[${label}](${url})` : label);
3
+ const eciCell = (eci, source) => `${eci.toFixed(1)}${source === "published" ? "" : ` (${source.slice(0, 4)}.)`}`;
4
+ /** The decision record as Markdown: no keys, no raw item contents. */
5
+ export function reportMarkdown(report) {
6
+ const best = report.recommendation;
7
+ const lines = [
8
+ `# Scout recommendation: ${best ? `\`${best.model}\`` : "none"}`,
9
+ "",
10
+ `- Task: ${report.task.replace(/\s+/g, " ").slice(0, 500)}`,
11
+ `- Run: \`${report.runId}\`, ${report.createdAt}`,
12
+ `- Dataset: ${link(`\`${report.dataset.name}\``, report.dataset.url)} (${report.dataset.source}, ${report.dataset.size} labelled items)`,
13
+ `- Candidates: ${report.candidates} runnable models with an Epoch Capabilities Index (published, or ScoutBM's estimate: "est."/"cali.")`,
14
+ `- Langfuse: ${report.langfuse ? `${report.langfuse.project} at ${report.langfuse.baseUrl}, ${report.langfuse.experiments} experiments recorded` : "not connected"}`,
15
+ `- Spend: ${usd(report.spend.measuredUsd, 4)} measured over ${report.spend.evaluations} evaluations${report.spend.unknownCostItems ? ` (${report.spend.unknownCostItems} without a cost)` : ""}${report.spend.openRouterCreditsUsd !== undefined ? `; OpenRouter account credit used during the run: ${usd(report.spend.openRouterCreditsUsd, 4)}` : ""}`,
16
+ "",
17
+ `**Why:** ${report.why}`,
18
+ "",
19
+ `## Finals (every item ${report.finals[0]?.runs ?? 1} times, one Langfuse experiment per run, read back from Langfuse)`,
20
+ "",
21
+ "| Rank | Model | Passes | Cost per item run | ECI | List price $/M in+out | Verified in Langfuse | Failed items |",
22
+ "|---|---|---|---|---|---|---|---|",
23
+ ...report.finals.map((item, index) => `| ${index + 1} | ${link(`\`${item.model}\``, item.experimentUrl)} | ${item.passes}/${item.total} | ${usd(item.costPerItemUsd)} | ${eciCell(item.eci, item.eciSource)} | ${item.listPrice === null ? "unknown" : item.listPrice.toFixed(2)} | ${item.verifiedInLangfuse ? "yes" : "no"}${item.missingScores.length ? ` (${item.missingScores.length} unscored)` : ""} | ${item.failedItems.map(id => `${id} (${item.passesByItem[id] ?? 0}/${item.runs})`).join(", ") || "none"} |`),
24
+ "",
25
+ `## Binary search (${report.search.criterion}; samples ${report.search.sampleIds.join(", ")})`,
26
+ "",
27
+ `Status: **${report.search.status}**${report.search.error ? ` — ${report.search.error}` : ""}. Search pick: ${report.search.selected ? `\`${report.search.selected.model}\`` : "none"}.`,
28
+ "",
29
+ "| # | Stage | Model | ECI | Passes | Verdict | Cost |",
30
+ "|---|---|---|---|---|---|---|",
31
+ ...report.search.trials.map((trial, index) => `| ${index + 1} | ${trial.stage} | ${link(`\`${trial.model}\``, trial.experimentUrl)} | ${eciCell(trial.eci, trial.eciSource)} | ${trial.passes}/${trial.total} | ${trial.accepted ? "pass" : "fail"} | ${usd(trial.costUsd, 4)} |`),
32
+ ...(report.search.unavailable.length ? ["", "Skipped (could not run, untested):", "", ...report.search.unavailable.map(item => `- \`${item.model}\` (${item.stage}): ${item.reason.slice(0, 200)}`)] : []),
33
+ "",
34
+ `Rule: ${report.search.finalRule}`,
35
+ "",
36
+ "## Limitations",
37
+ "",
38
+ ...report.limitations.map(item => `- ${item}`),
39
+ "",
40
+ ];
41
+ return lines.join("\n");
42
+ }
@@ -0,0 +1,110 @@
1
+ import type { Candidate } from "./candidates.ts";
2
+ /**
3
+ * Scout Model Binary Search, from the ScoutBM prototype:
4
+ * 1. Rank candidates by ECI, ascending, and draw `sampleSize` labelled items once.
5
+ * 2. Binary search: a model "performs well" only when it passes every sampled item. Passing moves
6
+ * to weaker (cheaper-on-capability) models, failing moves to stronger ones. The search pick is
7
+ * the weakest model that passed.
8
+ * 3. Screen the nearest neighbours on the same samples (weaker first at equal distance; once a
9
+ * weaker one fails, only stronger ones) until there are `finalists` passing models.
10
+ * 4. Finals: every finalist runs every dataset item. The caller decides the winner from the
11
+ * recorded results (Langfuse), falling back to `orderFinals`.
12
+ */
13
+ export interface EvalItem {
14
+ id: string;
15
+ input: unknown;
16
+ expectedOutput: unknown;
17
+ metadata?: Record<string, unknown> | null;
18
+ }
19
+ export interface Outcome {
20
+ itemId: string;
21
+ pass: boolean;
22
+ /** What the candidate produced, recorded as the experiment item's output. */
23
+ output?: unknown;
24
+ /** Measured provider cost of this item, when known. */
25
+ costUsd?: number | null;
26
+ latencyMs?: number;
27
+ usage?: {
28
+ input: number;
29
+ output: number;
30
+ total: number;
31
+ } | null;
32
+ /** Why the item passed or failed, in a sentence. */
33
+ comment?: string;
34
+ startMs?: number;
35
+ endMs?: number;
36
+ /** Which repeat of a final this was, from 1. Screens run once and leave it unset. */
37
+ repeat?: number;
38
+ }
39
+ export type Stage = "search" | "neighbour" | "challenger" | "final";
40
+ /**
41
+ * Thrown by an evaluator when this one candidate cannot run, such as a provider rejecting the model
42
+ * (400/404) or the balance not covering its request (402). The candidate is skipped and listed as
43
+ * untested; the search goes on without it. Any other throw stops the search.
44
+ */
45
+ export declare class CandidateUnavailable extends Error {
46
+ readonly name = "CandidateUnavailable";
47
+ }
48
+ /** Runs one item on one candidate. Throw on transport, auth, billing or harness failures: they never count as quality failures. */
49
+ export type Evaluate = (candidate: Candidate, item: EvalItem, context: {
50
+ stage: Stage;
51
+ signal?: AbortSignal;
52
+ }) => Promise<Omit<Outcome, "itemId">>;
53
+ export interface Trial {
54
+ stage: Stage;
55
+ candidate: Candidate;
56
+ outcomes: Outcome[];
57
+ passes: number;
58
+ /** Screens: every sample passed. Finals: always true. */
59
+ accepted: boolean;
60
+ /** Runs of each item: 1 for screens, `repeats` for finals. `outcomes` holds items × runs. */
61
+ runs: number;
62
+ }
63
+ export interface SearchOptions {
64
+ dataset: readonly EvalItem[];
65
+ /** Ascending by ECI, as `rankCandidates` returns them. */
66
+ candidates: readonly Candidate[];
67
+ evaluate: Evaluate;
68
+ sampleSize?: number;
69
+ finalists?: number;
70
+ /** Items evaluated at once within one probe. Default 5. */
71
+ concurrency?: number;
72
+ /** Runs of every item per finalist, so a model that passes by chance does not tie a reliable one (default 3). */
73
+ repeats?: number;
74
+ /** Untested stronger models no dearer than the leading finalist to screen after the finals (default 3; 0 turns it off). */
75
+ challengers?: number;
76
+ /** Called after every probe, screen and final, for example to record it in Langfuse. A throw stops the search. */
77
+ onTrial?: (trial: Trial) => Promise<void> | void;
78
+ pickIndex?: (remaining: number) => number;
79
+ signal?: AbortSignal;
80
+ log?: (line: string) => void;
81
+ }
82
+ export interface SearchResult {
83
+ status: "selected" | "no_passing_model" | "stopped";
84
+ error?: string;
85
+ criterion: string;
86
+ sampleIds: string[];
87
+ ordered: Candidate[];
88
+ /** The weakest model that passed every sample during the binary search. */
89
+ selected: Candidate | null;
90
+ trials: Trial[];
91
+ finals: Trial[];
92
+ /** Candidates skipped because they could not run; untested, not failed. */
93
+ unavailable: {
94
+ model: string;
95
+ stage: Stage;
96
+ reason: string;
97
+ }[];
98
+ shortfall?: string;
99
+ finalRule: string;
100
+ limitation: string;
101
+ }
102
+ export declare const SAMPLE_SIZE = 5;
103
+ export declare const FINALISTS = 5;
104
+ export declare function scoutSearch(input: SearchOptions): Promise<SearchResult>;
105
+ /** Models other than the pick, nearest by rank first; at equal distance the weaker one comes first. */
106
+ export declare function outward(ordered: readonly Candidate[], pick: Candidate): Candidate[];
107
+ /** Mean measured cost per item, or null when any item's cost is unknown. */
108
+ export declare function meanCost(outcomes: readonly Pick<Outcome, "costUsd">[]): number | null;
109
+ /** Most passes; then lower measured cost per item (list price only when a cost is unknown); then lower ECI. */
110
+ export declare function compareFinals(a: Trial, b: Trial): number;