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,169 @@
|
|
|
1
|
+
// Step 1 of Scout: find model and endpoint usages in the repository without changing it. Reads only
|
|
2
|
+
// files Git does not ignore (or, outside Git, skips dependency and build directories), minus the
|
|
3
|
+
// repository's `scan.exclude` patterns. A JSON file that names many models is data (a catalogue,
|
|
4
|
+
// saved results, a dataset), not configuration, and is skipped. Snippets are redacted: anything
|
|
5
|
+
// shaped like a key or token is replaced before it is shown, stored or sent.
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
8
|
+
import { extname, join, matchesGlob, relative } from "node:path";
|
|
9
|
+
import { catalogueIndex } from "../catalogue/catalogue.js";
|
|
10
|
+
const TEXT = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".rb", ".java", ".kt", ".rs", ".php", ".cs", ".swift",
|
|
11
|
+
".json", ".jsonc", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".env", ".example", ".sh", ".tf"]);
|
|
12
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "out", ".next", ".venv", "venv", "__pycache__", "target", ".openmerit", "coverage", ".amp"]);
|
|
13
|
+
const SKIP_FILES = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|poetry\.lock|Cargo\.lock|go\.sum)$/;
|
|
14
|
+
const MAX_BYTES = 512_000;
|
|
15
|
+
/** More model usages than this in one JSON file make it data rather than configuration. */
|
|
16
|
+
export const MAX_DATA_FILE_MODELS = 20;
|
|
17
|
+
/** True when `file` matches a pattern, or sits in a directory a pattern names. */
|
|
18
|
+
const excluded = (file, patterns) => patterns.some(pattern => matchesGlob(file, pattern) || matchesGlob(file, `${pattern.replace(/\/+$/, "")}/**`));
|
|
19
|
+
const SECRET_PATTERNS = [
|
|
20
|
+
/\b(sk|pk|rk)-(?:proj-|ant-|or-|lf-|live-|test-)?[A-Za-z0-9_-]{8,}/g,
|
|
21
|
+
/\bAKIA[0-9A-Z]{12,}\b/g, /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, /\bxox[abpr]-[A-Za-z0-9-]{10,}\b/g, /\bAIza[0-9A-Za-z_-]{20,}\b/g,
|
|
22
|
+
/(Bearer\s+)[A-Za-z0-9._~+/-]{12,}=*/gi,
|
|
23
|
+
/((?:api[_-]?key|apikey|token|secret|password|authorization)\w*["']?\s*[:=]\s*["'])[^"'\s]{4,}(["'])/gi,
|
|
24
|
+
];
|
|
25
|
+
/** Replaces key- and token-shaped text with [redacted]. */
|
|
26
|
+
export function redact(text) {
|
|
27
|
+
let out = text;
|
|
28
|
+
for (const pattern of SECRET_PATTERNS)
|
|
29
|
+
out = out.replace(pattern, (match, ...groups) => {
|
|
30
|
+
const prefix = typeof groups[0] === "string" && /[:=\s]$/.test(groups[0]) ? groups[0] : typeof groups[0] === "string" && /^Bearer/i.test(groups[0]) ? groups[0] : "";
|
|
31
|
+
const suffix = typeof groups[1] === "string" && /^["']$/.test(groups[1]) ? groups[1] : "";
|
|
32
|
+
return prefix || suffix ? `${prefix}[redacted]${suffix}` : "[redacted]";
|
|
33
|
+
});
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
export function listFiles(root) {
|
|
37
|
+
try {
|
|
38
|
+
const out = execFileSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
|
|
39
|
+
return out.split("\0").filter(Boolean).filter(file => !file.split("/").some(part => SKIP_DIRS.has(part)));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
const files = [];
|
|
43
|
+
const walk = (dir) => {
|
|
44
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
if (!SKIP_DIRS.has(entry.name))
|
|
47
|
+
walk(join(dir, entry.name));
|
|
48
|
+
}
|
|
49
|
+
else if (entry.isFile())
|
|
50
|
+
files.push(relative(root, join(dir, entry.name)));
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
walk(root);
|
|
54
|
+
return files;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const isText = (file) => TEXT.has(extname(file)) || /(^|\/)\.env(\.[\w-]+)?$/.test(file) || /(^|\/)Dockerfile$/.test(file);
|
|
58
|
+
const STRING = /(["'`])((?:\\.|(?!\1)[^\\\n])*)\1/g;
|
|
59
|
+
const MODEL_KEY = /\b(?:\w*model\w*|engine|deployment)\b["']?\s*(?::|=|=>|,)\s*/i;
|
|
60
|
+
const DYNAMIC = /\b(\w*model\w*)\b["']?\s*(?::|=(?!=))\s*([^"'`\s,;)}\]][^,;)}\]\n]*)/i;
|
|
61
|
+
const ENV = /(?:process\.env\.([A-Z][A-Z0-9_]*)|process\.env\[["']([A-Z][A-Z0-9_]*)["']\]|import\.meta\.env\.([A-Z][A-Z0-9_]*)|os\.environ(?:\.get)?\(?\[?["']([A-Z][A-Z0-9_]*)["']|os\.getenv\(["']([A-Z][A-Z0-9_]*)["']|Deno\.env\.get\(["']([A-Z][A-Z0-9_]*)["']|\$\{?([A-Z][A-Z0-9_]*MODEL[A-Z0-9_]*)\}?)/;
|
|
62
|
+
const URL_PATTERN = /https?:\/\/[^\s"'`<>)]+/g;
|
|
63
|
+
const ENDPOINT_HOSTS = /(api\.openai\.com|openrouter\.ai|api\.anthropic\.com|generativelanguage\.googleapis\.com|openai\.azure\.com|api\.together\.xyz|api\.groq\.com|api\.mistral\.ai|api\.deepseek\.com|api\.fireworks\.ai|bedrock|localhost:\d+\/v1|\/v1\/?$)/;
|
|
64
|
+
const BASE_URL_KEY = /\b(base_?url|baseURL|api_?base|endpoint)\b["']?\s*(?::|=(?!=))\s*([^\n,;]+)/i;
|
|
65
|
+
/** Scans the repository. Deterministic; no model is called. */
|
|
66
|
+
export function scanRepo(root, catalogue, options = {}) {
|
|
67
|
+
const index = catalogueIndex(catalogue);
|
|
68
|
+
const usages = [];
|
|
69
|
+
const skipped = [];
|
|
70
|
+
const files = listFiles(root).filter(file => isText(file) && !SKIP_FILES.test(file) && !excluded(file, options.exclude ?? []));
|
|
71
|
+
let scanned = 0;
|
|
72
|
+
for (const file of files) {
|
|
73
|
+
const path = join(root, file);
|
|
74
|
+
let text;
|
|
75
|
+
try {
|
|
76
|
+
if (statSync(path).size > MAX_BYTES)
|
|
77
|
+
continue;
|
|
78
|
+
text = readFileSync(path, "utf8");
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (text.includes("\0"))
|
|
84
|
+
continue;
|
|
85
|
+
scanned++;
|
|
86
|
+
const before = usages.length;
|
|
87
|
+
const lines = text.split("\n");
|
|
88
|
+
lines.forEach((raw, lineIndex) => {
|
|
89
|
+
if (raw.length > 2000)
|
|
90
|
+
return;
|
|
91
|
+
const line = lineIndex + 1;
|
|
92
|
+
const snippet = redact(raw.trim()).slice(0, 240);
|
|
93
|
+
const add = (usage) => {
|
|
94
|
+
if (usages.some(item => item.file === file && item.line === line && item.kind === usage.kind && item.value === usage.value))
|
|
95
|
+
return;
|
|
96
|
+
usages.push({ id: `u${usages.length + 1}`, file, line, snippet, ...usage });
|
|
97
|
+
};
|
|
98
|
+
let literalModel = false;
|
|
99
|
+
for (const match of raw.matchAll(STRING)) {
|
|
100
|
+
const value = match[2];
|
|
101
|
+
// catalogue:<endpoint>:<model> is how OpenMerit's own saved results name a model, not a model an app calls.
|
|
102
|
+
if (value.length < 3 || value.length > 120 || /\s/.test(value) || value.startsWith("catalogue:"))
|
|
103
|
+
continue;
|
|
104
|
+
const model = index.find(value);
|
|
105
|
+
const keyed = MODEL_KEY.test(raw.slice(Math.max(0, match.index - 40), match.index));
|
|
106
|
+
if (model && (keyed || value.includes("/") || /\d/.test(value))) {
|
|
107
|
+
literalModel = true;
|
|
108
|
+
const env = ENV.exec(raw);
|
|
109
|
+
const envVar = env?.slice(1).find(Boolean);
|
|
110
|
+
add({ kind: "model", value: redact(value), dynamic: !!envVar, ...(envVar ? { envVar } : {}), catalogueId: model.id });
|
|
111
|
+
}
|
|
112
|
+
else if (keyed && /^[\w.:/@-]+$/.test(value) && /[a-z]/i.test(value) && /\d|gpt|claude|gemini|llama|mistral|qwen|deepseek/i.test(value) && !/^https?:/.test(value)) {
|
|
113
|
+
literalModel = true;
|
|
114
|
+
add({ kind: "model", value: redact(value), dynamic: false, catalogueId: null });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!literalModel) {
|
|
118
|
+
const dynamic = DYNAMIC.exec(raw);
|
|
119
|
+
if (dynamic && !/^(true|false|null|undefined|None|\d)/.test(dynamic[2]) && !/\b(type|interface|import|from|def|class)\b/.test(raw.slice(0, dynamic.index)) && !/^\s*(\/\/|#|\*)/.test(raw)
|
|
120
|
+
&& !/[:]\s*(string|number|Model|ModelRef)\b/.test(raw)) {
|
|
121
|
+
const env = ENV.exec(raw);
|
|
122
|
+
const envVar = env?.slice(1).find(Boolean);
|
|
123
|
+
let expression = dynamic[2].trim().replace(/[\s,;]+$/, "").slice(0, 120);
|
|
124
|
+
expression += ")".repeat(Math.max(0, (expression.match(/\(/g) ?? []).length - (expression.match(/\)/g) ?? []).length));
|
|
125
|
+
if (envVar || /^[A-Za-z_$][\w$.[\]"'()]*$/.test(expression))
|
|
126
|
+
add({ kind: "model", value: redact(expression), dynamic: true, ...(envVar ? { envVar } : {}), catalogueId: null });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const match of raw.matchAll(URL_PATTERN)) {
|
|
130
|
+
const url = match[0].replace(/[.,]+$/, "");
|
|
131
|
+
if (ENDPOINT_HOSTS.test(url))
|
|
132
|
+
add({ kind: "endpoint", value: redact(url), dynamic: false, catalogueId: null });
|
|
133
|
+
}
|
|
134
|
+
const base = BASE_URL_KEY.exec(raw);
|
|
135
|
+
if (base && !URL_PATTERN.test(base[2])) {
|
|
136
|
+
const env = ENV.exec(base[2]);
|
|
137
|
+
const envVar = env?.slice(1).find(Boolean);
|
|
138
|
+
if (envVar)
|
|
139
|
+
add({ kind: "endpoint", value: redact(base[2].trim().replace(/[\s,;]+$/, "").slice(0, 120)), dynamic: true, envVar, catalogueId: null });
|
|
140
|
+
}
|
|
141
|
+
URL_PATTERN.lastIndex = 0;
|
|
142
|
+
});
|
|
143
|
+
// This file's usages are the last ones added, so dropping them keeps usage IDs sequential.
|
|
144
|
+
if (extname(file) === ".json" && usages.slice(before).filter(usage => usage.kind === "model").length > MAX_DATA_FILE_MODELS) {
|
|
145
|
+
usages.length = before;
|
|
146
|
+
skipped.push(file);
|
|
147
|
+
scanned--;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const groups = new Map();
|
|
151
|
+
for (const usage of usages.filter(item => item.kind === "model" && !item.dynamic || item.kind === "model" && item.catalogueId)) {
|
|
152
|
+
const key = usage.catalogueId ?? `?${usage.value}`;
|
|
153
|
+
const group = groups.get(key) ?? { catalogueId: usage.catalogueId, value: usage.catalogueId ?? usage.value, usageIds: [] };
|
|
154
|
+
group.usageIds.push(usage.id);
|
|
155
|
+
groups.set(key, group);
|
|
156
|
+
}
|
|
157
|
+
return { files: scanned, usages, models: [...groups.values()].sort((a, b) => b.usageIds.length - a.usageIds.length || a.value.localeCompare(b.value)), skipped };
|
|
158
|
+
}
|
|
159
|
+
/** A few lines around a usage, redacted, for the harness. */
|
|
160
|
+
export function context(root, usage, radius = 6) {
|
|
161
|
+
try {
|
|
162
|
+
const lines = readFileSync(join(root, usage.file), "utf8").split("\n");
|
|
163
|
+
const start = Math.max(0, usage.line - 1 - radius);
|
|
164
|
+
return lines.slice(start, usage.line + radius).map((line, offset) => `${start + offset + 1}${start + offset + 1 === usage.line ? ">" : " "} ${redact(line).slice(0, 300)}`).join("\n");
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return "";
|
|
168
|
+
}
|
|
169
|
+
}
|
package/dist/scout/search.d.ts
CHANGED
|
@@ -1,110 +1,37 @@
|
|
|
1
|
-
import type { Candidate } from "./candidates.js";
|
|
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
1
|
export declare class CandidateUnavailable extends Error {
|
|
46
2
|
readonly name = "CandidateUnavailable";
|
|
47
3
|
}
|
|
48
|
-
|
|
49
|
-
export
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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;
|
|
4
|
+
export declare const SAMPLE_SIZE = 5;
|
|
5
|
+
export declare const FINALISTS = 5;
|
|
6
|
+
export declare const CHALLENGERS = 3;
|
|
7
|
+
export interface Ranked {
|
|
8
|
+
key: string;
|
|
9
|
+
eci: number;
|
|
10
|
+
price: number | null;
|
|
81
11
|
}
|
|
82
|
-
export
|
|
83
|
-
|
|
84
|
-
error?: string;
|
|
85
|
-
criterion: string;
|
|
86
|
-
sampleIds: string[];
|
|
87
|
-
ordered: Candidate[];
|
|
12
|
+
export type Stage = "search" | "neighbour" | "challenger";
|
|
13
|
+
export interface SearchResult<T extends Ranked> {
|
|
88
14
|
/** The weakest model that passed every sample during the binary search. */
|
|
89
|
-
selected:
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
15
|
+
selected: T | null;
|
|
16
|
+
/** The pick and its passing neighbours, nearest first. */
|
|
17
|
+
finalists: T[];
|
|
18
|
+
trials: {
|
|
19
|
+
stage: Stage;
|
|
20
|
+
candidate: T;
|
|
21
|
+
accepted: boolean;
|
|
22
|
+
}[];
|
|
23
|
+
/** Skipped because they could not run: untested, not failed. */
|
|
93
24
|
unavailable: {
|
|
94
|
-
|
|
25
|
+
candidate: T;
|
|
95
26
|
stage: Stage;
|
|
96
27
|
reason: string;
|
|
97
28
|
}[];
|
|
98
29
|
shortfall?: string;
|
|
99
|
-
finalRule: string;
|
|
100
|
-
limitation: string;
|
|
101
30
|
}
|
|
102
|
-
|
|
103
|
-
export declare const
|
|
104
|
-
export declare function scoutSearch(input: SearchOptions): Promise<SearchResult>;
|
|
31
|
+
/** Ascending ECI; equal scores fall back to the key purely for repeatability. */
|
|
32
|
+
export declare const order: <T extends Ranked>(candidates: readonly T[]) => T[];
|
|
105
33
|
/** Models other than the pick, nearest by rank first; at equal distance the weaker one comes first. */
|
|
106
|
-
export declare function outward(ordered: readonly
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
export declare function compareFinals(a: Trial, b: Trial): number;
|
|
34
|
+
export declare function outward<T extends Ranked>(ordered: readonly T[], pick: T): T[];
|
|
35
|
+
export declare function binarySearch<T extends Ranked>(candidates: readonly T[], screen: (candidate: T, stage: Stage) => Promise<boolean>, finalistCount?: number): Promise<SearchResult<T>>;
|
|
36
|
+
/** Untested models stronger than the leader and no dearer by list price, cheapest first. */
|
|
37
|
+
export declare function challengers<T extends Ranked>(ordered: readonly T[], leader: T, tried: ReadonlySet<string>, count?: number): T[];
|
package/dist/scout/search.js
CHANGED
|
@@ -1,89 +1,51 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
// Scout Model Binary Search, from the ScoutBM prototype:
|
|
2
|
+
// 1. Rank candidates by ECI, ascending. Screens all run on the same few sampled items.
|
|
3
|
+
// 2. Binary search: a model "performs well" only when it passes every sampled item. Passing moves
|
|
4
|
+
// to weaker (cheaper-on-capability) models, failing moves to stronger ones. The search pick is
|
|
5
|
+
// the weakest model that passed.
|
|
6
|
+
// 3. Screen the nearest neighbours on the same samples (weaker first at equal distance; once a
|
|
7
|
+
// weaker one fails, only stronger ones) until there are `finalists` passing models.
|
|
8
|
+
// 4. Challengers: after the finals, a stronger model at the same or a lower list price than the
|
|
9
|
+
// leading finalist could be the better buy, but the neighbour rule never reaches it; screen the
|
|
10
|
+
// cheapest few. The caller runs finals and challengers on every item.
|
|
11
|
+
// A screen throws CandidateUnavailable when the model cannot run at all (the provider rejects it):
|
|
12
|
+
// it is skipped as untested and the search goes on. Any other throw stops the search.
|
|
7
13
|
export class CandidateUnavailable extends Error {
|
|
8
14
|
name = "CandidateUnavailable";
|
|
9
15
|
}
|
|
10
|
-
|
|
11
|
-
const unavailableError = (error) => error instanceof Error && error.name === "CandidateUnavailable";
|
|
16
|
+
const unavailable = (error) => error instanceof Error && error.name === "CandidateUnavailable";
|
|
12
17
|
export const SAMPLE_SIZE = 5;
|
|
13
18
|
export const FINALISTS = 5;
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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]);
|
|
19
|
+
export const CHALLENGERS = 3;
|
|
20
|
+
/** Ascending ECI; equal scores fall back to the key purely for repeatability. */
|
|
21
|
+
export const order = (candidates) => [...candidates].sort((a, b) => a.eci - b.eci || a.key.localeCompare(b.key));
|
|
22
|
+
/** Models other than the pick, nearest by rank first; at equal distance the weaker one comes first. */
|
|
23
|
+
export function outward(ordered, pick) {
|
|
24
|
+
const index = ordered.findIndex(item => item.key === pick.key);
|
|
25
|
+
if (index < 0)
|
|
26
|
+
throw new Error("The pick is not in the ranking");
|
|
27
|
+
const out = [];
|
|
28
|
+
for (let distance = 1; out.length < ordered.length - 1; distance++) {
|
|
29
|
+
if (ordered[index - distance])
|
|
30
|
+
out.push(ordered[index - distance]);
|
|
31
|
+
if (ordered[index + distance])
|
|
32
|
+
out.push(ordered[index + distance]);
|
|
34
33
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
export async function binarySearch(candidates, screen, finalistCount = FINALISTS) {
|
|
37
|
+
const ordered = order(candidates);
|
|
39
38
|
const trials = [];
|
|
40
|
-
const
|
|
41
|
-
const unavailable = [];
|
|
39
|
+
const skipped = [];
|
|
42
40
|
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
41
|
let lower = 0, upper = ordered.length - 1;
|
|
80
42
|
while (lower <= upper) {
|
|
81
43
|
const middle = Math.floor((lower + upper) / 2);
|
|
82
44
|
const candidate = ordered[middle];
|
|
83
45
|
try {
|
|
84
|
-
const
|
|
85
|
-
trials.push(
|
|
86
|
-
if (
|
|
46
|
+
const accepted = await screen(candidate, "search");
|
|
47
|
+
trials.push({ stage: "search", candidate, accepted });
|
|
48
|
+
if (accepted) {
|
|
87
49
|
selected = candidate;
|
|
88
50
|
upper = middle - 1;
|
|
89
51
|
}
|
|
@@ -91,20 +53,19 @@ export async function scoutSearch(input) {
|
|
|
91
53
|
lower = middle + 1;
|
|
92
54
|
}
|
|
93
55
|
catch (error) {
|
|
94
|
-
if (!
|
|
95
|
-
|
|
56
|
+
if (!unavailable(error))
|
|
57
|
+
throw error;
|
|
96
58
|
// Untested, not failed: drop it from the ranking and search the same range without it.
|
|
97
|
-
|
|
59
|
+
skipped.push({ candidate, stage: "search", reason: error.message });
|
|
98
60
|
ordered.splice(middle, 1);
|
|
99
61
|
upper -= 1;
|
|
100
62
|
}
|
|
101
63
|
}
|
|
102
64
|
if (!selected)
|
|
103
|
-
return
|
|
104
|
-
const screened = new Map(trials.map(trial => [trial.candidate.
|
|
65
|
+
return { selected: null, finalists: [], trials, unavailable: skipped };
|
|
66
|
+
const screened = new Map(trials.map(trial => [trial.candidate.key, trial.accepted]));
|
|
105
67
|
const finalists = [selected];
|
|
106
68
|
const pickAt = ordered.indexOf(selected);
|
|
107
|
-
const skipped = new Set();
|
|
108
69
|
let lookBelow = true;
|
|
109
70
|
for (const candidate of outward(ordered, selected)) {
|
|
110
71
|
if (finalists.length === finalistCount)
|
|
@@ -112,20 +73,16 @@ export async function scoutSearch(input) {
|
|
|
112
73
|
const below = ordered.indexOf(candidate) < pickAt;
|
|
113
74
|
if (below && !lookBelow)
|
|
114
75
|
continue;
|
|
115
|
-
|
|
116
|
-
continue;
|
|
117
|
-
let accepted = screened.get(candidate.model);
|
|
76
|
+
let accepted = screened.get(candidate.key);
|
|
118
77
|
if (accepted === undefined) {
|
|
119
78
|
try {
|
|
120
|
-
|
|
121
|
-
trials.push(
|
|
122
|
-
accepted = trial.accepted;
|
|
79
|
+
accepted = await screen(candidate, "neighbour");
|
|
80
|
+
trials.push({ stage: "neighbour", candidate, accepted });
|
|
123
81
|
}
|
|
124
82
|
catch (error) {
|
|
125
|
-
if (!
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
skipped.add(candidate.model);
|
|
83
|
+
if (!unavailable(error))
|
|
84
|
+
throw error;
|
|
85
|
+
skipped.push({ candidate, stage: "neighbour", reason: error.message });
|
|
129
86
|
continue;
|
|
130
87
|
}
|
|
131
88
|
}
|
|
@@ -134,72 +91,15 @@ export async function scoutSearch(input) {
|
|
|
134
91
|
else if (below)
|
|
135
92
|
lookBelow = false;
|
|
136
93
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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;
|
|
94
|
+
return {
|
|
95
|
+
selected, finalists, trials, unavailable: skipped,
|
|
96
|
+
...(finalists.length < finalistCount ? { shortfall: `Only ${finalists.length} finalists: no further model passed the screen within the neighbour rule (once a weaker neighbour fails, only stronger models are tried).` } : {}),
|
|
97
|
+
};
|
|
191
98
|
}
|
|
192
|
-
/**
|
|
193
|
-
export function
|
|
194
|
-
if (
|
|
195
|
-
return
|
|
196
|
-
|
|
197
|
-
|
|
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;
|
|
99
|
+
/** Untested models stronger than the leader and no dearer by list price, cheapest first. */
|
|
100
|
+
export function challengers(ordered, leader, tried, count = CHALLENGERS) {
|
|
101
|
+
if (leader.price === null)
|
|
102
|
+
return [];
|
|
103
|
+
return ordered.filter(item => item.eci > leader.eci && item.price !== null && item.price <= leader.price && !tried.has(item.key))
|
|
104
|
+
.sort((a, b) => a.price - b.price || b.eci - a.eci).slice(0, count);
|
|
205
105
|
}
|