textopt 0.0.0 → 0.2.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 (65) hide show
  1. package/README.md +65 -25
  2. package/dist/bootstrap-search/index.cjs +159 -73
  3. package/dist/bootstrap-search/index.d.cts +32 -10
  4. package/dist/bootstrap-search/index.d.mts +32 -10
  5. package/dist/bootstrap-search/index.mjs +150 -66
  6. package/dist/demos-9v5ts7F3.cjs +244 -0
  7. package/dist/{demos-B0pVQjYC.d.mts → demos-ASsSXYXA.d.mts} +10 -3
  8. package/dist/demos-Brobjfuc.mjs +215 -0
  9. package/dist/{demos-BTuzFNsp.d.cts → demos-ByaLZy-Z.d.cts} +10 -3
  10. package/dist/file-cache.cjs +27 -8
  11. package/dist/file-cache.d.cts +13 -0
  12. package/dist/file-cache.d.mts +13 -0
  13. package/dist/file-cache.mjs +27 -8
  14. package/dist/gepa/index.cjs +128 -80
  15. package/dist/gepa/index.d.cts +15 -7
  16. package/dist/gepa/index.d.mts +15 -7
  17. package/dist/gepa/index.mjs +101 -55
  18. package/dist/index.cjs +157 -30
  19. package/dist/index.d.cts +177 -7
  20. package/dist/index.d.mts +177 -7
  21. package/dist/index.mjs +139 -18
  22. package/dist/{math-COOofUyv.cjs → math-BhlziRPc.cjs} +60 -9
  23. package/dist/math-Dqme4rYz.mjs +123 -0
  24. package/dist/mipro/index.cjs +104 -70
  25. package/dist/mipro/index.d.cts +17 -14
  26. package/dist/mipro/index.d.mts +17 -14
  27. package/dist/mipro/index.mjs +90 -58
  28. package/dist/opro/index.cjs +136 -51
  29. package/dist/opro/index.d.cts +17 -9
  30. package/dist/opro/index.d.mts +17 -9
  31. package/dist/opro/index.mjs +121 -38
  32. package/dist/{optimizer-B7SpRwl7.d.cts → optimizer-4Zv-Zt2t.d.cts} +90 -5
  33. package/dist/{optimizer-DqCoth_w.d.mts → optimizer-Ds5mzYjz.d.mts} +90 -5
  34. package/dist/random-search/index.cjs +99 -49
  35. package/dist/random-search/index.d.cts +15 -13
  36. package/dist/random-search/index.d.mts +15 -13
  37. package/dist/random-search/index.mjs +89 -41
  38. package/dist/{reflection-Cr_upzU0.d.mts → reflection-CMezGu6u.d.mts} +38 -14
  39. package/dist/{reflection-CQToe-5B.d.cts → reflection-D0A7eahD.d.cts} +38 -14
  40. package/dist/reporting-bq007_2z.d.cts +294 -0
  41. package/dist/reporting-bq007_2z.d.mts +294 -0
  42. package/dist/simba/index.cjs +216 -83
  43. package/dist/simba/index.d.cts +53 -13
  44. package/dist/simba/index.d.mts +53 -13
  45. package/dist/simba/index.mjs +206 -75
  46. package/dist/testing.cjs +1 -0
  47. package/dist/testing.d.cts +5 -3
  48. package/dist/testing.d.mts +5 -3
  49. package/dist/testing.mjs +1 -1
  50. package/dist/{evaluation-OZOp6TB7.cjs → warnings-CWRJF-jA.cjs} +228 -5
  51. package/dist/{evaluation-BV0nSZVx.mjs → warnings-OxvDi9kN.mjs} +175 -6
  52. package/docs/adapters.md +169 -0
  53. package/docs/benchmark.md +90 -0
  54. package/docs/data-prep.md +113 -0
  55. package/docs/distillation.md +128 -0
  56. package/docs/evaluation.md +87 -0
  57. package/docs/metric-preflight.md +132 -0
  58. package/docs/optimizers.md +293 -0
  59. package/docs/tuning.md +130 -0
  60. package/package.json +6 -4
  61. package/dist/demos-B9BJiNKz.cjs +0 -143
  62. package/dist/demos-Degx6UmP.mjs +0 -126
  63. package/dist/math-DhrDmpFS.mjs +0 -78
  64. package/dist/types-CWv4IQFF.d.cts +0 -129
  65. package/dist/types-CWv4IQFF.d.mts +0 -129
package/README.md CHANGED
@@ -4,6 +4,8 @@ Core interfaces and optimizers for textopt.
4
4
 
5
5
  This package has no runtime dependencies. For an overview of the algorithms and guidance on choosing one, see the [project README](https://github.com/ctdio/textopt#readme).
6
6
 
7
+ The long-form guides ship with the package, under `docs/`, so an installed copy documents the version installed rather than whatever `main` has become. Agent-facing guidance lives there and in the doc comments on the API itself.
8
+
7
9
  ## Entry points
8
10
 
9
11
  | Import | Contains |
@@ -23,7 +25,7 @@ This package has no runtime dependencies. For an overview of the algorithms and
23
25
  ```ts
24
26
  import {
25
27
  assertResumable,
26
- bootstrapDemos,
28
+ harvestFewShotExamples,
27
29
  buildJudgePrompt,
28
30
  compare,
29
31
  componentNames,
@@ -32,11 +34,13 @@ import {
32
34
  createJudge,
33
35
  createMemoryCache,
34
36
  formatDemos,
37
+ harvestRollouts,
35
38
  mapWithConcurrency,
36
39
  parseDemos,
37
40
  parseProposedText,
38
41
  priceUsage,
39
42
  runFingerprint,
43
+ toTrainingJsonl,
40
44
  } from "textopt";
41
45
  import type {
42
46
  Adapter,
@@ -93,9 +97,9 @@ evaluate(args: EvaluateArgs<Datum, K>): Promise<EvaluationBatch<Trajectory, Outp
93
97
 
94
98
  **`transient`** marks scores caused by infrastructure failures such as rate limits, 5xx responses, or network errors. Transient scores are not cached.
95
99
 
96
- **`Optimizer<Stop extends string>`** defines `optimize(task: OptimizerTask) => Promise<OptimizerResult>`. `OptimizerTask` contains the shared run inputs: `seedCandidate`, `trainingSet`, `validationSet`, `testSet`, `adapter`, `maxMetricCalls`, `maxCostUsd`, `maxWallClockMs`, `cacheNamespace`, `retry`, and `signal`. `OptimizerResult` contains `bestCandidate`, `bestScore`, `bestOutputs`, `metricCalls`, `usage`, `testScore`, `testMetricCalls`, and `stopReason`. Optimizer-specific task and result types extend these interfaces.
100
+ **`Optimizer<Stop extends string>`** defines `optimize(task: OptimizerTask) => Promise<OptimizerResult>`. `OptimizerTask` contains the shared run inputs: `seedCandidate`, `trainingSet`, `validationSet`, `testSet`, `adapter`, `maxMetricCalls`, `maxCostUsd`, `maxWallClockMs`, `cacheNamespace`, `retry`, and `signal`. `OptimizerResult` contains `bestCandidate`, `bestScore`, `bestOutputs`, `metricCalls`, `usage`, `testScore`, `testMetricCalls`, `testUsage`, `warnings`, and `stopReason`. Optimizer-specific task and result types extend these interfaces.
97
101
 
98
- **`maxCostUsd`** and **`maxWallClockMs`** are checked between evaluations, so a run overruns by at most one of them. Neither follows from `maxMetricCalls`: reflective search grows the text it optimizes, so late rollouts cost more than early ones, and a run behind a rate limit spends almost nothing while taking as long as the provider makes it take.
102
+ **`maxCostUsd`** and **`maxWallClockMs`** are checked between evaluations, so a run overruns by whatever it had in flight when the ceiling was reached — one evaluation at the default concurrency, and up to `concurrency` of them above it. Neither bounds the held-out sweep, which runs once the search has already stopped: it is reported apart from the search as `testMetricCalls` and `testUsage`, and has to be budgeted for separately. Neither follows from `maxMetricCalls`: reflective search grows the text it optimizes, so late rollouts cost more than early ones, and a run behind a rate limit spends almost nothing while taking as long as the provider makes it take.
99
103
 
100
104
  **`cacheNamespace`** scopes every cache key to the system the rollouts were measured under — model id, decoding settings, scorer version. Change it whenever anything outside the candidate text changes.
101
105
 
@@ -103,7 +107,11 @@ evaluate(args: EvaluateArgs<Datum, K>): Promise<EvaluationBatch<Trajectory, Outp
103
107
 
104
108
  **`UsageTotals`** (`inputTokens`, `outputTokens`, `totalTokens`, `costUsd`, `rollouts`) is summed from the `RolloutUsage` entries an adapter reports. Zero throughout when the adapter reports none.
105
109
 
106
- **`testSet`** is excluded from search and evaluated once against the winner. Because candidates are selected on `validationSet`, `bestScore` may be fitted to it. `testScore` measures held-out performance. Test rollouts are reported as `testMetricCalls` and do not count against `maxMetricCalls`.
110
+ **`testSet`** is excluded from search and evaluated once against the winner. Because candidates are selected on `validationSet`, `bestScore` may be fitted to it. `testScore` measures held-out performance. Test rollouts are reported as `testMetricCalls` costing `testUsage`, outside `maxMetricCalls` and `maxCostUsd` both.
111
+
112
+ **`validationSet`** defaults to `trainingSet`, and a run that took that default reports a `validationSetReusesTraining` warning: the search selected candidates on the instances reflection read. Pass `validationSet: "reuseTraining"` to accept it by name and silence the warning.
113
+
114
+ **`warnings`** is a `RunWarning[]` of what a run cannot say about itself from its own numbers — selection that reused the training instances, a seed the metric scored identically on every instance. Never fatal, always present (empty when there is nothing to say), and repeated on the `finish` event so a reporter records them next to the score.
107
115
 
108
116
  **`TextModel`** is the provider-independent interface `({ prompt, signal }) => Promise<string>`.
109
117
 
@@ -117,15 +125,23 @@ For Redis, SQLite, or file-backed caching, implement **`EvaluationCache`** with
117
125
 
118
126
  **`componentNames(candidate)`** returns `Object.keys(candidate)` while preserving the component-name union.
119
127
 
120
- **`createEvaluator({ adapter, budget, cache, cacheNamespace, retry, trackOutputs, onEvaluation, signal, cacheHits })`** handles adapter calls, caching, budget accounting, transient scores, and evaluation events. `evaluate` returns a `ScoredBatch`. `evaluateTraced` returns an `EvaluationBatch`, or `null` when the remaining budget cannot cover the batch. A batch that exceeds the charged budget throws `BudgetExhausted`. All included optimizers use this evaluator.
128
+ **`createEvaluator({ adapter, budget, cache, cacheNamespace, retry, trackOutputs, onEvaluation, signal, cacheHits, usage })`** handles adapter calls, caching, budget accounting, transient scores, and evaluation events. `cacheHits` and `usage` seed the counters from a checkpoint, so a resumed run reports totals rather than deltas. `usage()` covers the charged rollouts a ceiling is checked against; `unchargedUsage()` covers what `charge: false` bought. `evaluate` returns a `ScoredBatch`. `evaluateTraced` returns an `EvaluationBatch`, or `null` when the remaining budget cannot cover the batch. A batch that exceeds the charged budget throws `BudgetExhausted`. All included optimizers use this evaluator.
129
+
130
+ **`harvestRollouts({ adapter, candidate, data, minScore, maxRollouts, batchSize, maxMetricCalls, maxCostUsd, rng, signal })`** runs a candidate over `data` and returns the `Rollout`s the metric rewarded, alongside `metricCalls` and `attempted`. Omit `minScore` to keep any rollout scoring above zero; omit `maxRollouts` to sweep the whole pool. It carries its own budget and does not use the score cache, because it needs the outputs a cache hit cannot return. `maxCostUsd` bounds its dollars, checked between batches — a caller bounding spend cannot bound this pass from outside, since it runs on its own evaluator. Sweeping a validation set is the mistake to avoid — see [Distilling a run](./docs/distillation.md).
131
+
132
+ **`toTrainingJsonl({ rollouts, render })`** serializes harvested rollouts as one chat-messages example per line. `render` turns a rollout into `{ messages }` or returns `null` to skip it, and decides how much of the optimized candidate stays in the training input. Returns the text; writing it is the caller's job.
121
133
 
122
- **`bootstrapDemos({ adapter, candidate, trainingSet, minScore, maxDemos, batchSize, maxMetricCalls, rng, renderDemo, signal })`** evaluates a candidate on `trainingSet` and keeps the rollouts the metric rewarded. Omit `minScore` to keep any rollout scoring above zero, as MIPROv2 does without a `metric_threshold`; pass a number to require at least that score. It returns the selected `demos`, a formatted `block`, and the metric calls used. It does not use the score cache because it needs rollout outputs.
134
+ **`harvestFewShotExamples({ adapter, candidate, trainingSet, minScore, maxDemos, batchSize, maxMetricCalls, maxCostUsd, rng, renderDemo, signal })`** evaluates a candidate on `trainingSet` and keeps the rollouts the metric rewarded. Omit `minScore` to keep any rollout scoring above zero, as MIPROv2 does without a `metric_threshold`; pass a number to require at least that score. It returns the selected `demos`, a formatted `block`, and the metric calls and usage it spent. It does not use the score cache because it needs rollout outputs.
123
135
 
124
136
  **`formatDemos(demos, { render })`** and **`parseDemos(text)`** write and read the `<demo>`, `<input>`, and `<output>` block format.
125
137
 
126
138
  **`parseProposedText(text)`** extracts a proposal from a reflection response, including responses with fenced blocks or surrounding commentary.
127
139
 
128
- **`createJudge({ model, criteria, scale = 5, renderInput, renderOutput, buildPrompt })`** returns a `Judge<Datum, Output>`: `({ input, output, expected, signal }) => Promise<ScoreResult>`. Each `JudgeCriterion` is graded on a small integer scale and normalized; per-criterion values are returned as `objectiveScores` and the aggregate `score` is their mean. A criterion the judge failed to grade returns a transient score rather than a zero, so the instance is retried instead of recorded as a failure. **`buildJudgePrompt`** is the default template and implements `JudgePromptBuilder`.
140
+ **`createJudge({ model, criteria, scale = 5, renderInput, renderOutput, buildPrompt })`** returns a `Judge<Datum, Output>`: `({ input, output, expected, signal }) => Promise<ScoreResult>`. Each `JudgeCriterion` is graded on a small integer scale and normalized; per-criterion values are returned as `objectiveScores` and the aggregate `score` is their weighted mean. A criterion the judge failed to grade returns a transient score rather than a zero, so the instance is retried instead of recorded as a failure. **`buildJudgePrompt`** is the default template and implements `JudgePromptBuilder`.
141
+
142
+ A `JudgeCriterion` takes `weight` (default 1; 0 reports the criterion as an objective without letting it move the score) and `gate`. **`gate`** is the grade, on the judge's own scale, that a criterion must reach for the instance to score at all — below it the instance scores 0 whatever the rest said, and the per-criterion objectives still report which one failed it. A mean lets a search trade a hard requirement away: a candidate that tanks one non-negotiable criterion and aces three cosmetic ones outranks the incumbent that kept the rule. Anything you would not ship without is a gate rather than a term in the average.
143
+
144
+ When `expected` is passed, the default prompt forbids the feedback from restating it. Feedback is rewritten into a reusable instruction, so "the instruction never says to state the thirty-day refund window" is addressed to the instructions as asked and copies the answer key into the prompt.
129
145
 
130
146
  **`compare({ entrants, seeds, concurrency = 1 })`** runs each entrant over every seed and returns a `Comparison` of `winner`, `summaries`, and `runs`. Entrants are `({ seed }) => Promise<OptimizerResult>`, so the caller builds the optimizer-specific task. Ranking is on `testScore` where a run reports one, because the validation score is the number the search selected against. Each `ComparisonSummary` carries `meanScore`, `sdScore`, `minScore`, `maxScore`, `meanMetricCalls`, `meanCostUsd`, and a paired sign-flip `pValueVsWinner`.
131
147
 
@@ -137,6 +153,18 @@ For Redis, SQLite, or file-backed caching, implement **`EvaluationCache`** with
137
153
 
138
154
  **`BatchSampler<Datum>`** and **`Rng`** are type-only exports. Their default implementations are internal.
139
155
 
156
+ ### Reporting
157
+
158
+ Every optimizer takes **`reporters`**, an array of **`Reporter<Event>`** with an optional `onEvent` and an optional `flush`. `onEvent` is called synchronously on the search's hot path, so a reporter that ships anywhere over a network buffers there and uploads in `flush`, which is awaited once as the run ends — including when it ends by throwing. A reporter that throws is warned about and skipped: observability never fails a run. Persisting a run so it can be resumed is `onCheckpoint`, which is durability and a separate concern.
159
+
160
+ Each search emits its own discriminated union, but two members are shared. **`CandidateAccepted`** carries `candidateId`, the `candidate` text, its `aggregateScore`, its `instanceScores` over the validation set, and its `outputs` under `trackBestOutputs`. **`RunFinished`** carries `bestCandidateId`, `bestScore`, `metricCalls`, and the winner's held-out `testScore`, `testInstanceScores` and `testOutputs`. In both rows, `undefined` marks an instance nothing measured — the evaluation policy skipped it, or an infrastructure failure lost it — and reporting one as a zero shows as a regression that never happened.
161
+
162
+ `candidateAccepted` fires only when the incumbent moves and a full validation sweep measured it, so `instanceScores` never means "a subset, and you work out which". SIMBA accepts on a minibatch, which is too small a sample to name a row against; it reports those in its own `candidate` event and emits `candidateAccepted` when the sweep that confirms a step winner lands.
163
+
164
+ Every optimizer reports the seed as `candidateId` 0 once its own sweep lands, so a report opens with the baseline every later candidate is read against rather than with the first improvement. A resumed run does not re-emit it. Each search adds its own fields to the event — GEPA its `iteration`, `parentIds` and `source`, MIPRO its `trial`, SIMBA its `step`, OPRO and random search their `round`, bootstrap search its `source` and `demos` — and drops anything the seed could not honestly carry, since the events that name a component or a menu selection already carry it.
165
+
166
+ A reporter that reads only those two takes **`OptimizerEvent`** — `{ type: string }` — and narrows with **`isCandidateAccepted`** and **`isRunFinished`**. Because a literal tag is assignable to `string` and the parameter position is contravariant, one such reporter drops into any optimizer's `reporters` array. **`ReportableEvent`** names the union it reads. `@textopt/langsmith` is written this way.
167
+
140
168
  ## `textopt/gepa`
141
169
 
142
170
  ```ts
@@ -185,17 +213,17 @@ These options control search behavior and can be reused across runs.
185
213
 
186
214
  Required: `seedCandidate`, `trainingSet`, `adapter` (a `GepaAdapter`), `reflect` (a `TextModel`), and `maxMetricCalls`.
187
215
 
188
- | Option | Default |
189
- | --------------------- | ------------------------------------------------------------------------------------ |
190
- | `validationSet` | the trainingSet |
191
- | `testSet` | none. Held out of the search and scored once, on the winner |
192
- | `componentSelector` | `roundRobinComponentSelector()` |
193
- | `batchSampler` | an epoch-shuffled sampler over `minibatchSize` |
194
- | `valEvaluationPolicy` | `fullEvaluationPolicy()` |
195
- | `instanceId` | a content hash of the datum, falling back to its position when it will not serialize |
196
- | `cache` | a per-run memory cache. Pass `false` to disable |
216
+ | Option | Default |
217
+ | --------------------- | ---------------------------------------------------------------------------------- |
218
+ | `validationSet` | the trainingSet |
219
+ | `testSet` | none. Held out of the search and scored once, on the winner |
220
+ | `componentSelector` | `roundRobinComponentSelector()` |
221
+ | `batchSampler` | an epoch-shuffled sampler over `minibatchSize` |
222
+ | `valEvaluationPolicy` | `fullEvaluationPolicy()` |
223
+ | `instanceId` | a content hash of the datum, falling back to its position when JSON cannot read it |
224
+ | `cache` | a per-run memory cache. Pass `false` to disable |
197
225
 
198
- `onEvent`, `onCheckpoint`, `resumeFrom`, and `signal` have no defaults.
226
+ `reporters`, `onCheckpoint`, `resumeFrom`, and `signal` have no defaults.
199
227
 
200
228
  TypeScript infers component names and the datum type from `seedCandidate` and `trainingSet`. Other fields use `NoInfer` and are checked against those inferred types.
201
229
 
@@ -204,11 +232,14 @@ TypeScript infers component names and the datum type from `seedCandidate` and `t
204
232
  Extends `Adapter` with what reflection needs:
205
233
 
206
234
  ```ts
235
+ evaluate(args: EvaluateArgs<Datum, K>): ReflectiveBatch<Trajectory, Output>
207
236
  makeReflectiveDataset(args: MakeReflectiveDatasetArgs<Datum, Trajectory, Output, K>): ReflectiveDataset<K>
208
237
  proposeNewTexts?(args: ProposeArgs<K>): ComponentPatch<K> // optional
209
238
  ```
210
239
 
211
- Both methods may be synchronous or asynchronous. `ReflectiveDataset` is a partial map from component names to `ReflectiveRecord[]`. Each record contains `inputs`, `generatedOutputs`, `feedback`, `score`, and a typed `evidence` field. Adapters only need to return records for the requested components.
240
+ A `ReflectiveBatch` is an `EvaluationBatch` with `feedback` required. It is optional on the shared type because the searches that never reflect have no use for it; here it is the input to the whole method. An adapter that returns scores and no prose reduces every rollout to a number, and reflection then rewrites the instruction from a prompt whose feedback blocks are empty — a run that spends its whole budget, reports a normal-looking `stopReason`, and has been doing blind search. Nothing in the result distinguishes that from a hard task, so it is a type error instead.
241
+
242
+ All three methods may be synchronous or asynchronous. `ReflectiveDataset` is a partial map from component names to `ReflectiveRecord[]`. Each record contains `inputs`, `generatedOutputs`, `feedback`, `score`, and a typed `evidence` field. Adapters only need to return records for the requested components.
212
243
 
213
244
  When `proposeNewTexts` is implemented, the adapter generates proposals without calling `reflect`. The task type still requires `reflect`, so offline runs can pass a stub.
214
245
 
@@ -230,7 +261,7 @@ When `proposeNewTexts` is implemented, the adapter generates proposals without c
230
261
 
231
262
  Each export is a factory. Selector and acceptance interfaces accept custom functions. A `ValEvaluationPolicy` is an object with `selectInstances` and `bestCandidate` methods.
232
263
 
233
- `pairedPermutationAcceptance` and `lowerBoundEvaluationPolicy` exist for metrics whose readings vary between runs of the same text. Both are strictly more conservative than the defaults, and on a metric that does not vary that is pure cost: in the twenty-seed benchmark the pair drops GEPA from 0.729 to 0.175 on the noiseless task and ties it on the noisy one. A sign-flip test also needs a wide enough minibatch to say anything — over three instances the smallest p-value it can produce is 0.125, so at the default `minibatchSize` no proposal clears an `alpha` below that.
264
+ `pairedPermutationAcceptance` and `lowerBoundEvaluationPolicy` exist for metrics whose readings vary between runs of the same text. Both are strictly more conservative than the defaults. In the twenty-seed benchmark the pair scores 0.931 against plain GEPA's 0.920 on the noisy task and 0.945 against 0.947 on the noiseless one, neither gap significant over those seeds — conservative enough to be worth having on a metric that varies, cheap enough not to hurt on one that does not. A sign-flip test also needs a wide enough minibatch to say anything — over three instances the smallest p-value it can produce is 0.125, so at the default `minibatchSize` no proposal clears an `alpha` below that. `GepaOptimizer` refuses that combination at construction rather than letting the run discover it: an acceptance policy reports the smallest batch it could ever accept on as `minimumPairs`, and a `minibatchSize` below it throws.
234
265
 
235
266
  ### Reflection prompts
236
267
 
@@ -270,7 +301,11 @@ Feedback is end-to-end and every module receives the same string. A metric score
270
301
 
271
302
  **`stopReason`** is one of `"budgetExhausted"`, `"costExhausted"`, `"deadlineReached"`, `"reflectionBudgetExhausted"`, `"aborted"`, or `"maxIterations"`.
272
303
 
273
- **`onEvent`** receives a discriminated `GepaEvent`: `start`, `iterationStart`, `evaluation`, `proposal`, `candidateAccepted`, `candidateRejected` (with `reason: "worse" | "notSelected"`), `error`, and `finish`.
304
+ **`reporters`** is an array of `Reporter<GepaEvent>`, each with an optional `onEvent` and an optional `flush`. `onEvent` receives a discriminated `GepaEvent`: `start`, `iterationStart`, `evaluation`, `proposal`, `candidateAccepted`, `candidateRejected` (with `reason: "worse" | "notSelected"`), `error`, and `finish`. It is called synchronously, on the search's hot path, so a reporter that ships anywhere over a network buffers in `onEvent` and uploads in `flush`, which is awaited once as the run ends — including when it ends by throwing. A reporter that throws is warned about and skipped: observability never fails a run.
305
+
306
+ `candidateAccepted` fires for the seed too, and carries the `candidate` text, its `instanceScores` over the validation set, and its `outputs` under `trackBestOutputs` — the row a candidate put on the frontier, not just the mean. `finish` carries the same for the held-out sweep in `testInstanceScores` and `testOutputs`. In both, `undefined` marks an instance nothing measured — the evaluation policy skipped it, or an infrastructure failure lost it — and reporting one as a zero shows as a regression that never happened.
307
+
308
+ Reporting is observability; persisting a run so it can be resumed is `onCheckpoint`, below.
274
309
 
275
310
  **`onCheckpoint`** runs after seed evaluation and each iteration with a JSON-serializable `GepaSnapshot`. Pass it as `resumeFrom` to continue. A fingerprint prevents resuming with a different seed candidate, instance set, or random seed. Every optimizer here has the same three: `onCheckpoint`, `resumeFrom`, and a `snapshot` on the result. A snapshot handed back as `resumeFrom` is copied, never mutated by the run that continues from it.
276
311
 
@@ -280,7 +315,7 @@ Feedback is end-to-end and every module receives the same string. A metric score
280
315
  import { SimbaOptimizer, buildAdvicePrompt, parseAdvice } from "textopt/simba";
281
316
  ```
282
317
 
283
- SIMBA uses the base `Adapter`: it reads outputs, scores, and feedback and builds its own evidence, so it needs no `makeReflectiveDataset`. `SimbaTask` adds `reflect`, `demoComponents`, `instructionComponents`, `renderDemo`, `buildAdvicePrompt`, `sampler`, `instanceId`, `cache`, `onEvent`, `onCheckpoint`, and `resumeFrom`.
318
+ SIMBA uses the base `Adapter`: it reads outputs, scores, and feedback and builds its own evidence, so it needs no `makeReflectiveDataset`. `SimbaTask` adds `reflect`, `demoComponents`, `instructionComponents`, `renderDemo`, `buildAdvicePrompt`, `sampler`, `instanceId`, `cache`, `reporters`, `onCheckpoint`, and `resumeFrom`.
284
319
 
285
320
  | Option | Default | Effect |
286
321
  | ---------------------- | -------------------- | -------------------------------------------------------------------------- |
@@ -320,7 +355,7 @@ Ported from DSPy's SIMBA with two deliberate changes. A trajectory sample runs o
320
355
  import { BootstrapSearchOptimizer } from "textopt/bootstrap-search";
321
356
  ```
322
357
 
323
- DSPy's `BootstrapFewShotWithRandomSearch`. It uses the base `Adapter` and no reflection model at all: every candidate is assembled from outputs the system itself produced, so the search costs rollouts and nothing else. `BootstrapSearchTask` adds `demoComponents` (required), `renderDemo`, `goldOutput`, `instanceId`, `cache`, `onEvent`, `onCheckpoint`, and `resumeFrom`.
358
+ DSPy's `BootstrapFewShotWithRandomSearch`. It uses the base `Adapter` and no reflection model at all: every candidate is assembled from outputs the system itself produced, so the search costs rollouts and nothing else. `BootstrapSearchTask` adds `demoComponents` (required), `renderDemo`, `goldOutput`, `instanceId`, `cache`, `reporters`, `onCheckpoint`, and `resumeFrom`.
324
359
 
325
360
  | Option | Default | Effect |
326
361
  | ------------------ | ------- | -------------------------------------------------------------------- |
@@ -352,7 +387,7 @@ Unlike DSPy, which bootstraps each predictor separately from the traces of one p
352
387
  import { OproOptimizer, buildOproPrompt } from "textopt/opro";
353
388
  ```
354
389
 
355
- OPRO uses the base `Adapter`. `OproTask` adds `reflect` and optional `renderDatum`, `instanceId`, `cache`, and `onEvent` fields.
390
+ OPRO uses the base `Adapter`. `OproTask` adds `reflect` and optional `renderDatum`, `instanceId`, `cache`, and `reporters` fields.
356
391
 
357
392
  | Option | Default | Effect |
358
393
  | -------------------- | ----------------- | ---------------------------------------------------------- |
@@ -399,7 +434,7 @@ For multi-component candidates, each attempt records the other components presen
399
434
  import { MiproOptimizer, proposeConfiguration } from "textopt/mipro";
400
435
  ```
401
436
 
402
- MIPRO uses the base `Adapter`. `MiproTask` adds `reflect`, `componentOptions`, `renderDatum`, `batchSampler`, `instanceId`, `cache`, and `onEvent`.
437
+ MIPRO uses the base `Adapter`. `MiproTask` adds `reflect`, `componentOptions`, `renderDatum`, `batchSampler`, `instanceId`, `cache`, and `reporters`.
403
438
 
404
439
  | Option | Default | Effect |
405
440
  | -------------------------- | ------------------ | ---------------------------------------------------------------------------------- |
@@ -474,13 +509,18 @@ The paraphrase prompt receives no score or feedback. Compare random search with
474
509
  ```ts
475
510
  import { createFileCache } from "textopt/file-cache";
476
511
 
477
- const cache = createFileCache({ path: ".textopt/scores.jsonl" });
512
+ const cache = createFileCache({
513
+ path: ".textopt/scores.jsonl",
514
+ namespace: "gpt-4o-mini@t0/scorer-v3",
515
+ });
478
516
  ```
479
517
 
480
518
  An `EvaluationCache` that outlives the process, as an append-only JSONL log. A long run against a real provider is measured in hours and dollars, and an in-memory cache throws all of it away when the run ends.
481
519
 
482
520
  Append-only rather than rewritten: a score is never invalidated, because the key names the candidate, the instance, and the environment — and a log survives a process killed mid-write, which a file rewritten in place does not. A record that does not parse is dropped rather than fatal, since the last line of an interrupted log is routinely half-written. Later records win, so a re-measured instance replaces its earlier reading.
483
521
 
522
+ `namespace` is required and scopes every key to the system the scores measure — model id, decoding settings, scorer version. It is not optional the way `cacheNamespace` is, because this log outlives every part of that system a key does not name: an alias the provider upgraded under you, an edited scorer. Entries written under one namespace are never served to a run under another. Change it whenever anything outside the candidate text changes.
523
+
484
524
  `maxEntries` (default 1,000,000) bounds what is held in memory; the file itself is never trimmed. `entries()` is deliberately absent — it exists so a checkpoint can carry scores that would otherwise be lost, and these are already on disk.
485
525
 
486
526
  This is the only entry point that imports `node:fs`.
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_evaluation = require("../evaluation-OZOp6TB7.cjs");
3
- const require_demos = require("../demos-B9BJiNKz.cjs");
2
+ const require_warnings = require("../warnings-CWRJF-jA.cjs");
3
+ const require_demos = require("../demos-9v5ts7F3.cjs");
4
4
  const require_rng = require("../rng-DbA_rPIo.cjs");
5
5
  //#region src/bootstrap-search/optimize.ts
6
6
  const DEFAULT_CANDIDATES = 16;
@@ -34,21 +34,31 @@ const DEFAULT_MAX_LABELED_DEMOS = 16;
34
34
  var BootstrapSearchOptimizer = class {
35
35
  #config;
36
36
  constructor(config = {}) {
37
+ assertBootstrapSearchConfig(config);
37
38
  this.#config = config;
38
39
  }
39
- optimize(task) {
40
- return run({
41
- config: this.#config,
42
- task
43
- });
40
+ async optimize(task) {
41
+ try {
42
+ return await run({
43
+ config: this.#config,
44
+ task
45
+ });
46
+ } finally {
47
+ await require_warnings.flushReporters(task.reporters ?? []);
48
+ }
44
49
  }
45
50
  };
46
51
  async function run(args) {
47
52
  const { config, task } = args;
48
- const { candidates: shuffledHarvests = DEFAULT_CANDIDATES, maxDemos = DEFAULT_MAX_DEMOS, minDemos = DEFAULT_MIN_DEMOS, maxLabeledDemos = DEFAULT_MAX_LABELED_DEMOS, demoMinScore, stopAtScore, seed = 0, trackBestOutputs = false, checkpointCache = true } = config;
49
- const { seedCandidate, trainingSet, validationSet = trainingSet, testSet, adapter, demoComponents, renderDemo, goldOutput, maxMetricCalls, cache, cacheNamespace, retry, maxCostUsd, maxWallClockMs, instanceId = defaultInstanceId, onEvent, onCheckpoint, resumeFrom, signal } = task;
50
- const deadline = require_evaluation.createDeadline({ maxWallClockMs });
51
- const components = require_evaluation.componentNames(seedCandidate);
53
+ const { candidates: shuffledHarvests = DEFAULT_CANDIDATES, maxDemos = DEFAULT_MAX_DEMOS, minDemos = DEFAULT_MIN_DEMOS, maxLabeledDemos = DEFAULT_MAX_LABELED_DEMOS, demoMinScore, stopAtScore, concurrency = 1, seed = 0, trackBestOutputs = false, checkpointCache = true } = config;
54
+ const { seedCandidate, trainingSet, validationSet: requestedValidationSet, testSet, adapter, demoComponents, renderDemo, goldOutput, maxMetricCalls, cache, cacheNamespace, retry, maxCostUsd, maxWallClockMs, instanceId = require_warnings.defaultInstanceId, reporters = [], onCheckpoint, resumeFrom, signal } = task;
55
+ const { validationSet, warnings } = require_warnings.resolveValidationSet({
56
+ validationSet: requestedValidationSet,
57
+ trainingSet
58
+ });
59
+ const emit = require_warnings.createEmitter(reporters);
60
+ const deadline = require_warnings.createDeadline({ maxWallClockMs });
61
+ const components = require_warnings.componentNames(seedCandidate);
52
62
  if (trainingSet.length === 0) throw new Error("optimize requires a non-empty trainingSet");
53
63
  if (validationSet.length === 0) throw new Error("optimize requires a non-empty validationSet");
54
64
  if (demoComponents.length === 0) throw new Error("optimize requires at least one demoComponent: this search has nothing to put demonstrations in otherwise");
@@ -61,7 +71,7 @@ async function run(args) {
61
71
  datum,
62
72
  index
63
73
  })) ?? [];
64
- const fingerprint = require_evaluation.runFingerprint({
74
+ const fingerprint = require_warnings.runFingerprint({
65
75
  seedCandidate,
66
76
  trainingIds: trainingSet.map((datum, index) => instanceId({
67
77
  datum,
@@ -71,17 +81,17 @@ async function run(args) {
71
81
  seed,
72
82
  ...cacheNamespace === void 0 ? {} : { cacheNamespace }
73
83
  });
74
- require_evaluation.assertResumable({
84
+ require_warnings.assertResumable({
75
85
  fingerprint,
76
86
  ...resumeFrom === void 0 ? {} : { snapshot: resumeFrom }
77
87
  });
78
88
  const rng = require_rng.createSeededRng(seed, resumeFrom?.rngState);
79
- const budget = require_evaluation.createBudget({
89
+ const budget = require_warnings.createBudget({
80
90
  maxMetricCalls,
81
91
  spent: resumeFrom?.metricCalls ?? 0
82
92
  });
83
- const evaluationCache = cache === false ? void 0 : cache ?? require_evaluation.createMemoryCache();
84
- const evaluator = require_evaluation.createEvaluator({
93
+ const evaluationCache = cache === false ? void 0 : cache ?? require_warnings.createMemoryCache();
94
+ const evaluator = require_warnings.createEvaluator({
85
95
  adapter,
86
96
  budget,
87
97
  ...retry === void 0 ? {} : { retry },
@@ -89,8 +99,9 @@ async function run(args) {
89
99
  ...evaluationCache === void 0 ? {} : { cache: evaluationCache },
90
100
  trackOutputs: trackBestOutputs,
91
101
  cacheHits: resumeFrom?.cacheHits ?? 0,
102
+ ...resumeFrom?.usage === void 0 ? {} : { usage: resumeFrom.usage },
92
103
  ...signal === void 0 ? {} : { signal },
93
- onEvaluation: (event) => onEvent?.({
104
+ onEvaluation: (event) => emit({
94
105
  type: "evaluation",
95
106
  ...event
96
107
  })
@@ -100,7 +111,7 @@ async function run(args) {
100
111
  let drawn = resumeFrom?.drawn ?? 0;
101
112
  let bootstrapMetricCalls = resumeFrom?.bootstrapMetricCalls ?? 0;
102
113
  let stopReason = "candidatesExhausted";
103
- onEvent?.({
114
+ emit({
104
115
  type: "start",
105
116
  components,
106
117
  validationSetSize: validationSet.length
@@ -116,13 +127,25 @@ async function run(args) {
116
127
  iteration: evaluated.length
117
128
  });
118
129
  }
119
- const seedScore = resumeFrom?.seedScore ?? require_evaluation.requireMeasuredMean({
120
- batch: await sweep(seedCandidate, "seed"),
130
+ const seedEvaluation = resumeFrom === void 0 ? await sweep(seedCandidate, "seed") : void 0;
131
+ const seedScore = seedEvaluation === void 0 ? resumeFrom.seedScore : require_warnings.requireMeasuredMean({
132
+ batch: seedEvaluation,
121
133
  phase: "seed"
122
134
  });
123
135
  let best = resumeFrom?.best ?? seedCandidate;
124
136
  let bestScore = resumeFrom?.bestScore ?? seedScore;
125
137
  let bestOutputs;
138
+ let acceptedCandidates = resumeFrom?.acceptedCandidates ?? 0;
139
+ if (seedEvaluation !== void 0) emit({
140
+ type: "candidateAccepted",
141
+ source: "zeroShot",
142
+ demos: 0,
143
+ candidateId: 0,
144
+ candidate: seedCandidate,
145
+ aggregateScore: seedScore,
146
+ instanceScores: require_warnings.instanceRow(seedEvaluation),
147
+ ...trackBestOutputs ? { outputs: seedEvaluation.outputs } : {}
148
+ });
126
149
  function takeSnapshot() {
127
150
  const cached = checkpointCache ? evaluationCache?.entries?.() : void 0;
128
151
  return {
@@ -136,6 +159,8 @@ async function run(args) {
136
159
  metricCalls: budget.spent(),
137
160
  bootstrapMetricCalls,
138
161
  cacheHits: evaluator.cacheHits(),
162
+ usage: evaluator.usage(),
163
+ acceptedCandidates,
139
164
  rngState: rng.state(),
140
165
  ...cached === void 0 ? {} : { cache: cached }
141
166
  };
@@ -148,12 +173,13 @@ async function run(args) {
148
173
  shuffledHarvests,
149
174
  labeled: goldOutput !== void 0
150
175
  });
151
- for (; drawn < plan.length; drawn += 1) {
176
+ const waveSize = stopAtScore === void 0 ? concurrency : 1;
177
+ while (drawn < plan.length) {
152
178
  if (signal?.aborted) {
153
179
  stopReason = "aborted";
154
180
  break;
155
181
  }
156
- if (require_evaluation.costExhausted({
182
+ if (require_warnings.costExhausted({
157
183
  usage: evaluator.usage(),
158
184
  maxCostUsd
159
185
  })) {
@@ -164,58 +190,95 @@ async function run(args) {
164
190
  stopReason = "deadlineReached";
165
191
  break;
166
192
  }
167
- if (!budget.canAfford(validationSet.length + 1)) {
168
- stopReason = "budgetExhausted";
169
- break;
170
- }
171
- const source = plan[drawn];
172
- const block = await buildBlock(source);
173
- const candidate = withDemos(block);
174
- let evaluation;
175
- try {
176
- evaluation = await sweep(candidate, "validation");
177
- } catch (err) {
178
- if (err instanceof require_evaluation.BudgetExhausted) {
179
- stopReason = "budgetExhausted";
180
- break;
181
- }
182
- if (signal?.aborted) {
183
- stopReason = "aborted";
193
+ const wave = [];
194
+ const inFlight = /* @__PURE__ */ new Map();
195
+ let waveStop;
196
+ while (wave.length < waveSize && drawn + wave.length < plan.length) {
197
+ if (!budget.canAfford(validationSet.length + 1)) {
198
+ waveStop = "budgetExhausted";
184
199
  break;
185
200
  }
186
- throw err;
201
+ const source = plan[drawn + wave.length];
202
+ const block = await buildBlock(source);
203
+ const candidate = withDemos(block);
204
+ const key = require_warnings.candidateHash(candidate);
205
+ const prior = inFlight.get(key);
206
+ const sweeping = prior === void 0 ? settled(sweep(candidate, "validation")) : prior.then(() => settled(sweep(candidate, "validation")));
207
+ inFlight.set(key, sweeping);
208
+ wave.push({
209
+ source,
210
+ candidate,
211
+ block,
212
+ sweep: sweeping
213
+ });
187
214
  }
188
- const score = require_evaluation.measuredMean(evaluation);
189
- if (score === void 0) continue;
190
- const accepted = score > bestScore;
191
- evaluated.push({
192
- candidate,
193
- source,
194
- demos: countDemos(block),
195
- score
196
- });
197
- onEvent?.({
198
- type: "candidate",
199
- index: evaluated.length - 1,
200
- source,
201
- demos: countDemos(block),
202
- score,
203
- accepted
204
- });
205
- if (accepted) {
206
- best = candidate;
207
- bestScore = score;
208
- bestOutputs = evaluation.outputs;
215
+ let waveError;
216
+ let draining = false;
217
+ for (const entry of wave) {
218
+ const outcome = await entry.sweep;
219
+ if (draining) continue;
220
+ drawn += 1;
221
+ if (outcome.failed === true) {
222
+ draining = true;
223
+ if (outcome.err instanceof require_warnings.BudgetExhausted) {
224
+ waveStop = "budgetExhausted";
225
+ continue;
226
+ }
227
+ if (signal?.aborted) {
228
+ waveStop = "aborted";
229
+ continue;
230
+ }
231
+ waveError = outcome.err;
232
+ continue;
233
+ }
234
+ const evaluation = outcome.evaluation;
235
+ const score = require_warnings.measuredMean(evaluation);
236
+ if (score === void 0) continue;
237
+ const accepted = score > bestScore;
238
+ evaluated.push({
239
+ candidate: entry.candidate,
240
+ source: entry.source,
241
+ demos: countDemos(entry.block),
242
+ score
243
+ });
244
+ emit({
245
+ type: "candidate",
246
+ index: evaluated.length - 1,
247
+ source: entry.source,
248
+ demos: countDemos(entry.block),
249
+ score,
250
+ accepted
251
+ });
252
+ if (accepted) {
253
+ acceptedCandidates += 1;
254
+ emit({
255
+ type: "candidateAccepted",
256
+ source: entry.source,
257
+ demos: countDemos(entry.block),
258
+ candidateId: acceptedCandidates,
259
+ candidate: entry.candidate,
260
+ aggregateScore: score,
261
+ instanceScores: require_warnings.instanceRow(evaluation),
262
+ ...trackBestOutputs ? { outputs: evaluation.outputs } : {}
263
+ });
264
+ best = entry.candidate;
265
+ bestScore = score;
266
+ bestOutputs = evaluation.outputs;
267
+ }
268
+ if (stopAtScore !== void 0 && score >= stopAtScore) {
269
+ waveStop = "scoreReached";
270
+ draining = true;
271
+ }
209
272
  }
273
+ if (waveError !== void 0) throw waveError;
210
274
  await checkpoint();
211
- if (stopAtScore !== void 0 && score >= stopAtScore) {
212
- stopReason = "scoreReached";
213
- drawn += 1;
275
+ if (waveStop !== void 0) {
276
+ stopReason = waveStop;
214
277
  break;
215
278
  }
216
279
  }
217
280
  if (signal?.aborted) stopReason = "aborted";
218
- const testScore = testSet === void 0 ? void 0 : require_evaluation.measuredMean(await evaluator.evaluate({
281
+ const heldOut = testSet === void 0 ? void 0 : await evaluator.evaluate({
219
282
  candidate: best,
220
283
  batch: testSet,
221
284
  ids: testIds,
@@ -224,13 +287,18 @@ async function run(args) {
224
287
  candidateId: null,
225
288
  iteration: evaluated.length,
226
289
  charge: false
227
- }));
228
- onEvent?.({
290
+ });
291
+ const testScore = heldOut === void 0 ? void 0 : require_warnings.measuredMean(heldOut);
292
+ emit({
229
293
  type: "finish",
230
294
  reason: stopReason,
295
+ warnings,
296
+ bestCandidateId: acceptedCandidates,
231
297
  bestScore,
232
298
  metricCalls: budget.spent(),
233
- ...testScore === void 0 ? {} : { testScore }
299
+ ...testScore === void 0 ? {} : { testScore },
300
+ ...heldOut === void 0 ? {} : { testInstanceScores: require_warnings.instanceRow(heldOut) },
301
+ ...heldOut === void 0 || !trackBestOutputs ? {} : { testOutputs: heldOut.outputs }
234
302
  });
235
303
  return {
236
304
  bestCandidate: best,
@@ -245,8 +313,10 @@ async function run(args) {
245
313
  ...trackBestOutputs && bestOutputs !== void 0 ? { bestOutputs } : {},
246
314
  ...testScore === void 0 ? {} : {
247
315
  testScore,
248
- testMetricCalls: testSet?.length ?? 0
316
+ testMetricCalls: evaluator.unchargedCalls(),
317
+ testUsage: evaluator.unchargedUsage()
249
318
  },
319
+ warnings,
250
320
  stopReason
251
321
  };
252
322
  function withDemos(block) {
@@ -260,19 +330,21 @@ async function run(args) {
260
330
  const requested = source === "unshuffled" ? maxDemos : minDemos + rng.nextInt(Math.max(1, maxDemos - minDemos + 1));
261
331
  const affordable = Math.min(trainingSet.length, budget.remaining() - validationSet.length);
262
332
  if (affordable < 1) return "";
263
- const harvest = await require_demos.bootstrapDemos({
333
+ const harvest = await require_demos.harvestFewShotExamples({
264
334
  adapter,
265
335
  candidate: seedCandidate,
266
336
  trainingSet,
267
337
  ...demoMinScore === void 0 ? {} : { minScore: demoMinScore },
268
338
  maxDemos: requested,
269
339
  maxMetricCalls: affordable,
340
+ ...maxCostUsd === void 0 ? {} : { maxCostUsd: maxCostUsd - evaluator.usage().costUsd },
270
341
  ...source === "unshuffled" ? {} : { rng },
271
342
  ...renderDemo === void 0 ? {} : { renderDemo },
272
343
  ...signal === void 0 ? {} : { signal }
273
344
  });
274
345
  bootstrapMetricCalls += harvest.metricCalls;
275
346
  budget.reserve(harvest.metricCalls);
347
+ evaluator.absorbUsage(harvest.usage);
276
348
  return harvest.block;
277
349
  }
278
350
  function labeledBlock() {
@@ -298,11 +370,25 @@ function candidatePlan(args) {
298
370
  ...Array.from({ length: shuffledHarvests }, () => "bootstrapped")
299
371
  ];
300
372
  }
373
+ /** Turns a sweep into a value, so a dispatched one never rejects unobserved. */
374
+ function settled(sweeping) {
375
+ return sweeping.then((evaluation) => ({ evaluation }), (err) => ({
376
+ failed: true,
377
+ err
378
+ }));
379
+ }
380
+ /**
381
+ * Range checks on the search knobs, run at construction so a configuration
382
+ * that could never terminate is refused before a task is ever handed to it.
383
+ */
384
+ function assertBootstrapSearchConfig(config) {
385
+ const { concurrency = 1 } = config;
386
+ if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error(`concurrency must be a positive integer, received ${concurrency}`);
387
+ }
301
388
  function countDemos(block) {
302
389
  return block.split("<demo>").length - 1;
303
390
  }
304
- function defaultInstanceId(args) {
305
- return String(args.index);
306
- }
307
391
  //#endregion
308
392
  exports.BootstrapSearchOptimizer = BootstrapSearchOptimizer;
393
+ exports.isCandidateAccepted = require_warnings.isCandidateAccepted;
394
+ exports.isRunFinished = require_warnings.isRunFinished;