@trazum/core 1.8.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.
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/dist/advisories.d.ts +23 -0
- package/dist/advisories.d.ts.map +1 -0
- package/dist/advisories.js +376 -0
- package/dist/advisories.js.map +1 -0
- package/dist/aws-sigv4.d.ts +88 -0
- package/dist/aws-sigv4.d.ts.map +1 -0
- package/dist/aws-sigv4.js +117 -0
- package/dist/aws-sigv4.js.map +1 -0
- package/dist/baseline.d.ts +171 -0
- package/dist/baseline.d.ts.map +1 -0
- package/dist/baseline.js +273 -0
- package/dist/baseline.js.map +1 -0
- package/dist/cache.d.ts +26 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +28 -0
- package/dist/cache.js.map +1 -0
- package/dist/changes.d.ts +29 -0
- package/dist/changes.d.ts.map +1 -0
- package/dist/changes.js +142 -0
- package/dist/changes.js.map +1 -0
- package/dist/compare.d.ts +65 -0
- package/dist/compare.d.ts.map +1 -0
- package/dist/compare.js +58 -0
- package/dist/compare.js.map +1 -0
- package/dist/config-schema.d.ts +118 -0
- package/dist/config-schema.d.ts.map +1 -0
- package/dist/config-schema.js +315 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +112 -0
- package/dist/config.js.map +1 -0
- package/dist/detect.d.ts +70 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +228 -0
- package/dist/detect.js.map +1 -0
- package/dist/evaluate.d.ts +98 -0
- package/dist/evaluate.d.ts.map +1 -0
- package/dist/evaluate.js +110 -0
- package/dist/evaluate.js.map +1 -0
- package/dist/extract.d.ts +81 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +280 -0
- package/dist/extract.js.map +1 -0
- package/dist/gcp-auth.d.ts +58 -0
- package/dist/gcp-auth.d.ts.map +1 -0
- package/dist/gcp-auth.js +113 -0
- package/dist/gcp-auth.js.map +1 -0
- package/dist/glob.d.ts +49 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +154 -0
- package/dist/glob.js.map +1 -0
- package/dist/host.d.ts +30 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +69 -0
- package/dist/host.js.map +1 -0
- package/dist/i18n/en.d.ts +4 -0
- package/dist/i18n/en.d.ts.map +1 -0
- package/dist/i18n/en.js +168 -0
- package/dist/i18n/en.js.map +1 -0
- package/dist/i18n/es.d.ts +4 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +168 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/index.d.ts +36 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +50 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/i18n/types.d.ts +180 -0
- package/dist/i18n/types.d.ts.map +1 -0
- package/dist/i18n/types.js +11 -0
- package/dist/i18n/types.js.map +1 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/index.js.map +1 -0
- package/dist/llm.d.ts +226 -0
- package/dist/llm.d.ts.map +1 -0
- package/dist/llm.js +485 -0
- package/dist/llm.js.map +1 -0
- package/dist/nearest.d.ts +20 -0
- package/dist/nearest.d.ts.map +1 -0
- package/dist/nearest.js +54 -0
- package/dist/nearest.js.map +1 -0
- package/dist/net.d.ts +90 -0
- package/dist/net.d.ts.map +1 -0
- package/dist/net.js +203 -0
- package/dist/net.js.map +1 -0
- package/dist/node.d.ts +32 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +33 -0
- package/dist/node.js.map +1 -0
- package/dist/openrouter.d.ts +25 -0
- package/dist/openrouter.d.ts.map +1 -0
- package/dist/openrouter.js +72 -0
- package/dist/openrouter.js.map +1 -0
- package/dist/optimize.d.ts +38 -0
- package/dist/optimize.d.ts.map +1 -0
- package/dist/optimize.js +183 -0
- package/dist/optimize.js.map +1 -0
- package/dist/otlp.d.ts +91 -0
- package/dist/otlp.d.ts.map +1 -0
- package/dist/otlp.js +102 -0
- package/dist/otlp.js.map +1 -0
- package/dist/phrases.d.ts +169 -0
- package/dist/phrases.d.ts.map +1 -0
- package/dist/phrases.js +939 -0
- package/dist/phrases.js.map +1 -0
- package/dist/pricing-overlay.d.ts +55 -0
- package/dist/pricing-overlay.d.ts.map +1 -0
- package/dist/pricing-overlay.js +241 -0
- package/dist/pricing-overlay.js.map +1 -0
- package/dist/pricing.d.ts +115 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +400 -0
- package/dist/pricing.js.map +1 -0
- package/dist/profile.d.ts +71 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +55 -0
- package/dist/profile.js.map +1 -0
- package/dist/promptfoo.d.ts +58 -0
- package/dist/promptfoo.d.ts.map +1 -0
- package/dist/promptfoo.js +149 -0
- package/dist/promptfoo.js.map +1 -0
- package/dist/prune.d.ts +91 -0
- package/dist/prune.d.ts.map +1 -0
- package/dist/prune.js +110 -0
- package/dist/prune.js.map +1 -0
- package/dist/reorder.d.ts +82 -0
- package/dist/reorder.d.ts.map +1 -0
- package/dist/reorder.js +215 -0
- package/dist/reorder.js.map +1 -0
- package/dist/review.d.ts +54 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/review.js +131 -0
- package/dist/review.js.map +1 -0
- package/dist/rules.d.ts +5 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +279 -0
- package/dist/rules.js.map +1 -0
- package/dist/savings.d.ts +36 -0
- package/dist/savings.d.ts.map +1 -0
- package/dist/savings.js +83 -0
- package/dist/savings.js.map +1 -0
- package/dist/segment.d.ts +8 -0
- package/dist/segment.d.ts.map +1 -0
- package/dist/segment.js +74 -0
- package/dist/segment.js.map +1 -0
- package/dist/shared-prefix.d.ts +63 -0
- package/dist/shared-prefix.d.ts.map +1 -0
- package/dist/shared-prefix.js +151 -0
- package/dist/shared-prefix.js.map +1 -0
- package/dist/similarity.d.ts +13 -0
- package/dist/similarity.d.ts.map +1 -0
- package/dist/similarity.js +30 -0
- package/dist/similarity.js.map +1 -0
- package/dist/structure.d.ts +144 -0
- package/dist/structure.d.ts.map +1 -0
- package/dist/structure.js +455 -0
- package/dist/structure.js.map +1 -0
- package/dist/suggest.d.ts +100 -0
- package/dist/suggest.d.ts.map +1 -0
- package/dist/suggest.js +151 -0
- package/dist/suggest.js.map +1 -0
- package/dist/tokenizer.d.ts +57 -0
- package/dist/tokenizer.d.ts.map +1 -0
- package/dist/tokenizer.js +157 -0
- package/dist/tokenizer.js.map +1 -0
- package/dist/types.d.ts +296 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/walk.d.ts +40 -0
- package/dist/walk.d.ts.map +1 -0
- package/dist/walk.js +95 -0
- package/dist/walk.js.map +1 -0
- package/package.json +56 -0
- package/src/advisories.ts +431 -0
- package/src/aws-sigv4.ts +174 -0
- package/src/baseline.ts +390 -0
- package/src/cache.ts +54 -0
- package/src/changes.ts +158 -0
- package/src/compare.ts +131 -0
- package/src/config-schema.ts +451 -0
- package/src/config.ts +161 -0
- package/src/detect.ts +312 -0
- package/src/evaluate.ts +188 -0
- package/src/extract.ts +336 -0
- package/src/gcp-auth.ts +166 -0
- package/src/glob.ts +160 -0
- package/src/host.ts +90 -0
- package/src/i18n/en.ts +236 -0
- package/src/i18n/es.ts +236 -0
- package/src/i18n/index.ts +68 -0
- package/src/i18n/types.ts +230 -0
- package/src/index.ts +228 -0
- package/src/llm.ts +708 -0
- package/src/nearest.ts +61 -0
- package/src/net.ts +233 -0
- package/src/node.ts +63 -0
- package/src/openrouter.ts +125 -0
- package/src/optimize.ts +228 -0
- package/src/otlp.ts +179 -0
- package/src/phrases.ts +1047 -0
- package/src/pricing-overlay.ts +319 -0
- package/src/pricing.ts +468 -0
- package/src/profile.ts +124 -0
- package/src/promptfoo.ts +213 -0
- package/src/prune.ts +211 -0
- package/src/reorder.ts +307 -0
- package/src/review.ts +180 -0
- package/src/rules.ts +324 -0
- package/src/savings.ts +121 -0
- package/src/segment.ts +106 -0
- package/src/shared-prefix.ts +198 -0
- package/src/similarity.ts +28 -0
- package/src/structure.ts +652 -0
- package/src/suggest.ts +254 -0
- package/src/tokenizer.ts +190 -0
- package/src/types.ts +323 -0
- package/src/walk.ts +117 -0
package/src/promptfoo.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { getModel } from './pricing.js';
|
|
2
|
+
import { segment } from './segment.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Exports a before/after pair as a suite somebody else's harness can run.
|
|
6
|
+
*
|
|
7
|
+
* `trazum eval` answers "does the model still say the same thing?" — semantic
|
|
8
|
+
* agreement, measured against the model's own variance. That is the question
|
|
9
|
+
* Trazum is qualified to ask, and it is not the question a team actually needs
|
|
10
|
+
* answered before shipping. *Their* question is whether the classifier still
|
|
11
|
+
* hits 94%, whether the JSON still parses, whether the refusal rate moved. Those
|
|
12
|
+
* are assertions about their task, and Trazum has no business inventing them.
|
|
13
|
+
*
|
|
14
|
+
* So this hands over the part it *can* build correctly — a suite in which the
|
|
15
|
+
* only variable is the prompt, with both versions and every case already wired
|
|
16
|
+
* — and leaves the assertions where they belong.
|
|
17
|
+
*
|
|
18
|
+
* ## Why JSON rather than YAML
|
|
19
|
+
*
|
|
20
|
+
* promptfoo reads `promptfooconfig.json` as readily as the YAML. This package
|
|
21
|
+
* has no dependencies and is not going to acquire a YAML emitter, and a
|
|
22
|
+
* hand-rolled one would be a quoting bug waiting for the first prompt
|
|
23
|
+
* containing a colon, a tab, or a line ending in a space. `JSON.stringify`
|
|
24
|
+
* escapes everything correctly, and JSON is a subset of YAML, so the file can
|
|
25
|
+
* be renamed if somebody prefers.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export interface PromptfooExport {
|
|
29
|
+
/** The config, ready for `JSON.stringify`. */
|
|
30
|
+
config: Record<string, unknown>;
|
|
31
|
+
/** Things the reader has to know before trusting the run. */
|
|
32
|
+
warnings: PromptfooWarning[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type PromptfooWarning =
|
|
36
|
+
/** `${x}` or `{x}`: promptfoo substitutes `{{x}}` and will leave these alone. */
|
|
37
|
+
| { kind: 'unsupported-placeholder'; detail: string }
|
|
38
|
+
/** More than one distinct placeholder, and one value per case to fill them. */
|
|
39
|
+
| { kind: 'multiple-placeholders'; detail: string }
|
|
40
|
+
/** The provider has no promptfoo id here, so the model string is a guess. */
|
|
41
|
+
| { kind: 'unmapped-provider'; detail: string }
|
|
42
|
+
/** No placeholder at all: the case is appended, as `trazum eval` does. */
|
|
43
|
+
| { kind: 'appended-input'; detail: string };
|
|
44
|
+
|
|
45
|
+
export interface PromptfooOptions {
|
|
46
|
+
/** Model id from Trazum's catalogue, mapped to a promptfoo provider. */
|
|
47
|
+
model?: string;
|
|
48
|
+
/** Rule level used to produce the optimised prompt, for the label. */
|
|
49
|
+
level?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The variable a prompt with no `{{placeholder}}` gets. */
|
|
53
|
+
const APPENDED_VAR = 'input';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* promptfoo names providers `<vendor>:<model>`. Only the two vendors whose
|
|
57
|
+
* shape is certain are mapped; anything else is emitted as-is with a warning,
|
|
58
|
+
* because a wrong provider id fails at run time with a message about the
|
|
59
|
+
* harness rather than about this file.
|
|
60
|
+
*/
|
|
61
|
+
function providerId(modelId: string | undefined): { id: string; warning: string | null } {
|
|
62
|
+
if (modelId === undefined) return { id: 'openai:gpt-4o-mini', warning: null };
|
|
63
|
+
const model = getModel(modelId);
|
|
64
|
+
switch (model.provider) {
|
|
65
|
+
case 'anthropic':
|
|
66
|
+
return { id: `anthropic:messages:${model.id}`, warning: null };
|
|
67
|
+
case 'openai':
|
|
68
|
+
return { id: `openai:${model.id}`, warning: null };
|
|
69
|
+
default:
|
|
70
|
+
return {
|
|
71
|
+
id: model.id,
|
|
72
|
+
warning: `${model.provider ?? 'this provider'} has no known promptfoo id; "${model.id}" is a guess`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Placeholders in the prompt, in order of appearance, deduplicated. */
|
|
78
|
+
function placeholdersIn(prompt: string): string[] {
|
|
79
|
+
const found = segment(prompt)
|
|
80
|
+
.filter((piece) => piece.kind === 'protected' && piece.protection === 'placeholder')
|
|
81
|
+
.map((piece) => piece.text);
|
|
82
|
+
return [...new Set(found)];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `{{query}}` → `query`, or null when promptfoo would not substitute it. */
|
|
86
|
+
function varNameOf(placeholder: string): string | null {
|
|
87
|
+
const match = /^\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}$/.exec(placeholder);
|
|
88
|
+
return match?.[1] ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether the prompt demands JSON output.
|
|
94
|
+
*
|
|
95
|
+
* Defined narrowly and checkably: a fenced block tagged `json`, or an untagged
|
|
96
|
+
* fenced block whose body parses as JSON. Nothing about the surrounding prose,
|
|
97
|
+
* because "return JSON" in a sentence is a phrase, and a phrase is where
|
|
98
|
+
* guessing starts.
|
|
99
|
+
*
|
|
100
|
+
* The first version of this asked `findRestatedFormat`, which was the wrong
|
|
101
|
+
* question wearing a convenient shape. That function answers "is this prompt
|
|
102
|
+
* wasting tokens restating its own schema?" — so a prompt demanding JSON
|
|
103
|
+
* *cleanly* got no assertion while a wasteful one did, which is exactly
|
|
104
|
+
* backwards.
|
|
105
|
+
*/
|
|
106
|
+
function asksForJson(prompt: string): boolean {
|
|
107
|
+
const fenced = /^[ \t]*(?:```|~~~)([A-Za-z0-9]*)[ \t]*\n([\s\S]*?)^[ \t]*(?:```|~~~)/gm;
|
|
108
|
+
|
|
109
|
+
let match: RegExpExecArray | null;
|
|
110
|
+
while ((match = fenced.exec(prompt)) !== null) {
|
|
111
|
+
const lang = (match[1] ?? '').toLowerCase();
|
|
112
|
+
const body = (match[2] ?? '').trim();
|
|
113
|
+
if (/^jsonc?5?$/.test(lang)) return true;
|
|
114
|
+
if (lang !== '' ) continue;
|
|
115
|
+
if (!body.startsWith('{') && !body.startsWith('[')) continue;
|
|
116
|
+
try {
|
|
117
|
+
JSON.parse(body);
|
|
118
|
+
return true;
|
|
119
|
+
} catch {
|
|
120
|
+
// An untagged block of something else. Not our business.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function toPromptfoo(
|
|
127
|
+
original: string,
|
|
128
|
+
optimized: string,
|
|
129
|
+
cases: readonly string[],
|
|
130
|
+
options: PromptfooOptions = {},
|
|
131
|
+
): PromptfooExport {
|
|
132
|
+
const warnings: PromptfooWarning[] = [];
|
|
133
|
+
|
|
134
|
+
const placeholders = placeholdersIn(original);
|
|
135
|
+
const supported = placeholders.map(varNameOf);
|
|
136
|
+
|
|
137
|
+
// The variable the cases fill. `trazum eval` substitutes the *first*
|
|
138
|
+
// placeholder and appends when there is none; promptfoo is driven the same
|
|
139
|
+
// way so the two commands are testing the same prompt.
|
|
140
|
+
let variable = APPENDED_VAR;
|
|
141
|
+
let originalTemplate = original;
|
|
142
|
+
let optimizedTemplate = optimized;
|
|
143
|
+
|
|
144
|
+
if (placeholders.length === 0) {
|
|
145
|
+
variable = APPENDED_VAR;
|
|
146
|
+
originalTemplate = `${original.trimEnd()}\n\n{{${APPENDED_VAR}}}`;
|
|
147
|
+
optimizedTemplate = `${optimized.trimEnd()}\n\n{{${APPENDED_VAR}}}`;
|
|
148
|
+
warnings.push({
|
|
149
|
+
kind: 'appended-input',
|
|
150
|
+
detail:
|
|
151
|
+
'This prompt has no placeholder, so each case is appended at the end — the same ' +
|
|
152
|
+
'thing `trazum eval` does. If the prompt is meant to be a template, add ' +
|
|
153
|
+
'{{a_variable}} and export again.',
|
|
154
|
+
});
|
|
155
|
+
} else {
|
|
156
|
+
const first = supported[0] ?? null;
|
|
157
|
+
if (first === null) {
|
|
158
|
+
warnings.push({
|
|
159
|
+
kind: 'unsupported-placeholder',
|
|
160
|
+
detail:
|
|
161
|
+
`promptfoo substitutes {{name}}; this prompt uses ${placeholders[0]}, which it will ` +
|
|
162
|
+
'leave untouched. Every case would run against the literal template.',
|
|
163
|
+
});
|
|
164
|
+
variable = APPENDED_VAR;
|
|
165
|
+
} else {
|
|
166
|
+
variable = first;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const distinct = placeholders.length;
|
|
170
|
+
if (distinct > 1) {
|
|
171
|
+
warnings.push({
|
|
172
|
+
kind: 'multiple-placeholders',
|
|
173
|
+
detail:
|
|
174
|
+
`The prompt has ${distinct} placeholders (${placeholders.join(', ')}) and the case ` +
|
|
175
|
+
`file supplies one value each, which fills ${placeholders[0]} only. Give the others ` +
|
|
176
|
+
'defaults in `defaultTest.vars`, or the run tests a prompt nobody sends.',
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const { id, warning } = providerId(options.model);
|
|
182
|
+
if (warning !== null) warnings.push({ kind: 'unmapped-provider', detail: warning });
|
|
183
|
+
|
|
184
|
+
// The one assertion Trazum can make without guessing at the task: if the
|
|
185
|
+
// prompt asks for JSON, both versions still have to produce it. Everything
|
|
186
|
+
// else — accuracy, refusal rate, format specifics — is the team's to write,
|
|
187
|
+
// and inventing it here would be a tool with opinions about somebody else's
|
|
188
|
+
// product.
|
|
189
|
+
const assertions = asksForJson(original) ? [{ type: 'is-json' }] : [];
|
|
190
|
+
|
|
191
|
+
const label = options.level ? `after (trazum, ${options.level})` : 'after (trazum)';
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
config: {
|
|
195
|
+
description:
|
|
196
|
+
'Generated by trazum. The only difference between the two prompts is the ' +
|
|
197
|
+
'optimisation — same provider, same cases, same everything else.\n\n' +
|
|
198
|
+
'The assertions are yours to write: `trazum eval` already answers "does the model ' +
|
|
199
|
+
'still say the same thing", and this suite exists for the question it cannot ask — ' +
|
|
200
|
+
'whether your accuracy, your format and your refusal rate survived. Add them under ' +
|
|
201
|
+
'`defaultTest.assert` or per test.\n\n' +
|
|
202
|
+
'Docs: https://www.promptfoo.dev/docs/configuration/expected-outputs/',
|
|
203
|
+
prompts: [
|
|
204
|
+
{ label: 'before', raw: originalTemplate },
|
|
205
|
+
{ label, raw: optimizedTemplate },
|
|
206
|
+
],
|
|
207
|
+
providers: [id],
|
|
208
|
+
...(assertions.length > 0 ? { defaultTest: { assert: assertions } } : {}),
|
|
209
|
+
tests: cases.map((input) => ({ vars: { [variable]: input } })),
|
|
210
|
+
},
|
|
211
|
+
warnings,
|
|
212
|
+
};
|
|
213
|
+
}
|
package/src/prune.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { findExamples } from './structure.js';
|
|
2
|
+
import { estimateTokens } from './tokenizer.js';
|
|
3
|
+
import { agreement, pooled, verdictFor } from './evaluate.js';
|
|
4
|
+
import type { EvalVerdict } from './evaluate.js';
|
|
5
|
+
import type { LlmProvider, TokenCounter } from './types.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Which few-shot examples earn their tokens, measured rather than guessed.
|
|
9
|
+
*
|
|
10
|
+
* The `redundant-examples` advisory answers a *textual* question: does this
|
|
11
|
+
* example look like an earlier one? That catches the way few-shot blocks actually
|
|
12
|
+
* grow — copy the last one, change two fields — and it is the cheapest useful
|
|
13
|
+
* thing to say, because it costs nothing.
|
|
14
|
+
*
|
|
15
|
+
* This answers a different and much stronger question: **does removing this
|
|
16
|
+
* example change any answer?** Two examples can be textually unalike and teach the
|
|
17
|
+
* same thing, and a block nobody has measured is usually where the tokens are: a
|
|
18
|
+
* few-shot section is routinely most of a prompt.
|
|
19
|
+
*
|
|
20
|
+
* The method is leave-one-out against the prompt's own noise floor. Ask the full
|
|
21
|
+
* prompt twice to find out how much the model disagrees with *itself*, then remove
|
|
22
|
+
* one example and ask again. If the answer moves no further than the model already
|
|
23
|
+
* moves on its own, that example is not doing observable work.
|
|
24
|
+
*
|
|
25
|
+
* **It spends the caller's money and must never run by default.** The bill is
|
|
26
|
+
* `2 + examples` calls per input, and `plannedCalls` exists so a caller can print
|
|
27
|
+
* the figure before deciding rather than discovering it afterwards.
|
|
28
|
+
*
|
|
29
|
+
* **What it cannot tell you.** An example may exist for a case these inputs do not
|
|
30
|
+
* contain — the boundary condition somebody hit in production last March and added
|
|
31
|
+
* a demonstration for. Removing it would change nothing measurable here and break
|
|
32
|
+
* that case. So this reports "no effect on these inputs", never "delete this", and
|
|
33
|
+
* the wording is deliberate: the strength of the claim is bounded by the inputs
|
|
34
|
+
* given, and only the caller knows whether those cover what matters.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export interface ExampleContribution {
|
|
38
|
+
/** Position in the prompt's example block, from zero. */
|
|
39
|
+
index: number;
|
|
40
|
+
/** The example itself, so a report can quote its first line. */
|
|
41
|
+
text: string;
|
|
42
|
+
tokens: number;
|
|
43
|
+
/**
|
|
44
|
+
* Mean agreement between the full prompt's answer and the answer with this
|
|
45
|
+
* example removed, across every input.
|
|
46
|
+
*/
|
|
47
|
+
agreementWithout: number;
|
|
48
|
+
/**
|
|
49
|
+
* `indistinguishable` and `within-noise` both mean the removal changed nothing
|
|
50
|
+
* this measurement can see. `diverges` means it did. `inconclusive` means the
|
|
51
|
+
* model disagreed with itself too much for any of this to mean anything.
|
|
52
|
+
*/
|
|
53
|
+
verdict: EvalVerdict;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PruneReport {
|
|
57
|
+
provider: string;
|
|
58
|
+
model: string;
|
|
59
|
+
/** The model's agreement with itself, given the full prompt. The yardstick. */
|
|
60
|
+
selfAgreement: number;
|
|
61
|
+
contributions: ExampleContribution[];
|
|
62
|
+
/** Tokens held by examples whose removal changed nothing observable. */
|
|
63
|
+
recoverableTokens: number;
|
|
64
|
+
/** Calls actually made, so the bill is never a surprise. */
|
|
65
|
+
callsMade: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface PruneOptions {
|
|
69
|
+
concurrency?: number;
|
|
70
|
+
countTokens?: TokenCounter;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What this will cost, before it costs it.
|
|
75
|
+
*
|
|
76
|
+
* Pure and exported so a CLI can print the number and let somebody say no. A
|
|
77
|
+
* feature that spends money and only reports the total afterwards is a feature
|
|
78
|
+
* people run once.
|
|
79
|
+
*/
|
|
80
|
+
export function plannedCalls(examples: number, inputs: number): number {
|
|
81
|
+
if (examples < 2 || inputs < 1) return 0;
|
|
82
|
+
// Two baseline runs per input to establish the noise floor, then one run per
|
|
83
|
+
// example removed. The baselines are shared across every example, which is the
|
|
84
|
+
// only reason this is affordable at all.
|
|
85
|
+
return inputs * (2 + examples);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The prompt with one example block removed, located by position rather than by
|
|
90
|
+
* text.
|
|
91
|
+
*
|
|
92
|
+
* `prompt.replace(block.text, '')` would be shorter and wrong: two identical
|
|
93
|
+
* example blocks — which is exactly what a copy-paste few-shot section contains —
|
|
94
|
+
* would both match the first occurrence, so removing the second would silently
|
|
95
|
+
* remove the first and the measurement would describe a prompt nobody asked about.
|
|
96
|
+
* Scanning forward from the end of the previous block gives each block its true
|
|
97
|
+
* offset.
|
|
98
|
+
*/
|
|
99
|
+
export function withoutExample(
|
|
100
|
+
prompt: string,
|
|
101
|
+
examples: readonly { text: string }[],
|
|
102
|
+
index: number,
|
|
103
|
+
): string {
|
|
104
|
+
let cursor = 0;
|
|
105
|
+
for (let position = 0; position < examples.length; position++) {
|
|
106
|
+
const text = examples[position]?.text ?? '';
|
|
107
|
+
const at = prompt.indexOf(text, cursor);
|
|
108
|
+
if (at === -1) break;
|
|
109
|
+
if (position === index) {
|
|
110
|
+
const before = prompt.slice(0, at);
|
|
111
|
+
const after = prompt.slice(at + text.length);
|
|
112
|
+
// Collapse the blank lines the removal leaves behind, so the resulting
|
|
113
|
+
// prompt is one an author might have written rather than one with a hole.
|
|
114
|
+
return `${before}${after}`.replace(/\n{3,}/g, '\n\n');
|
|
115
|
+
}
|
|
116
|
+
cursor = at + text.length;
|
|
117
|
+
}
|
|
118
|
+
return prompt;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function mean(values: readonly number[]): number {
|
|
122
|
+
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Thrown rather than returned: there is nothing to report and a reason to say. */
|
|
126
|
+
export class NothingToPrune extends Error {}
|
|
127
|
+
|
|
128
|
+
export async function pruneExamples(
|
|
129
|
+
prompt: string,
|
|
130
|
+
inputs: readonly string[],
|
|
131
|
+
provider: LlmProvider,
|
|
132
|
+
options: PruneOptions = {},
|
|
133
|
+
): Promise<PruneReport> {
|
|
134
|
+
const count = options.countTokens ?? estimateTokens;
|
|
135
|
+
const concurrency = Math.max(1, options.concurrency ?? 3);
|
|
136
|
+
const examples = findExamples(prompt, count);
|
|
137
|
+
|
|
138
|
+
if (examples.length < 2) {
|
|
139
|
+
throw new NothingToPrune(
|
|
140
|
+
'this prompt has fewer than two few-shot examples, so there is nothing to compare',
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (inputs.length === 0) {
|
|
144
|
+
throw new NothingToPrune('no inputs were given, and leave-one-out needs something to run on');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let callsMade = 0;
|
|
148
|
+
const run = async (text: string, input: string): Promise<string> => {
|
|
149
|
+
callsMade++;
|
|
150
|
+
return provider.complete({ system: text.replaceAll('{{input}}', input), user: input });
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Baselines first, and sequentially within an input.
|
|
155
|
+
*
|
|
156
|
+
* The two runs exist to measure the model's own variance, and issuing them
|
|
157
|
+
* together invites a provider to serve one from a cache and report a variance of
|
|
158
|
+
* zero — which would make every example look load-bearing.
|
|
159
|
+
*/
|
|
160
|
+
const baselines = await pooled(
|
|
161
|
+
inputs.map((input) => async () => {
|
|
162
|
+
const first = await run(prompt, input);
|
|
163
|
+
const second = await run(prompt, input);
|
|
164
|
+
return { input, first, selfSimilarity: agreement(first, second) };
|
|
165
|
+
}),
|
|
166
|
+
concurrency,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const selfAgreement = mean(baselines.map((baseline) => baseline.selfSimilarity));
|
|
170
|
+
|
|
171
|
+
const contributions = await pooled(
|
|
172
|
+
examples.map((example, index) => async (): Promise<ExampleContribution> => {
|
|
173
|
+
const reduced = withoutExample(prompt, examples, index);
|
|
174
|
+
const answers = await pooled(
|
|
175
|
+
baselines.map((baseline) => () => run(reduced, baseline.input)),
|
|
176
|
+
1,
|
|
177
|
+
);
|
|
178
|
+
const agreementWithout = mean(
|
|
179
|
+
answers.map((answer, position) => agreement(baselines[position]!.first, answer)),
|
|
180
|
+
);
|
|
181
|
+
return {
|
|
182
|
+
index,
|
|
183
|
+
text: example.text,
|
|
184
|
+
tokens: example.tokens,
|
|
185
|
+
agreementWithout,
|
|
186
|
+
// `verdictFor` and `agreement` both come from evaluate.ts rather than
|
|
187
|
+
// being reimplemented. A repository where two features disagree about what
|
|
188
|
+
// "within the noise" means is one where the answer depends on which
|
|
189
|
+
// command you ran — and the first draft of this file did exactly that.
|
|
190
|
+
verdict: verdictFor(selfAgreement, agreementWithout),
|
|
191
|
+
};
|
|
192
|
+
}),
|
|
193
|
+
concurrency,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const recoverableTokens = contributions
|
|
197
|
+
.filter(
|
|
198
|
+
(contribution) =>
|
|
199
|
+
contribution.verdict === 'indistinguishable' || contribution.verdict === 'within-noise',
|
|
200
|
+
)
|
|
201
|
+
.reduce((sum, contribution) => sum + contribution.tokens, 0);
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
provider: provider.name,
|
|
205
|
+
model: provider.model,
|
|
206
|
+
selfAgreement,
|
|
207
|
+
contributions,
|
|
208
|
+
recoverableTokens,
|
|
209
|
+
callsMade,
|
|
210
|
+
};
|
|
211
|
+
}
|