@textopt/langsmith 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/index.cjs +223 -0
- package/dist/index.d.cts +107 -0
- package/dist/index.d.mts +107 -0
- package/dist/index.mjs +222 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Charlie Duong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# @textopt/langsmith
|
|
2
|
+
|
|
3
|
+
LangSmith experiment reporting for [textopt](https://github.com/ctdio/textopt#readme).
|
|
4
|
+
|
|
5
|
+
`createLangSmithReporter` writes a run to LangSmith as **one experiment per accepted candidate over one fixed dataset**. Selecting several of those experiments in LangSmith's comparison view renders the candidate x instance score matrix selection reads: which instances a candidate won, and which it paid for.
|
|
6
|
+
|
|
7
|
+
It works with any optimizer. The reporter reads the acceptance payload every search emits — the candidate text and its per-instance row over the validation set — rather than one search's event union, and folds whatever else the optimizer put on the event (GEPA's lineage, MIPRO's menu choices, SIMBA's step) into the experiment's metadata.
|
|
8
|
+
|
|
9
|
+
Install `langsmith` separately. This package matches its `Client` structurally and declares no runtime dependency on it.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { GepaOptimizer } from "textopt/gepa";
|
|
15
|
+
import { createLangSmithReporter } from "@textopt/langsmith";
|
|
16
|
+
import { Client } from "langsmith";
|
|
17
|
+
|
|
18
|
+
const instanceId = ({ datum }: { datum: Ticket }) => datum.id;
|
|
19
|
+
|
|
20
|
+
const result = await new GepaOptimizer({ trackBestOutputs: true }).optimize({
|
|
21
|
+
seedCandidate,
|
|
22
|
+
trainingSet,
|
|
23
|
+
validationSet,
|
|
24
|
+
testSet,
|
|
25
|
+
adapter,
|
|
26
|
+
reflect,
|
|
27
|
+
maxMetricCalls: 300,
|
|
28
|
+
instanceId,
|
|
29
|
+
reporters: [
|
|
30
|
+
createLangSmithReporter({
|
|
31
|
+
client: new Client(),
|
|
32
|
+
dataset: "ticket-triage",
|
|
33
|
+
experimentPrefix: "gepa-2026-08-19",
|
|
34
|
+
validationSet,
|
|
35
|
+
testSet,
|
|
36
|
+
instanceId,
|
|
37
|
+
toInput: (datum) => ({ ticket: datum.text }),
|
|
38
|
+
toExpected: (datum) => ({ label: datum.label }),
|
|
39
|
+
}),
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Experiments are named `<experimentPrefix>/cand-<candidateId>`, starting with `cand-0` for the seed — the baseline every later candidate is read against. Each carries the candidate's text, iteration, parents, source and aggregate score as metadata, so a score that moved sits next to the edit that moved it.
|
|
45
|
+
|
|
46
|
+
| Option | Default | Effect |
|
|
47
|
+
| ------------------ | -------------------- | ------------------------------------------------------------------- |
|
|
48
|
+
| `client` | required | A LangSmith `Client`, or anything with the same shape. |
|
|
49
|
+
| `dataset` | required | Dataset holding the validation split. Created once, then reused. |
|
|
50
|
+
| `experimentPrefix` | required | Names the experiments. Use a value unique to the run. |
|
|
51
|
+
| `validationSet` | required | The validation set the run was given, in the same order. |
|
|
52
|
+
| `testSet` | none | Uploaded as its own dataset, swept once, for the winner only. |
|
|
53
|
+
| `testDataset` | `<dataset>-held-out` | Names that second dataset. |
|
|
54
|
+
| `instanceId` | the row's position | Keys a dataset row across runs. |
|
|
55
|
+
| `toInput` | the datum itself | The example's `inputs`. |
|
|
56
|
+
| `toExpected` | none | The example's `outputs`, when the dataset should carry a reference. |
|
|
57
|
+
| `concurrency` | `8` | Rows uploaded at once within one experiment. |
|
|
58
|
+
|
|
59
|
+
## What is deliberately not logged
|
|
60
|
+
|
|
61
|
+
**Minibatch rollouts.** They are small random subsets of the _training_ set that differ every iteration, so they cannot be compared across candidates and would bury the experiments that can. They are still traceable: with `LANGSMITH_TRACING=1`, `@textopt/langsmith`'s sibling `@textopt/langchain` tags every rollout with its iteration, phase, split and candidate id.
|
|
62
|
+
|
|
63
|
+
**A held-out experiment per candidate.** GEPA scores the held-out set only once selection is over, precisely so that nothing can be chosen against it. There is one held-out experiment, for the winner, on its own dataset — and no option to change that, because the first time a candidate is picked because it looked better there, the number stops meaning anything.
|
|
64
|
+
|
|
65
|
+
**Zeros for unmeasured instances.** An instance the evaluation policy skipped, or one an infrastructure failure lost, has no row. Written as a zero it reads as a regression that never happened.
|
|
66
|
+
|
|
67
|
+
## Notes
|
|
68
|
+
|
|
69
|
+
Uploads are queued, not awaited: `onEvent` runs on the search's hot path, and the reporter's `flush` is awaited once as the run ends, including when it ends by throwing. A LangSmith that is unreachable degrades to a warning — the run that bought the rollouts finishes either way.
|
|
70
|
+
|
|
71
|
+
Dataset row ids are derived from the dataset name and `instanceId`, not assigned by the server, so a repeated or resumed run writes to the rows it already has. If the dataset already exists, its examples are left alone; a validation set that changed shape wants a new `dataset` name.
|
|
72
|
+
|
|
73
|
+
Uploading is chatty: one run plus one feedback per measured instance per accepted candidate. Ten candidates over a hundred validation instances is a few thousand calls.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let textopt = require("textopt");
|
|
3
|
+
//#region src/reporter.ts
|
|
4
|
+
const DEFAULT_CONCURRENCY = 8;
|
|
5
|
+
/**
|
|
6
|
+
* A UUIDv5 namespace for this library, so a dataset row's id is a pure
|
|
7
|
+
* function of the dataset it belongs to and the instance it names. Fixed
|
|
8
|
+
* forever: changing it renames every row textopt has ever written.
|
|
9
|
+
*/
|
|
10
|
+
const TEXTOPT_NAMESPACE = "6f9619ff-8b86-d011-b42d-00c04fc964ff";
|
|
11
|
+
/**
|
|
12
|
+
* Reports a GEPA run to LangSmith as one experiment per accepted candidate
|
|
13
|
+
* over one fixed dataset — which is what makes the candidates comparable.
|
|
14
|
+
* Selecting several in LangSmith's comparison view renders the candidate x
|
|
15
|
+
* instance score matrix the Pareto frontier is chosen on: which instances a
|
|
16
|
+
* candidate won, and which it paid for.
|
|
17
|
+
*
|
|
18
|
+
* Minibatch rollouts are deliberately absent. They are small random subsets of
|
|
19
|
+
* the *training* set that differ every iteration, so they cannot be compared
|
|
20
|
+
* across candidates and would bury the experiments that can. They remain
|
|
21
|
+
* traceable through the adapter, which tags every rollout with its iteration,
|
|
22
|
+
* phase, split and candidate id.
|
|
23
|
+
*
|
|
24
|
+
* The held-out sweep gets exactly one experiment, for the winner, on its own
|
|
25
|
+
* dataset. There is no option to log one per candidate: GEPA evaluates the
|
|
26
|
+
* held-out set only after selection is over precisely so that nothing can be
|
|
27
|
+
* chosen against it, and a per-candidate view hands that back the moment
|
|
28
|
+
* someone reads it.
|
|
29
|
+
*/
|
|
30
|
+
function createLangSmithReporter(options) {
|
|
31
|
+
const { client, dataset, experimentPrefix, validationSet, testSet, testDataset = `${dataset}-held-out`, instanceId = ({ index }) => String(index), toInput = (datum) => datum, toExpected, concurrency = DEFAULT_CONCURRENCY } = options;
|
|
32
|
+
let pending = Promise.resolve();
|
|
33
|
+
let validationDatasetId;
|
|
34
|
+
let heldOutDatasetId;
|
|
35
|
+
function enqueue(work) {
|
|
36
|
+
pending = pending.then(work).catch((err) => {
|
|
37
|
+
console.warn("[textopt-langsmith] upload failed", { err });
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
async function ensureDataset(args) {
|
|
41
|
+
if (await client.hasDataset({ datasetName: args.name })) return (await client.readDataset({ datasetName: args.name })).id;
|
|
42
|
+
const created = await client.createDataset(args.name, { description: `textopt: ${String(args.rows.length)} instances` });
|
|
43
|
+
await client.createExamples(await Promise.all(args.rows.map(async (datum, index) => ({
|
|
44
|
+
id: await exampleIdFor({
|
|
45
|
+
dataset: args.name,
|
|
46
|
+
instance: instanceId({
|
|
47
|
+
datum,
|
|
48
|
+
index
|
|
49
|
+
})
|
|
50
|
+
}),
|
|
51
|
+
dataset_id: created.id,
|
|
52
|
+
inputs: toInput(datum),
|
|
53
|
+
...toExpected === void 0 ? {} : { outputs: toExpected(datum) },
|
|
54
|
+
metadata: { textopt_instance_id: instanceId({
|
|
55
|
+
datum,
|
|
56
|
+
index
|
|
57
|
+
}) }
|
|
58
|
+
}))));
|
|
59
|
+
return created.id;
|
|
60
|
+
}
|
|
61
|
+
async function writeExperiment(args) {
|
|
62
|
+
const project = await client.createProject({
|
|
63
|
+
projectName: args.projectName,
|
|
64
|
+
referenceDatasetId: args.datasetId,
|
|
65
|
+
metadata: args.metadata
|
|
66
|
+
});
|
|
67
|
+
const scored = args.rows.flatMap((datum, index) => {
|
|
68
|
+
const score = args.scores[index];
|
|
69
|
+
return score === void 0 ? [] : [{
|
|
70
|
+
datum,
|
|
71
|
+
index,
|
|
72
|
+
score
|
|
73
|
+
}];
|
|
74
|
+
});
|
|
75
|
+
await (0, textopt.mapWithConcurrency)({
|
|
76
|
+
items: scored,
|
|
77
|
+
limit: concurrency,
|
|
78
|
+
task: async ({ datum, index, score }) => {
|
|
79
|
+
const runId = crypto.randomUUID();
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
await client.createRun({
|
|
82
|
+
id: runId,
|
|
83
|
+
name: args.projectName,
|
|
84
|
+
run_type: "chain",
|
|
85
|
+
inputs: toInput(datum),
|
|
86
|
+
outputs: { output: args.outputs?.[index] ?? null },
|
|
87
|
+
project_name: args.projectName,
|
|
88
|
+
reference_example_id: await exampleIdFor({
|
|
89
|
+
dataset: args.datasetName,
|
|
90
|
+
instance: instanceId({
|
|
91
|
+
datum,
|
|
92
|
+
index
|
|
93
|
+
})
|
|
94
|
+
}),
|
|
95
|
+
start_time: now,
|
|
96
|
+
end_time: now
|
|
97
|
+
});
|
|
98
|
+
await client.createFeedback(runId, "score", {
|
|
99
|
+
score,
|
|
100
|
+
sessionId: project.id
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
onEvent: (event) => {
|
|
107
|
+
if ((0, textopt.isCandidateAccepted)(event)) enqueue(async () => {
|
|
108
|
+
validationDatasetId ??= ensureDataset({
|
|
109
|
+
name: dataset,
|
|
110
|
+
rows: validationSet
|
|
111
|
+
});
|
|
112
|
+
await writeExperiment({
|
|
113
|
+
projectName: `${experimentPrefix}/cand-${String(event.candidateId)}`,
|
|
114
|
+
datasetId: await validationDatasetId,
|
|
115
|
+
datasetName: dataset,
|
|
116
|
+
rows: validationSet,
|
|
117
|
+
scores: event.instanceScores,
|
|
118
|
+
outputs: event.outputs,
|
|
119
|
+
metadata: experimentMetadata({
|
|
120
|
+
candidate: event.candidate,
|
|
121
|
+
candidateId: event.candidateId,
|
|
122
|
+
aggregateScore: event.aggregateScore,
|
|
123
|
+
search: searchSpecific({ ...event })
|
|
124
|
+
})
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
if ((0, textopt.isRunFinished)(event) && event.testInstanceScores !== void 0 && testSet !== void 0) {
|
|
128
|
+
const heldOut = event.testInstanceScores;
|
|
129
|
+
const outputs = event.testOutputs;
|
|
130
|
+
const { bestCandidateId, testScore } = event;
|
|
131
|
+
enqueue(async () => {
|
|
132
|
+
heldOutDatasetId ??= ensureDataset({
|
|
133
|
+
name: testDataset,
|
|
134
|
+
rows: testSet
|
|
135
|
+
});
|
|
136
|
+
await writeExperiment({
|
|
137
|
+
projectName: `${experimentPrefix}/held-out-cand-${String(bestCandidateId)}`,
|
|
138
|
+
datasetId: await heldOutDatasetId,
|
|
139
|
+
datasetName: testDataset,
|
|
140
|
+
rows: testSet,
|
|
141
|
+
scores: heldOut,
|
|
142
|
+
outputs,
|
|
143
|
+
metadata: {
|
|
144
|
+
textopt_candidate_id: bestCandidateId,
|
|
145
|
+
textopt_split: "test",
|
|
146
|
+
...testScore === void 0 ? {} : { textopt_score: testScore }
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
flush: async () => {
|
|
153
|
+
await pending;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function experimentMetadata(args) {
|
|
158
|
+
const search = Object.fromEntries(Object.entries(args.search).map(([key, value]) => [`textopt_${key}`, value]));
|
|
159
|
+
return {
|
|
160
|
+
...args.candidate,
|
|
161
|
+
textopt_candidate_id: args.candidateId,
|
|
162
|
+
textopt_score: args.aggregateScore,
|
|
163
|
+
...search
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The fields the emitting optimizer added to the shared acceptance payload:
|
|
168
|
+
* GEPA's lineage, MIPRO's menu choices, SIMBA's step. Read structurally rather
|
|
169
|
+
* than per optimizer so a new search reports its own vocabulary without this
|
|
170
|
+
* package learning about it.
|
|
171
|
+
*/
|
|
172
|
+
function searchSpecific(event) {
|
|
173
|
+
const shared = /* @__PURE__ */ new Set([
|
|
174
|
+
"type",
|
|
175
|
+
"candidateId",
|
|
176
|
+
"candidate",
|
|
177
|
+
"aggregateScore",
|
|
178
|
+
"instanceScores",
|
|
179
|
+
"outputs"
|
|
180
|
+
]);
|
|
181
|
+
return Object.fromEntries(Object.entries(event).filter(([key]) => !shared.has(key)));
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* A dataset row's id, derived from the dataset and the instance it names
|
|
185
|
+
* rather than assigned by the server.
|
|
186
|
+
*
|
|
187
|
+
* Server-assigned ids would give a resumed or repeated run a fresh set of
|
|
188
|
+
* rows, and every experiment logged before the restart would have nothing left
|
|
189
|
+
* to compare against.
|
|
190
|
+
*/
|
|
191
|
+
async function exampleIdFor(args) {
|
|
192
|
+
return uuidV5({
|
|
193
|
+
namespace: TEXTOPT_NAMESPACE,
|
|
194
|
+
name: `${args.dataset}:${args.instance}`
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/** RFC 4122 name-based UUID, SHA-1 flavour. LangSmith ids must be UUIDs. */
|
|
198
|
+
async function uuidV5(args) {
|
|
199
|
+
const namespaceBytes = uuidToBytes(args.namespace);
|
|
200
|
+
const nameBytes = new TextEncoder().encode(args.name);
|
|
201
|
+
const payload = new Uint8Array(namespaceBytes.length + nameBytes.length);
|
|
202
|
+
payload.set(namespaceBytes);
|
|
203
|
+
payload.set(nameBytes, namespaceBytes.length);
|
|
204
|
+
const bytes = new Uint8Array(await crypto.subtle.digest("SHA-1", payload)).slice(0, 16);
|
|
205
|
+
bytes[6] = bytes[6] & 15 | 80;
|
|
206
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
207
|
+
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
208
|
+
return [
|
|
209
|
+
hex.slice(0, 8),
|
|
210
|
+
hex.slice(8, 12),
|
|
211
|
+
hex.slice(12, 16),
|
|
212
|
+
hex.slice(16, 20),
|
|
213
|
+
hex.slice(20)
|
|
214
|
+
].join("-");
|
|
215
|
+
}
|
|
216
|
+
function uuidToBytes(uuid) {
|
|
217
|
+
const hex = uuid.replaceAll("-", "");
|
|
218
|
+
const bytes = /* @__PURE__ */ new Uint8Array(16);
|
|
219
|
+
for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
|
|
220
|
+
return bytes;
|
|
221
|
+
}
|
|
222
|
+
//#endregion
|
|
223
|
+
exports.createLangSmithReporter = createLangSmithReporter;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { OptimizerEvent, Reporter } from "textopt";
|
|
2
|
+
//#region src/reporter.d.ts
|
|
3
|
+
interface LangSmithDataset {
|
|
4
|
+
id: string;
|
|
5
|
+
}
|
|
6
|
+
interface LangSmithProject {
|
|
7
|
+
id: string;
|
|
8
|
+
/** Optional because LangSmith's own `TracerSession` declares it so. */
|
|
9
|
+
name?: string;
|
|
10
|
+
}
|
|
11
|
+
interface LangSmithExample {
|
|
12
|
+
id?: string;
|
|
13
|
+
dataset_id?: string;
|
|
14
|
+
inputs: Record<string, unknown>;
|
|
15
|
+
outputs?: Record<string, unknown>;
|
|
16
|
+
metadata?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
interface LangSmithRun {
|
|
19
|
+
id?: string;
|
|
20
|
+
name: string;
|
|
21
|
+
run_type: string;
|
|
22
|
+
inputs: Record<string, unknown>;
|
|
23
|
+
outputs?: Record<string, unknown>;
|
|
24
|
+
project_name?: string;
|
|
25
|
+
reference_example_id?: string;
|
|
26
|
+
start_time?: number;
|
|
27
|
+
end_time?: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The part of LangSmith's `Client` this reporter uses. Structural rather than
|
|
31
|
+
* imported so the package carries no runtime dependency on the SDK, and so a
|
|
32
|
+
* test can hand it a recording client instead of a network.
|
|
33
|
+
*/
|
|
34
|
+
interface LangSmithClientLike {
|
|
35
|
+
hasDataset(args: {
|
|
36
|
+
datasetName: string;
|
|
37
|
+
}): Promise<boolean>;
|
|
38
|
+
readDataset(args: {
|
|
39
|
+
datasetName: string;
|
|
40
|
+
}): Promise<LangSmithDataset>;
|
|
41
|
+
createDataset(name: string, options?: {
|
|
42
|
+
description?: string;
|
|
43
|
+
}): Promise<LangSmithDataset>;
|
|
44
|
+
createExamples(uploads: LangSmithExample[]): Promise<LangSmithExample[]>;
|
|
45
|
+
createProject(args: {
|
|
46
|
+
projectName: string;
|
|
47
|
+
referenceDatasetId?: string;
|
|
48
|
+
metadata?: Record<string, unknown>;
|
|
49
|
+
}): Promise<LangSmithProject>;
|
|
50
|
+
createRun(run: LangSmithRun): Promise<unknown>;
|
|
51
|
+
createFeedback(runId: string | null, key: string, options: {
|
|
52
|
+
score?: number;
|
|
53
|
+
sessionId?: string;
|
|
54
|
+
}): Promise<unknown>;
|
|
55
|
+
}
|
|
56
|
+
interface LangSmithReporterOptions<Datum> {
|
|
57
|
+
client: LangSmithClientLike;
|
|
58
|
+
/** Dataset holding the validation split. Created once, reused across runs. */
|
|
59
|
+
dataset: string;
|
|
60
|
+
/** Experiments are named `<experimentPrefix>/cand-<candidateId>`. */
|
|
61
|
+
experimentPrefix: string;
|
|
62
|
+
/** The validation set the run was given, in the same order. */
|
|
63
|
+
validationSet: readonly Datum[];
|
|
64
|
+
/**
|
|
65
|
+
* The held-out set the run was given. Uploaded as its own dataset, and only
|
|
66
|
+
* once the winner has been chosen — see the note on the reporter itself.
|
|
67
|
+
*/
|
|
68
|
+
testSet?: readonly Datum[];
|
|
69
|
+
/** Defaults to `<dataset>-held-out`. */
|
|
70
|
+
testDataset?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Names a dataset row. Defaults to its position, which is enough for a run
|
|
73
|
+
* whose validation set is stable; give a real id if the set is ever
|
|
74
|
+
* reordered or re-generated, since a row's identity is what a later run
|
|
75
|
+
* matches against when it writes to the same dataset.
|
|
76
|
+
*/
|
|
77
|
+
instanceId?: (args: {
|
|
78
|
+
datum: Datum;
|
|
79
|
+
index: number;
|
|
80
|
+
}) => string;
|
|
81
|
+
toInput?: (datum: Datum) => Record<string, unknown>;
|
|
82
|
+
toExpected?: (datum: Datum) => Record<string, unknown>;
|
|
83
|
+
/** Rows uploaded at once within one experiment. Default 8. */
|
|
84
|
+
concurrency?: number;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Reports a GEPA run to LangSmith as one experiment per accepted candidate
|
|
88
|
+
* over one fixed dataset — which is what makes the candidates comparable.
|
|
89
|
+
* Selecting several in LangSmith's comparison view renders the candidate x
|
|
90
|
+
* instance score matrix the Pareto frontier is chosen on: which instances a
|
|
91
|
+
* candidate won, and which it paid for.
|
|
92
|
+
*
|
|
93
|
+
* Minibatch rollouts are deliberately absent. They are small random subsets of
|
|
94
|
+
* the *training* set that differ every iteration, so they cannot be compared
|
|
95
|
+
* across candidates and would bury the experiments that can. They remain
|
|
96
|
+
* traceable through the adapter, which tags every rollout with its iteration,
|
|
97
|
+
* phase, split and candidate id.
|
|
98
|
+
*
|
|
99
|
+
* The held-out sweep gets exactly one experiment, for the winner, on its own
|
|
100
|
+
* dataset. There is no option to log one per candidate: GEPA evaluates the
|
|
101
|
+
* held-out set only after selection is over precisely so that nothing can be
|
|
102
|
+
* chosen against it, and a per-candidate view hands that back the moment
|
|
103
|
+
* someone reads it.
|
|
104
|
+
*/
|
|
105
|
+
declare function createLangSmithReporter<Datum>(options: LangSmithReporterOptions<Datum>): Reporter<OptimizerEvent>;
|
|
106
|
+
//#endregion
|
|
107
|
+
export { type LangSmithClientLike, type LangSmithDataset, type LangSmithExample, type LangSmithProject, type LangSmithReporterOptions, type LangSmithRun, createLangSmithReporter };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { OptimizerEvent, Reporter } from "textopt";
|
|
2
|
+
//#region src/reporter.d.ts
|
|
3
|
+
interface LangSmithDataset {
|
|
4
|
+
id: string;
|
|
5
|
+
}
|
|
6
|
+
interface LangSmithProject {
|
|
7
|
+
id: string;
|
|
8
|
+
/** Optional because LangSmith's own `TracerSession` declares it so. */
|
|
9
|
+
name?: string;
|
|
10
|
+
}
|
|
11
|
+
interface LangSmithExample {
|
|
12
|
+
id?: string;
|
|
13
|
+
dataset_id?: string;
|
|
14
|
+
inputs: Record<string, unknown>;
|
|
15
|
+
outputs?: Record<string, unknown>;
|
|
16
|
+
metadata?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
interface LangSmithRun {
|
|
19
|
+
id?: string;
|
|
20
|
+
name: string;
|
|
21
|
+
run_type: string;
|
|
22
|
+
inputs: Record<string, unknown>;
|
|
23
|
+
outputs?: Record<string, unknown>;
|
|
24
|
+
project_name?: string;
|
|
25
|
+
reference_example_id?: string;
|
|
26
|
+
start_time?: number;
|
|
27
|
+
end_time?: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The part of LangSmith's `Client` this reporter uses. Structural rather than
|
|
31
|
+
* imported so the package carries no runtime dependency on the SDK, and so a
|
|
32
|
+
* test can hand it a recording client instead of a network.
|
|
33
|
+
*/
|
|
34
|
+
interface LangSmithClientLike {
|
|
35
|
+
hasDataset(args: {
|
|
36
|
+
datasetName: string;
|
|
37
|
+
}): Promise<boolean>;
|
|
38
|
+
readDataset(args: {
|
|
39
|
+
datasetName: string;
|
|
40
|
+
}): Promise<LangSmithDataset>;
|
|
41
|
+
createDataset(name: string, options?: {
|
|
42
|
+
description?: string;
|
|
43
|
+
}): Promise<LangSmithDataset>;
|
|
44
|
+
createExamples(uploads: LangSmithExample[]): Promise<LangSmithExample[]>;
|
|
45
|
+
createProject(args: {
|
|
46
|
+
projectName: string;
|
|
47
|
+
referenceDatasetId?: string;
|
|
48
|
+
metadata?: Record<string, unknown>;
|
|
49
|
+
}): Promise<LangSmithProject>;
|
|
50
|
+
createRun(run: LangSmithRun): Promise<unknown>;
|
|
51
|
+
createFeedback(runId: string | null, key: string, options: {
|
|
52
|
+
score?: number;
|
|
53
|
+
sessionId?: string;
|
|
54
|
+
}): Promise<unknown>;
|
|
55
|
+
}
|
|
56
|
+
interface LangSmithReporterOptions<Datum> {
|
|
57
|
+
client: LangSmithClientLike;
|
|
58
|
+
/** Dataset holding the validation split. Created once, reused across runs. */
|
|
59
|
+
dataset: string;
|
|
60
|
+
/** Experiments are named `<experimentPrefix>/cand-<candidateId>`. */
|
|
61
|
+
experimentPrefix: string;
|
|
62
|
+
/** The validation set the run was given, in the same order. */
|
|
63
|
+
validationSet: readonly Datum[];
|
|
64
|
+
/**
|
|
65
|
+
* The held-out set the run was given. Uploaded as its own dataset, and only
|
|
66
|
+
* once the winner has been chosen — see the note on the reporter itself.
|
|
67
|
+
*/
|
|
68
|
+
testSet?: readonly Datum[];
|
|
69
|
+
/** Defaults to `<dataset>-held-out`. */
|
|
70
|
+
testDataset?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Names a dataset row. Defaults to its position, which is enough for a run
|
|
73
|
+
* whose validation set is stable; give a real id if the set is ever
|
|
74
|
+
* reordered or re-generated, since a row's identity is what a later run
|
|
75
|
+
* matches against when it writes to the same dataset.
|
|
76
|
+
*/
|
|
77
|
+
instanceId?: (args: {
|
|
78
|
+
datum: Datum;
|
|
79
|
+
index: number;
|
|
80
|
+
}) => string;
|
|
81
|
+
toInput?: (datum: Datum) => Record<string, unknown>;
|
|
82
|
+
toExpected?: (datum: Datum) => Record<string, unknown>;
|
|
83
|
+
/** Rows uploaded at once within one experiment. Default 8. */
|
|
84
|
+
concurrency?: number;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Reports a GEPA run to LangSmith as one experiment per accepted candidate
|
|
88
|
+
* over one fixed dataset — which is what makes the candidates comparable.
|
|
89
|
+
* Selecting several in LangSmith's comparison view renders the candidate x
|
|
90
|
+
* instance score matrix the Pareto frontier is chosen on: which instances a
|
|
91
|
+
* candidate won, and which it paid for.
|
|
92
|
+
*
|
|
93
|
+
* Minibatch rollouts are deliberately absent. They are small random subsets of
|
|
94
|
+
* the *training* set that differ every iteration, so they cannot be compared
|
|
95
|
+
* across candidates and would bury the experiments that can. They remain
|
|
96
|
+
* traceable through the adapter, which tags every rollout with its iteration,
|
|
97
|
+
* phase, split and candidate id.
|
|
98
|
+
*
|
|
99
|
+
* The held-out sweep gets exactly one experiment, for the winner, on its own
|
|
100
|
+
* dataset. There is no option to log one per candidate: GEPA evaluates the
|
|
101
|
+
* held-out set only after selection is over precisely so that nothing can be
|
|
102
|
+
* chosen against it, and a per-candidate view hands that back the moment
|
|
103
|
+
* someone reads it.
|
|
104
|
+
*/
|
|
105
|
+
declare function createLangSmithReporter<Datum>(options: LangSmithReporterOptions<Datum>): Reporter<OptimizerEvent>;
|
|
106
|
+
//#endregion
|
|
107
|
+
export { type LangSmithClientLike, type LangSmithDataset, type LangSmithExample, type LangSmithProject, type LangSmithReporterOptions, type LangSmithRun, createLangSmithReporter };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { isCandidateAccepted, isRunFinished, mapWithConcurrency } from "textopt";
|
|
2
|
+
//#region src/reporter.ts
|
|
3
|
+
const DEFAULT_CONCURRENCY = 8;
|
|
4
|
+
/**
|
|
5
|
+
* A UUIDv5 namespace for this library, so a dataset row's id is a pure
|
|
6
|
+
* function of the dataset it belongs to and the instance it names. Fixed
|
|
7
|
+
* forever: changing it renames every row textopt has ever written.
|
|
8
|
+
*/
|
|
9
|
+
const TEXTOPT_NAMESPACE = "6f9619ff-8b86-d011-b42d-00c04fc964ff";
|
|
10
|
+
/**
|
|
11
|
+
* Reports a GEPA run to LangSmith as one experiment per accepted candidate
|
|
12
|
+
* over one fixed dataset — which is what makes the candidates comparable.
|
|
13
|
+
* Selecting several in LangSmith's comparison view renders the candidate x
|
|
14
|
+
* instance score matrix the Pareto frontier is chosen on: which instances a
|
|
15
|
+
* candidate won, and which it paid for.
|
|
16
|
+
*
|
|
17
|
+
* Minibatch rollouts are deliberately absent. They are small random subsets of
|
|
18
|
+
* the *training* set that differ every iteration, so they cannot be compared
|
|
19
|
+
* across candidates and would bury the experiments that can. They remain
|
|
20
|
+
* traceable through the adapter, which tags every rollout with its iteration,
|
|
21
|
+
* phase, split and candidate id.
|
|
22
|
+
*
|
|
23
|
+
* The held-out sweep gets exactly one experiment, for the winner, on its own
|
|
24
|
+
* dataset. There is no option to log one per candidate: GEPA evaluates the
|
|
25
|
+
* held-out set only after selection is over precisely so that nothing can be
|
|
26
|
+
* chosen against it, and a per-candidate view hands that back the moment
|
|
27
|
+
* someone reads it.
|
|
28
|
+
*/
|
|
29
|
+
function createLangSmithReporter(options) {
|
|
30
|
+
const { client, dataset, experimentPrefix, validationSet, testSet, testDataset = `${dataset}-held-out`, instanceId = ({ index }) => String(index), toInput = (datum) => datum, toExpected, concurrency = DEFAULT_CONCURRENCY } = options;
|
|
31
|
+
let pending = Promise.resolve();
|
|
32
|
+
let validationDatasetId;
|
|
33
|
+
let heldOutDatasetId;
|
|
34
|
+
function enqueue(work) {
|
|
35
|
+
pending = pending.then(work).catch((err) => {
|
|
36
|
+
console.warn("[textopt-langsmith] upload failed", { err });
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
async function ensureDataset(args) {
|
|
40
|
+
if (await client.hasDataset({ datasetName: args.name })) return (await client.readDataset({ datasetName: args.name })).id;
|
|
41
|
+
const created = await client.createDataset(args.name, { description: `textopt: ${String(args.rows.length)} instances` });
|
|
42
|
+
await client.createExamples(await Promise.all(args.rows.map(async (datum, index) => ({
|
|
43
|
+
id: await exampleIdFor({
|
|
44
|
+
dataset: args.name,
|
|
45
|
+
instance: instanceId({
|
|
46
|
+
datum,
|
|
47
|
+
index
|
|
48
|
+
})
|
|
49
|
+
}),
|
|
50
|
+
dataset_id: created.id,
|
|
51
|
+
inputs: toInput(datum),
|
|
52
|
+
...toExpected === void 0 ? {} : { outputs: toExpected(datum) },
|
|
53
|
+
metadata: { textopt_instance_id: instanceId({
|
|
54
|
+
datum,
|
|
55
|
+
index
|
|
56
|
+
}) }
|
|
57
|
+
}))));
|
|
58
|
+
return created.id;
|
|
59
|
+
}
|
|
60
|
+
async function writeExperiment(args) {
|
|
61
|
+
const project = await client.createProject({
|
|
62
|
+
projectName: args.projectName,
|
|
63
|
+
referenceDatasetId: args.datasetId,
|
|
64
|
+
metadata: args.metadata
|
|
65
|
+
});
|
|
66
|
+
const scored = args.rows.flatMap((datum, index) => {
|
|
67
|
+
const score = args.scores[index];
|
|
68
|
+
return score === void 0 ? [] : [{
|
|
69
|
+
datum,
|
|
70
|
+
index,
|
|
71
|
+
score
|
|
72
|
+
}];
|
|
73
|
+
});
|
|
74
|
+
await mapWithConcurrency({
|
|
75
|
+
items: scored,
|
|
76
|
+
limit: concurrency,
|
|
77
|
+
task: async ({ datum, index, score }) => {
|
|
78
|
+
const runId = crypto.randomUUID();
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
await client.createRun({
|
|
81
|
+
id: runId,
|
|
82
|
+
name: args.projectName,
|
|
83
|
+
run_type: "chain",
|
|
84
|
+
inputs: toInput(datum),
|
|
85
|
+
outputs: { output: args.outputs?.[index] ?? null },
|
|
86
|
+
project_name: args.projectName,
|
|
87
|
+
reference_example_id: await exampleIdFor({
|
|
88
|
+
dataset: args.datasetName,
|
|
89
|
+
instance: instanceId({
|
|
90
|
+
datum,
|
|
91
|
+
index
|
|
92
|
+
})
|
|
93
|
+
}),
|
|
94
|
+
start_time: now,
|
|
95
|
+
end_time: now
|
|
96
|
+
});
|
|
97
|
+
await client.createFeedback(runId, "score", {
|
|
98
|
+
score,
|
|
99
|
+
sessionId: project.id
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
onEvent: (event) => {
|
|
106
|
+
if (isCandidateAccepted(event)) enqueue(async () => {
|
|
107
|
+
validationDatasetId ??= ensureDataset({
|
|
108
|
+
name: dataset,
|
|
109
|
+
rows: validationSet
|
|
110
|
+
});
|
|
111
|
+
await writeExperiment({
|
|
112
|
+
projectName: `${experimentPrefix}/cand-${String(event.candidateId)}`,
|
|
113
|
+
datasetId: await validationDatasetId,
|
|
114
|
+
datasetName: dataset,
|
|
115
|
+
rows: validationSet,
|
|
116
|
+
scores: event.instanceScores,
|
|
117
|
+
outputs: event.outputs,
|
|
118
|
+
metadata: experimentMetadata({
|
|
119
|
+
candidate: event.candidate,
|
|
120
|
+
candidateId: event.candidateId,
|
|
121
|
+
aggregateScore: event.aggregateScore,
|
|
122
|
+
search: searchSpecific({ ...event })
|
|
123
|
+
})
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
if (isRunFinished(event) && event.testInstanceScores !== void 0 && testSet !== void 0) {
|
|
127
|
+
const heldOut = event.testInstanceScores;
|
|
128
|
+
const outputs = event.testOutputs;
|
|
129
|
+
const { bestCandidateId, testScore } = event;
|
|
130
|
+
enqueue(async () => {
|
|
131
|
+
heldOutDatasetId ??= ensureDataset({
|
|
132
|
+
name: testDataset,
|
|
133
|
+
rows: testSet
|
|
134
|
+
});
|
|
135
|
+
await writeExperiment({
|
|
136
|
+
projectName: `${experimentPrefix}/held-out-cand-${String(bestCandidateId)}`,
|
|
137
|
+
datasetId: await heldOutDatasetId,
|
|
138
|
+
datasetName: testDataset,
|
|
139
|
+
rows: testSet,
|
|
140
|
+
scores: heldOut,
|
|
141
|
+
outputs,
|
|
142
|
+
metadata: {
|
|
143
|
+
textopt_candidate_id: bestCandidateId,
|
|
144
|
+
textopt_split: "test",
|
|
145
|
+
...testScore === void 0 ? {} : { textopt_score: testScore }
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
flush: async () => {
|
|
152
|
+
await pending;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function experimentMetadata(args) {
|
|
157
|
+
const search = Object.fromEntries(Object.entries(args.search).map(([key, value]) => [`textopt_${key}`, value]));
|
|
158
|
+
return {
|
|
159
|
+
...args.candidate,
|
|
160
|
+
textopt_candidate_id: args.candidateId,
|
|
161
|
+
textopt_score: args.aggregateScore,
|
|
162
|
+
...search
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The fields the emitting optimizer added to the shared acceptance payload:
|
|
167
|
+
* GEPA's lineage, MIPRO's menu choices, SIMBA's step. Read structurally rather
|
|
168
|
+
* than per optimizer so a new search reports its own vocabulary without this
|
|
169
|
+
* package learning about it.
|
|
170
|
+
*/
|
|
171
|
+
function searchSpecific(event) {
|
|
172
|
+
const shared = /* @__PURE__ */ new Set([
|
|
173
|
+
"type",
|
|
174
|
+
"candidateId",
|
|
175
|
+
"candidate",
|
|
176
|
+
"aggregateScore",
|
|
177
|
+
"instanceScores",
|
|
178
|
+
"outputs"
|
|
179
|
+
]);
|
|
180
|
+
return Object.fromEntries(Object.entries(event).filter(([key]) => !shared.has(key)));
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* A dataset row's id, derived from the dataset and the instance it names
|
|
184
|
+
* rather than assigned by the server.
|
|
185
|
+
*
|
|
186
|
+
* Server-assigned ids would give a resumed or repeated run a fresh set of
|
|
187
|
+
* rows, and every experiment logged before the restart would have nothing left
|
|
188
|
+
* to compare against.
|
|
189
|
+
*/
|
|
190
|
+
async function exampleIdFor(args) {
|
|
191
|
+
return uuidV5({
|
|
192
|
+
namespace: TEXTOPT_NAMESPACE,
|
|
193
|
+
name: `${args.dataset}:${args.instance}`
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/** RFC 4122 name-based UUID, SHA-1 flavour. LangSmith ids must be UUIDs. */
|
|
197
|
+
async function uuidV5(args) {
|
|
198
|
+
const namespaceBytes = uuidToBytes(args.namespace);
|
|
199
|
+
const nameBytes = new TextEncoder().encode(args.name);
|
|
200
|
+
const payload = new Uint8Array(namespaceBytes.length + nameBytes.length);
|
|
201
|
+
payload.set(namespaceBytes);
|
|
202
|
+
payload.set(nameBytes, namespaceBytes.length);
|
|
203
|
+
const bytes = new Uint8Array(await crypto.subtle.digest("SHA-1", payload)).slice(0, 16);
|
|
204
|
+
bytes[6] = bytes[6] & 15 | 80;
|
|
205
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
206
|
+
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
207
|
+
return [
|
|
208
|
+
hex.slice(0, 8),
|
|
209
|
+
hex.slice(8, 12),
|
|
210
|
+
hex.slice(12, 16),
|
|
211
|
+
hex.slice(16, 20),
|
|
212
|
+
hex.slice(20)
|
|
213
|
+
].join("-");
|
|
214
|
+
}
|
|
215
|
+
function uuidToBytes(uuid) {
|
|
216
|
+
const hex = uuid.replaceAll("-", "");
|
|
217
|
+
const bytes = /* @__PURE__ */ new Uint8Array(16);
|
|
218
|
+
for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
|
|
219
|
+
return bytes;
|
|
220
|
+
}
|
|
221
|
+
//#endregion
|
|
222
|
+
export { createLangSmithReporter };
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@textopt/langsmith",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "LangSmith experiment reporting for textopt",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"prompt-optimization",
|
|
7
|
+
"gepa",
|
|
8
|
+
"langsmith",
|
|
9
|
+
"evaluation",
|
|
10
|
+
"llm"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"homepage": "https://github.com/ctdio/textopt#readme",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/ctdio/textopt.git",
|
|
17
|
+
"directory": "packages/langsmith"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/ctdio/textopt/issues"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"import": {
|
|
33
|
+
"types": "./dist/index.d.mts",
|
|
34
|
+
"default": "./dist/index.mjs"
|
|
35
|
+
},
|
|
36
|
+
"require": {
|
|
37
|
+
"types": "./dist/index.d.cts",
|
|
38
|
+
"default": "./dist/index.cjs"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"main": "./dist/index.cjs",
|
|
43
|
+
"module": "./dist/index.mjs",
|
|
44
|
+
"types": "./dist/index.d.cts",
|
|
45
|
+
"files": [
|
|
46
|
+
"dist"
|
|
47
|
+
],
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"textopt": "^0.1.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"langsmith": "0.8.11"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsdown",
|
|
56
|
+
"typecheck": "tsc --noEmit"
|
|
57
|
+
}
|
|
58
|
+
}
|