@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 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;
@@ -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 };
@@ -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
+ }