nexus-ai-pro 1.14.0 → 1.15.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/API_STABILITY.md CHANGED
@@ -355,6 +355,23 @@ subpaths are public and follow the 1.x rules. None is exported from the root imp
355
355
  - `AgentLoop`, `tool()`, and `ToolExecutor` keep their behaviour and are re-exported from
356
356
  `nexus-ai-pro/agent` as well as the root.
357
357
 
358
+ ## Evaluation stage (1.15.0)
359
+
360
+ `nexus-ai-pro/evaluate` is public and follows the 1.x rules, and is not exported from the root.
361
+ `Dataset`, `DatasetExample`, `Experiment`, `ExampleResult`, `MetricSummary`, and the comparison shapes
362
+ are normalized; new optional fields and new bundled evaluators may be added in a minor release.
363
+
364
+ - A dataset version is derived from example content. The hash itself is an implementation detail, but
365
+ the guarantee is not: identical examples produce identical versions, and any change produces a
366
+ different one.
367
+ - `compareExperiments()` is statistical. Its intervals come from a seeded paired bootstrap, so the
368
+ same inputs give the same verdict; the exact interval bounds may change if the method improves, and
369
+ a verdict is a judgement about evidence rather than a compatibility guarantee.
370
+ - `evaluate()` reports a target's failure as a failed example and an evaluator's failure as a failed
371
+ measurement; neither throws.
372
+ - The existing `EvalRunner` and `MediaEvalRunner` keep their APIs unchanged. Rebuilding them on this
373
+ entry point is a later, internal change.
374
+
358
375
  ## Deprecation process
359
376
 
360
377
  Deprecated APIs are marked with `@deprecated` in declarations and described in the changelog. Removals
package/CHANGELOG.md CHANGED
@@ -4,6 +4,40 @@ Notable changes to this project are recorded here. The format follows [Keep a Ch
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.15.0] - 2026-09-21
8
+
9
+ Evaluation. One entry point for evaluating anything, datasets versioned by their content, experiments
10
+ that can be compared with a verdict rather than a vibe, and the loop from a production failure back to
11
+ a permanent regression test.
12
+
13
+ ### Added
14
+
15
+ - **`nexus-ai-pro/evaluate`.** `evaluate(target, dataset, evaluators, options)` accepts any target: a
16
+ completion, an agent, a graph, an image operation, or plain code.
17
+ - Examples run concurrently, with repetitions for a target that is not deterministic, a timeout per
18
+ example, and per-metric statistics including a 95% interval.
19
+ - A target that throws is recorded as a failed example; an evaluator that throws is recorded as a
20
+ failed measurement. Neither voids the experiment.
21
+ - **Datasets versioned by content.** Change an example and the version changes with it, so two
22
+ experiments are comparable only when they ran over the same data. `MemoryDatasetStore` and
23
+ `FileDatasetStore` (one reviewable JSON file per version), plus `splitOf()` for train and test
24
+ splits.
25
+ - **`datasetFromTraces()`.** Turns recorded runs into examples, each keeping the run it came from, so
26
+ yesterday's production failure becomes tomorrow's regression test.
27
+ - **Bundled evaluators.** `exactMatch`, `contains`, `mustNotMatch`, `completed`, `underLatency`,
28
+ `embeddingSimilarity` through any injected embedder, `pairwise` for side-by-side judgements, and
29
+ `trajectory`, which scores how an answer was reached rather than only what it said. `passRate` and
30
+ `totalCost` summarize a whole experiment.
31
+ - **`compareExperiments()` with a real verdict.** A paired bootstrap over per-example differences
32
+ gives each metric a 95% interval; a metric whose interval spans zero is `unchanged` rather than an
33
+ improvement. The bootstrap is seeded, so a CI gate cannot flip between runs. The report names the
34
+ examples that moved most, the failures that are new, the ones that were fixed, and any dataset
35
+ version mismatch. `formatComparison()` prints it for a pull request.
36
+ - **`evaluateOnline()`.** Scores sampled production runs, writes the scores back as trace feedback so
37
+ alert rules can watch them, and sends uncertain results to people.
38
+ - **`AnnotationQueue`.** Review work with a rubric, claims that expire so nothing is stranded,
39
+ consensus across several reviewers, and `toExamples()` to turn reviewed items into dataset examples.
40
+
7
41
  ## [1.14.0] - 2026-09-20
8
42
 
9
43
  The memory, agent, and MCP work planned for 1.13.0 ships here together with tracing, so no 1.13.0 was
@@ -717,7 +751,8 @@ shared policy vocabulary is in place for a future release that revisits this.
717
751
  - URL fetching rejects unsafe private-network targets and limits response reads.
718
752
  - CLI and audit findings no longer disclose detected secret values.
719
753
 
720
- [Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.14.0...HEAD
754
+ [Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.15.0...HEAD
755
+ [1.15.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.14.0...v1.15.0
721
756
  [1.14.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.12.0...v1.14.0
722
757
  [1.12.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.11.0...v1.12.0
723
758
  [1.11.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.10.0...v1.11.0
package/README.md CHANGED
@@ -724,6 +724,7 @@ The Model Context Protocol, both directions, implemented directly rather than th
724
724
  ```ts
725
725
  import { McpClient, createStdioTransport } from 'nexus-ai-pro/mcp';
726
726
  import { Tracer, MemoryTraceStore } from 'nexus-ai-pro/tracing';
727
+ import { evaluate, createDataset, compareExperiments } from 'nexus-ai-pro/evaluate';
727
728
 
728
729
  const client = new McpClient(createStdioTransport({ command: 'npx', args: ['-y', 'some-mcp-server'] }));
729
730
  const agent = createAgent({ client: ai, tools: await client.toNexusTools({ prefix: 'files' }) });
@@ -810,6 +811,93 @@ setInterval(() => void alerts.evaluate(), 60_000);
810
811
  An alert names the rule, the measured value, and three runs that contributed, so the next step is
811
812
  reading them rather than starting an investigation.
812
813
 
814
+ ## Evaluation
815
+
816
+ One entry point for evaluating anything: a completion, an agent, a graph, an image operation, or
817
+ plain code. A dataset of examples, a target that turns an example into an output, evaluators that
818
+ score it, and a stored experiment a later run can be compared against.
819
+
820
+ ```ts
821
+ import { evaluate, createDataset, exactMatch, contains, trajectory, passRate } from 'nexus-ai-pro/evaluate';
822
+
823
+ const dataset = createDataset({
824
+ name: 'support-questions',
825
+ examples: [
826
+ { id: 'refund', inputs: { question: 'how do I get a refund?' }, expected: 'Open the order and choose refund.' },
827
+ { id: 'hours', inputs: { question: 'when are you open?' }, expected: 'Weekdays, nine to five.' },
828
+ ],
829
+ });
830
+
831
+ const experiment = await evaluate(
832
+ (inputs) => agent.invoke(agentInput(inputs.question)),
833
+ dataset,
834
+ [contains(['refund']), trajectory({ expected: ['search_orders'] })],
835
+ { repetitions: 3, concurrency: 4, summary: [passRate()], store: experiments },
836
+ );
837
+ ```
838
+
839
+ **A dataset is versioned by its content**, so two experiments are comparable only when they really
840
+ ran over the same examples. Change an example and the version changes with it.
841
+
842
+ **Datasets can come from production.** `datasetFromTraces({ store, query: { status: 'error' } })`
843
+ turns recorded runs into examples, each keeping the run it came from — the request that failed
844
+ yesterday becomes tomorrow's regression test.
845
+
846
+ **Evaluators are ordinary functions.** Bundled: `exactMatch`, `contains`, `mustNotMatch`, `completed`,
847
+ `underLatency`, `embeddingSimilarity` through any embedder, `pairwise` for side-by-side judgements,
848
+ and `trajectory`, which scores *how* an answer was reached — an agent that gets the right answer by
849
+ calling the refund tool three times is not working. The LLM judge from `nexus-ai-pro/evals/judge`
850
+ plugs in as one more evaluator.
851
+
852
+ ### Was the change real?
853
+
854
+ ```ts
855
+ import { compareExperiments, formatComparison } from 'nexus-ai-pro/evaluate';
856
+
857
+ const comparison = compareExperiments(baseline, candidate);
858
+ console.log(formatComparison(comparison));
859
+ if (comparison.regressed) process.exit(1);
860
+ ```
861
+
862
+ A mean that moves from 0.81 to 0.83 says nothing on its own; with twenty examples that is noise, and
863
+ shipping on it makes a quality gate a coin toss. The verdict comes from a **paired bootstrap** over
864
+ the per-example differences, which assumes nothing about how the scores are distributed, and the
865
+ pairing removes the variation caused by the examples themselves. A metric whose interval spans zero
866
+ is reported as `unchanged`, not as an improvement. The bootstrap is seeded, so CI gives the same
867
+ verdict twice.
868
+
869
+ The report names the examples that moved most, the failures that are new, and the ones that were
870
+ fixed. A comparison across different dataset versions says so rather than pretending it is like for
871
+ like.
872
+
873
+ ### The runs you did not think of
874
+
875
+ ```ts
876
+ import { evaluateOnline, AnnotationQueue } from 'nexus-ai-pro/evaluate';
877
+
878
+ const queue = new AnnotationQueue({
879
+ rubric: [{ key: 'helpful', prompt: 'Did this answer the question?', type: 'boolean' }],
880
+ consensus: 2,
881
+ });
882
+
883
+ await evaluateOnline({
884
+ store: traceStore,
885
+ evaluators: [mustNotMatch([/i cannot help/i])],
886
+ sampleRate: 0.1,
887
+ reviewQueue: queue,
888
+ reviewWhen: (scores) => scores.some((score) => score.passed === false),
889
+ });
890
+ ```
891
+
892
+ A dataset tells you whether a change works on the cases you thought of; online evaluation tells you
893
+ how it is doing on the rest. Scores are written back as feedback on the run, so an alert rule can
894
+ watch them, and anything uncertain goes to a person.
895
+
896
+ **The review queue keeps track of what people owe you.** Claims expire, so a reviewer who closes the
897
+ tab does not strand an item; `consensus` lets two reviewers see the same item when one opinion is not
898
+ enough; and `toExamples()` turns reviewed items into dataset examples, which closes the loop from a
899
+ production failure to a permanent regression test.
900
+
813
901
  ## Provider Batch Tiers
814
902
 
815
903
  Both OpenAI and Anthropic sell an asynchronous tier at roughly half price, in exchange for a
@@ -2094,6 +2182,7 @@ only, so it never appears in an import graph.
2094
2182
  | `nexus-ai-pro/realtime/openai-webrtc` | 46 KB | 8% | none |
2095
2183
  | `nexus-ai-pro/security` | 40 KB | 7% | +3.4 MB |
2096
2184
  | `nexus-ai-pro/models` | 35 KB | 6% | none |
2185
+ | `nexus-ai-pro/evaluate` | 33 KB | 5% | none |
2097
2186
  | `nexus-ai-pro/images/inputs` | 31 KB | 5% | none |
2098
2187
  | `nexus-ai-pro/realtime/openai-websocket` | 29 KB | 5% | none |
2099
2188
  | `nexus-ai-pro/tracing` | 26 KB | 4% | none |
@@ -0,0 +1,64 @@
1
+ import type { EvaluationScore, Experiment } from '../types/evaluate.js';
2
+ export interface MetricComparison {
3
+ key: string;
4
+ baseline: number;
5
+ candidate: number;
6
+ delta: number;
7
+ /** 95% interval for the delta, from a paired bootstrap. Excludes zero when the change is real. */
8
+ ci95: [number, number];
9
+ /** `better`, `worse`, or `unchanged` — unchanged when the interval spans zero. */
10
+ verdict: 'better' | 'worse' | 'unchanged';
11
+ }
12
+ export interface ExampleComparison {
13
+ exampleId: string;
14
+ key: string;
15
+ baseline: number;
16
+ candidate: number;
17
+ delta: number;
18
+ }
19
+ export interface ExperimentComparison {
20
+ baseline: {
21
+ id: string;
22
+ name: string;
23
+ };
24
+ candidate: {
25
+ id: string;
26
+ name: string;
27
+ };
28
+ /** Present when the two experiments did not run over the same dataset version. */
29
+ datasetMismatch?: {
30
+ baseline: string;
31
+ candidate: string;
32
+ };
33
+ metrics: MetricComparison[];
34
+ /** The examples that moved most, worst first. */
35
+ regressions: ExampleComparison[];
36
+ improvements: ExampleComparison[];
37
+ /** True when any metric got worse beyond noise, or a new error appeared. */
38
+ regressed: boolean;
39
+ newErrors: string[];
40
+ fixedErrors: string[];
41
+ }
42
+ export interface CompareOptions {
43
+ /** Metrics where a lower number is better, such as latency or cost. */
44
+ lowerIsBetter?: readonly string[];
45
+ /** Bootstrap resamples used for the interval. Defaults to 2,000. */
46
+ resamples?: number;
47
+ /** Deterministic sampling, so a comparison in CI is reproducible. Defaults to a fixed seed. */
48
+ seed?: number;
49
+ /** Examples listed in `regressions` and `improvements`. Defaults to 10. */
50
+ topExamples?: number;
51
+ }
52
+ /**
53
+ * Compares two experiments and says whether the change is real.
54
+ *
55
+ * A mean that moved from 0.81 to 0.83 says nothing on its own — with twenty examples that is noise,
56
+ * and shipping on it is how a quality gate becomes a coin toss. The interval comes from a paired
57
+ * bootstrap over the per-example deltas, which needs no assumption about how the scores are
58
+ * distributed, and pairing is what removes the variation caused by the examples themselves.
59
+ */
60
+ export declare function compareExperiments(baseline: Experiment, candidate: Experiment, options?: CompareOptions): ExperimentComparison;
61
+ /** A comparison as text, for a pull-request comment or a CI log. */
62
+ export declare function formatComparison(comparison: ExperimentComparison): string;
63
+ /** Summary scores as a map, for asserting on one in a test or a gate. */
64
+ export declare function scoreMap(scores: EvaluationScore[]): Record<string, number>;
@@ -0,0 +1,148 @@
1
+ import { stats } from './run.js';
2
+ /**
3
+ * Compares two experiments and says whether the change is real.
4
+ *
5
+ * A mean that moved from 0.81 to 0.83 says nothing on its own — with twenty examples that is noise,
6
+ * and shipping on it is how a quality gate becomes a coin toss. The interval comes from a paired
7
+ * bootstrap over the per-example deltas, which needs no assumption about how the scores are
8
+ * distributed, and pairing is what removes the variation caused by the examples themselves.
9
+ */
10
+ export function compareExperiments(baseline, candidate, options = {}) {
11
+ const lowerIsBetter = new Set(options.lowerIsBetter ?? ['latency', 'total-cost', 'cost']);
12
+ const baselineScores = index(baseline.results);
13
+ const candidateScores = index(candidate.results);
14
+ const keys = [
15
+ ...new Set([...baselineScores.keys(), ...candidateScores.keys()].map((entry) => entry.split('␟')[1])),
16
+ ];
17
+ const metrics = [];
18
+ const exampleDeltas = [];
19
+ for (const key of keys) {
20
+ const pairs = [];
21
+ for (const [entry, value] of baselineScores) {
22
+ const [exampleId, metric] = entry.split('␟');
23
+ if (metric !== key)
24
+ continue;
25
+ const other = candidateScores.get(entry);
26
+ if (other === undefined)
27
+ continue;
28
+ pairs.push({ exampleId, baseline: value, candidate: other });
29
+ }
30
+ if (pairs.length === 0)
31
+ continue;
32
+ const deltas = pairs.map((pair) => pair.candidate - pair.baseline);
33
+ const meanDelta = stats(deltas).mean;
34
+ const ci95 = bootstrapInterval(deltas, options.resamples ?? 2_000, options.seed ?? 12_345);
35
+ const improved = lowerIsBetter.has(key) ? meanDelta < 0 : meanDelta > 0;
36
+ const significant = ci95[0] > 0 || ci95[1] < 0;
37
+ metrics.push({
38
+ key,
39
+ baseline: stats(pairs.map((pair) => pair.baseline)).mean,
40
+ candidate: stats(pairs.map((pair) => pair.candidate)).mean,
41
+ delta: meanDelta,
42
+ ci95,
43
+ verdict: !significant ? 'unchanged' : improved ? 'better' : 'worse',
44
+ });
45
+ for (const pair of pairs) {
46
+ if (pair.candidate === pair.baseline)
47
+ continue;
48
+ exampleDeltas.push({
49
+ exampleId: pair.exampleId,
50
+ key,
51
+ baseline: pair.baseline,
52
+ candidate: pair.candidate,
53
+ delta: lowerIsBetter.has(key) ? pair.baseline - pair.candidate : pair.candidate - pair.baseline,
54
+ });
55
+ }
56
+ }
57
+ const failed = (experiment) => new Set(experiment.results.filter((result) => result.error).map((result) => result.exampleId));
58
+ const baselineFailures = failed(baseline);
59
+ const candidateFailures = failed(candidate);
60
+ const newErrors = [...candidateFailures].filter((id) => !baselineFailures.has(id));
61
+ const fixedErrors = [...baselineFailures].filter((id) => !candidateFailures.has(id));
62
+ const top = options.topExamples ?? 10;
63
+ return {
64
+ baseline: { id: baseline.id, name: baseline.name },
65
+ candidate: { id: candidate.id, name: candidate.name },
66
+ ...(baseline.dataset.version === candidate.dataset.version
67
+ ? {}
68
+ : { datasetMismatch: { baseline: baseline.dataset.version, candidate: candidate.dataset.version } }),
69
+ metrics,
70
+ regressions: exampleDeltas
71
+ .filter((item) => item.delta < 0)
72
+ .sort((a, b) => a.delta - b.delta)
73
+ .slice(0, top),
74
+ improvements: exampleDeltas
75
+ .filter((item) => item.delta > 0)
76
+ .sort((a, b) => b.delta - a.delta)
77
+ .slice(0, top),
78
+ regressed: metrics.some((metric) => metric.verdict === 'worse') || newErrors.length > 0,
79
+ newErrors,
80
+ fixedErrors,
81
+ };
82
+ }
83
+ /** A comparison as text, for a pull-request comment or a CI log. */
84
+ export function formatComparison(comparison) {
85
+ const lines = [`${comparison.candidate.name} vs ${comparison.baseline.name}`];
86
+ if (comparison.datasetMismatch) {
87
+ lines.push(` ! dataset differs: ${comparison.datasetMismatch.baseline} vs ${comparison.datasetMismatch.candidate}; the comparison is not like for like`);
88
+ }
89
+ for (const metric of comparison.metrics) {
90
+ const arrow = metric.verdict === 'better' ? '▲' : metric.verdict === 'worse' ? '▼' : '=';
91
+ lines.push(` ${arrow} ${metric.key}: ${round(metric.baseline)} → ${round(metric.candidate)} (${signed(metric.delta)}, 95% CI ${round(metric.ci95[0])}..${round(metric.ci95[1])})`);
92
+ }
93
+ if (comparison.newErrors.length > 0)
94
+ lines.push(` ! new failures: ${comparison.newErrors.join(', ')}`);
95
+ if (comparison.fixedErrors.length > 0)
96
+ lines.push(` + fixed failures: ${comparison.fixedErrors.join(', ')}`);
97
+ for (const example of comparison.regressions.slice(0, 5)) {
98
+ lines.push(` - ${example.exampleId} ${example.key}: ${round(example.baseline)} → ${round(example.candidate)}`);
99
+ }
100
+ return lines.join('\n');
101
+ }
102
+ function index(results) {
103
+ // Repetitions of one example are averaged first, so a noisy target does not dominate the pairing.
104
+ const grouped = new Map();
105
+ for (const result of results) {
106
+ for (const score of result.scores) {
107
+ const key = `${result.exampleId}␟${score.key}`;
108
+ grouped.set(key, [...(grouped.get(key) ?? []), score.score]);
109
+ }
110
+ }
111
+ return new Map([...grouped.entries()].map(([key, values]) => [key, stats(values).mean]));
112
+ }
113
+ /** Paired bootstrap: resample the per-example deltas and take the middle 95% of the means. */
114
+ function bootstrapInterval(deltas, resamples, seed) {
115
+ if (deltas.length < 2)
116
+ return [0, 0];
117
+ const random = mulberry32(seed);
118
+ const means = [];
119
+ for (let sample = 0; sample < resamples; sample += 1) {
120
+ let total = 0;
121
+ for (let index = 0; index < deltas.length; index += 1) {
122
+ total += deltas[Math.floor(random() * deltas.length)];
123
+ }
124
+ means.push(total / deltas.length);
125
+ }
126
+ means.sort((a, b) => a - b);
127
+ return [means[Math.floor(resamples * 0.025)], means[Math.floor(resamples * 0.975)]];
128
+ }
129
+ /** A small deterministic generator, so a CI comparison gives the same verdict twice. */
130
+ function mulberry32(seed) {
131
+ let state = seed >>> 0;
132
+ return () => {
133
+ state = (state + 0x6d_2b_79_f5) >>> 0;
134
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
135
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
136
+ return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
137
+ };
138
+ }
139
+ function round(value) {
140
+ return Number.isInteger(value) ? String(value) : value.toFixed(3);
141
+ }
142
+ function signed(value) {
143
+ return `${value >= 0 ? '+' : ''}${round(value)}`;
144
+ }
145
+ /** Summary scores as a map, for asserting on one in a test or a gate. */
146
+ export function scoreMap(scores) {
147
+ return Object.fromEntries(scores.map((score) => [score.key, score.score]));
148
+ }
@@ -0,0 +1,79 @@
1
+ import type { Dataset, DatasetExample, DatasetStore, Experiment, ExperimentStore } from '../types/evaluate.js';
2
+ import type { Run, RunQuery, TraceStore } from '../types/tracing.js';
3
+ export interface CreateDatasetOptions<I, O> {
4
+ name: string;
5
+ examples: Array<Omit<DatasetExample<I, O>, 'id'> & {
6
+ id?: string;
7
+ }>;
8
+ description?: string;
9
+ tags?: string[];
10
+ /** Defaults to a hash of the examples, so identical content is the same version. */
11
+ version?: string;
12
+ now?: () => Date;
13
+ }
14
+ /**
15
+ * Builds a dataset, versioned by its content.
16
+ *
17
+ * A version derived from the examples means two experiments can be compared only when they really
18
+ * ran over the same data: change an example and the version changes with it, rather than silently
19
+ * invalidating every earlier comparison.
20
+ */
21
+ export declare function createDataset<I = unknown, O = unknown>(options: CreateDatasetOptions<I, O>): Dataset<I, O>;
22
+ export declare function contentVersion(examples: readonly DatasetExample[]): string;
23
+ /** Examples of one split, as a dataset in its own right. */
24
+ export declare function splitOf<I, O>(dataset: Dataset<I, O>, split: string): Dataset<I, O>;
25
+ export interface FromTracesOptions {
26
+ name: string;
27
+ store: TraceStore;
28
+ query?: RunQuery;
29
+ /** Turns a run into an example. Defaults to its inputs and outputs. */
30
+ toExample?: (run: Run) => Omit<DatasetExample, 'id'> | undefined;
31
+ limit?: number;
32
+ }
33
+ /**
34
+ * Builds a dataset from recorded production runs.
35
+ *
36
+ * The most valuable examples are the ones that already happened: the request that failed, the answer
37
+ * a user marked wrong. Each example keeps `sourceRunId`, so a result can always be traced back to
38
+ * the run it came from.
39
+ */
40
+ export declare function datasetFromTraces(options: FromTracesOptions): Promise<Dataset>;
41
+ /** Datasets in memory, keyed by name and version. */
42
+ export declare class MemoryDatasetStore implements DatasetStore {
43
+ private readonly datasets;
44
+ save(dataset: Dataset): void;
45
+ get(name: string, version?: string): Dataset | undefined;
46
+ list(): Array<{
47
+ name: string;
48
+ versions: string[];
49
+ }>;
50
+ }
51
+ /**
52
+ * Datasets as JSON files in a directory, one file per version.
53
+ *
54
+ * Readable, diffable, and reviewable: a dataset belongs in version control next to the code it
55
+ * tests, which a database row does not allow.
56
+ */
57
+ export declare class FileDatasetStore implements DatasetStore {
58
+ private readonly directory;
59
+ constructor(directory: string);
60
+ save(dataset: Dataset): Promise<void>;
61
+ get(name: string, version?: string): Promise<Dataset | undefined>;
62
+ list(): Promise<Array<{
63
+ name: string;
64
+ versions: string[];
65
+ }>>;
66
+ private read;
67
+ private fileOf;
68
+ }
69
+ /** Experiments in memory, newest first. */
70
+ export declare class MemoryExperimentStore implements ExperimentStore {
71
+ private readonly experiments;
72
+ save(experiment: Experiment): void;
73
+ get(id: string): Experiment | undefined;
74
+ list(filter?: {
75
+ name?: string;
76
+ dataset?: string;
77
+ limit?: number;
78
+ }): Experiment[];
79
+ }
@@ -0,0 +1,144 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ /**
5
+ * Builds a dataset, versioned by its content.
6
+ *
7
+ * A version derived from the examples means two experiments can be compared only when they really
8
+ * ran over the same data: change an example and the version changes with it, rather than silently
9
+ * invalidating every earlier comparison.
10
+ */
11
+ export function createDataset(options) {
12
+ const examples = options.examples.map((example, index) => ({
13
+ ...example,
14
+ id: example.id ?? `ex-${index + 1}`,
15
+ }));
16
+ return {
17
+ name: options.name,
18
+ version: options.version ?? contentVersion(examples),
19
+ ...(options.description ? { description: options.description } : {}),
20
+ examples,
21
+ ...(options.tags ? { tags: options.tags } : {}),
22
+ createdAt: (options.now ?? (() => new Date()))().toISOString(),
23
+ };
24
+ }
25
+ export function contentVersion(examples) {
26
+ const hash = createHash('sha256');
27
+ for (const example of examples) {
28
+ hash.update(JSON.stringify({ id: example.id, inputs: example.inputs, expected: example.expected }));
29
+ }
30
+ return `v${hash.digest('hex').slice(0, 12)}`;
31
+ }
32
+ /** Examples of one split, as a dataset in its own right. */
33
+ export function splitOf(dataset, split) {
34
+ const examples = dataset.examples.filter((example) => example.split === split);
35
+ return { ...dataset, name: `${dataset.name}:${split}`, version: contentVersion(examples), examples };
36
+ }
37
+ /**
38
+ * Builds a dataset from recorded production runs.
39
+ *
40
+ * The most valuable examples are the ones that already happened: the request that failed, the answer
41
+ * a user marked wrong. Each example keeps `sourceRunId`, so a result can always be traced back to
42
+ * the run it came from.
43
+ */
44
+ export async function datasetFromTraces(options) {
45
+ const runs = await options.store.query({ limit: options.limit ?? 100, ...options.query });
46
+ const examples = [];
47
+ for (const run of runs) {
48
+ const example = options.toExample?.(run) ?? { inputs: run.inputs, expected: run.outputs };
49
+ if (example)
50
+ examples.push({ ...example, id: run.id, sourceRunId: run.id });
51
+ }
52
+ return createDataset({ name: options.name, examples, description: 'Built from recorded runs' });
53
+ }
54
+ /** Datasets in memory, keyed by name and version. */
55
+ export class MemoryDatasetStore {
56
+ datasets = new Map();
57
+ save(dataset) {
58
+ this.datasets.set(`${dataset.name}@${dataset.version}`, dataset);
59
+ }
60
+ get(name, version) {
61
+ if (version)
62
+ return this.datasets.get(`${name}@${version}`);
63
+ const versions = [...this.datasets.values()].filter((dataset) => dataset.name === name);
64
+ return versions.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
65
+ }
66
+ list() {
67
+ const byName = new Map();
68
+ for (const dataset of this.datasets.values()) {
69
+ byName.set(dataset.name, [...(byName.get(dataset.name) ?? []), dataset.version]);
70
+ }
71
+ return [...byName.entries()].map(([name, versions]) => ({ name, versions }));
72
+ }
73
+ }
74
+ /**
75
+ * Datasets as JSON files in a directory, one file per version.
76
+ *
77
+ * Readable, diffable, and reviewable: a dataset belongs in version control next to the code it
78
+ * tests, which a database row does not allow.
79
+ */
80
+ export class FileDatasetStore {
81
+ directory;
82
+ constructor(directory) {
83
+ this.directory = directory;
84
+ }
85
+ async save(dataset) {
86
+ await mkdir(this.directory, { recursive: true });
87
+ await writeFile(this.fileOf(dataset.name, dataset.version), `${JSON.stringify(dataset, null, 2)}\n`, 'utf8');
88
+ }
89
+ async get(name, version) {
90
+ if (version)
91
+ return this.read(this.fileOf(name, version));
92
+ const versions = (await this.list()).find((entry) => entry.name === name)?.versions ?? [];
93
+ const newest = (await Promise.all(versions.map((item) => this.read(this.fileOf(name, item)))))
94
+ .filter((dataset) => dataset !== undefined)
95
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
96
+ return newest[0];
97
+ }
98
+ async list() {
99
+ const { readdir } = await import('node:fs/promises');
100
+ let files;
101
+ try {
102
+ files = await readdir(this.directory);
103
+ }
104
+ catch {
105
+ return [];
106
+ }
107
+ const byName = new Map();
108
+ for (const file of files) {
109
+ const match = /^(.*)@(.*)\.json$/.exec(file);
110
+ if (!match)
111
+ continue;
112
+ const [, name, version] = match;
113
+ byName.set(decodeURIComponent(name), [...(byName.get(decodeURIComponent(name)) ?? []), version]);
114
+ }
115
+ return [...byName.entries()].map(([name, versions]) => ({ name, versions }));
116
+ }
117
+ async read(file) {
118
+ try {
119
+ return JSON.parse(await readFile(file, 'utf8'));
120
+ }
121
+ catch {
122
+ return undefined;
123
+ }
124
+ }
125
+ fileOf(name, version) {
126
+ return path.join(this.directory, `${encodeURIComponent(name)}@${version}.json`);
127
+ }
128
+ }
129
+ /** Experiments in memory, newest first. */
130
+ export class MemoryExperimentStore {
131
+ experiments = [];
132
+ save(experiment) {
133
+ this.experiments.unshift(experiment);
134
+ }
135
+ get(id) {
136
+ return this.experiments.find((experiment) => experiment.id === id);
137
+ }
138
+ list(filter = {}) {
139
+ return this.experiments
140
+ .filter((experiment) => (!filter.name || experiment.name === filter.name) &&
141
+ (!filter.dataset || experiment.dataset.name === filter.dataset))
142
+ .slice(0, filter.limit ?? 50);
143
+ }
144
+ }