@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.
Files changed (223) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +163 -0
  3. package/dist/advisories.d.ts +23 -0
  4. package/dist/advisories.d.ts.map +1 -0
  5. package/dist/advisories.js +376 -0
  6. package/dist/advisories.js.map +1 -0
  7. package/dist/aws-sigv4.d.ts +88 -0
  8. package/dist/aws-sigv4.d.ts.map +1 -0
  9. package/dist/aws-sigv4.js +117 -0
  10. package/dist/aws-sigv4.js.map +1 -0
  11. package/dist/baseline.d.ts +171 -0
  12. package/dist/baseline.d.ts.map +1 -0
  13. package/dist/baseline.js +273 -0
  14. package/dist/baseline.js.map +1 -0
  15. package/dist/cache.d.ts +26 -0
  16. package/dist/cache.d.ts.map +1 -0
  17. package/dist/cache.js +28 -0
  18. package/dist/cache.js.map +1 -0
  19. package/dist/changes.d.ts +29 -0
  20. package/dist/changes.d.ts.map +1 -0
  21. package/dist/changes.js +142 -0
  22. package/dist/changes.js.map +1 -0
  23. package/dist/compare.d.ts +65 -0
  24. package/dist/compare.d.ts.map +1 -0
  25. package/dist/compare.js +58 -0
  26. package/dist/compare.js.map +1 -0
  27. package/dist/config-schema.d.ts +118 -0
  28. package/dist/config-schema.d.ts.map +1 -0
  29. package/dist/config-schema.js +315 -0
  30. package/dist/config-schema.js.map +1 -0
  31. package/dist/config.d.ts +47 -0
  32. package/dist/config.d.ts.map +1 -0
  33. package/dist/config.js +112 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/detect.d.ts +70 -0
  36. package/dist/detect.d.ts.map +1 -0
  37. package/dist/detect.js +228 -0
  38. package/dist/detect.js.map +1 -0
  39. package/dist/evaluate.d.ts +98 -0
  40. package/dist/evaluate.d.ts.map +1 -0
  41. package/dist/evaluate.js +110 -0
  42. package/dist/evaluate.js.map +1 -0
  43. package/dist/extract.d.ts +81 -0
  44. package/dist/extract.d.ts.map +1 -0
  45. package/dist/extract.js +280 -0
  46. package/dist/extract.js.map +1 -0
  47. package/dist/gcp-auth.d.ts +58 -0
  48. package/dist/gcp-auth.d.ts.map +1 -0
  49. package/dist/gcp-auth.js +113 -0
  50. package/dist/gcp-auth.js.map +1 -0
  51. package/dist/glob.d.ts +49 -0
  52. package/dist/glob.d.ts.map +1 -0
  53. package/dist/glob.js +154 -0
  54. package/dist/glob.js.map +1 -0
  55. package/dist/host.d.ts +30 -0
  56. package/dist/host.d.ts.map +1 -0
  57. package/dist/host.js +69 -0
  58. package/dist/host.js.map +1 -0
  59. package/dist/i18n/en.d.ts +4 -0
  60. package/dist/i18n/en.d.ts.map +1 -0
  61. package/dist/i18n/en.js +168 -0
  62. package/dist/i18n/en.js.map +1 -0
  63. package/dist/i18n/es.d.ts +4 -0
  64. package/dist/i18n/es.d.ts.map +1 -0
  65. package/dist/i18n/es.js +168 -0
  66. package/dist/i18n/es.js.map +1 -0
  67. package/dist/i18n/index.d.ts +36 -0
  68. package/dist/i18n/index.d.ts.map +1 -0
  69. package/dist/i18n/index.js +50 -0
  70. package/dist/i18n/index.js.map +1 -0
  71. package/dist/i18n/types.d.ts +180 -0
  72. package/dist/i18n/types.d.ts.map +1 -0
  73. package/dist/i18n/types.js +11 -0
  74. package/dist/i18n/types.js.map +1 -0
  75. package/dist/index.d.ts +66 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +69 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/llm.d.ts +226 -0
  80. package/dist/llm.d.ts.map +1 -0
  81. package/dist/llm.js +485 -0
  82. package/dist/llm.js.map +1 -0
  83. package/dist/nearest.d.ts +20 -0
  84. package/dist/nearest.d.ts.map +1 -0
  85. package/dist/nearest.js +54 -0
  86. package/dist/nearest.js.map +1 -0
  87. package/dist/net.d.ts +90 -0
  88. package/dist/net.d.ts.map +1 -0
  89. package/dist/net.js +203 -0
  90. package/dist/net.js.map +1 -0
  91. package/dist/node.d.ts +32 -0
  92. package/dist/node.d.ts.map +1 -0
  93. package/dist/node.js +33 -0
  94. package/dist/node.js.map +1 -0
  95. package/dist/openrouter.d.ts +25 -0
  96. package/dist/openrouter.d.ts.map +1 -0
  97. package/dist/openrouter.js +72 -0
  98. package/dist/openrouter.js.map +1 -0
  99. package/dist/optimize.d.ts +38 -0
  100. package/dist/optimize.d.ts.map +1 -0
  101. package/dist/optimize.js +183 -0
  102. package/dist/optimize.js.map +1 -0
  103. package/dist/otlp.d.ts +91 -0
  104. package/dist/otlp.d.ts.map +1 -0
  105. package/dist/otlp.js +102 -0
  106. package/dist/otlp.js.map +1 -0
  107. package/dist/phrases.d.ts +169 -0
  108. package/dist/phrases.d.ts.map +1 -0
  109. package/dist/phrases.js +939 -0
  110. package/dist/phrases.js.map +1 -0
  111. package/dist/pricing-overlay.d.ts +55 -0
  112. package/dist/pricing-overlay.d.ts.map +1 -0
  113. package/dist/pricing-overlay.js +241 -0
  114. package/dist/pricing-overlay.js.map +1 -0
  115. package/dist/pricing.d.ts +115 -0
  116. package/dist/pricing.d.ts.map +1 -0
  117. package/dist/pricing.js +400 -0
  118. package/dist/pricing.js.map +1 -0
  119. package/dist/profile.d.ts +71 -0
  120. package/dist/profile.d.ts.map +1 -0
  121. package/dist/profile.js +55 -0
  122. package/dist/profile.js.map +1 -0
  123. package/dist/promptfoo.d.ts +58 -0
  124. package/dist/promptfoo.d.ts.map +1 -0
  125. package/dist/promptfoo.js +149 -0
  126. package/dist/promptfoo.js.map +1 -0
  127. package/dist/prune.d.ts +91 -0
  128. package/dist/prune.d.ts.map +1 -0
  129. package/dist/prune.js +110 -0
  130. package/dist/prune.js.map +1 -0
  131. package/dist/reorder.d.ts +82 -0
  132. package/dist/reorder.d.ts.map +1 -0
  133. package/dist/reorder.js +215 -0
  134. package/dist/reorder.js.map +1 -0
  135. package/dist/review.d.ts +54 -0
  136. package/dist/review.d.ts.map +1 -0
  137. package/dist/review.js +131 -0
  138. package/dist/review.js.map +1 -0
  139. package/dist/rules.d.ts +5 -0
  140. package/dist/rules.d.ts.map +1 -0
  141. package/dist/rules.js +279 -0
  142. package/dist/rules.js.map +1 -0
  143. package/dist/savings.d.ts +36 -0
  144. package/dist/savings.d.ts.map +1 -0
  145. package/dist/savings.js +83 -0
  146. package/dist/savings.js.map +1 -0
  147. package/dist/segment.d.ts +8 -0
  148. package/dist/segment.d.ts.map +1 -0
  149. package/dist/segment.js +74 -0
  150. package/dist/segment.js.map +1 -0
  151. package/dist/shared-prefix.d.ts +63 -0
  152. package/dist/shared-prefix.d.ts.map +1 -0
  153. package/dist/shared-prefix.js +151 -0
  154. package/dist/shared-prefix.js.map +1 -0
  155. package/dist/similarity.d.ts +13 -0
  156. package/dist/similarity.d.ts.map +1 -0
  157. package/dist/similarity.js +30 -0
  158. package/dist/similarity.js.map +1 -0
  159. package/dist/structure.d.ts +144 -0
  160. package/dist/structure.d.ts.map +1 -0
  161. package/dist/structure.js +455 -0
  162. package/dist/structure.js.map +1 -0
  163. package/dist/suggest.d.ts +100 -0
  164. package/dist/suggest.d.ts.map +1 -0
  165. package/dist/suggest.js +151 -0
  166. package/dist/suggest.js.map +1 -0
  167. package/dist/tokenizer.d.ts +57 -0
  168. package/dist/tokenizer.d.ts.map +1 -0
  169. package/dist/tokenizer.js +157 -0
  170. package/dist/tokenizer.js.map +1 -0
  171. package/dist/types.d.ts +296 -0
  172. package/dist/types.d.ts.map +1 -0
  173. package/dist/types.js +3 -0
  174. package/dist/types.js.map +1 -0
  175. package/dist/walk.d.ts +40 -0
  176. package/dist/walk.d.ts.map +1 -0
  177. package/dist/walk.js +95 -0
  178. package/dist/walk.js.map +1 -0
  179. package/package.json +56 -0
  180. package/src/advisories.ts +431 -0
  181. package/src/aws-sigv4.ts +174 -0
  182. package/src/baseline.ts +390 -0
  183. package/src/cache.ts +54 -0
  184. package/src/changes.ts +158 -0
  185. package/src/compare.ts +131 -0
  186. package/src/config-schema.ts +451 -0
  187. package/src/config.ts +161 -0
  188. package/src/detect.ts +312 -0
  189. package/src/evaluate.ts +188 -0
  190. package/src/extract.ts +336 -0
  191. package/src/gcp-auth.ts +166 -0
  192. package/src/glob.ts +160 -0
  193. package/src/host.ts +90 -0
  194. package/src/i18n/en.ts +236 -0
  195. package/src/i18n/es.ts +236 -0
  196. package/src/i18n/index.ts +68 -0
  197. package/src/i18n/types.ts +230 -0
  198. package/src/index.ts +228 -0
  199. package/src/llm.ts +708 -0
  200. package/src/nearest.ts +61 -0
  201. package/src/net.ts +233 -0
  202. package/src/node.ts +63 -0
  203. package/src/openrouter.ts +125 -0
  204. package/src/optimize.ts +228 -0
  205. package/src/otlp.ts +179 -0
  206. package/src/phrases.ts +1047 -0
  207. package/src/pricing-overlay.ts +319 -0
  208. package/src/pricing.ts +468 -0
  209. package/src/profile.ts +124 -0
  210. package/src/promptfoo.ts +213 -0
  211. package/src/prune.ts +211 -0
  212. package/src/reorder.ts +307 -0
  213. package/src/review.ts +180 -0
  214. package/src/rules.ts +324 -0
  215. package/src/savings.ts +121 -0
  216. package/src/segment.ts +106 -0
  217. package/src/shared-prefix.ts +198 -0
  218. package/src/similarity.ts +28 -0
  219. package/src/structure.ts +652 -0
  220. package/src/suggest.ts +254 -0
  221. package/src/tokenizer.ts +190 -0
  222. package/src/types.ts +323 -0
  223. package/src/walk.ts +117 -0
@@ -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
+ }