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 +17 -0
- package/CHANGELOG.md +36 -1
- package/README.md +89 -0
- package/dist/evaluate/compare.d.ts +64 -0
- package/dist/evaluate/compare.js +148 -0
- package/dist/evaluate/datasets.d.ts +79 -0
- package/dist/evaluate/datasets.js +144 -0
- package/dist/evaluate/evaluators.d.ts +71 -0
- package/dist/evaluate/evaluators.js +190 -0
- package/dist/evaluate/index.d.ts +6 -0
- package/dist/evaluate/index.js +5 -0
- package/dist/evaluate/review.d.ts +103 -0
- package/dist/evaluate/review.js +168 -0
- package/dist/evaluate/run.d.ts +34 -0
- package/dist/evaluate/run.js +162 -0
- package/dist/types/evaluate.d.ts +117 -0
- package/dist/types/evaluate.js +10 -0
- package/dist-cjs/evaluate/compare.d.ts +64 -0
- package/dist-cjs/evaluate/compare.js +153 -0
- package/dist-cjs/evaluate/datasets.d.ts +79 -0
- package/dist-cjs/evaluate/datasets.js +190 -0
- package/dist-cjs/evaluate/evaluators.d.ts +71 -0
- package/dist-cjs/evaluate/evaluators.js +202 -0
- package/dist-cjs/evaluate/index.d.ts +6 -0
- package/dist-cjs/evaluate/index.js +33 -0
- package/dist-cjs/evaluate/review.d.ts +103 -0
- package/dist-cjs/evaluate/review.js +173 -0
- package/dist-cjs/evaluate/run.d.ts +34 -0
- package/dist-cjs/evaluate/run.js +167 -0
- package/dist-cjs/types/evaluate.d.ts +117 -0
- package/dist-cjs/types/evaluate.js +11 -0
- package/package.json +14 -1
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.
|
|
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
|
+
}
|