textopt 0.1.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.
- package/README.md +24 -6
- package/dist/bootstrap-search/index.cjs +29 -23
- package/dist/bootstrap-search/index.d.cts +3 -3
- package/dist/bootstrap-search/index.d.mts +3 -3
- package/dist/bootstrap-search/index.mjs +9 -3
- package/dist/{demos-D2o0qSSo.cjs → demos-9v5ts7F3.cjs} +4 -4
- package/dist/{demos-1b7JiUue.d.mts → demos-ASsSXYXA.d.mts} +1 -1
- package/dist/{demos-DE2oxNWX.mjs → demos-Brobjfuc.mjs} +1 -1
- package/dist/{demos-CU9dy2oT.d.cts → demos-ByaLZy-Z.d.cts} +1 -1
- package/dist/file-cache.cjs +16 -5
- package/dist/file-cache.d.cts +13 -0
- package/dist/file-cache.d.mts +13 -0
- package/dist/file-cache.mjs +16 -5
- package/dist/gepa/index.cjs +87 -44
- package/dist/gepa/index.d.cts +8 -6
- package/dist/gepa/index.d.mts +8 -6
- package/dist/gepa/index.mjs +55 -12
- package/dist/index.cjs +44 -19
- package/dist/index.d.cts +37 -5
- package/dist/index.d.mts +37 -5
- package/dist/index.mjs +29 -6
- package/dist/mipro/index.cjs +31 -25
- package/dist/mipro/index.d.cts +3 -3
- package/dist/mipro/index.d.mts +3 -3
- package/dist/mipro/index.mjs +9 -3
- package/dist/opro/index.cjs +31 -25
- package/dist/opro/index.d.cts +2 -2
- package/dist/opro/index.d.mts +2 -2
- package/dist/opro/index.mjs +8 -2
- package/dist/{optimizer-Bh5DPRMH.d.cts → optimizer-4Zv-Zt2t.d.cts} +41 -2
- package/dist/{optimizer-Ck6-e_8o.d.mts → optimizer-Ds5mzYjz.d.mts} +41 -2
- package/dist/random-search/index.cjs +27 -21
- package/dist/random-search/index.d.cts +2 -2
- package/dist/random-search/index.d.mts +2 -2
- package/dist/random-search/index.mjs +8 -2
- package/dist/{reflection-LRaAZP4e.d.mts → reflection-CMezGu6u.d.mts} +32 -4
- package/dist/{reflection-Dt3QrXhM.d.cts → reflection-D0A7eahD.d.cts} +32 -4
- package/dist/{reporting-DQbAohc9.d.cts → reporting-bq007_2z.d.cts} +55 -1
- package/dist/{reporting-DQbAohc9.d.mts → reporting-bq007_2z.d.mts} +55 -1
- package/dist/simba/index.cjs +31 -25
- package/dist/simba/index.d.cts +24 -5
- package/dist/simba/index.d.mts +24 -5
- package/dist/simba/index.mjs +9 -3
- package/dist/testing.d.cts +2 -2
- package/dist/testing.d.mts +2 -2
- package/dist/{reporting-CNHzbJC-.cjs → warnings-CWRJF-jA.cjs} +63 -0
- package/dist/{reporting-DY-DC4HG.mjs → warnings-OxvDi9kN.mjs} +52 -1
- package/docs/adapters.md +169 -0
- package/docs/benchmark.md +90 -0
- package/docs/data-prep.md +113 -0
- package/docs/distillation.md +128 -0
- package/docs/evaluation.md +87 -0
- package/docs/metric-preflight.md +132 -0
- package/docs/optimizers.md +293 -0
- package/docs/tuning.md +130 -0
- package/package.json +3 -2
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Measuring a result
|
|
2
|
+
|
|
3
|
+
Two questions a finished run cannot answer about itself: how much of its score
|
|
4
|
+
is fitted to the set that picked the winner, and whether it beat another
|
|
5
|
+
optimizer by more than noise.
|
|
6
|
+
|
|
7
|
+
## What a run says about itself
|
|
8
|
+
|
|
9
|
+
`result.warnings` is what a run could see about its own measurement and its
|
|
10
|
+
numbers could not say. It is never fatal and never empty of meaning: an entry is
|
|
11
|
+
a reason to read `bestScore` as less than it appears.
|
|
12
|
+
|
|
13
|
+
| Code | What it means |
|
|
14
|
+
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
15
|
+
| `validationSetReusesTraining` | No `validationSet` was given, so selection ran on the instances reflection read. Pass one, or `"reuseTraining"` to accept it |
|
|
16
|
+
| `seedScoreSaturated` | The seed already scores perfectly on every validation instance, so every proposal ties and acceptance resolves noise |
|
|
17
|
+
| `seedScoreFloored` | The seed scores 0 on every validation instance — a seed with everything to gain, or a metric that scores nothing |
|
|
18
|
+
|
|
19
|
+
The last two are read once, off the seed's own validation row, before the search
|
|
20
|
+
spends anything. Both describe a metric that does not separate the instances it
|
|
21
|
+
is being asked to rank candidates by, which no amount of search budget fixes.
|
|
22
|
+
|
|
23
|
+
The same list rides on the `finish` event, so a reporter writing a score
|
|
24
|
+
somewhere permanent writes the caveat beside it rather than leaving it in a
|
|
25
|
+
console nobody kept.
|
|
26
|
+
|
|
27
|
+
## Held-out evaluation
|
|
28
|
+
|
|
29
|
+
The optimizer selects candidates against `validationSet`, so `bestScore` is fitted to that set and may overstate performance on unseen data.
|
|
30
|
+
|
|
31
|
+
Pass a `testSet` and the winner is scored on it once, after the search is over:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
const result = await optimizer.optimize({
|
|
35
|
+
seedCandidate,
|
|
36
|
+
trainingSet,
|
|
37
|
+
validationSet,
|
|
38
|
+
testSet, // never seen by the search
|
|
39
|
+
adapter,
|
|
40
|
+
reflect,
|
|
41
|
+
maxMetricCalls: 300,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
result.bestScore; // on the validation set — the search selected for this
|
|
45
|
+
result.testScore; // on instances no candidate was ever selected against
|
|
46
|
+
result.testMetricCalls; // charged separately, not against maxMetricCalls
|
|
47
|
+
result.testUsage; // and costed separately, outside maxCostUsd
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The gap between `bestScore` and `testScore` estimates validation overfitting. Test rollouts are reported separately and are outside every ceiling: they do not count against `maxMetricCalls`, and their tokens are in `testUsage` rather than `usage`, because the sweep runs after the search has already stopped. Budget for it the way you would budget for one full validation sweep. The resume fingerprint ignores `testSet`, so it can be added when resuming a run.
|
|
51
|
+
|
|
52
|
+
All optimizers expose these fields through `OptimizerTask` and `OptimizerResult`.
|
|
53
|
+
|
|
54
|
+
## Reading the winning text
|
|
55
|
+
|
|
56
|
+
`result.bestCandidate` is a prompt. That it is readable is the entire advantage over tuning weights, and it is the check no metric performs. Look for:
|
|
57
|
+
|
|
58
|
+
- **Absorbed facts.** Specific names, numbers, or dates from validation instances written into the instruction. That is memorisation, and it will not transfer, whatever `testScore` said.
|
|
59
|
+
- **Accreted rules.** A long tail of narrow "if the input mentions X, do Y" clauses is a search patching instances rather than learning the task.
|
|
60
|
+
- **Instructions that contradict.** Reflective search appends; nothing prunes.
|
|
61
|
+
|
|
62
|
+
If the prompt looks wrong and the number looks good, believe the prompt.
|
|
63
|
+
|
|
64
|
+
## Comparing optimizers
|
|
65
|
+
|
|
66
|
+
A difference in means over a handful of seeds is usually noise. `compare()` runs each entrant over the same seeds, ranks them on `testScore` where a run reports one, and reports a paired sign-flip p-value against the winner:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { compare } from "textopt";
|
|
70
|
+
|
|
71
|
+
const comparison = await compare({
|
|
72
|
+
seeds: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
|
73
|
+
concurrency: 4,
|
|
74
|
+
entrants: {
|
|
75
|
+
gepa: ({ seed }) => new GepaOptimizer({ seed }).optimize(task()),
|
|
76
|
+
opro: ({ seed }) => new OproOptimizer({ seed }).optimize(task()),
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
comparison.winner; // highest mean score
|
|
81
|
+
comparison.summaries; // mean, sd, min, max, rollouts, cost, pValueVsWinner
|
|
82
|
+
comparison.runs; // every individual run
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Entrants are functions of a seed, not optimizer instances: the seed is constructor config, and every optimizer here is deterministic given one, so comparing two entrants at a single seed compares two anecdotes. Build a fresh task inside each entrant — a shared reflection model with internal state would make each result depend on the runs before it.
|
|
86
|
+
|
|
87
|
+
Ranking on `testScore` matters. The validation score is the number the search selected against for its whole run, so an entrant that overfits looks strongest on exactly the number it fitted.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Validating a metric before a search runs
|
|
2
|
+
|
|
3
|
+
Three things have to be true before any number a run reports means anything:
|
|
4
|
+
|
|
5
|
+
- the splits must not leak — see [Preparing the data](./data-prep.md)
|
|
6
|
+
- the metric must have range: it must separate candidates, and move over an
|
|
7
|
+
interval wide enough to see
|
|
8
|
+
- its noise must be smaller than the effect you are looking for
|
|
9
|
+
|
|
10
|
+
They are preconditions, not a checklist. Everything downstream — which
|
|
11
|
+
optimizer, which minibatch size, which acceptance test — is reasoning stacked
|
|
12
|
+
on an instrument, and reasoning about the instrument is cheaper than every
|
|
13
|
+
argument it supports. All three are checkable in an hour, without an optimizer.
|
|
14
|
+
|
|
15
|
+
The four checks below establish them, in the order to run them. Do not start a
|
|
16
|
+
real run until they pass.
|
|
17
|
+
|
|
18
|
+
## 1. Discrimination on known controls
|
|
19
|
+
|
|
20
|
+
The highest-value check and the one almost nobody runs.
|
|
21
|
+
|
|
22
|
+
Write three to five candidates whose ranking you already know:
|
|
23
|
+
|
|
24
|
+
- the seed
|
|
25
|
+
- one deliberately sabotaged — drop the constraint you care most about
|
|
26
|
+
- one hand-tuned, as good as you can write by hand
|
|
27
|
+
- optionally: an empty instruction, and one that is verbose but wrong
|
|
28
|
+
|
|
29
|
+
Score each on a slice of the validation set and compare the ranking to your
|
|
30
|
+
own. If the metric does not reproduce it, no search will find anything. It
|
|
31
|
+
will wander, accept noise, and report a number.
|
|
32
|
+
|
|
33
|
+
This is also the fastest way to discover reward hacking before the search
|
|
34
|
+
does. A verbose-but-wrong candidate that outranks a terse correct one tells you
|
|
35
|
+
the judge is paying for length.
|
|
36
|
+
|
|
37
|
+
## 2. Test-retest variance
|
|
38
|
+
|
|
39
|
+
Score the _same_ candidate over the _same_ instances three times, with caching
|
|
40
|
+
off. The per-instance spread is the metric's own noise.
|
|
41
|
+
|
|
42
|
+
That number decides two things the defaults cannot decide for you:
|
|
43
|
+
|
|
44
|
+
- whether `pairedPermutationAcceptance` and `lowerBoundEvaluationPolicy` are
|
|
45
|
+
worth their cost. Turn them on when you have measured large variance, not on
|
|
46
|
+
principle — on the benchmark's pipeline task the pair loses to plain GEPA at
|
|
47
|
+
p = 0.027, because a significance bar on every acceptance is expensive when
|
|
48
|
+
improvements to one component only pay off after another is finished
|
|
49
|
+
- how wide a minibatch has to be to say anything. A sign-flip test over n
|
|
50
|
+
instances bottoms out at a p-value of 2^-n
|
|
51
|
+
|
|
52
|
+
Deterministic metric, zero variance: keep the defaults.
|
|
53
|
+
|
|
54
|
+
## 3. Spread, not ceiling or floor
|
|
55
|
+
|
|
56
|
+
Look at the distribution of the seed's per-instance scores, not its mean.
|
|
57
|
+
|
|
58
|
+
- **Everything at the ceiling.** Nothing to improve; every proposal ties and
|
|
59
|
+
acceptance resolves ties by whatever the noise did. A run reports
|
|
60
|
+
`seedScoreSaturated`.
|
|
61
|
+
- **Everything at zero.** Ambiguous. It is what a seed with everything to gain
|
|
62
|
+
looks like _and_ what a broken metric looks like, and the score cannot tell
|
|
63
|
+
them apart — check 1 can. Reported as `seedScoreFloored`.
|
|
64
|
+
- **Everything identical in the middle.** The metric is measuring something the
|
|
65
|
+
candidate does not control.
|
|
66
|
+
|
|
67
|
+
What you want is real per-instance variation. GEPA's frontier is taken over
|
|
68
|
+
validation instances, so a seed row with no spread makes the frontier
|
|
69
|
+
degenerate before the search starts.
|
|
70
|
+
|
|
71
|
+
Then ask the same question of the scale itself. A judge's aggregate is bounded
|
|
72
|
+
by 0 and 1, but the interval it actually moves in on realistic candidates is
|
|
73
|
+
usually narrower, and it is the realized span — not the theoretical one — that
|
|
74
|
+
every later number is denominated in. Score your controls from check 1, take
|
|
75
|
+
the highest and the lowest, and write the difference down. If the best
|
|
76
|
+
candidate you can write by hand scores 1.0 and the sabotaged one scores 0.6,
|
|
77
|
+
your instrument has a span of 0.4, and an improvement that reads as "+0.02" is
|
|
78
|
+
5% of the available range rather than 2% of it.
|
|
79
|
+
|
|
80
|
+
A narrow span is not automatically wrong, but it is always worth an
|
|
81
|
+
explanation. The common cause is a requirement enforced twice: see the note on
|
|
82
|
+
`gate` below.
|
|
83
|
+
|
|
84
|
+
## 4. Agreement with your own labels
|
|
85
|
+
|
|
86
|
+
Hand-label 30–50 outputs — good, bad, and the ambiguous middle — then have the
|
|
87
|
+
judge grade the same ones and compare.
|
|
88
|
+
|
|
89
|
+
If the judge cannot reproduce your labels, the search is optimizing the judge's
|
|
90
|
+
idiosyncrasies rather than the task. That is still optimization, and the number
|
|
91
|
+
will still climb.
|
|
92
|
+
|
|
93
|
+
Use the disagreements to rewrite the criteria. A criterion the judge and you
|
|
94
|
+
read differently is a criterion description problem, not a model problem.
|
|
95
|
+
|
|
96
|
+
## What to fix, in order
|
|
97
|
+
|
|
98
|
+
| Finding | Fix |
|
|
99
|
+
| --------------------------------------- | ----------------------------------------------------------------------- |
|
|
100
|
+
| Ranking of controls is wrong | The criteria. Nothing downstream is worth doing until this passes |
|
|
101
|
+
| Judge rewards length or confident tone | Add an explicit brevity criterion; make correctness the higher `weight` |
|
|
102
|
+
| A hard requirement can be averaged away | Give it a `gate`, and keep its `weight` low — see below |
|
|
103
|
+
| Realized span is much narrower than 0–1 | Look for a requirement enforced twice, by a `gate` and a heavy `weight` |
|
|
104
|
+
| Variance is large | Widen the minibatch first, then consider the significance guards |
|
|
105
|
+
| Seed saturated | A harder validation set, or a metric that separates these instances |
|
|
106
|
+
| Seed floored and controls do not rank | The metric is broken, not the seed |
|
|
107
|
+
| Judge disagrees with your labels | Rewrite the criterion descriptions; re-check against the same labels |
|
|
108
|
+
|
|
109
|
+
## Enforce a requirement once
|
|
110
|
+
|
|
111
|
+
`gate` and `weight` are different instruments and it is tempting to reach for
|
|
112
|
+
both. A gate makes a requirement non-negotiable: below the threshold the
|
|
113
|
+
instance scores 0 whatever else it did. Once a gate is doing that, a heavy
|
|
114
|
+
weight on the same criterion buys nothing — every candidate that clears the
|
|
115
|
+
gate satisfies the requirement, so the weight is only redistributing score
|
|
116
|
+
among candidates that already agree.
|
|
117
|
+
|
|
118
|
+
What it costs is range. A heavy weight on a criterion nearly every surviving
|
|
119
|
+
candidate maxes out pins that share of the aggregate near its ceiling, and the
|
|
120
|
+
span left for the search to move in is whatever the other criteria carry.
|
|
121
|
+
Gate the requirement, then weight it low, and let the weights go to the
|
|
122
|
+
criteria that actually vary.
|
|
123
|
+
|
|
124
|
+
The general form: when a second mechanism starts enforcing a constraint, check
|
|
125
|
+
whether the first still needs to.
|
|
126
|
+
|
|
127
|
+
## A note on what this is
|
|
128
|
+
|
|
129
|
+
This is a manual procedure, not a library function. `createJudge` and the
|
|
130
|
+
optimizers give you the pieces; the judgement is yours. If you run these checks
|
|
131
|
+
often against the same task, the natural thing is to script them — a few
|
|
132
|
+
`adapter.evaluate` calls over a fixed batch and a printed table.
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# Optimizers
|
|
2
|
+
|
|
3
|
+
What each search reads, and what its knobs do. See [Tuning a run](./tuning.md)
|
|
4
|
+
for what a run costs and [Benchmark](./benchmark.md) for how the six compare on
|
|
5
|
+
offline tasks.
|
|
6
|
+
|
|
7
|
+
## Choosing an optimizer
|
|
8
|
+
|
|
9
|
+
All six use the same `Optimizer` interface, adapter, and budget accounting.
|
|
10
|
+
|
|
11
|
+
| Optimizer | Required signal | Use it for |
|
|
12
|
+
| -------------------------- | --------------------------------- | ----------------------------------------------------------------- |
|
|
13
|
+
| `GepaOptimizer` | per-instance **textual feedback** | revising text from written explanations of failures |
|
|
14
|
+
| `SimbaOptimizer` | per-instance **textual feedback** | noisy metrics, where the same instance scores differently per run |
|
|
15
|
+
| `OproOptimizer` | a **scalar** score | proposing text from a history of scored attempts |
|
|
16
|
+
| `MiproOptimizer` | a **scalar** score | searching combinations of interacting component options |
|
|
17
|
+
| `BootstrapSearchOptimizer` | a **scalar** score | few-shot demonstrations, with no proposal model at all |
|
|
18
|
+
| `RandomSearchOptimizer` | a **scalar** score | establishing a score-independent paraphrasing baseline |
|
|
19
|
+
|
|
20
|
+
Use GEPA when the metric can explain failures in text. A scalar such as `0.0` gives its reflection step little useful information. OPRO only needs scalar scores: its prompt lists previous attempts by score and asks the model to improve on them.
|
|
21
|
+
|
|
22
|
+
SIMBA also needs textual feedback, but reads a different signal. Instead of reflecting on a failure, it runs several programs over the same instance and reflects on the _contrast_ between the best and worst run of it — a comparison with the input held fixed. That contrast has to exist to be worth paying for, so SIMBA earns its extra rollouts on metrics that vary between runs and wastes them on ones that do not.
|
|
23
|
+
|
|
24
|
+
GEPA and OPRO update components separately. MIPRO instead searches combinations of per-component options, which lets it find options that work well only together. It screens configurations on minibatches and evaluates promising ones against the full validation set.
|
|
25
|
+
|
|
26
|
+
MIPRO's default multivariate TPE models complete configurations. Set `multivariate: false` to model each component independently; that usually needs fewer observations but cannot model interactions between components.
|
|
27
|
+
|
|
28
|
+
`BootstrapSearchOptimizer` writes no text at all. It harvests demonstrations from rollouts the metric already rewarded and searches over which set of them to keep, so it needs no proposal model and no textual feedback. Try it first when the instruction is roughly right and the output format is not.
|
|
29
|
+
|
|
30
|
+
`RandomSearchOptimizer` paraphrases components without using their scores. Run it as a baseline to check whether reflection improves enough to justify its model calls.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { SimbaOptimizer } from "textopt/simba";
|
|
34
|
+
import { OproOptimizer } from "textopt/opro";
|
|
35
|
+
import { MiproOptimizer } from "textopt/mipro";
|
|
36
|
+
import { BootstrapSearchOptimizer } from "textopt/bootstrap-search";
|
|
37
|
+
import { RandomSearchOptimizer } from "textopt/random-search";
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
[Benchmark](./benchmark.md) reports what each search does on four offline tasks; read it as evidence about the search rather than about your task, and run [`compare()`](./evaluation.md#comparing-optimizers) on your own metric before choosing.
|
|
41
|
+
|
|
42
|
+
## How GEPA works
|
|
43
|
+
|
|
44
|
+
GEPA maintains a pool of candidates and a **Pareto frontier over validation instances**, not objectives. A candidate remains on the frontier while it has the best score for at least one instance. Parent sampling is weighted by the number of instances each candidate wins, preserving candidates with useful strengths even when their mean score is lower.
|
|
45
|
+
|
|
46
|
+
Each iteration:
|
|
47
|
+
|
|
48
|
+
1. **Select** a parent from the frontier, and one or more of its components to update.
|
|
49
|
+
2. **Evaluate** the parent on a fresh minibatch, capturing traces.
|
|
50
|
+
3. **Reflect** on per-component evidence from the scored batch: inputs, outputs, feedback, and scores. Recent rejected proposals are included to reduce repetition.
|
|
51
|
+
4. **Screen** the child on the same minibatch. Only improvements proceed.
|
|
52
|
+
5. **Sweep** accepted children over the validation set and update the frontier.
|
|
53
|
+
6. **Merge** lineages that improved different components. Merge is enabled by default for multi-component candidates.
|
|
54
|
+
|
|
55
|
+
`maxMetricCalls` limits scored rollouts; cache hits do not count. Reflection calls have a separate limit.
|
|
56
|
+
|
|
57
|
+
Based on _GEPA: Reflective Prompt Evolution Can Outperform Reinforcement Learning_.
|
|
58
|
+
|
|
59
|
+
## Configuring GEPA
|
|
60
|
+
|
|
61
|
+
Pass search settings to the `GepaOptimizer` constructor:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
new GepaOptimizer({
|
|
65
|
+
minibatchSize: 3,
|
|
66
|
+
maxIterations: 50,
|
|
67
|
+
seed: 11,
|
|
68
|
+
proposals: { perIteration: 3, concurrency: 3, selection: "best" },
|
|
69
|
+
reflection: { maxCalls: 40, maxRecords: 5, maxCharacters: 20_000 },
|
|
70
|
+
// ...or `strategies: diverseReflectionStrategies()` in place of one prompt
|
|
71
|
+
merge: { enabled: true, maxInvocations: 5 },
|
|
72
|
+
candidateSelector: paretoSelector(),
|
|
73
|
+
acceptance: improvementAcceptance(),
|
|
74
|
+
skipPerfectScore: true,
|
|
75
|
+
rejectedProposalMemory: 3,
|
|
76
|
+
trackBestOutputs: true,
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`optimize` takes a `GepaTask`. Required fields are `seedCandidate`, `trainingSet`, `adapter`, `reflect`, and `maxMetricCalls`. `validationSet` defaults to `trainingSet`; `testSet` is optional and held out. Other options are `componentSelector`, `batchSampler`, `valEvaluationPolicy`, `instanceId`, `cache`, `reporters`, `onCheckpoint`, `resumeFrom`, and `signal`.
|
|
81
|
+
|
|
82
|
+
Component names are inferred from `seedCandidate`. Other positions use `NoInfer`, so misspelled component names fail type checking.
|
|
83
|
+
|
|
84
|
+
`proposals.perIteration` is the setting that moves the bill: each proposal is priced at two minibatch evaluations, its parent's and its own, so raising it from one to three triples what an iteration costs before any child is swept ([Sizing a run](./tuning.md#sizing-a-run)). What that buys is width — every slot draws its own parent and its own minibatch, so the slots diagnose different failures and `concurrency` can run them at once, and `selection: "best"` then promotes only the strongest improving child of the batch. Pair it with `reflection.strategies`, which is what keeps two slots that landed on the same parent from writing the same revision.
|
|
85
|
+
|
|
86
|
+
`textopt/gepa` exports the following strategies: `paretoSelector`, `currentBestSelector`, `epsilonGreedySelector`, `topKParetoSelector`, `roundRobinComponentSelector`, `allComponentsSelector`, `improvementAcceptance`, `pairedPermutationAcceptance`, `fullEvaluationPolicy`, `subsampledEvaluationPolicy`, and `lowerBoundEvaluationPolicy`.
|
|
87
|
+
|
|
88
|
+
### Proposal strategies
|
|
89
|
+
|
|
90
|
+
By default, every proposal uses the same reflection prompt. Multiple calls against one parent can therefore produce similar revisions.
|
|
91
|
+
|
|
92
|
+
`reflection.strategies` rotates over several framings instead, one per proposal slot:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { diverseReflectionStrategies } from "textopt/gepa";
|
|
96
|
+
|
|
97
|
+
new GepaOptimizer({
|
|
98
|
+
proposals: { perIteration: 4, concurrency: 4 },
|
|
99
|
+
reflection: { strategies: diverseReflectionStrategies() },
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The included rotation alternates the standard prompt with prompts that simplify, generalize, or rewrite the candidate. The builders are exported as `buildReflectionPrompt`, `buildSimplifyPrompt`, `buildGeneralizePrompt`, and `buildRewritePrompt`; custom `ReflectionPromptBuilder` functions use the same interface.
|
|
104
|
+
|
|
105
|
+
This behavior is opt-in. The default is `buildReflectionPrompt`, adapted from the GEPA paper's reflection prompt rather than copied from it: it carries the same evidence and asks for the same thing, but tags and wording differ from the reference implementation's template, so proposals drawn from identical evidence will not match it.
|
|
106
|
+
|
|
107
|
+
## Few-shot demos
|
|
108
|
+
|
|
109
|
+
Few-shot examples can be stored in a candidate component and optimized with the other text. textopt can populate that component from successful training rollouts.
|
|
110
|
+
|
|
111
|
+
Before optimization, `harvestFewShotExamples` evaluates the seed candidate on `trainingSet` and keeps high-scoring rollouts:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { harvestFewShotExamples } from "textopt";
|
|
115
|
+
|
|
116
|
+
const { block, demos, metricCalls } = await harvestFewShotExamples({
|
|
117
|
+
adapter,
|
|
118
|
+
candidate: seedCandidate,
|
|
119
|
+
trainingSet,
|
|
120
|
+
minScore: 0.9,
|
|
121
|
+
maxDemos: 4,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const seed = { instruction: "Route the ticket.", demos: block };
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
During optimization, `createDemoProposer` reads demos from the existing reflective dataset without additional rollouts or reflection calls:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { createDemoProposer } from "textopt/gepa";
|
|
131
|
+
|
|
132
|
+
const adapter = {
|
|
133
|
+
...baseAdapter,
|
|
134
|
+
proposeNewTexts: createDemoProposer({
|
|
135
|
+
components: ["demos"],
|
|
136
|
+
minScore: 0.9,
|
|
137
|
+
maxDemos: 4,
|
|
138
|
+
fallback: baseProposer, // writes the other components normally
|
|
139
|
+
}),
|
|
140
|
+
};
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Each proposal appends demos to its parent's block. A demo remains in the lineage only when the candidate containing it is accepted.
|
|
144
|
+
|
|
145
|
+
## The other optimizers
|
|
146
|
+
|
|
147
|
+
Apart from SIMBA, these use the base `Adapter`: they need no reflective dataset. Their results include both `seedScore` and `bestScore`.
|
|
148
|
+
|
|
149
|
+
### SIMBA
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { SimbaOptimizer } from "textopt/simba";
|
|
153
|
+
|
|
154
|
+
const result = await new SimbaOptimizer({
|
|
155
|
+
minibatchSize: 16,
|
|
156
|
+
candidates: 4,
|
|
157
|
+
maxSteps: 8,
|
|
158
|
+
seed: 11,
|
|
159
|
+
}).optimize({
|
|
160
|
+
seedCandidate,
|
|
161
|
+
trainingSet,
|
|
162
|
+
validationSet,
|
|
163
|
+
adapter, // the base Adapter — no makeReflectiveDataset needed
|
|
164
|
+
reflect,
|
|
165
|
+
demoComponents: ["demos"], // optional; enables the appendDemo mutation
|
|
166
|
+
maxMetricCalls: 900, // 250 reserved for finalists, 80 a step, over 50 validation instances
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
result.finalists; // the step winners, scored on the full validation set, best first
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Each step samples several programs from the pool over one minibatch, ranks the instances by how much those programs disagreed, and mutates toward whichever run won. Two mutations are drawn at random per instance:
|
|
173
|
+
|
|
174
|
+
- **`appendDemo`** keeps the winning rollout as a few-shot example. Costs no model call. Requires `demoComponents`.
|
|
175
|
+
- **`appendRule`** shows the better and worse run of the same instance to `reflect` and appends the advice it returns to each instruction component.
|
|
176
|
+
|
|
177
|
+
Neither replaces text, so candidates accumulate; demonstrations are dropped at a Poisson rate so a growing block cannot crowd out the instruction. `strategies` pins the mutation to one of the two.
|
|
178
|
+
|
|
179
|
+
Ported from DSPy's SIMBA with two deliberate changes. A trajectory sample runs one program across the whole minibatch rather than resampling a program per instance, because the adapter owns decoding here and there is no temperature knob to vary — the variability comes from the program pool instead. And the percentile guards are strict rather than inclusive, so a step on which every rollout ties still produces a mutation instead of doing nothing at all.
|
|
180
|
+
|
|
181
|
+
Only the finalists are scored on the full validation set: the step winners are sampled evenly across the run, so early winners stay in the running. Those rollouts are reserved before the search starts, which is why a small `maxMetricCalls` buys fewer steps than the arithmetic suggests — see [Sizing a run](./tuning.md#sizing-a-run) for what the reserve costs.
|
|
182
|
+
|
|
183
|
+
Its batch defaults are wide on purpose: a step reads the disagreement between programs over a batch, and a narrow batch leaves little to rank. When a run has to get cheaper, lower `candidates` first — it shrinks both the step and the reserve, where `minibatchSize` shrinks the step alone and narrows the batch the ranking reads.
|
|
184
|
+
|
|
185
|
+
`concurrency` overlaps the two places a step's work is independent: scoring the candidates it built, and the finalist sweeps at the end. The trajectory samples and the mutations that read them stay in sequence — each reads what the one before it wrote — so the ceiling on what it buys is the reserve and the scoring, not the whole step. See [Concurrency](./tuning.md#concurrency).
|
|
186
|
+
|
|
187
|
+
### Bootstrapped few-shot search
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { BootstrapSearchOptimizer } from "textopt/bootstrap-search";
|
|
191
|
+
|
|
192
|
+
const result = await new BootstrapSearchOptimizer({
|
|
193
|
+
candidates: 16,
|
|
194
|
+
maxDemos: 4,
|
|
195
|
+
seed: 11,
|
|
196
|
+
}).optimize({
|
|
197
|
+
seedCandidate: { instruction, demos: "" },
|
|
198
|
+
trainingSet,
|
|
199
|
+
validationSet,
|
|
200
|
+
adapter,
|
|
201
|
+
demoComponents: ["demos"],
|
|
202
|
+
goldOutput: (datum) => datum.answer, // optional; enables the labels-only candidate
|
|
203
|
+
maxMetricCalls: 1500, // 19 candidates swept over 50 validation instances, plus their harvests
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
result.candidates; // every set tried, with its source and how many demos it held
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
DSPy's `BootstrapFewShotWithRandomSearch`. It calls no model to write text: every candidate is assembled from outputs the system itself produced, so the search costs rollouts and nothing else. The fixed candidates come first — zero-shot, then labels-only when `goldOutput` is given, then one unshuffled full-size harvest — followed by shuffled harvests of random size.
|
|
210
|
+
|
|
211
|
+
Zero-shot stays in the running throughout. Demonstrations can hurt, and a search that cannot return "no demos" has no baseline to report against.
|
|
212
|
+
|
|
213
|
+
`concurrency` overlaps a candidate's sweep with the harvest of the candidates behind it, which is most of the wall clock on a run whose harvests are as expensive as its sweeps. Harvesting itself stays in plan order, because every harvest draws from the same random stream. It is ignored when `stopAtScore` is set, and it moves checkpointing from once per candidate to once per wave — see [Concurrency](./tuning.md#concurrency).
|
|
214
|
+
|
|
215
|
+
`candidates` is the whole search: each one is a fresh harvest and a full sweep, so it sets both the breadth and the bill. `demoMinScore` is the knob that surprises — a strict threshold does not cost less, it costs more, because a harvest keeps rolling out training instances until it has collected `maxDemos` of them or run out of set. If harvests come back with no demos, the seed cannot yet produce work its own metric rewards, and few-shot search is the wrong tool until it can.
|
|
216
|
+
|
|
217
|
+
### OPRO
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
const result = await new OproOptimizer({
|
|
221
|
+
proposalsPerRound: 4,
|
|
222
|
+
historySize: 10,
|
|
223
|
+
maxReflectionCalls: 40,
|
|
224
|
+
seed: 11,
|
|
225
|
+
}).optimize({
|
|
226
|
+
seedCandidate,
|
|
227
|
+
trainingSet,
|
|
228
|
+
validationSet,
|
|
229
|
+
adapter,
|
|
230
|
+
reflect,
|
|
231
|
+
maxMetricCalls: 2000, // 4 proposals a round, each swept over 50 validation instances
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
result.trajectory; // every candidate scored, in the order it was tried
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
By default, every proposal is scored on the full `validationSet`. With `scoringSetSize`, proposals are screened on a fixed subset of `trainingSet`, and the incumbent receives a full sweep every `fullEvalInterval` rounds. In a 30-instance validation set, screening on 12 instances halved rollout count without reducing the measured best score.
|
|
238
|
+
|
|
239
|
+
The meta-prompt lists the strongest attempts in ascending score order, placing the best attempt nearest the request. `scoreScale` converts scores to integers (100 by default), because models distinguish values such as 41 and 68 more reliably than 0.41 and 0.68.
|
|
240
|
+
|
|
241
|
+
Because every proposal in a round sees the same history and is screened against the same incumbent, `concurrency` runs a whole round — proposals and screens together — at once, and the round still records its attempts in the order it drew them.
|
|
242
|
+
|
|
243
|
+
Rounds are where OPRO gets its signal: every proposal in a round sees the same history, so `proposalsPerRound` widens a round rather than deepening the search, and the history a later prompt reads only grows between rounds. A run also moves one component per round, in turn, so a two-component candidate needs twice the rounds to revise each as often. Budget for rounds first, then set `proposalsPerRound` to what a round can afford — `maxReflectionCalls` caps the two together, at `maxReflectionCalls / proposalsPerRound` rounds.
|
|
244
|
+
|
|
245
|
+
### MIPRO
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
const result = await new MiproOptimizer({
|
|
249
|
+
instructionsPerComponent: 5,
|
|
250
|
+
maxTrials: 30,
|
|
251
|
+
seed: 11,
|
|
252
|
+
}).optimize({
|
|
253
|
+
seedCandidate,
|
|
254
|
+
trainingSet,
|
|
255
|
+
validationSet,
|
|
256
|
+
adapter,
|
|
257
|
+
reflect,
|
|
258
|
+
demoComponents: ["demos"], // menu bootstrapped from the training set
|
|
259
|
+
maxMetricCalls: 1600, // 30 trials of 35, six sweeps of 50, and the demo harvests
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
result.menu; // the space that was searched, per component
|
|
263
|
+
result.observations; // every configuration tried, and which earned a full sweep
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
MIPRO first builds a menu for each component from the seed text and variants generated by `reflect`. A TPE surrogate then proposes configurations from those menus. Trials run on minibatches; selected configurations receive a full validation sweep.
|
|
267
|
+
|
|
268
|
+
`componentOptions` adds menu entries without reflection calls. `demoComponents` builds menus of few-shot blocks from successful training rollouts, allowing instructions and demonstrations to be searched together.
|
|
269
|
+
|
|
270
|
+
Every `fullEvalInterval` trials, MIPRO fully evaluates the unswept configuration with the highest average minibatch score. Averaging repeated observations reduces the effect of a lucky minibatch.
|
|
271
|
+
|
|
272
|
+
The space is the product of the menus, so it grows multiplicatively with components while `maxTrials` grows by hand — three components of six options each is 216 configurations, and the default thirty trials sees a seventh of them. The surrogate also spends its first ten trials sampling at random before it models anything, so a short run is mostly random search with extra steps. Give it trials in proportion to the menu, or trim the menu with `instructionsPerComponent`.
|
|
273
|
+
|
|
274
|
+
### Random search
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
const result = await new RandomSearchOptimizer({
|
|
278
|
+
variants: 4,
|
|
279
|
+
}).optimize({
|
|
280
|
+
seedCandidate,
|
|
281
|
+
trainingSet,
|
|
282
|
+
validationSet,
|
|
283
|
+
adapter,
|
|
284
|
+
reflect,
|
|
285
|
+
maxMetricCalls: 1000, // 4 variants a round, each swept over 50 validation instances
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Random search paraphrases one component per round and keeps the highest-scoring candidate. Its prompt receives no performance data. Compare it with a reflective optimizer under the same metric budget to measure the benefit of reflection.
|
|
290
|
+
|
|
291
|
+
`concurrency` runs a round's variants at once, proposal and sweep together, and is the one setting here that changes nothing about the search — the round still accepts a variant only if it beat every variant drawn before it.
|
|
292
|
+
|
|
293
|
+
Being a baseline is the whole configuration: give it the `maxMetricCalls` and `validationSet` of the run it stands against, and change nothing else. A baseline on a smaller budget answers a different question than the one being asked of it. [`compare()`](./evaluation.md#comparing-optimizers) runs both over the same seeds and ranks them on the held-out score, but each entrant builds its own task, so keeping the budgets equal is still yours to do.
|