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
@@ -0,0 +1,169 @@
1
+ # Adapters and metrics
2
+
3
+ An adapter is how an optimizer runs and scores your system. Everything that
4
+ feeds a search — the framework it calls, the model that revises text, a
5
+ model-graded metric, per-module attribution — hangs off it.
6
+
7
+ ## The adapter
8
+
9
+ The adapter connects an optimizer to the system being evaluated:
10
+
11
+ ```ts
12
+ interface GepaAdapter<Datum, Trajectory, Output, K extends string> {
13
+ evaluate(
14
+ args: EvaluateArgs<Datum, K>,
15
+ ): Promise<EvaluationBatch<Trajectory, Output>>;
16
+ makeReflectiveDataset(
17
+ args: MakeReflectiveDatasetArgs<Datum, Trajectory, Output, K>,
18
+ ): ReflectiveDataset<K>;
19
+ proposeNewTexts?(args: ProposeArgs<K>): ComponentPatch<K>; // replaces the reflection LLM entirely
20
+ }
21
+ ```
22
+
23
+ Methods may be synchronous or asynchronous; the example shows the asynchronous form.
24
+
25
+ `evaluate` returns one score per instance and may include textual feedback. GEPA uses that feedback during reflection.
26
+
27
+ - **`args.run`** identifies the rollout's `iteration`, `phase`, `split`, and `candidateId`. Forward it to your tracing system.
28
+ - **`transient`** marks scores caused by infrastructure failures such as rate limits or 5xx responses. Transient scores are not cached.
29
+
30
+ ### Vercel AI SDK
31
+
32
+ Beta, and not published to npm: use it from a checkout of this repository until its interface settles.
33
+
34
+ ```ts
35
+ import { createAiSdkAdapter } from "@textopt/ai-sdk";
36
+ import { generateText } from "ai";
37
+
38
+ const adapter = createAiSdkAdapter<Ticket>({
39
+ run: ({ candidate, datum, signal }) =>
40
+ generateText({
41
+ model: taskModel,
42
+ system: candidate.system ?? "",
43
+ prompt: datum.text,
44
+ abortSignal: signal,
45
+ }),
46
+
47
+ score: ({ datum, output }) =>
48
+ output === datum.label
49
+ ? { score: 1, feedback: `Correct: ${datum.label}.` }
50
+ : {
51
+ score: 0,
52
+ feedback: `Predicted "${output}" but the correct queue is "${datum.label}". ${datum.why}`,
53
+ },
54
+
55
+ concurrency: 4,
56
+ });
57
+ ```
58
+
59
+ ### LangChain
60
+
61
+ ```ts
62
+ import { createLangChainAdapter } from "@textopt/langchain";
63
+
64
+ const adapter = createLangChainAdapter<Ticket, string>({
65
+ buildRunnable: (candidate) => buildChain(candidate.system, candidate.rubric),
66
+ score: ({ datum, output, trace }) => ({
67
+ score: grade(datum, output),
68
+ feedback: explain(trace),
69
+ }),
70
+ });
71
+ ```
72
+
73
+ The adapter rebuilds the runnable for each candidate. Traces include LLM, tool, and retriever spans; set `includeChainSteps` to include chain spans. Each rollout also includes `textopt_iteration`, `textopt_phase`, `textopt_split`, and `textopt_candidate_id` metadata for filtering in LangSmith.
74
+
75
+ ### Braintrust
76
+
77
+ Beta, and not published to npm: use it from a checkout of this repository until its interface settles.
78
+
79
+ ```ts
80
+ import {
81
+ createBraintrustScorer,
82
+ withBraintrustLogging,
83
+ } from "@textopt/braintrust";
84
+ import { ExactMatch, Levenshtein } from "autoevals";
85
+
86
+ const score = createBraintrustScorer<string>({
87
+ scorers: [ExactMatch, Levenshtein],
88
+ weights: { ExactMatch: 3 },
89
+ });
90
+
91
+ const adapter = withBraintrustLogging({
92
+ adapter: baseAdapter,
93
+ logger: initLogger({ projectName: "ticket-routing" }),
94
+ });
95
+ ```
96
+
97
+ The scorer maps scorer rationales to feedback and individual scores to `objectiveScores`. The logging decorator works with the AI SDK adapter, the LangChain adapter, or a custom adapter.
98
+
99
+ ## The reflection model
100
+
101
+ `reflect` implements the provider-independent `TextModel` interface: `({ prompt, signal }) => Promise<string>`.
102
+
103
+ ```ts
104
+ import type { TextModel } from "textopt";
105
+ import { generateText } from "ai";
106
+
107
+ const reflect: TextModel = async ({ prompt, signal }) => {
108
+ const result = await generateText({ model, prompt, abortSignal: signal });
109
+ return result.text;
110
+ };
111
+ ```
112
+
113
+ A LangChain chat model, vendor SDK call, local model, or deterministic function can implement `TextModel`. If the adapter implements `proposeNewTexts`, it generates proposals without calling `reflect`; the type still requires `reflect`, so pass a stub as shown in the `pareto` example.
114
+
115
+ The model under optimization is usually cheaper than the reflection model, which must analyze failures and revise the candidate.
116
+
117
+ ## Judging with a model
118
+
119
+ When the metric cannot be written as a string match, `createJudge` builds one from a model and returns written feedback alongside the score, which is what reflective search actually runs on:
120
+
121
+ ```ts
122
+ import { createJudge } from "textopt";
123
+
124
+ const judge = createJudge<Ticket, string>({
125
+ model: reflect,
126
+ scale: 5,
127
+ criteria: [
128
+ {
129
+ name: "accuracy",
130
+ description: "Every claim is supported by the ticket.",
131
+ },
132
+ { name: "tone", description: "Direct and free of filler." },
133
+ ],
134
+ });
135
+
136
+ const { score, feedback, objectiveScores } = await judge({
137
+ input: ticket,
138
+ output: answer,
139
+ });
140
+ ```
141
+
142
+ Each criterion is graded on a small integer scale and normalized afterwards, because models discriminate between 2 and 4 far more reliably than between 0.4 and 0.8. Per-criterion scores are returned as `objectiveScores`, so a Pareto frontier can be taken over them; the aggregate `score` is their mean.
143
+
144
+ The prompt asks for feedback addressed to the _instructions_ rather than to the graded output. "The instruction never says to state the refund window" is something a rewriting model can act on; "this answer should have mentioned the refund window" is not. A criterion the judge failed to grade comes back as a transient score, so the instance is retried rather than recorded as a zero.
145
+
146
+ ## Multi-module pipelines
147
+
148
+ When a system runs several modules in sequence and each has its own instruction, reflection is only as good as the evidence it sees — and the evidence a module needs is what _it_ received and produced, not the pipeline's input and final answer. `createPipelineAdapter` builds that attribution:
149
+
150
+ ```ts
151
+ import { createPipelineAdapter } from "textopt/gepa";
152
+
153
+ const adapter = createPipelineAdapter<Ticket, string, "planner" | "writer">({
154
+ modules: [
155
+ {
156
+ component: "planner",
157
+ run: ({ instruction, datum }) => plan(instruction, datum),
158
+ },
159
+ {
160
+ component: "writer",
161
+ run: ({ instruction, input }) => write(instruction, input),
162
+ },
163
+ ],
164
+ score: ({ datum, output, steps }) => judgeAnswer(datum, output, steps),
165
+ concurrency: 4,
166
+ });
167
+ ```
168
+
169
+ Each module's output is threaded into the next, and the reflective dataset gives each component only its own step. The feedback is end-to-end and every module sees the same string: a metric scores the final output, so nothing in a score alone says which module lost the point. `score` is handed the whole trace for callers who can attribute better.
@@ -0,0 +1,90 @@
1
+ # Benchmark
2
+
3
+ `pnpm bench` runs every optimizer over four offline tasks and twenty seeds, and writes [`bench/results/latest.json`](../../../bench/results/latest.json).
4
+
5
+ Each task is a support-ticket policy. A ticket has four features — `tier`, `channel`, `issue` and `region` — and a hidden policy of nine rules says which actions a correct answer must take. The system under optimization reads rules out of the candidate and applies them to the ticket, so a candidate is scored on what it made the system do rather than on the words it contains. No rule keys on `region`: it is there to be mistaken for a reason, and a search that believes it pays for the belief.
6
+
7
+ All seventy-two feature combinations are dealt into three disjoint splits of twenty-four. The splits are balanced, not merely disjoint — the same mix of every feature value in each — because every search here selects on the validation set and is reported on the test set, and two splits that ask for different things turn a held-out score into a lottery. No pair of features is in lockstep within a split either, so no rule can hide behind another. A candidate that fitted the combinations it was shown scores zero; one that found the rules scores the same on tickets it never saw.
8
+
9
+ Held-out score, twenty seeds:
10
+
11
+ | entrant | `clean` | `noisy` | `interacting` | `demonstrated` |
12
+ | ------------------- | --------- | --------- | ------------- | -------------- |
13
+ | `gepa` | **0.947** | 0.920 | **0.891** | 0.894 |
14
+ | `gepaVarianceAware` | 0.945 | **0.931** | 0.835 | **0.910** |
15
+ | `opro` | 0.285 | 0.283 | 0.170 | 0.693 |
16
+ | `simba` | 0.249 | 0.282 | 0.185 | 0.657 |
17
+ | `randomSearch` | 0.266 | 0.268 | 0.111 | 0.646 |
18
+ | `mipro` | 0.094 | 0.140 | 0.085 | 0.649 |
19
+ | `bootstrapSearch` | 0.000 | 0.060 | 0.000 | 0.740 |
20
+
21
+ Read these four rows first. They are what the table has to be judged against:
22
+
23
+ | reference | `clean` | `noisy` | `interacting` | `demonstrated` | what it is |
24
+ | ----------- | ------- | ------- | ------------- | -------------- | ----------------------------------------- |
25
+ | `policy` | 1.000 | 0.945 | 1.000 | 1.000 | the rules being searched for; the ceiling |
26
+ | `bestFixed` | 0.400 | 0.409 | 0.400 | 0.583 | the best answer that ignores the ticket |
27
+ | `shotgun` | 0.358 | 0.378 | 0.358 | 0.358 | every action on every ticket |
28
+ | `zeroShot` | 0.000 | 0.060 | 0.000 | 0.625 | the seed candidate, unoptimised |
29
+
30
+ `bestFixed` and `shotgun` are the floors a search has to beat to have found anything. Neither conditions on the ticket at all: `shotgun` sprays all nine actions and eats the bloat penalty for the ones that miss, and `bestFixed` is the best subset of actions to spray, chosen over the training and validation sets and never over the held-out set it is reported on — a floor selected on the number it is published at is an oracle, not a floor. On the three rule-shaped tasks the two GEPA rows are the only entrants that clear either floor. Every other search — `opro`, `simba`, `randomSearch`, `mipro`, `bootstrapSearch` — finishes below a candidate written without searching at all, having spent its whole budget to get there. On `demonstrated` all seven clear the floors, because the seed candidate already does.
31
+
32
+ ## What the diagnosis is worth
33
+
34
+ Every entrant is run a second time with exactly one thing removed: the metric's per-instance diagnosis is stripped from its prompt, leaving it the blind draw a score-only search already gets. Its settings are the ones it was tuned at, and its proposals are capped at nine — the fewest any entrant makes when left alone — because blind a proposal is a draw from a fixed pool rather than an induction, and an entrant that buys more draws scores better without searching better.
35
+
36
+ | entrant | `clean` | `noisy` | `interacting` | `demonstrated` |
37
+ | ------------------- | ------------ | ------------ | ------------- | -------------- |
38
+ | `gepa` | 0.185 −0.762 | 0.179 −0.741 | 0.089 −0.802 | 0.656 −0.238 |
39
+ | `gepaVarianceAware` | 0.059 −0.885 | 0.121 −0.811 | 0.022 −0.813 | 0.631 −0.279 |
40
+ | `opro` | 0.097 −0.188 | 0.134 −0.149 | 0.065 −0.105 | 0.647 −0.045 |
41
+ | `simba` | 0.137 −0.112 | 0.169 −0.113 | 0.087 −0.097 | 0.659 +0.002 |
42
+ | `mipro` | 0.094 0.000 | 0.140 0.000 | 0.085 0.000 | 0.649 0.000 |
43
+ | `randomSearch` | 0.266 0.000 | 0.268 0.000 | 0.111 0.000 | 0.646 0.000 |
44
+ | `bootstrapSearch` | 0.000 0.000 | 0.060 0.000 | 0.000 0.000 | 0.740 0.000 |
45
+
46
+ The blind score first, then what redaction cost against the table above.
47
+
48
+ This is the largest effect in the benchmark, and it is not a fact about search. Blind at nine proposals apiece the entrants land in a band — 0.022 to 0.266 on the three rule-shaped tasks — and the winner of that band is `randomSearch`, which does not search reflectively at all. `gepa` blind scores 0.185 on `clean` against its own 0.947. Whatever separates these algorithms when they can read the diagnosis, almost none of it survives when they cannot.
49
+
50
+ The bottom three rows lose exactly nothing, which is the check that the redaction is measuring what it claims to. `bootstrapSearch` calls no proposal model, and neither `mipro` nor `randomSearch` was ever shown a per-instance diagnosis to begin with — so redacting one changes nothing about what they were given, and their scores are identical to the digit. Those are also the only rows whose proposals the cap does not reach: `mipro` proposes its menu up front, seventeen entries on `interacting`, and `randomSearch` takes sixteen to twenty. The cap binds on every entrant whose score moves at all.
51
+
52
+ What the middle rows show is that reading the diagnosis is necessary but not sufficient. `opro` and `simba` both get it and both lose real ground without it — 0.188 and 0.112 on `clean` — but that is a fifth of what `gepa` loses, because they were converting much less of it into rules in the first place. So the column separates three things a single table cannot: what an entrant is given, what it does with it, and what it would score on neither.
53
+
54
+ Anyone choosing an optimizer from the table above should read this one alongside it. Most of what the top rows are buying is a proposal step that reads what the metric said about individual tickets. That is a real difference between these algorithms — what each one asks its model for is part of the algorithm — but it is a difference in the question they ask, not in how well they search the answers.
55
+
56
+ ## The tasks
57
+
58
+ `clean` is the reference case: a noiseless metric with a clean gradient.
59
+
60
+ `noisy` adds per-instance jitter to the same metric, which is why its ceiling is 0.945 rather than 1.000 — noise costs even a perfect candidate something. It is the one task where `gepaVarianceAware` wins, which is what its acceptance test is for.
61
+
62
+ `interacting` splits the policy across two components in a pipeline: `triage` holds the rules about the issue, `response` those about the tier and channel, and the system applies `response` only to tickets `triage` already handled correctly. Every improvement to the second component scores nothing until the first is complete, so the task measures where a search spends. It has the widest spread between seeds of any task here (`gepa` sd 0.109), which is what a search that can be sent down the wrong component looks like, and it is the one task where the two GEPA rows genuinely separate.
63
+
64
+ `demonstrated` changes the system rather than the metric — it answers a share of tickets correctly with no help from its prompt, which is the only condition under which harvesting rollouts has anything to harvest. It is also the task where the unoptimised seed already scores 0.625, and where the entrants that search instructions barely move off it: `mipro` 0.649, `randomSearch` 0.646. `bootstrapSearch` reaches 0.740 while calling no proposal model at all. When a system's failures are inconsistency rather than instruction, searching the instruction is the wrong tool, and this row is what that costs.
65
+
66
+ It is the one task where `simba` harvests. SIMBA has two mutations — append a rewarded rollout as a demonstration, or ask a model what the better run did and append that as a rule — and which of the two it draws from is tuned like any other setting. Demonstrations are selected here and nowhere else, worth 0.657 against 0.628 for rules alone, and the three tasks above select rules, where a demonstration spends a draw on a rollout that carries nothing its prompt did not.
67
+
68
+ ## Two rows that are not what they look like
69
+
70
+ `bootstrapSearch` scores 0.000 on `clean` and `interacting`, the only entrant with no seed spread at all on them. There is nothing to harvest there — the answer on those tasks is a pure function of the candidate, so a rollout tells the search nothing its prompt did not, and a demonstration search can only hand a candidate its own words back. It is run on all four tasks rather than only where it wins precisely so that row is visible.
71
+
72
+ `mipro` scores 0.094 on `clean` and 0.085 on `interacting`. Its menu is proposed once, up front, from a dataset summary and a sample of task inputs — nine reflection calls on `clean`, not one of which carries a per-instance diagnosis. That is MIPROv2's grounded proposer working as designed, and it goes deeper than the stand-in: this benchmark's training data carries no gold labels at all, since what a correct answer requires lives in the metric rather than in the dataset. A real model in MIPRO's up-front slot would have nothing linking features to actions either. On this substrate `mipro` measures propose-before-evidence, and the blind column above puts a number on that: redacting the diagnosis costs it exactly nothing on all four tasks, because it was never shown one.
73
+
74
+ ## How the numbers are produced
75
+
76
+ Every entrant gets the same `maxMetricCalls` on a task, the same data and the same proposal model. The held-out set is evaluated once, on the candidate the search already chose, outside the budget — so no optimizer can spend rollouts on the number it is reported at.
77
+
78
+ Settings are tuned per entrant per task over comparable grids, on seeds 100–109, choosing on the validation score alone. The reported score comes from seeds 0–19. Nothing about how an entrant was run is fitted to the seeds or the split it is scored on; `tuning` in the JSON records what each one was given. An earlier version of this benchmark swept one entrant's hyperparameters against the published metric and left the others at a default, which is enough on its own to decide a table.
79
+
80
+ `p` is a paired sign-flip test against the winner, Holm-adjusted across the entrants in the same comparison. It is withheld where every seed produced the same margin: a test over twenty identical differences reports a precision that twenty runs of one realization never earned. `distinctScores` says how many distinct outcomes an entrant actually had — `randomSearch` is 1 everywhere, since it takes no seed and is deterministic against a deterministic model.
81
+
82
+ The two GEPA rows separate on one task out of four. Holm-adjusted, the winner's margin over the other runs p = 0.027 on `interacting`, and 0.334, 0.088 and 0.125 on `clean`, `noisy` and `demonstrated` — so only `interacting` clears 0.05, where plain GEPA wins. Every other margin in the table clears it at p < 0.0001. Read the top two rows as one result with two spellings everywhere except `interacting`.
83
+
84
+ ## What this does and does not tell you
85
+
86
+ The proposal model is a deterministic stand-in. It induces a rule only when a feature value and a missing action co-occur across at least two tickets in its prompt, and otherwise draws from the hundred and eight rules the language can express, of which nine are correct.
87
+
88
+ That stand-in has one property no real model has: it is exact counting, so it sharpens with the size of its reflection batch and stops being wrong at all past roughly a dozen observations. At that point tuning stops selecting a search and starts selecting whichever entrant asks for the biggest prompt. The grid is therefore bounded at a batch of nine, where the proposer is right about four times in five, and this bound is load-bearing — every minibatch-shaped entrant tunes straight to it. That is a modelling decision to keep the comparison about search, and it is the first thing to be suspicious of if these numbers ever look too clean.
89
+
90
+ So these numbers are about search behaviour under a fixed, weak proposer. They are not about what any of these optimizers will do with a real model that can read a dataset and write a sensible instruction unprompted. Read the table as evidence about the searches, read the blind column, `bestFixed` and `shotgun` as the scale it should be read at, and then run [`compare()`](./evaluation.md#comparing-optimizers) on your own task and metric — which is the only measurement that answers the question you actually have.
@@ -0,0 +1,113 @@
1
+ # Preparing the data a search runs on
2
+
3
+ The data decides what a search can find. A metric can be perfect and a budget
4
+ generous, and a run will still report a number that means nothing if the rows
5
+ are arranged wrong. Do this before the metric pre-flight, because two of the
6
+ checks there need splits that already exist.
7
+
8
+ ## The three sets do different jobs
9
+
10
+ ```ts
11
+ await optimizer.optimize({
12
+ trainingSet, // reflection reads these: outputs, feedback, what went wrong
13
+ validationSet, // the search selects candidates against these
14
+ testSet, // scored once, at the end, on the winner only
15
+ // …
16
+ });
17
+ ```
18
+
19
+ `trainingSet` is where evidence comes from. Reflective search mines these rows
20
+ for what a candidate got wrong, so a training row earns its place by being
21
+ _diagnostic_ — a row every candidate passes teaches the rewriter nothing.
22
+
23
+ `validationSet` is what selection pressure is applied to, for the whole run.
24
+ `bestScore` is a mean over it, which is why `bestScore` is partly fitted and
25
+ the result says so.
26
+
27
+ `testSet` is the only set nothing was ever selected against, and `testScore` is
28
+ therefore the only number in a result you can report to someone else. Omit it
29
+ and there is no such number — `testScore` is simply absent.
30
+
31
+ Passing no `validationSet` at all silently reuses `trainingSet`, and the run
32
+ carries `validationSetReusesTraining` to say so. That is the right default for
33
+ a first look and the wrong thing to report from, because reflection mined the
34
+ same rows that then judged the result.
35
+
36
+ ## Split by group, never by row
37
+
38
+ This is the failure that survives every other check.
39
+
40
+ Real datasets contain families: the same ticket filed twice, a question and its
41
+ paraphrase, five rows generated from one template, transcripts from one long
42
+ session. Shuffle and slice, and a family lands on both sides of the boundary.
43
+ Reflection then reads one member and the search is selected on its twin, so the
44
+ prompt memorises a fact that scores on validation and generalises to nothing.
45
+ The gap does not show up as a warning; it shows up as a `testScore` well below
46
+ `bestScore`, or worse, not at all if you never held out a test set.
47
+
48
+ Read how the corpus was built before you infer anything about it. Structure
49
+ that is expensive to detect from the rows is often free to read about: a
50
+ benchmark that says it ships contrast pairs, a generator with a template count,
51
+ a README naming the sampling frame. Deliberate structure is the kind that leaks
52
+ worst and the kind most likely to be documented.
53
+
54
+ Then find the grouping key — a source id, a customer, a template, a document, a
55
+ session. If there is no explicit key, near-duplicate text is a usable stand-in: normalise whitespace and case, and cluster on a shared
56
+ distinctive phrase or a similarity threshold. Then assign whole groups to
57
+ splits. Never assign rows.
58
+
59
+ Check it afterwards rather than trusting it: no group id appears in two splits,
60
+ and the three splits are disjoint and cover the rows you meant to use.
61
+
62
+ ## Every class in every split
63
+
64
+ A class absent from `validationSet` is a class the search is free to break,
65
+ because nothing measures it. A class absent from `testSet` means the final
66
+ number says nothing about it.
67
+
68
+ Rare classes are where this bites, and they are usually the ones that matter —
69
+ the refund case, the abuse report, the one legal asked about. Stratify the
70
+ split by label so each set holds some of each, and when a class is too small to
71
+ stratify, say so out loud rather than letting the split decide silently.
72
+
73
+ Imbalance also quietly sets the metric's ceiling. If 80% of rows are one easy
74
+ class, a candidate that handles only that class scores 0.8, and the 0.8 looks
75
+ like progress.
76
+
77
+ ## Sizes
78
+
79
+ There is no defensible universal number, but the constraints are real:
80
+
81
+ | Set | What sets the floor |
82
+ | --------------- | ------------------------------------------------------------------------------------------------------ |
83
+ | `trainingSet` | Enough diagnostic rows that a minibatch shows a candidate failing in more than one way |
84
+ | `validationSet` | Enough that the mean is not moved by one instance; this is also what a full sweep costs per acceptance |
85
+ | `testSet` | Enough that a difference you would act on is larger than its own noise |
86
+
87
+ A validation set of 10 makes every instance worth 10% of the score. A search
88
+ will find the one instance it can flip.
89
+
90
+ Cost scales with `validationSet`, not with the other two — see `tuning.md` for
91
+ what a sweep costs per optimizer, and note that SIMBA reserves
92
+ `min(candidates + 1, maxSteps + 1) × |val|` rollouts before the first step.
93
+
94
+ ## Order of operations
95
+
96
+ 1. Dedup exact repeats. They inflate whichever split they land in.
97
+ 2. Find the grouping key. Cluster near-duplicates if there is none.
98
+ 3. Split by group, stratified by label.
99
+ 4. Verify: groups disjoint across splits, every class present in each.
100
+ 5. Only now run the metric pre-flight in `metric-preflight.md`, using the
101
+ splits you just made.
102
+
103
+ ## What to do when there is not enough data
104
+
105
+ Small sets are the normal case, not a failure. What matters is that the
106
+ smallness is stated rather than hidden:
107
+
108
+ - Prefer a real `testSet` over a larger `validationSet`. A fitted number
109
+ measured on more rows is still fitted.
110
+ - With too few rows to make three sets, make two and report that `bestScore` is
111
+ all you have — do not pass `testSet` and then report `bestScore` anyway.
112
+ - Do not manufacture rows by paraphrasing existing ones into another split.
113
+ That is the group-leakage failure, performed deliberately.
@@ -0,0 +1,128 @@
1
+ # Distilling a run
2
+
3
+ Prompt optimization makes a prompt better partly by making it longer, and you
4
+ pay for that length on every inference forever. Distillation is what makes the
5
+ length free: run the optimized candidate on the strong model, keep the rollouts
6
+ the metric rewarded, and train a smaller model on them. The text moves into
7
+ weights.
8
+
9
+ textopt collects and serializes the training data. It never trains anything —
10
+ that is a provider's job, and the providers change.
11
+
12
+ ## The order matters
13
+
14
+ Distill before optimizing and you freeze whatever your first draft happened to
15
+ do. The search is what finds the behaviour worth freezing.
16
+
17
+ ["Fine-Tuning and Prompt Optimization: Two Great Steps that Work Better
18
+ Together"](https://arxiv.org/abs/2407.10930) goes further: alternating the two
19
+ beats weight optimization alone by up to 60% and prompt optimization alone by
20
+ up to 6%. The second prompt pass is an ordinary `optimize()` call with an
21
+ adapter pointed at the fine-tuned model, so nothing here is a one-way door —
22
+ provided the render step below leaves a prompt to optimize.
23
+
24
+ ## Harvesting
25
+
26
+ `harvestRollouts` runs a candidate over data and keeps what the metric
27
+ rewarded. It is the same primitive `harvestFewShotExamples` uses, without the
28
+ four-example ceiling:
29
+
30
+ ```ts
31
+ import { harvestRollouts } from "textopt";
32
+
33
+ const harvest = await harvestRollouts({
34
+ adapter,
35
+ candidate: result.bestCandidate, // the run's winner
36
+ data: unlabeledPool,
37
+ minScore: 0.9,
38
+ maxMetricCalls: 5000,
39
+ });
40
+
41
+ harvest.rollouts; // { input, output, score }[]
42
+ harvest.attempted; // instances run, including the ones that failed the bar
43
+ ```
44
+
45
+ Filtering on `minScore` is what makes this rejection sampling rather than
46
+ imitation: you are copying the strong model on the occasions your metric says
47
+ it was right. A boolean metric makes `minScore: 1` the only sensible threshold;
48
+ a graded metric asked for a perfect score throws away every rollout that was
49
+ most of the way there, which on a hard task is all of them.
50
+
51
+ **Sweep the right data.** Not the validation set — that is the set that
52
+ selected the winning candidate, so its rollouts are enriched for the
53
+ candidate's fit to those particular instances rather than to the task, and
54
+ there are only ever tens to low hundreds of them. Use the training set, or
55
+ better, a pool held out of the run entirely. Volume comes from the sweep, not
56
+ from the run: a search bounded at 150 metric calls is a seed, and 5,000
57
+ unlabeled instances is a dataset.
58
+
59
+ The harvest carries its own budget. It is a separate spend from the run that
60
+ produced the candidate, and `maxMetricCalls` bounds it.
61
+
62
+ ## Rendering
63
+
64
+ `toTrainingJsonl` serializes rollouts as one chat-messages example per line —
65
+ the shape Axolotl, Together, Fireworks and the Hugging Face trainers all
66
+ ingest. Your `render` callback decides what each example looks like, because
67
+ only you can read a `Datum`:
68
+
69
+ ```ts
70
+ import { toTrainingJsonl } from "textopt";
71
+
72
+ const jsonl = toTrainingJsonl({
73
+ rollouts: harvest.rollouts,
74
+ render: ({ rollout }) => ({
75
+ messages: [
76
+ { role: "user", content: rollout.input.question },
77
+ { role: "assistant", content: String(rollout.output) },
78
+ ],
79
+ }),
80
+ });
81
+ ```
82
+
83
+ Return `null` from `render` to drop a rollout. Write the string yourself —
84
+ nothing here touches the filesystem.
85
+
86
+ ### How much of the prompt to leave in
87
+
88
+ This is the decision that matters, and there is no default worth picking for
89
+ you.
90
+
91
+ **Drop it entirely.** The input is the bare instance; the whole optimized
92
+ candidate moves into weights. Maximum compression, and it works when the task
93
+ is inferable from the input distribution. It fails when the task specification
94
+ lives in the prompt — a custom label taxonomy, an output schema, a routing
95
+ rubric — because the student then has to reverse-engineer that from examples.
96
+ It also ends the alternating loop above: there is no prompt left to optimize.
97
+
98
+ **Keep a short task statement.** Train on a brief instruction plus the
99
+ instance, serve with the same brief instruction. What you distill away is the
100
+ optimized delta — the tips and examples the search accreted — rather than the
101
+ statement of the job. This is the right default for most systems.
102
+
103
+ **Keep the full optimized candidate.** No compression at all, which is the
104
+ point: the student learns under the prompt it will be served with, and the next
105
+ optimization pass tunes that prompt against the new weights. This is the
106
+ BetterTogether shape.
107
+
108
+ ## Checking it worked
109
+
110
+ The number that matters is not the training loss. Point an adapter at the
111
+ fine-tuned model and run both against the same held-out `testSet` — data that
112
+ neither the prompt search nor the fine-tune ever saw:
113
+
114
+ ```ts
115
+ import { compare } from "textopt";
116
+
117
+ const comparison = await compare({
118
+ seeds: [0, 1, 2, 3, 4],
119
+ entrants: {
120
+ teacher: () => evaluatePrompted(teacherAdapter, result.bestCandidate),
121
+ student: () => evaluateDistilled(studentAdapter),
122
+ },
123
+ });
124
+ ```
125
+
126
+ `compare()` ranks on `testScore` and reports a paired sign-flip p-value against
127
+ the winner, so "the distilled model matches the prompted one" is a measurement
128
+ rather than an impression. See [Measuring a result](evaluation.md).
@@ -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.